From 4796cee2dc7882a592801ece308d6eeebc82ba60 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 26 Jun 2024 16:51:30 +0100 Subject: [PATCH 001/206] #2676: Put global variables in dataclass --- src/primaite/simulator/network/nmne.py | 81 ++++++++++++++------------ 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py index 5c0c657b..d6f1763f 100644 --- a/src/primaite/simulator/network/nmne.py +++ b/src/primaite/simulator/network/nmne.py @@ -1,48 +1,55 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from typing import Dict, Final, List - -CAPTURE_NMNE: bool = True -"""Indicates whether Malicious Network Events (MNEs) should be captured. Default is True.""" - -NMNE_CAPTURE_KEYWORDS: List[str] = [] -"""List of keywords to identify malicious network events.""" - -# TODO: Remove final and make configurable after example layout when the NICObservation creates nmne structure dynamically -CAPTURE_BY_DIRECTION: Final[bool] = True -"""Flag to determine if captures should be organized by traffic direction (inbound/outbound).""" -CAPTURE_BY_IP_ADDRESS: Final[bool] = False -"""Flag to determine if captures should be organized by source or destination IP address.""" -CAPTURE_BY_PROTOCOL: Final[bool] = False -"""Flag to determine if captures should be organized by network protocol (e.g., TCP, UDP).""" -CAPTURE_BY_PORT: Final[bool] = False -"""Flag to determine if captures should be organized by source or destination port.""" -CAPTURE_BY_KEYWORD: Final[bool] = False -"""Flag to determine if captures should be filtered and categorised based on specific keywords.""" +from dataclasses import dataclass, field +from typing import Dict, List -def set_nmne_config(nmne_config: Dict): +@dataclass +class nmne_data: + """Store all the information to perform NMNE operations.""" + + capture_nmne: bool = True + """Indicates whether Malicious Network Events (MNEs) should be captured.""" + nmne_capture_keywords: List[str] = field(default_factory=list) + """List of keywords to identify malicious network events.""" + capture_by_direction: bool = True + """Captures should be organized by traffic direction (inbound/outbound).""" + capture_by_ip_address: bool = False + """Captures should be organized by source or destination IP address.""" + capture_by_protocol: bool = False + """Captures should be organized by network protocol (e.g., TCP, UDP).""" + capture_by_port: bool = False + """Captures should be organized by source or destination port.""" + capture_by_keyword: bool = False + """Captures should be filtered and categorised based on specific keywords.""" + + +def set_nmne_config(nmne_config: Dict) -> nmne_data: """ - Sets the configuration for capturing Malicious Network Events (MNEs) based on a provided dictionary. + Sets the configuration for capturing Malicious Network Events (MNEs) based on a provided + dictionary. - This function updates global settings related to NMNE capture, including whether to capture NMNEs and what - keywords to use for identifying NMNEs. + This function updates global settings related to NMNE capture, including whether to capture + NMNEs and what keywords to use for identifying NMNEs. - The function ensures that the settings are updated only if they are provided in the `nmne_config` dictionary, - and maintains type integrity by checking the types of the provided values. + The function ensures that the settings are updated only if they are provided in the + `nmne_config` dictionary, and maintains type integrity by checking the types of the provided + values. - :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys include: - "capture_nmne" (bool) to indicate whether NMNEs should be captured, "nmne_capture_keywords" (list of strings) - to specify keywords for NMNE identification. + :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys + include: + "capture_nmne" (bool) to indicate whether NMNEs should be captured; + "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. + :rvar dataclass with data read from config file. """ - global NMNE_CAPTURE_KEYWORDS - global CAPTURE_NMNE - + nmne_capture_keywords = [] # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect - CAPTURE_NMNE = nmne_config.get("capture_nmne", False) - if not isinstance(CAPTURE_NMNE, bool): - CAPTURE_NMNE = True # Revert to default True if the provided value is not a boolean + capture_nmne = nmne_config.get("capture_nmne", False) + if not isinstance(capture_nmne, bool): + capture_nmne = True # Revert to default True if the provided value is not a boolean # Update the NMNE capture keywords, appending new keywords if provided - NMNE_CAPTURE_KEYWORDS += nmne_config.get("nmne_capture_keywords", []) - if not isinstance(NMNE_CAPTURE_KEYWORDS, list): - NMNE_CAPTURE_KEYWORDS = [] # Reset to empty list if the provided value is not a list + nmne_capture_keywords += nmne_config.get("nmne_capture_keywords", []) + if not isinstance(nmne_capture_keywords, list): + nmne_capture_keywords = [] # Reset to empty list if the provided value is not a list + + return nmne_data(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) From dbc1d73c34f31a3d01e38471ddaa88834a7dfe63 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 2 Jul 2024 11:15:31 +0100 Subject: [PATCH 002/206] #2676: Update naming of NMNE class --- src/primaite/game/game.py | 13 ++++++++++--- src/primaite/simulator/network/nmne.py | 11 +++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 8a79d068..cc559b4d 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -23,7 +23,7 @@ from primaite.simulator.network.hardware.nodes.network.firewall import Firewall from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.nmne import set_nmne_config +from primaite.simulator.network.nmne import store_nmne_config, NmneData from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient @@ -113,6 +113,9 @@ class PrimaiteGame: self._reward_calculation_order: List[str] = [name for name in self.agents] """Agent order for reward evaluation, as some rewards can be dependent on other agents' rewards.""" + self.nmne_config: NmneData = None + """ Config data from Number of Malicious Network Events.""" + def step(self): """ Perform one step of the simulation/agent loop. @@ -496,10 +499,11 @@ class PrimaiteGame: # Validate that if any agents are sharing rewards, they aren't forming an infinite loop. game.setup_reward_sharing() - # Set the NMNE capture config - set_nmne_config(network_config.get("nmne_config", {})) game.update_agents(game.get_sim_state()) + # Set the NMNE capture config + game.nmne_config = store_nmne_config(network_config.get("nmne_config", {})) + return game def setup_reward_sharing(self): @@ -539,3 +543,6 @@ class PrimaiteGame: # sort the agents so the rewards that depend on other rewards are always evaluated later self._reward_calculation_order = topological_sort(graph) + + def get_nmne_config(self) -> NmneData: + return self.nmne_config diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py index d6f1763f..947f27ac 100644 --- a/src/primaite/simulator/network/nmne.py +++ b/src/primaite/simulator/network/nmne.py @@ -4,7 +4,7 @@ from typing import Dict, List @dataclass -class nmne_data: +class NmneData: """Store all the information to perform NMNE operations.""" capture_nmne: bool = True @@ -23,10 +23,9 @@ class nmne_data: """Captures should be filtered and categorised based on specific keywords.""" -def set_nmne_config(nmne_config: Dict) -> nmne_data: +def store_nmne_config(nmne_config: Dict) -> NmneData: """ - Sets the configuration for capturing Malicious Network Events (MNEs) based on a provided - dictionary. + Store configuration for capturing Malicious Network Events (MNEs). This function updates global settings related to NMNE capture, including whether to capture NMNEs and what keywords to use for identifying NMNEs. @@ -41,7 +40,7 @@ def set_nmne_config(nmne_config: Dict) -> nmne_data: "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. :rvar dataclass with data read from config file. """ - nmne_capture_keywords = [] + nmne_capture_keywords: List[str] = [] # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect capture_nmne = nmne_config.get("capture_nmne", False) if not isinstance(capture_nmne, bool): @@ -52,4 +51,4 @@ def set_nmne_config(nmne_config: Dict) -> nmne_data: if not isinstance(nmne_capture_keywords, list): nmne_capture_keywords = [] # Reset to empty list if the provided value is not a list - return nmne_data(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) + return NmneData(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) From bd05f4d4e81b1c5038dc45fc74916de2e53f6fe4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 2 Jul 2024 15:02:59 +0100 Subject: [PATCH 003/206] #2711 - Initial commit of Terminal Service Skeleton framework. Added in a placeholder SSHPacket class. Currently, this allows the Terminal 'service' to be installed onto a HostNode class, and Port 22 - SSH to be visible when using .show(). Functionality and testing still to be completed --- .../system/services/terminal.rst | 26 +++ src/primaite/game/game.py | 2 + .../network/hardware/nodes/host/host_node.py | 2 + .../simulator/network/protocols/ssh.py | 71 +++++++ .../system/services/terminal/__init__.py | 1 + .../system/services/terminal/terminal.py | 190 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 docs/source/simulation_components/system/services/terminal.rst create mode 100644 src/primaite/simulator/network/protocols/ssh.py create mode 100644 src/primaite/simulator/system/services/terminal/__init__.py create mode 100644 src/primaite/simulator/system/services/terminal/terminal.py diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst new file mode 100644 index 00000000..bf8072e8 --- /dev/null +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -0,0 +1,26 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +.. _Terminal: + +Terminal +######## + +The ``Terminal`` provides a generic terminal simulation, by extending the base Service class + +Key capabilities +================ + + - Authenticates User connection by maintaining an active User account. + - Ensures packets are matched to an existing session + - Simulates common Terminal commands + - Leverages the Service base class for install/uninstall, status tracking etc. + + +Usage +===== + + - Install on a node via the ``SoftwareManager`` to start the Terminal + - Terminal Clients connect, execute commands and disconnect. + - Service runs on SSH port 22 by default. diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 8a79d068..908eecbb 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -38,6 +38,7 @@ from primaite.simulator.system.services.ftp.ftp_client import FTPClient from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.ntp.ntp_client import NTPClient from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.services.web_server.web_server import WebServer _LOGGER = getLogger(__name__) @@ -60,6 +61,7 @@ SERVICE_TYPES_MAPPING = { "FTPServer": FTPServer, "NTPClient": NTPClient, "NTPServer": NTPServer, + "Terminal": Terminal, } """List of available services that can be installed on nodes in the PrimAITE Simulation.""" diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index fdb28339..5848ade4 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -15,6 +15,7 @@ from primaite.simulator.system.services.arp.arp import ARP, ARPPacket from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.icmp.icmp import ICMP from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.utils.validators import IPV4Address _LOGGER = getLogger(__name__) @@ -306,6 +307,7 @@ class HostNode(Node): "NTPClient": NTPClient, "WebBrowser": WebBrowser, "NMAP": NMAP, + "Terminal": Terminal, } """List of system software that is automatically installed on nodes.""" diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py new file mode 100644 index 00000000..448f0fec --- /dev/null +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -0,0 +1,71 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +from enum import IntEnum +from typing import Dict, Optional + +from primaite.interface.request import RequestResponse +from primaite.simulator.network.protocols.packet import DataPacket + +# TODO: Elaborate / Confirm / Validate - See 2709. +# Placeholder implementation for Terminal Class implementation. + + +class SSHTransportMessage(IntEnum): + """ + Enum list of Transport layer messages that can be handled by the simulation. + + Each msg value is equivalent to the real-world. + """ + + SSH_MSG_USERAUTH_REQUEST = 50 + """Requests User Authentication.""" + + SSH_MSG_USERAUTH_FAILURE = 51 + """Indicates User Authentication failed.""" + + SSH_MSG_USERAUTH_SUCCESS = 52 + """Indicates User Authentication failed was successful.""" + + SSH_MSG_SERVICE_REQUEST = 24 + """Requests a service - such as executing a command.""" + + # These two msgs are invented for primAITE however are modelled on reality + + SSH_MSG_SERVICE_FAILED = 25 + """Indicates that the requested service failed.""" + + SSH_MSG_SERVICE_SUCCESS = 26 + """Indicates that the requested service was successful.""" + + +class SSHConnectionMessage(IntEnum): + """Int Enum list of all SSH's connection protocol messages that can be handled by the simulation.""" + + SSH_MSG_CHANNEL_OPEN = 80 + """Requests an open channel - Used in combination with SSH_MSG_USERAUTH_REQUEST.""" + + SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 81 + """Confirms an open channel.""" + + SSH_MSG_CHANNEL_OPEN_FAILED = 82 + """Indicates that channel opening failure.""" + + SSH_MSG_CHANNEL_DATA = 84 + """Indicates that data is being sent through the channel.""" + + SSH_MSG_CHANNEL_CLOSE = 87 + """Closes the channel.""" + + +class SSHPacket(DataPacket): + """Represents an SSHPacket.""" + + transport_message: SSHTransportMessage + + connection_message: SSHConnectionMessage + + ssh_command: Optional[any] = None # This is the request string + + ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse + + user_account: Optional[Dict] = None # The user account we will use to login if we do not have a current connection. diff --git a/src/primaite/simulator/system/services/terminal/__init__.py b/src/primaite/simulator/system/services/terminal/__init__.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/terminal/__init__.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py new file mode 100644 index 00000000..d86d21c6 --- /dev/null +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -0,0 +1,190 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Optional, Union +from uuid import uuid4 + +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestPermissionValidator +from primaite.simulator.network.protocols.icmp import ICMPPacket + +# from primaite.simulator.network.protocols.ssh import SSHPacket, SSHTransportMessage, SSHConnectionMessage +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service, ServiceOperatingState + + +class Terminal(Service): + """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" + + user_account: Optional[str] = None + "The User Account used for login" + + connected: bool = False + "Boolean Value for whether connected" + + connection_uuid: Optional[str] = None + "Uuid for connection requests" + + def __init__(self, **kwargs): + kwargs["name"] = "Terminal" + kwargs["port"] = Port.SSH + kwargs["protocol"] = IPProtocol.TCP + + super().__init__(**kwargs) + self.operating_state = ServiceOperatingState.RUNNING + + class _LoginValidator(RequestPermissionValidator): + """ + When requests come in, this validator will only let them through if we have valid login credentials. + + This should ensure that no actions are resolved without valid user credentials. + """ + + terminal: Terminal + + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Return whether the login credentials are valid.""" + pass + + @property + def fail_message(self) -> str: + """Message that is reported when a request is rejected by this validator.""" + return ( + f"Cannot perform request on Terminal '{self.terminal.hostname}' because login credentials are invalid" + ) + + def _validate_login(self) -> bool: + """Validate login credentials when receiving commands.""" + # TODO: Implement + return True + + def receive_payload_from_software_manager( + self, + payload: Any, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + session_id: Optional[str] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + icmp_packet: Optional[ICMPPacket] = None, + connection_id: Optional[str] = None, + ) -> Union[Any, None]: + """Receive Software Manager Payload.""" + self._validate_login() + + def _init_request_manager(self) -> RequestManager: + """Initialise Request manager.""" + # _login_is_valid = Terminal._LoginValidator(terminal=self) + rm = super()._init_request_manager() + + return rm + + def send( + self, + payload: Any, + dest_ip_address: Optional[IPv4Address] = None, + session_id: Optional[str] = None, + ) -> bool: + """Send Request to Software Manager.""" + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + # TBD + state.update({"hostname": self.hostname}) + return state + + def execute(self, command: Any, request: Any) -> Optional[RequestResponse]: + """Execute Command.""" + # Returning the request to the request manager. + if self._validate_login(): + return self.apply_request(request) + else: + self.sys_log.error("Invalid login credentials provided.") + return None + + def apply_request(self, request: List[str | int | float | Dict], context: Dict | None = None) -> RequestResponse: + """Apply Temrinal Request.""" + return super().apply_request(request, context) + + def login(self, dest_ip_address: IPv4Address) -> bool: + """ + Perform an initial login request. + + If this fails, raises an error. + """ + # TODO: This will need elaborating when user accounts are implemented + self.sys_log.info("Attempting Login") + self._ssh_process_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) + + def _generate_connection_id(self) -> str: + """Generate a unique connection ID.""" + return str(uuid4()) + + # %% + + # def _ssh_process_login(self, user_account: dict, **kwargs) -> SSHPacket: + # """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" + # # we assume that the login fails unless we meet all the criteria. + # transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE + # connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED + # # operating state validation here(if overwhelmed) + + # # Hard coded at current - replace with another method to handle local accounts. + # if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded + # connection_id = self._generate_connection_id() + # if not self.add_connection(self, connection_id="ssh_connection", session_id=self.session_id): + # self.sys_log.warning(f"{self.name}: Connect request for {self.src_ip} declined. + # Service is at capacity.") + # ... + # else: + # self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + # transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + # connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + + # payload: SSHPacket = SSHPacket(transport_message = transport_message, connection_message = connection_message) + # return payload + + # %% + # Copy + Paste from Terminal Wiki + + # def ssh_remote_login(self, dest_ip_address = IPv4Address, user_account: Optional[dict] = None) -> bool: + # if user_account: + # # Setting default creds (Best to use this until we have more clarification on the specifics of user accounts) + # self.user_account = {self.user_name:"placeholder", self.password:"placeholder"} + + # # Implement SSHPacket class + # payload: SSHPacket = SSHPacket(transport_message= SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, + # connection_message= SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + # user_account=user_account) + # if self.send(payload=payload,dest_ip_address=dest_ip_address): + # if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + # self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") + # # Need to confirm if self.uuid is correct. + # self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) + # return True + # else: + # self.sys_log.error("Payload type incorrect, Login Failed") + # return False + # else: + # self.sys_log.error("Incorrect credentials provided. Login Failed.") + # return False + # %% + + def connect(self, **kwargs): + """Send connect request.""" + self._connect(self, **kwargs) + + def _connect(self): + """Do something.""" + pass From ebf6e7a90ebf8b713ac0ae3dc958896a80ed0bea Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 2 Jul 2024 16:47:39 +0100 Subject: [PATCH 004/206] #2711 - Added in remote_login and process_login methods. Minor updates to make pydantic happy. Starting to flesh out functionality of Terminal Service in more detail --- .../simulator/network/protocols/ssh.py | 6 +- .../system/services/terminal/terminal.py | 139 +++++++++--------- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 448f0fec..7be81982 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -60,11 +60,11 @@ class SSHConnectionMessage(IntEnum): class SSHPacket(DataPacket): """Represents an SSHPacket.""" - transport_message: SSHTransportMessage + transport_message: SSHTransportMessage = None - connection_message: SSHConnectionMessage + connection_message: SSHConnectionMessage = None - ssh_command: Optional[any] = None # This is the request string + ssh_command: Optional[str] = None # This is the request string ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index d86d21c6..e1964f78 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -8,8 +8,7 @@ from uuid import uuid4 from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestPermissionValidator from primaite.simulator.network.protocols.icmp import ICMPPacket - -# from primaite.simulator.network.protocols.ssh import SSHPacket, SSHTransportMessage, SSHConnectionMessage +from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.services.service import Service, ServiceOperatingState @@ -21,19 +20,22 @@ class Terminal(Service): user_account: Optional[str] = None "The User Account used for login" - connected: bool = False + is_connected: bool = False "Boolean Value for whether connected" connection_uuid: Optional[str] = None "Uuid for connection requests" + operating_state: ServiceOperatingState = ServiceOperatingState.INSTALLING + """Service Operating State""" # Install at start ??? Maybe ??? + def __init__(self, **kwargs): kwargs["name"] = "Terminal" kwargs["port"] = Port.SSH kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - self.operating_state = ServiceOperatingState.RUNNING + # self.operating_state = ServiceOperatingState.RUNNING class _LoginValidator(RequestPermissionValidator): """ @@ -46,34 +48,22 @@ class Terminal(Service): def __call__(self, request: RequestFormat, context: Dict) -> bool: """Return whether the login credentials are valid.""" - pass + # TODO: Expand & Implement logic when we have User Accounts. + if self.terminal.is_connected: + return True + else: + self.terminal.sys_log.error("terminal is not logged in.") @property def fail_message(self) -> str: """Message that is reported when a request is rejected by this validator.""" - return ( - f"Cannot perform request on Terminal '{self.terminal.hostname}' because login credentials are invalid" - ) + return f"Cannot perform request on Terminal '{self.terminal.name}' because login credentials are invalid" def _validate_login(self) -> bool: """Validate login credentials when receiving commands.""" # TODO: Implement return True - def receive_payload_from_software_manager( - self, - payload: Any, - dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, - src_port: Optional[Port] = None, - dst_port: Optional[Port] = None, - session_id: Optional[str] = None, - ip_protocol: IPProtocol = IPProtocol.TCP, - icmp_packet: Optional[ICMPPacket] = None, - connection_id: Optional[str] = None, - ) -> Union[Any, None]: - """Receive Software Manager Payload.""" - self._validate_login() - def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" # _login_is_valid = Terminal._LoginValidator(terminal=self) @@ -101,7 +91,7 @@ class Terminal(Service): """ state = super().describe_state() # TBD - state.update({"hostname": self.hostname}) + state.update({"hostname": self.name}) return state def execute(self, command: Any, request: Any) -> Optional[RequestResponse]: @@ -133,58 +123,69 @@ class Terminal(Service): # %% - # def _ssh_process_login(self, user_account: dict, **kwargs) -> SSHPacket: - # """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" - # # we assume that the login fails unless we meet all the criteria. - # transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE - # connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED - # # operating state validation here(if overwhelmed) + def _ssh_process_login(self, user_account: dict, **kwargs) -> SSHPacket: + """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" + # we assume that the login fails unless we meet all the criteria. + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED + # operating state validation here(if overwhelmed) - # # Hard coded at current - replace with another method to handle local accounts. - # if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded - # connection_id = self._generate_connection_id() - # if not self.add_connection(self, connection_id="ssh_connection", session_id=self.session_id): - # self.sys_log.warning(f"{self.name}: Connect request for {self.src_ip} declined. - # Service is at capacity.") - # ... - # else: - # self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") - # transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS - # connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + # Hard coded at current - replace with another method to handle local accounts. + if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded + connection_id = self._generate_connection_id() + if not self.add_connection(self, connection_id="ssh_connection", session_id=self.session_id): + self.sys_log.warning( + f"{self.name}: Connect request for {self.src_ip} declined. Service is at capacity." + ) + else: + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + self.is_connected = True - # payload: SSHPacket = SSHPacket(transport_message = transport_message, connection_message = connection_message) - # return payload + payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) + return payload # %% # Copy + Paste from Terminal Wiki - # def ssh_remote_login(self, dest_ip_address = IPv4Address, user_account: Optional[dict] = None) -> bool: - # if user_account: - # # Setting default creds (Best to use this until we have more clarification on the specifics of user accounts) - # self.user_account = {self.user_name:"placeholder", self.password:"placeholder"} + def ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: + """Remote login to terminal via SSH.""" + if user_account: + # Setting default creds (Best to use this until we have more clarification around user accounts) + self.user_account = {self.user_name: "placeholder", self.password: "placeholder"} + + # Implement SSHPacket class + payload: SSHPacket = SSHPacket( + transport_message=SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + user_account=user_account, + ) + if self.send(payload=payload, dest_ip_address=dest_ip_address): + if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") + # Need to confirm if self.uuid is correct. + self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) + return True + else: + self.sys_log.error("Payload type incorrect, Login Failed") + return False + else: + self.sys_log.error("Incorrect credentials provided. Login Failed.") + return False - # # Implement SSHPacket class - # payload: SSHPacket = SSHPacket(transport_message= SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, - # connection_message= SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, - # user_account=user_account) - # if self.send(payload=payload,dest_ip_address=dest_ip_address): - # if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: - # self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") - # # Need to confirm if self.uuid is correct. - # self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) - # return True - # else: - # self.sys_log.error("Payload type incorrect, Login Failed") - # return False - # else: - # self.sys_log.error("Incorrect credentials provided. Login Failed.") - # return False # %% - def connect(self, **kwargs): - """Send connect request.""" - self._connect(self, **kwargs) - - def _connect(self): - """Do something.""" - pass + def receive_payload_from_software_manager( + self, + payload: Any, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + session_id: Optional[str] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + icmp_packet: Optional[ICMPPacket] = None, + connection_id: Optional[str] = None, + ) -> Union[Any, None]: + """Receive Software Manager Payload.""" + self._validate_login() From 47df2aa56940c26047c4e2b6672867e9016c8b1f Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 4 Jul 2024 15:41:13 +0100 Subject: [PATCH 005/206] #2676: Store NMNE config data in class variable. --- src/primaite/game/game.py | 13 ++---- .../simulator/network/hardware/base.py | 42 +++++++------------ 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index cc559b4d..9636bd23 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -15,7 +15,7 @@ from primaite.game.agent.scripted_agents.probabilistic_agent import Probabilisti from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort -from primaite.simulator.network.hardware.base import NodeOperatingState +from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Printer, Server @@ -23,7 +23,7 @@ from primaite.simulator.network.hardware.nodes.network.firewall import Firewall from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.nmne import store_nmne_config, NmneData +from primaite.simulator.network.nmne import NmneData, store_nmne_config from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient @@ -239,6 +239,8 @@ class PrimaiteGame: nodes_cfg = network_config.get("nodes", []) links_cfg = network_config.get("links", []) + # Set the NMNE capture config + NetworkInterface.nmne_config = store_nmne_config(network_config.get("nmne_config", {})) for node_cfg in nodes_cfg: n_type = node_cfg["type"] @@ -500,10 +502,6 @@ class PrimaiteGame: game.setup_reward_sharing() game.update_agents(game.get_sim_state()) - - # Set the NMNE capture config - game.nmne_config = store_nmne_config(network_config.get("nmne_config", {})) - return game def setup_reward_sharing(self): @@ -543,6 +541,3 @@ class PrimaiteGame: # sort the agents so the rewards that depend on other rewards are always evaluated later self._reward_calculation_order = topological_sort(graph) - - def get_nmne_config(self) -> NmneData: - return self.nmne_config diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 01745215..6d753731 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,12 +6,11 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, Dict, Optional, Type, TypeVar, Union +from typing import Any, ClassVar, Dict, Optional, Type, TypeVar, Union from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel, Field -import primaite.simulator.network.nmne from primaite import getLogger from primaite.exceptions import NetworkError from primaite.interface.request import RequestResponse @@ -20,15 +19,7 @@ from primaite.simulator.core import RequestFormat, RequestManager, RequestPermis from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState -from primaite.simulator.network.nmne import ( - CAPTURE_BY_DIRECTION, - CAPTURE_BY_IP_ADDRESS, - CAPTURE_BY_KEYWORD, - CAPTURE_BY_PORT, - CAPTURE_BY_PROTOCOL, - CAPTURE_NMNE, - NMNE_CAPTURE_KEYWORDS, -) +from primaite.simulator.network.nmne import NmneData from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.system.applications.application import Application @@ -108,8 +99,8 @@ class NetworkInterface(SimComponent, ABC): pcap: Optional[PacketCapture] = None "A PacketCapture instance for capturing and analysing packets passing through this interface." - nmne: Dict = Field(default_factory=lambda: {}) - "A dict containing details of the number of malicious network events captured." + nmne_config: ClassVar[NmneData] = None + "A dataclass defining malicious network events to be captured." traffic: Dict = Field(default_factory=lambda: {}) "A dict containing details of the inbound and outbound traffic by port and protocol." @@ -117,7 +108,6 @@ class NetworkInterface(SimComponent, ABC): def setup_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" super().setup_for_episode(episode=episode) - self.nmne = {} self.traffic = {} if episode and self.pcap and SIM_OUTPUT.save_pcap_logs: self.pcap.current_episode = episode @@ -152,8 +142,8 @@ class NetworkInterface(SimComponent, ABC): "enabled": self.enabled, } ) - if CAPTURE_NMNE: - state.update({"nmne": {k: v for k, v in self.nmne.items()}}) + if self.nmne_config and self.nmne_config.capture_nmne: + state.update({"nmne": {self.nmne_config.__dict__}}) state.update({"traffic": convert_dict_enum_keys_to_enum_values(self.traffic)}) return state @@ -186,7 +176,7 @@ class NetworkInterface(SimComponent, ABC): :param inbound: Boolean indicating if the frame direction is inbound. Defaults to True. """ # Exit function if NMNE capturing is disabled - if not CAPTURE_NMNE: + if not (self.nmne_config and self.nmne_config.capture_nmne): return # Initialise basic frame data variables @@ -207,27 +197,27 @@ class NetworkInterface(SimComponent, ABC): frame_str = str(frame.payload) # Proceed only if any NMNE keyword is present in the frame payload - if any(keyword in frame_str for keyword in NMNE_CAPTURE_KEYWORDS): + if any(keyword in frame_str for keyword in self.nmne_config.nmne_capture_keywords): # Start with the root of the NMNE capture structure - current_level = self.nmne + current_level = self.nmne_config # Update NMNE structure based on enabled settings - if CAPTURE_BY_DIRECTION: + if self.nmne_config.capture_by_direction: # Set or get the dictionary for the current direction current_level = current_level.setdefault("direction", {}) current_level = current_level.setdefault(direction, {}) - if CAPTURE_BY_IP_ADDRESS: + if self.nmne_config.capture_by_ip_address: # Set or get the dictionary for the current IP address current_level = current_level.setdefault("ip_address", {}) current_level = current_level.setdefault(ip_address, {}) - if CAPTURE_BY_PROTOCOL: + if self.nmne_config.capture_by_protocol: # Set or get the dictionary for the current protocol current_level = current_level.setdefault("protocol", {}) current_level = current_level.setdefault(protocol, {}) - if CAPTURE_BY_PORT: + if self.nmne_config.capture_by_port: # Set or get the dictionary for the current port current_level = current_level.setdefault("port", {}) current_level = current_level.setdefault(port, {}) @@ -236,8 +226,8 @@ class NetworkInterface(SimComponent, ABC): keyword_level = current_level.setdefault("keywords", {}) # Increment the count for detected keywords in the payload - if CAPTURE_BY_KEYWORD: - for keyword in NMNE_CAPTURE_KEYWORDS: + if self.nmne_config.capture_by_keyword: + for keyword in self.nmne_config.nmne_capture_keywords: if keyword in frame_str: # Update the count for each keyword found keyword_level[keyword] = keyword_level.get(keyword, 0) + 1 @@ -1067,7 +1057,7 @@ class Node(SimComponent): ip_address, network_interface.speed, "Enabled" if network_interface.enabled else "Disabled", - network_interface.nmne if primaite.simulator.network.nmne.CAPTURE_NMNE else "Disabled", + network_interface.nmne if self.nmne_config.capture_nmne else "Disabled", ] ) print(table) From 3867ec40c9c571d719ff6fe818c505721a99cbc0 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 4 Jul 2024 17:05:00 +0100 Subject: [PATCH 006/206] #2676: Fix nmne_config dict conversion --- src/primaite/simulator/network/hardware/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 6d753731..3c52a65d 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1,6 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from __future__ import annotations +from dataclasses import asdict import re import secrets from abc import ABC, abstractmethod @@ -143,7 +144,7 @@ class NetworkInterface(SimComponent, ABC): } ) if self.nmne_config and self.nmne_config.capture_nmne: - state.update({"nmne": {self.nmne_config.__dict__}}) + state.update({"nmne": asdict(self.nmne_config)}) state.update({"traffic": convert_dict_enum_keys_to_enum_values(self.traffic)}) return state From 589ea2fed4e96e14365a4f92c504ad7fbf21b6c2 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 5 Jul 2024 12:19:52 +0100 Subject: [PATCH 007/206] #2676: Add local nmne dict --- src/primaite/simulator/network/hardware/base.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 3c52a65d..e611f9b2 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -103,12 +103,16 @@ class NetworkInterface(SimComponent, ABC): nmne_config: ClassVar[NmneData] = None "A dataclass defining malicious network events to be captured." + nmne: Dict = Field(default_factory=lambda: {}) + "A dict containing details of the number of malicious events captured." + traffic: Dict = Field(default_factory=lambda: {}) "A dict containing details of the inbound and outbound traffic by port and protocol." def setup_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" super().setup_for_episode(episode=episode) + self.nmne = {} self.traffic = {} if episode and self.pcap and SIM_OUTPUT.save_pcap_logs: self.pcap.current_episode = episode @@ -144,7 +148,7 @@ class NetworkInterface(SimComponent, ABC): } ) if self.nmne_config and self.nmne_config.capture_nmne: - state.update({"nmne": asdict(self.nmne_config)}) + state.update({"nmne": self.nmne}) state.update({"traffic": convert_dict_enum_keys_to_enum_values(self.traffic)}) return state @@ -200,7 +204,7 @@ class NetworkInterface(SimComponent, ABC): # Proceed only if any NMNE keyword is present in the frame payload if any(keyword in frame_str for keyword in self.nmne_config.nmne_capture_keywords): # Start with the root of the NMNE capture structure - current_level = self.nmne_config + current_level = self.nmne # Update NMNE structure based on enabled settings if self.nmne_config.capture_by_direction: From 18ae3acf3734f36a86cf46045ef5f0ed5c51e02c Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 5 Jul 2024 14:09:39 +0100 Subject: [PATCH 008/206] #2676: Update nmne tests --- src/primaite/simulator/network/hardware/base.py | 1 - .../network/test_capture_nmne.py | 16 +++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index e611f9b2..f161b2b5 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1,7 +1,6 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from __future__ import annotations -from dataclasses import asdict import re import secrets from abc import ABC, abstractmethod diff --git a/tests/integration_tests/network/test_capture_nmne.py b/tests/integration_tests/network/test_capture_nmne.py index a8f1f245..f6e4c685 100644 --- a/tests/integration_tests/network/test_capture_nmne.py +++ b/tests/integration_tests/network/test_capture_nmne.py @@ -1,12 +1,14 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from primaite.game.agent.observations.nic_observations import NICObservation +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server -from primaite.simulator.network.nmne import set_nmne_config +from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection -def test_capture_nmne(uc2_network): +def test_capture_nmne(uc2_network: Network): """ Conducts a test to verify that Malicious Network Events (MNEs) are correctly captured. @@ -33,7 +35,7 @@ def test_capture_nmne(uc2_network): } # Apply the NMNE configuration settings - set_nmne_config(nmne_config) + NIC.nmne_config = store_nmne_config(nmne_config) # Assert that initially, there are no captured MNEs on both web and database servers assert web_server_nic.nmne == {} @@ -82,7 +84,7 @@ def test_capture_nmne(uc2_network): assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 3}}}} -def test_describe_state_nmne(uc2_network): +def test_describe_state_nmne(uc2_network: Network): """ Conducts a test to verify that Malicious Network Events (MNEs) are correctly represented in the nic state. @@ -110,7 +112,7 @@ def test_describe_state_nmne(uc2_network): } # Apply the NMNE configuration settings - set_nmne_config(nmne_config) + NIC.nmne_config = store_nmne_config(nmne_config) # Assert that initially, there are no captured MNEs on both web and database servers web_server_nic_state = web_server_nic.describe_state() @@ -190,7 +192,7 @@ def test_describe_state_nmne(uc2_network): assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 4}}}} -def test_capture_nmne_observations(uc2_network): +def test_capture_nmne_observations(uc2_network: Network): """ Tests the NICObservation class's functionality within a simulated network environment. @@ -219,7 +221,7 @@ def test_capture_nmne_observations(uc2_network): } # Apply the NMNE configuration settings - set_nmne_config(nmne_config) + NIC.nmne_config = store_nmne_config(nmne_config) # Define observations for the NICs of the database and web servers db_server_nic_obs = NICObservation(where=["network", "nodes", "database_server", "NICs", 1], include_nmne=True) From 219d448adc0f7a0be2bbaa5c246af46a25cb66b4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 8 Jul 2024 07:58:10 +0100 Subject: [PATCH 009/206] #2711 - Rewrite of the majority of the terminal class after not liking how I originally did it. This takes a heavier inspiration for handling connections from the database_client/server --- .../system/services/terminal.rst | 30 +++ .../system/services/terminal/terminal.py | 254 ++++++++++-------- 2 files changed, 171 insertions(+), 113 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index bf8072e8..afa79c0a 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -24,3 +24,33 @@ Usage - Install on a node via the ``SoftwareManager`` to start the Terminal - Terminal Clients connect, execute commands and disconnect. - Service runs on SSH port 22 by default. + +Implementation +============== + +- Manages SSH commands +- Ensures User login before sending commands +- Processes SSH commands +- Returns results in a ** format. + + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + + client = Computer( + hostname="client", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + operating_state=NodeOperatingState.ON, + ) + + terminal: Terminal = client.software_manager.software.get("Terminal") diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index e1964f78..5f8719ac 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -1,19 +1,57 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from __future__ import annotations -from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, List, Optional, Union +from ipaddress import IPv4Address +from typing import Dict, List, Optional from uuid import uuid4 -from primaite.interface.request import RequestFormat, RequestResponse -from primaite.simulator.core import RequestManager, RequestPermissionValidator -from primaite.simulator.network.protocols.icmp import ICMPPacket +from pydantic import BaseModel + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState +class TerminalClientConnection(BaseModel): + """ + TerminalClientConnection Class. + + This class is used to record current User Connections within the Terminal class. + """ + + connection_id: str + """Connection UUID.""" + + parent_node: HostNode + """The parent Node that this connection was created on.""" + + is_active: bool = True + """Flag to state whether the connection is still active or not.""" + + _dest_ip_address: IPv4Address + """Destination IP address of connection""" + + @property + def dest_ip_address(self) -> Optional[IPv4Address]: + """Destination IP Address.""" + return self._dest_ip_address + + @property + def client(self) -> Optional[Terminal]: + """The Terminal that holds this connection.""" + return self.parent_node.software_manager.software.get("Terminal") + + def disconnect(self): + """Disconnect the connection.""" + if self.client and self.is_active: + self.client._disconnect(self.connection_id) # noqa + + class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" @@ -26,59 +64,17 @@ class Terminal(Service): connection_uuid: Optional[str] = None "Uuid for connection requests" - operating_state: ServiceOperatingState = ServiceOperatingState.INSTALLING - """Service Operating State""" # Install at start ??? Maybe ??? + operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING + """Initial Operating State""" + + user_connections: Dict[str, TerminalClientConnection] = {} + """List of authenticated connected users""" def __init__(self, **kwargs): kwargs["name"] = "Terminal" kwargs["port"] = Port.SSH kwargs["protocol"] = IPProtocol.TCP - super().__init__(**kwargs) - # self.operating_state = ServiceOperatingState.RUNNING - - class _LoginValidator(RequestPermissionValidator): - """ - When requests come in, this validator will only let them through if we have valid login credentials. - - This should ensure that no actions are resolved without valid user credentials. - """ - - terminal: Terminal - - def __call__(self, request: RequestFormat, context: Dict) -> bool: - """Return whether the login credentials are valid.""" - # TODO: Expand & Implement logic when we have User Accounts. - if self.terminal.is_connected: - return True - else: - self.terminal.sys_log.error("terminal is not logged in.") - - @property - def fail_message(self) -> str: - """Message that is reported when a request is rejected by this validator.""" - return f"Cannot perform request on Terminal '{self.terminal.name}' because login credentials are invalid" - - def _validate_login(self) -> bool: - """Validate login credentials when receiving commands.""" - # TODO: Implement - return True - - def _init_request_manager(self) -> RequestManager: - """Initialise Request manager.""" - # _login_is_valid = Terminal._LoginValidator(terminal=self) - rm = super()._init_request_manager() - - return rm - - def send( - self, - payload: Any, - dest_ip_address: Optional[IPv4Address] = None, - session_id: Optional[str] = None, - ) -> bool: - """Send Request to Software Manager.""" - return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) def describe_state(self) -> Dict: """ @@ -90,23 +86,64 @@ class Terminal(Service): :rtype: Dict """ state = super().describe_state() - # TBD + state.update({"hostname": self.name}) return state - def execute(self, command: Any, request: Any) -> Optional[RequestResponse]: - """Execute Command.""" - # Returning the request to the request manager. - if self._validate_login(): - return self.apply_request(request) - else: - self.sys_log.error("Invalid login credentials provided.") - return None - def apply_request(self, request: List[str | int | float | Dict], context: Dict | None = None) -> RequestResponse: """Apply Temrinal Request.""" return super().apply_request(request, context) + def _init_request_manager(self) -> RequestManager: + """Initialise Request manager.""" + # TODO: Expand with a login validator? + rm = super()._init_request_manager() + return rm + + # %% Inbound + + def _generate_connection_id(self) -> str: + """Generate a unique connection ID.""" + return str(uuid4()) + + def process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + """Process User request to login to Terminal.""" + if user_account in self.user_connections: + self.sys_log.debug("User authentication passed") + return True + else: + self._ssh_process_login(dest_ip_address=dest_ip_address, user_account=user_account) + self.process_login(dest_ip_address=dest_ip_address, user_account=user_account) + + def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" + # we assume that the login fails unless we meet all the criteria. + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED + + # Hard coded at current - replace with another method to handle local accounts. + if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded + connection_id = self._generate_connection_id() + if not self.add_connection(self, connection_id=connection_id): + self.sys_log.warning( + f"{self.name}: Connect request for {dest_ip_address} declined. Service is at capacity." + ) + return False + else: + self.sys_log.info(f"{self.name}: Connect request for ID: {connection_id} authorised") + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + new_connection = TerminalClientConnection(connection_id=connection_id, dest_ip_address=dest_ip_address) + self.user_connections[connection_id] = new_connection + self.is_connected = True + + payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) + + self.send(payload=payload, dest_ip_address=dest_ip_address) + return True + + # %% Outbound + def login(self, dest_ip_address: IPv4Address) -> bool: """ Perform an initial login request. @@ -115,45 +152,13 @@ class Terminal(Service): """ # TODO: This will need elaborating when user accounts are implemented self.sys_log.info("Attempting Login") - self._ssh_process_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) - - def _generate_connection_id(self) -> str: - """Generate a unique connection ID.""" - return str(uuid4()) - - # %% - - def _ssh_process_login(self, user_account: dict, **kwargs) -> SSHPacket: - """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" - # we assume that the login fails unless we meet all the criteria. - transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED - # operating state validation here(if overwhelmed) - - # Hard coded at current - replace with another method to handle local accounts. - if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded - connection_id = self._generate_connection_id() - if not self.add_connection(self, connection_id="ssh_connection", session_id=self.session_id): - self.sys_log.warning( - f"{self.name}: Connect request for {self.src_ip} declined. Service is at capacity." - ) - else: - self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") - transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION - self.is_connected = True - - payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) - return payload - - # %% - # Copy + Paste from Terminal Wiki + return self.ssh_remote_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) def ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: """Remote login to terminal via SSH.""" - if user_account: + if not user_account: # Setting default creds (Best to use this until we have more clarification around user accounts) - self.user_account = {self.user_name: "placeholder", self.password: "placeholder"} + user_account = {self.user_name: "placeholder", self.password: "placeholder"} # Implement SSHPacket class payload: SSHPacket = SSHPacket( @@ -161,6 +166,7 @@ class Terminal(Service): connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, user_account=user_account, ) + # self.send will return bool, payload unchanged? if self.send(payload=payload, dest_ip_address=dest_ip_address): if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") @@ -168,24 +174,46 @@ class Terminal(Service): self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) return True else: - self.sys_log.error("Payload type incorrect, Login Failed") + self.sys_log.error("Login Failed. Incorrect credentials provided.") return False else: - self.sys_log.error("Incorrect credentials provided. Login Failed.") + self.sys_log.error("Login Failed. Incorrect credentials provided.") return False - # %% + def check_connection(self, connection_id: str) -> bool: + """Check whether the connection is valid.""" + if self.is_connected: + return self.send(dest_ip_address=self.dest_ip_address, connection_id=connection_id) + else: + return False - def receive_payload_from_software_manager( - self, - payload: Any, - dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, - src_port: Optional[Port] = None, - dst_port: Optional[Port] = None, - session_id: Optional[str] = None, - ip_protocol: IPProtocol = IPProtocol.TCP, - icmp_packet: Optional[ICMPPacket] = None, - connection_id: Optional[str] = None, - ) -> Union[Any, None]: - """Receive Software Manager Payload.""" - self._validate_login() + def disconnect(self, connection_id: str): + """Disconnect from remote.""" + self._disconnect(connection_id) + self.is_connected = False + + def _disconnect(self, connection_id: str) -> bool: + if not self.is_connected: + return False + + if len(self.user_connections) == 0: + self.sys_log.warning(f"{self.name}: Unable to disconnect, no active connections.") + return False + if not self.user_connections.get(connection_id): + return False + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_id}, + dest_ip_address=self.server_ip_address, + dest_port=self.port, + ) + connection = self.user_connections.pop(connection_id) + self.terminate_connection(connection_id=connection_id) + + connection.is_active = False + + self.sys_log.info( + f"{self.name}: Disconnected {connection_id} from: {self.user_connections[connection_id]._dest_ip_address}" + ) + self.connected = False + return True From 252214b4689444fb6f54f53a9ae9199158b1c5e4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 8 Jul 2024 08:25:42 +0100 Subject: [PATCH 010/206] #2711 Updating Changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bf3557..1f2db4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made observation space flattening optional (on by default). To turn off for an agent, change the `agent_settings.flatten_obs` setting in the config. - Added support for SQL INSERT command. - Added ability to log each agent's action choices in each step to a JSON file. -- Removal of Link bandwidth hardcoding. This can now be configured via the network configuraiton yaml. Will default to 100 if not present. +- Removal of Link bandwidth hardcoding. This can now be configured via the network configuration yaml. Will default to 100 if not present. - Added NMAP application to all host and layer-3 network nodes. +- Added Terminal Class for HostNode components ### Bug Fixes From 47a1daa5806bb611ff3bb6ee12368e6dd8d53a52 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 8 Jul 2024 15:10:06 +0100 Subject: [PATCH 011/206] #2735 - Initial work done around User, UserManager, and UserSessionManager --- .../simulator/network/hardware/base.py | 22 ++- .../network/hardware/nodes/host/host_node.py | 9 +- .../system/services/access/__init__.py | 1 + .../system/services/access/user_manager.py | 186 ++++++++++++++++++ .../services/access/user_session_manager.py | 98 +++++++++ .../simulator/system/services/service.py | 2 +- src/primaite/simulator/system/software.py | 2 +- 7 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 src/primaite/simulator/system/services/access/__init__.py create mode 100644 src/primaite/simulator/system/services/access/user_manager.py create mode 100644 src/primaite/simulator/system/services/access/user_session_manager.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 6942d280..ada9c57a 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,7 +6,7 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, Dict, Optional, TypeVar, Union +from typing import Any, ClassVar, Dict, Optional, TypeVar, Union from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel, Field @@ -37,6 +37,8 @@ from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.processes.process import Process +from primaite.simulator.system.services.access.user_manager import UserManager +from primaite.simulator.system.services.access.user_session_manager import UserSessionManager from primaite.simulator.system.services.service import Service from primaite.simulator.system.software import IOSoftware from primaite.utils.converters import convert_dict_enum_keys_to_enum_values @@ -821,7 +823,16 @@ class Node(SimComponent): super().__init__(**kwargs) self.session_manager.node = self self.session_manager.software_manager = self.software_manager - self._install_system_software() + self.software_manager.install(UserSessionManager) + self.software_manager.install(UserManager) + + # @property + # def user_manager(self) -> UserManager: + # return self.software_manager.software["UserManager"] # noqa + # + # @property + # def _user_session_manager(self) -> UserSessionManager: + # return self.software_manager.software["UserSessionManager"] # noqa def ip_is_network_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: """ @@ -876,7 +887,7 @@ class Node(SimComponent): @property def fail_message(self) -> str: """Message that is reported when a request is rejected by this validator.""" - return f"Cannot perform request on node '{self.node.hostname}' because it is not turned on." + return f"Cannot perform request on node '{self.node.hostname}' because it is not powered on." def _init_request_manager(self) -> RequestManager: """ @@ -1000,10 +1011,6 @@ class Node(SimComponent): return rm - def _install_system_software(self): - """Install System Software - software that is usually provided with the OS.""" - pass - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -1184,6 +1191,7 @@ class Node(SimComponent): def pre_timestep(self, timestep: int) -> None: """Apply pre-timestep logic.""" super().pre_timestep(timestep) + self._ for network_interface in self.network_interfaces.values(): network_interface.pre_timestep(timestep=timestep) diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index fdb28339..80f80a04 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -11,6 +11,8 @@ from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.nmap import NMAP from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.access.user_manager import UserManager +from primaite.simulator.system.services.access.user_session_manager import UserSessionManager from primaite.simulator.system.services.arp.arp import ARP, ARPPacket from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.icmp.icmp import ICMP @@ -306,6 +308,8 @@ class HostNode(Node): "NTPClient": NTPClient, "WebBrowser": WebBrowser, "NMAP": NMAP, + # "UserSessionManager": UserSessionManager, + # "UserManager": UserManager, } """List of system software that is automatically installed on nodes.""" @@ -314,9 +318,10 @@ class HostNode(Node): network_interface: Dict[int, NIC] = {} "The NICs on the node by port id." - def __init__(self, ip_address: IPV4Address, subnet_mask: IPV4Address, **kwargs): + def __init__(self, ip_address: IPV4Address, subnet_mask: IPV4Address, username: str, password: str, **kwargs): super().__init__(**kwargs) self.connect_nic(NIC(ip_address=ip_address, subnet_mask=subnet_mask)) + self.user_manager.add_user(username=username, password=password, is_admin=True, bypass_can_perform_action=True) @property def nmap(self) -> Optional[NMAP]: @@ -348,8 +353,6 @@ class HostNode(Node): for _, software_class in self.SYSTEM_SOFTWARE.items(): self.software_manager.install(software_class) - super()._install_system_software() - def default_gateway_hello(self): """ Sends a hello message to the default gateway to establish connectivity and resolve the gateway's MAC address. diff --git a/src/primaite/simulator/system/services/access/__init__.py b/src/primaite/simulator/system/services/access/__init__.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/access/__init__.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/services/access/user_manager.py b/src/primaite/simulator/system/services/access/user_manager.py new file mode 100644 index 00000000..09f8950e --- /dev/null +++ b/src/primaite/simulator/system/services/access/user_manager.py @@ -0,0 +1,186 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Dict, Optional + +from prettytable import MARKDOWN, PrettyTable +from pydantic import Field + +from primaite.simulator.core import SimComponent +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service + + +class User(SimComponent): + """ + Represents a user in the PrimAITE system. + + :param username: The username of the user + :param password: The password of the user + :param disabled: Boolean flag indicating whether the user is disabled + :param is_admin: Boolean flag indicating whether the user has admin privileges + """ + + username: str + password: str + disabled: bool = False + is_admin: bool = False + + def describe_state(self) -> Dict: + """ + Returns a dictionary representing the current state of the user. + + :return: A dict containing the state of the user + """ + return self.model_dump() + + +class UserManager(Service): + """ + Manages users within the PrimAITE system, handling creation, authentication, and administration. + + :param users: A dictionary of all users by their usernames + :param admins: A dictionary of admin users by their usernames + :param disabled_admins: A dictionary of currently disabled admin users by their usernames + """ + + users: Dict[str, User] = Field(default_factory=dict) + admins: Dict[str, User] = Field(default_factory=dict) + disabled_admins: Dict[str, User] = Field(default_factory=dict) + + def __init__(self, **kwargs): + """ + Initializes a UserManager instanc. + + :param username: The username for the default admin user + :param password: The password for the default admin user + """ + kwargs["name"] = "UserManager" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Returns the state of the UserManager along with the number of users and admins. + + :return: A dict containing detailed state information + """ + state = super().describe_state() + state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)}) + return state + + def show(self, markdown: bool = False): + """ + Display the Users. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["Username", "Admin", "Enabled"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} User Manager)" + for user in self.users.values(): + table.add_row([user.username, user.is_admin, user.disabled]) + print(table.get_string(sortby="Username")) + + def _is_last_admin(self, username: str) -> bool: + return username in self.admins and len(self.admins) == 1 + + def add_user( + self, username: str, password: str, is_admin: bool = False, bypass_can_perform_action: bool = False + ) -> bool: + """ + Adds a new user to the system. + + :param username: The username for the new user + :param password: The password for the new user + :param is_admin: Flag indicating if the new user is an admin + :return: True if user was successfully added, False otherwise + """ + if not bypass_can_perform_action and not self._can_perform_action(): + return False + if username in self.users: + return False + user = User(username=username, password=password, is_admin=is_admin) + self.users[username] = user + if is_admin: + self.admins[username] = user + self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}") + return True + + def authenticate_user(self, username: str, password: str) -> Optional[User]: + """ + Authenticates a user's login attempt. + + :param username: The username of the user trying to log in + :param password: The password provided by the user + :return: The User object if authentication is successful, None otherwise + """ + if not self._can_perform_action(): + return None + user = self.users.get(username) + if user and not user.disabled and user.password == password: + self.sys_log.info(f"{self.name}: User authenticated: {username}") + return user + self.sys_log.info(f"{self.name}: Authentication failed for: {username}") + return None + + def change_user_password(self, username: str, current_password: str, new_password: str) -> bool: + """ + Changes a user's password. + + :param username: The username of the user changing their password + :param current_password: The current password of the user + :param new_password: The new password for the user + :return: True if the password was changed successfully, False otherwise + """ + if not self._can_perform_action(): + return False + user = self.users.get(username) + if user and user.password == current_password: + user.password = new_password + self.sys_log.info(f"{self.name}: Password changed for {username}") + return True + self.sys_log.info(f"{self.name}: Password change failed for {username}") + return False + + def disable_user(self, username: str) -> bool: + """ + Disables a user account, preventing them from logging in. + + :param username: The username of the user to disable + :return: True if the user was disabled successfully, False otherwise + """ + if not self._can_perform_action(): + return False + if username in self.users and not self.users[username].disabled: + if self._is_last_admin(username): + self.sys_log.info(f"{self.name}: Cannot disable User {username} as they are the only enabled admin") + return False + self.users[username].disabled = True + self.sys_log.info(f"{self.name}: User disabled: {username}") + if username in self.admins: + self.disabled_admins[username] = self.admins.pop(username) + return True + self.sys_log.info(f"{self.name}: Failed to disable user: {username}") + return False + + def enable_user(self, username: str) -> bool: + """ + Enables a previously disabled user account. + + :param username: The username of the user to enable + :return: True if the user was enabled successfully, False otherwise + """ + if not self._can_perform_action(): + return False + if username in self.users and self.users[username].disabled: + self.users[username].disabled = False + self.sys_log.info(f"{self.name}: User enabled: {username}") + if username in self.disabled_admins: + self.admins[username] = self.disabled_admins.pop(username) + return True + self.sys_log.info(f"{self.name}: Failed to enable user: {username}") + return False diff --git a/src/primaite/simulator/system/services/access/user_session_manager.py b/src/primaite/simulator/system/services/access/user_session_manager.py new file mode 100644 index 00000000..03d2dd93 --- /dev/null +++ b/src/primaite/simulator/system/services/access/user_session_manager.py @@ -0,0 +1,98 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Dict, List, Optional +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from primaite.simulator.core import SimComponent +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.access.user_manager import User, UserManager +from primaite.simulator.system.services.service import Service +from primaite.utils.validators import IPV4Address + + +class UserSession(SimComponent): + user: User + start_step: int + last_active_step: int + end_step: Optional[int] = None + local: bool = True + + @classmethod + def create(cls, user: User, timestep: int) -> UserSession: + return UserSession(user=user, start_step=timestep, last_active_step=timestep) + def describe_state(self) -> Dict: + return self.model_dump() + + +class RemoteUserSession(UserSession): + remote_ip_address: IPV4Address + local: bool = False + + def describe_state(self) -> Dict: + state = super().describe_state() + state["remote_ip_address"] = str(self.remote_ip_address) + return state + + +class UserSessionManager(BaseModel): + node: + local_session: Optional[UserSession] = None + remote_sessions: Dict[str, RemoteUserSession] = Field(default_factory=dict) + historic_sessions: List[UserSession] = Field(default_factory=list) + + local_session_timeout_steps: int = 30 + remote_session_timeout_steps: int = 5 + max_remote_sessions: int = 3 + + current_timestep: int = 0 + + @property + def _user_manager(self) -> UserManager: + return self.software_manager.software["UserManager"] # noqa + + def pre_timestep(self, timestep: int) -> None: + """Apply any pre-timestep logic that helps make sure we have the correct observations.""" + self.current_timestep = timestep + if self.local_session: + if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: + self._timeout_session(self.local_session) + + def _timeout_session(self, session: UserSession) -> None: + session.end_step = self.current_timestep + session_identity = session.user.username + if session.local: + self.local_session = None + session_type = "Local" + else: + self.remote_sessions.pop(session.uuid) + session_type = "Remote" + session_identity = f"{session_identity} {session.remote_ip_address}" + + self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") + + def login(self, username: str, password: str) -> Optional[str]: + if not self._can_perform_action(): + return None + user = self._user_manager.authenticate_user(username=username, password=password) + if user: + self.logout() + self.local_session = UserSession.create(user=user, timestep=self.current_timestep) + self.sys_log.info(f"{self.name}: User {user.username} logged in") + return self.local_session.uuid + else: + self.sys_log.info(f"{self.name}: Incorrect username or password") + + def logout(self): + if not self._can_perform_action(): + return False + if self.local_session: + session = self.local_session + session.end_step = self.current_timestep + self.historic_sessions.append(session) + self.local_session = None + self.sys_log.info(f"{self.name}: User {session.user.username} logged out") diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index e6ce2c87..bef9804f 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -43,7 +43,7 @@ class Service(IOSoftware): restart_countdown: Optional[int] = None "If currently restarting, how many timesteps remain until the restart is finished." - def __init__(self, **kwargs): + def __init__(self, **kwargs):c super().__init__(**kwargs) def _can_perform_action(self) -> bool: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7ea67dcd..7c27534a 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -291,7 +291,7 @@ class IOSoftware(Software): """ if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: self.software_manager.node.sys_log.error( - f"{self.name} Error: {self.software_manager.node.hostname} is not online." + f"{self.name} Error: {self.software_manager.node.hostname} is not powered on." ) return False return True From 42602be953470c61caad47cfe9e813a6c440fa28 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 9 Jul 2024 11:54:33 +0100 Subject: [PATCH 012/206] #2710 - Initial implementation f the receive/send methods. Committing to change branch --- .../network/hardware/nodes/host/host_node.py | 1 + .../system/services/terminal/terminal.py | 68 +++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index 5848ade4..1fb936cd 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -293,6 +293,7 @@ class HostNode(Node): * DNS (Domain Name System) Client: Resolves domain names to IP addresses. * FTP (File Transfer Protocol) Client: Enables file transfers between the host and FTP servers. * NTP (Network Time Protocol) Client: Synchronizes the system clock with NTP servers. + * Terminal Client: Handles SSH requests between HostNode and external components. Applications: ------------ diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 5f8719ac..bf852823 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -2,14 +2,14 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from uuid import uuid4 from pydantic import BaseModel from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager -from primaite.simulator.network.hardware.nodes.host.host_node import HostNode +from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -27,7 +27,7 @@ class TerminalClientConnection(BaseModel): connection_id: str """Connection UUID.""" - parent_node: HostNode + parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. """The parent Node that this connection was created on.""" is_active: bool = True @@ -116,7 +116,7 @@ class Terminal(Service): self.process_login(dest_ip_address=dest_ip_address, user_account=user_account) def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: - """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" + """Processes the login attempt. Returns a bool which either rejects the login or accepts it.""" # we assume that the login fails unless we meet all the criteria. transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED @@ -142,6 +142,56 @@ class Terminal(Service): self.send(payload=payload, dest_ip_address=dest_ip_address) return True + def validate_user(self, user: Dict[str]) -> bool: + return True if user.get("username") in self.user_connections else False + + + def _ssh_process_logoff(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" + + if self.validate_user(user_account): + # Account is logged in + self.user_connections.pop[user_account["username"]] # assumption atm + self.is_connected = False + return True + else: + self.sys_log.warning("User account credentials invalid.") + + def _ssh_process_command(self, session_id: str, *args, **kwargs) -> bool: + return True + + def send_logoff_ack(self): + """Send confirmation of successful disconnect""" + transport_message = SSHTransportMessage.SSH_MSG_SERVICE_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE + payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message, ssh_output=RequestResponse(status="success")) + self.send(payload=payload) + + def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: + self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") + if payload.connection_message ==SSHConnectionMessage. SSH_MSG_CHANNEL_CLOSE: + result = self._ssh_process_logoff(session_id=session_id) + # We need to close on the other machine as well + self.send_logoff_ack() + + elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: + src_ip = kwargs.get("frame").ip.src_ip_address + user_account = payload.get("user_account", {}) + result = self._ssh_process_login(src_ip=src_ip, session_id=session_id, user_account=user_account) + + elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: + # Ensure we only ever process requests if we have a established connection (e.g session_id is provided and validated) + result = self._ssh_process_command(session_id=session_id) + + else: + self.sys_log.warning("Encounter unexpected message type, rejecting connection") + # send a SSH_MSG_CHANNEL_CLOSE if there is a session_id otherwise SSH_MSG_OPEN_FAILED + return False + + self.send(payload=result, session_id=session_id) + return True + + # %% Outbound def login(self, dest_ip_address: IPv4Address) -> bool: @@ -217,3 +267,13 @@ class Terminal(Service): ) self.connected = False return True + + + def send( + self, + payload: SSHPacket, + dest_ip_address: Optional[IPv4Address] = None, + session_id: Optional[str] = None, + ) -> bool: + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) + From 8061102587f36c180203b60e1cb167427e1147c9 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 9 Jul 2024 11:55:16 +0100 Subject: [PATCH 013/206] #2710 - commit before changing branch --- .../system/services/terminal/terminal.py | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index bf852823..1dd3133d 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -27,7 +27,7 @@ class TerminalClientConnection(BaseModel): connection_id: str """Connection UUID.""" - parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. + parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. """The parent Node that this connection was created on.""" is_active: bool = True @@ -145,13 +145,12 @@ class Terminal(Service): def validate_user(self, user: Dict[str]) -> bool: return True if user.get("username") in self.user_connections else False - def _ssh_process_logoff(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" - + if self.validate_user(user_account): # Account is logged in - self.user_connections.pop[user_account["username"]] # assumption atm + self.user_connections.pop[user_account["username"]] # assumption atm self.is_connected = False return True else: @@ -164,33 +163,36 @@ class Terminal(Service): """Send confirmation of successful disconnect""" transport_message = SSHTransportMessage.SSH_MSG_SERVICE_SUCCESS connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE - payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message, ssh_output=RequestResponse(status="success")) + payload: SSHPacket = SSHPacket( + transport_message=transport_message, + connection_message=connection_message, + ssh_output=RequestResponse(status="success"), + ) self.send(payload=payload) def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: - self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") - if payload.connection_message ==SSHConnectionMessage. SSH_MSG_CHANNEL_CLOSE: - result = self._ssh_process_logoff(session_id=session_id) - # We need to close on the other machine as well - self.send_logoff_ack() + self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") + if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: + result = self._ssh_process_logoff(session_id=session_id) + # We need to close on the other machine as well + self.send_logoff_ack() - elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - src_ip = kwargs.get("frame").ip.src_ip_address - user_account = payload.get("user_account", {}) - result = self._ssh_process_login(src_ip=src_ip, session_id=session_id, user_account=user_account) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: + src_ip = kwargs.get("frame").ip.src_ip_address + user_account = payload.get("user_account", {}) + result = self._ssh_process_login(src_ip=src_ip, session_id=session_id, user_account=user_account) - elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: - # Ensure we only ever process requests if we have a established connection (e.g session_id is provided and validated) - result = self._ssh_process_command(session_id=session_id) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: + # Ensure we only ever process requests if we have a established connection (e.g session_id is provided and validated) + result = self._ssh_process_command(session_id=session_id) - else: - self.sys_log.warning("Encounter unexpected message type, rejecting connection") - # send a SSH_MSG_CHANNEL_CLOSE if there is a session_id otherwise SSH_MSG_OPEN_FAILED - return False - - self.send(payload=result, session_id=session_id) - return True + else: + self.sys_log.warning("Encounter unexpected message type, rejecting connection") + # send a SSH_MSG_CHANNEL_CLOSE if there is a session_id otherwise SSH_MSG_OPEN_FAILED + return False + self.send(payload=result, session_id=session_id) + return True # %% Outbound @@ -268,12 +270,10 @@ class Terminal(Service): self.connected = False return True - def send( - self, - payload: SSHPacket, - dest_ip_address: Optional[IPv4Address] = None, - session_id: Optional[str] = None, - ) -> bool: - return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) - + self, + payload: SSHPacket, + dest_ip_address: Optional[IPv4Address] = None, + session_id: Optional[str] = None, + ) -> bool: + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) From dc3558bc4dbf4c4afb70cb1ae6e0a7b973e6bc89 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 10 Jul 2024 17:39:45 +0100 Subject: [PATCH 014/206] #2710 - End of Day commit --- .../simulator/network/protocols/ssh.py | 2 + .../system/services/terminal/terminal.py | 74 ++++++++++++++----- tests/integration_tests/system/test_nmap.py | 2 +- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 7be81982..361c2552 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -56,6 +56,8 @@ class SSHConnectionMessage(IntEnum): SSH_MSG_CHANNEL_CLOSE = 87 """Closes the channel.""" + SSH_LOGOFF_ACK = 89 + """Logoff confirmation acknowledgement""" class SSHPacket(DataPacket): """Represents an SSHPacket.""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 1dd3133d..e5ff9054 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -24,8 +24,8 @@ class TerminalClientConnection(BaseModel): This class is used to record current User Connections within the Terminal class. """ - connection_id: str - """Connection UUID.""" + session_id: str + """Session UUID.""" parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. """The parent Node that this connection was created on.""" @@ -76,6 +76,8 @@ class Terminal(Service): kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) + # %% Util + def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -100,6 +102,22 @@ class Terminal(Service): rm = super()._init_request_manager() return rm + def _validate_login(self, user_account: Optional[str]) -> bool: + """Validate login credentials are valid.""" + # Pending login/Usermanager implementation + if user_account: + # validate bits - poke UserManager with provided info + # return self.user_manager.validate(user_account) + pass + else: + pass + # user_account = next(iter(self.user_connections)) + # return self.user_manager.validate(user_account) + + return True + + + # %% Inbound def _generate_connection_id(self) -> str: @@ -142,40 +160,50 @@ class Terminal(Service): self.send(payload=payload, dest_ip_address=dest_ip_address) return True - def validate_user(self, user: Dict[str]) -> bool: - return True if user.get("username") in self.user_connections else False + def validate_user(self, session_id: str) -> bool: + return True - def _ssh_process_logoff(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + def _ssh_process_logoff(self, session_id: str, *args, **kwargs) -> bool: """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" - if self.validate_user(user_account): + if self.validate_user(session_id): # Account is logged in - self.user_connections.pop[user_account["username"]] # assumption atm - self.is_connected = False return True else: self.sys_log.warning("User account credentials invalid.") + return False def _ssh_process_command(self, session_id: str, *args, **kwargs) -> bool: return True - def send_logoff_ack(self): + def send_logoff_ack(self, session_id: str): """Send confirmation of successful disconnect""" transport_message = SSHTransportMessage.SSH_MSG_SERVICE_SUCCESS - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE + connection_message = SSHConnectionMessage.SSH_LOGOFF_ACK payload: SSHPacket = SSHPacket( transport_message=transport_message, connection_message=connection_message, - ssh_output=RequestResponse(status="success"), + ssh_output=RequestResponse(status="success", data={"reason": "Successfully Disconnected"}), ) - self.send(payload=payload) + self.send(payload=payload, session_id=session_id) def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: + # shouldn't be expecting to see anything other than SSHPacket payloads currently + # confirm that we are receiving the + if not isinstance(payload, SSHPacket): + return False self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") - if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: - result = self._ssh_process_logoff(session_id=session_id) + + if payload.connection_message == SSHConnectionMessage.SSH_LOGOFF_ACK: + # Logoff acknowledgement received. NFA needed. + self.sys_log.debug("Received confirmation of successful disconnect") + return True + + elif payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: + self._ssh_process_logoff(session_id=session_id) + self.sys_log.debug("Disconnect message received, sending logoff ack") # We need to close on the other machine as well - self.send_logoff_ack() + self.send_logoff_ack(session_id=session_id) elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: src_ip = kwargs.get("frame").ip.src_ip_address @@ -191,12 +219,13 @@ class Terminal(Service): # send a SSH_MSG_CHANNEL_CLOSE if there is a session_id otherwise SSH_MSG_OPEN_FAILED return False - self.send(payload=result, session_id=session_id) + # self.send(payload=result, session_id=session_id) return True + # %% Outbound - def login(self, dest_ip_address: IPv4Address) -> bool: + def login(self, dest_ip_address: IPv4Address, user_account: dict[str]) -> bool: """ Perform an initial login request. @@ -204,13 +233,14 @@ class Terminal(Service): """ # TODO: This will need elaborating when user accounts are implemented self.sys_log.info("Attempting Login") - return self.ssh_remote_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) + return self._ssh_remote_login(self, dest_ip_address=dest_ip_address, user_account=user_account) - def ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: + def _ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: """Remote login to terminal via SSH.""" if not user_account: # Setting default creds (Best to use this until we have more clarification around user accounts) user_account = {self.user_name: "placeholder", self.password: "placeholder"} + # something like self.user_manager.get_user_details ? # Implement SSHPacket class payload: SSHPacket = SSHPacket( @@ -275,5 +305,9 @@ class Terminal(Service): payload: SSHPacket, dest_ip_address: Optional[IPv4Address] = None, session_id: Optional[str] = None, + user_account: Optional[str] = None, ) -> bool: - return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) + """Send a payload out from the Terminal.""" + self._validate_login(user_account) + self.sys_log.debug(f"Sending payload: {payload} from session: {session_id}") + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id) diff --git a/tests/integration_tests/system/test_nmap.py b/tests/integration_tests/system/test_nmap.py index bbfa4f43..a261f272 100644 --- a/tests/integration_tests/system/test_nmap.py +++ b/tests/integration_tests/system/test_nmap.py @@ -106,7 +106,7 @@ def test_port_scan_full_subnet_all_ports_and_protocols(example_network): expected_result = { IPv4Address("192.168.10.1"): {IPProtocol.UDP: [Port.ARP]}, IPv4Address("192.168.10.22"): { - IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS], + IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS, Port.SSH], IPProtocol.UDP: [Port.ARP, Port.NTP], }, } From 2eb36149b28a55cdea48e6d8ea63f6e883de9112 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 15 Jul 2024 08:20:11 +0100 Subject: [PATCH 015/206] #2710 - Prep for draft PR --- .../simulator/network/hardware/base.py | 1 - .../simulator/network/protocols/ssh.py | 1 + .../services/database/database_service.py | 2 +- .../system/services/terminal/terminal.py | 189 ++++++++---------- .../_system/_services/test_terminal.py | 112 +++++++++++ 5 files changed, 199 insertions(+), 106 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 6942d280..610dd071 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -256,7 +256,6 @@ class NetworkInterface(SimComponent, ABC): """ # Determine the direction of the traffic direction = "inbound" if inbound else "outbound" - # Initialize protocol and port variables protocol = None port = None diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 361c2552..7d1f915e 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -59,6 +59,7 @@ class SSHConnectionMessage(IntEnum): SSH_LOGOFF_ACK = 89 """Logoff confirmation acknowledgement""" + class SSHPacket(DataPacket): """Represents an SSHPacket.""" diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 22ae0ff3..d6feafbd 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -19,7 +19,7 @@ _LOGGER = getLogger(__name__) class DatabaseService(Service): """ - A class for simulating a generic SQL Server service. +A class for simulating a generic SQL Server service. This class inherits from the `Service` class and provides methods to simulate a SQL database. """ diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index e5ff9054..3324c4e4 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -2,7 +2,7 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from uuid import uuid4 from pydantic import BaseModel @@ -24,9 +24,6 @@ class TerminalClientConnection(BaseModel): This class is used to record current User Connections within the Terminal class. """ - session_id: str - """Session UUID.""" - parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. """The parent Node that this connection was created on.""" @@ -104,34 +101,44 @@ class Terminal(Service): def _validate_login(self, user_account: Optional[str]) -> bool: """Validate login credentials are valid.""" - # Pending login/Usermanager implementation - if user_account: - # validate bits - poke UserManager with provided info - # return self.user_manager.validate(user_account) - pass + # TODO: Interact with UserManager to check user_account details + if len(self.user_connections) == 0: + # No current connections + self.sys_log.warning("Login Required!") + return False else: - pass - # user_account = next(iter(self.user_connections)) - # return self.user_manager.validate(user_account) - - return True - - + return True # %% Inbound - def _generate_connection_id(self) -> str: + def _generate_connection_uuid(self) -> str: """Generate a unique connection ID.""" return str(uuid4()) - def process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: - """Process User request to login to Terminal.""" - if user_account in self.user_connections: + def login(self, dest_ip_address: IPv4Address, **kwargs) -> bool: + """Process User request to login to Terminal. + + :param dest_ip_address: The IP address of the node we want to connect to. + :return: True if successful, False otherwise. + """ + if self.operating_state != ServiceOperatingState.RUNNING: + self.sys_log.warning("Cannot process login as service is not running") + return False + user_account = f"Username: placeholder, Password: placeholder" + if self.connection_uuid in self.user_connections: self.sys_log.debug("User authentication passed") return True else: - self._ssh_process_login(dest_ip_address=dest_ip_address, user_account=user_account) - self.process_login(dest_ip_address=dest_ip_address, user_account=user_account) + # Need to send a login request + # TODO: Refactor with UserManager changes to provide correct credentials and validate. + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN + payload: SSHPacket = SSHPacket(payload="login", + transport_message=transport_message, + connection_message=connection_message) + + self.sys_log.debug(f"Sending login request to {dest_ip_address}") + self.send(payload=payload, dest_ip_address=dest_ip_address) def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: """Processes the login attempt. Returns a bool which either rejects the login or accepts it.""" @@ -140,19 +147,20 @@ class Terminal(Service): connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED # Hard coded at current - replace with another method to handle local accounts. - if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded - connection_id = self._generate_connection_id() - if not self.add_connection(self, connection_id=connection_id): + if user_account == "Username: placeholder, Password: placeholder": # hardcoded + self.connection_uuid = self._generate_connection_uuid() + if not self.add_connection(connection_id=self.connection_uuid): self.sys_log.warning( f"{self.name}: Connect request for {dest_ip_address} declined. Service is at capacity." ) return False else: - self.sys_log.info(f"{self.name}: Connect request for ID: {connection_id} authorised") + self.sys_log.info(f"{self.name}: Connect request for ID: {self.connection_uuid} authorised") transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION - new_connection = TerminalClientConnection(connection_id=connection_id, dest_ip_address=dest_ip_address) - self.user_connections[connection_id] = new_connection + new_connection = TerminalClientConnection(parent_node = self.software_manager.node, + connection_id=self.connection_uuid, dest_ip_address=dest_ip_address) + self.user_connections[self.connection_uuid] = new_connection self.is_connected = True payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) @@ -160,86 +168,51 @@ class Terminal(Service): self.send(payload=payload, dest_ip_address=dest_ip_address) return True - def validate_user(self, session_id: str) -> bool: - return True - def _ssh_process_logoff(self, session_id: str, *args, **kwargs) -> bool: """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" - - if self.validate_user(session_id): - # Account is logged in - return True - else: - self.sys_log.warning("User account credentials invalid.") - return False - - def _ssh_process_command(self, session_id: str, *args, **kwargs) -> bool: - return True - - def send_logoff_ack(self, session_id: str): - """Send confirmation of successful disconnect""" - transport_message = SSHTransportMessage.SSH_MSG_SERVICE_SUCCESS - connection_message = SSHConnectionMessage.SSH_LOGOFF_ACK - payload: SSHPacket = SSHPacket( - transport_message=transport_message, - connection_message=connection_message, - ssh_output=RequestResponse(status="success", data={"reason": "Successfully Disconnected"}), - ) - self.send(payload=payload, session_id=session_id) + # TODO: Should remove def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: - # shouldn't be expecting to see anything other than SSHPacket payloads currently - # confirm that we are receiving the + """Receive Payload and process for a response.""" if not isinstance(payload, SSHPacket): return False + + if self.operating_state != ServiceOperatingState.RUNNING: + self.sys_log.warning(f"Cannot process message as not running") + return False + self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") - if payload.connection_message == SSHConnectionMessage.SSH_LOGOFF_ACK: - # Logoff acknowledgement received. NFA needed. - self.sys_log.debug("Received confirmation of successful disconnect") - return True - - elif payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: + if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: + connection_id = kwargs["connection_id"] + dest_ip_address = kwargs["dest_ip_address"] self._ssh_process_logoff(session_id=session_id) - self.sys_log.debug("Disconnect message received, sending logoff ack") + self.disconnect(dest_ip_address=dest_ip_address) + self.sys_log.debug(f"Disconnecting {connection_id}") # We need to close on the other machine as well - self.send_logoff_ack(session_id=session_id) elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - src_ip = kwargs.get("frame").ip.src_ip_address - user_account = payload.get("user_account", {}) - result = self._ssh_process_login(src_ip=src_ip, session_id=session_id, user_account=user_account) + # validate login + user_account = "Username: placeholder, Password: placeholder" + self._ssh_process_login(dest_ip_address="192.168.0.10", user_account=user_account) - elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: - # Ensure we only ever process requests if we have a established connection (e.g session_id is provided and validated) - result = self._ssh_process_command(session_id=session_id) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + self.sys_log.debug("Login Successful") + self.is_connected = True + return True else: self.sys_log.warning("Encounter unexpected message type, rejecting connection") - # send a SSH_MSG_CHANNEL_CLOSE if there is a session_id otherwise SSH_MSG_OPEN_FAILED return False - # self.send(payload=result, session_id=session_id) return True - # %% Outbound - - def login(self, dest_ip_address: IPv4Address, user_account: dict[str]) -> bool: - """ - Perform an initial login request. - - If this fails, raises an error. - """ - # TODO: This will need elaborating when user accounts are implemented - self.sys_log.info("Attempting Login") - return self._ssh_remote_login(self, dest_ip_address=dest_ip_address, user_account=user_account) - def _ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: """Remote login to terminal via SSH.""" if not user_account: - # Setting default creds (Best to use this until we have more clarification around user accounts) - user_account = {self.user_name: "placeholder", self.password: "placeholder"} + # TODO: Generic hardcoded info, will need to be updated with UserManager. + user_account = f"Username: placeholder, Password: placeholder" # something like self.user_manager.get_user_details ? # Implement SSHPacket class @@ -248,7 +221,6 @@ class Terminal(Service): connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, user_account=user_account, ) - # self.send will return bool, payload unchanged? if self.send(payload=payload, dest_ip_address=dest_ip_address): if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") @@ -269,45 +241,54 @@ class Terminal(Service): else: return False - def disconnect(self, connection_id: str): - """Disconnect from remote.""" - self._disconnect(connection_id) + def disconnect(self, dest_ip_address: IPv4Address) -> bool: + """Disconnect from remote connection. + + :param dest_ip_address: The IP address fo the connection we are terminating. + :return: True if successful, False otherwise. + """ + self._disconnect(dest_ip_address=dest_ip_address) self.is_connected = False - def _disconnect(self, connection_id: str) -> bool: + def _disconnect(self, dest_ip_address: IPv4Address) -> bool: if not self.is_connected: return False if len(self.user_connections) == 0: self.sys_log.warning(f"{self.name}: Unable to disconnect, no active connections.") return False - if not self.user_connections.get(connection_id): + if not self.user_connections.get(self.connection_uuid): return False software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": connection_id}, - dest_ip_address=self.server_ip_address, - dest_port=self.port, + payload={"type": "disconnect", "connection_id": self.connection_uuid}, + dest_ip_address=dest_ip_address, + dest_port=self.port ) - connection = self.user_connections.pop(connection_id) - self.terminate_connection(connection_id=connection_id) + connection = self.user_connections.pop(self.connection_uuid) connection.is_active = False self.sys_log.info( - f"{self.name}: Disconnected {connection_id} from: {self.user_connections[connection_id]._dest_ip_address}" + f"{self.name}: Disconnected {self.connection_uuid}" ) - self.connected = False return True def send( self, payload: SSHPacket, - dest_ip_address: Optional[IPv4Address] = None, - session_id: Optional[str] = None, - user_account: Optional[str] = None, + dest_ip_address: IPv4Address, ) -> bool: - """Send a payload out from the Terminal.""" - self._validate_login(user_account) - self.sys_log.debug(f"Sending payload: {payload} from session: {session_id}") - return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id) + """ + Send a payload out from the Terminal. + + :param payload: The payload to be sent. + :param dest_up_address: The IP address of the payload destination. + """ + if self.operating_state != ServiceOperatingState.RUNNING: + self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!") + return False + self.sys_log.debug(f"Sending payload: {payload}") + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port + ) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py new file mode 100644 index 00000000..62933b5c --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -0,0 +1,112 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.services.terminal.terminal import Terminal +from primaite.simulator.system.software import SoftwareHealthState + +@pytest.fixture(scope="function") +def terminal_on_computer() -> Tuple[Terminal, Computer]: + computer: Computer = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + computer.power_on() + terminal: Terminal = computer.software_manager.software.get("Terminal") + + return [terminal, computer] + +@pytest.fixture(scope="function") +def basic_network() -> Network: + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_a.software_manager.get_open_ports() + + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + return network + + +def test_terminal_creation(terminal_on_computer): + terminal, computer = terminal_on_computer + terminal.describe_state() + +def test_terminal_install_default(): + """Terminal should be auto installed onto Nodes""" + computer = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + computer.power_on() + + assert computer.software_manager.software.get("Terminal") + +def test_terminal_not_on_switch(): + """Ensure terminal does not auto-install to switch""" + test_switch = Switch(hostname="Test") + + assert not test_switch.software_manager.software.get("Terminal") + +def test_terminal_send(basic_network): + """Check that Terminal can send """ + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + + payload: SSHPacket = SSHPacket(payload="Test_Payload", + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN) + + + assert terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") + + +def test_terminal_fail_when_closed(basic_network): + """Ensure Terminal won't attempt to send/receive when off""" + network: Network = basic_network + computer: Computer = network.get_node_by_hostname("node_a") + terminal: Terminal = computer.software_manager.software.get("Terminal") + + terminal.operating_state = ServiceOperatingState.STOPPED + + assert terminal.login(dest_ip_address="192.168.0.11") is False + + +def test_terminal_disconnect(basic_network): + """Terminal should set is_connected to false on disconnect""" + network: Network = basic_network + computer: Computer = network.get_node_by_hostname("node_a") + terminal: Terminal = computer.software_manager.software.get("Terminal") + + assert terminal.is_connected is False + + terminal.login(dest_ip_address="192.168.0.11") + + assert terminal.is_connected is True + + terminal.disconnect(dest_ip_address="192.168.0.11") + + assert terminal.is_connected is False + +def test_terminal_ignores_when_off(basic_network): + """Terminal should ignore commands when not running""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + + computer_b: Computer = network.get_node_by_hostname("node_b") + + terminal_a.login(dest_ip_address="192.168.0.11") # login to computer_b + + assert terminal_a.is_connected is True + + terminal_a.operating_state = ServiceOperatingState.STOPPED + + payload: SSHPacket = SSHPacket(payload="Test_Payload", + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA) + + assert not terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") From 32c2ea0b100e39e6db28b14e1f939852c7ca2c21 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 15 Jul 2024 08:22:18 +0100 Subject: [PATCH 016/206] #2710 - Pre-commit run ahead of raising PR --- .../services/database/database_service.py | 4 +- .../system/services/terminal/terminal.py | 38 +++++++++---------- .../_system/_services/test_terminal.py | 31 ++++++++++----- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index d6feafbd..f061b3c7 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -19,9 +19,9 @@ _LOGGER = getLogger(__name__) class DatabaseService(Service): """ -A class for simulating a generic SQL Server service. + A class for simulating a generic SQL Server service. - This class inherits from the `Service` class and provides methods to simulate a SQL database. + This class inherits from the `Service` class and provides methods to simulate a SQL database. """ password: Optional[str] = None diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 3324c4e4..589492ba 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -117,14 +117,13 @@ class Terminal(Service): def login(self, dest_ip_address: IPv4Address, **kwargs) -> bool: """Process User request to login to Terminal. - - :param dest_ip_address: The IP address of the node we want to connect to. + + :param dest_ip_address: The IP address of the node we want to connect to. :return: True if successful, False otherwise. """ if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning("Cannot process login as service is not running") return False - user_account = f"Username: placeholder, Password: placeholder" if self.connection_uuid in self.user_connections: self.sys_log.debug("User authentication passed") return True @@ -133,9 +132,9 @@ class Terminal(Service): # TODO: Refactor with UserManager changes to provide correct credentials and validate. transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN - payload: SSHPacket = SSHPacket(payload="login", - transport_message=transport_message, - connection_message=connection_message) + payload: SSHPacket = SSHPacket( + payload="login", transport_message=transport_message, connection_message=connection_message + ) self.sys_log.debug(f"Sending login request to {dest_ip_address}") self.send(payload=payload, dest_ip_address=dest_ip_address) @@ -158,8 +157,11 @@ class Terminal(Service): self.sys_log.info(f"{self.name}: Connect request for ID: {self.connection_uuid} authorised") transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION - new_connection = TerminalClientConnection(parent_node = self.software_manager.node, - connection_id=self.connection_uuid, dest_ip_address=dest_ip_address) + new_connection = TerminalClientConnection( + parent_node=self.software_manager.node, + connection_id=self.connection_uuid, + dest_ip_address=dest_ip_address, + ) self.user_connections[self.connection_uuid] = new_connection self.is_connected = True @@ -170,7 +172,7 @@ class Terminal(Service): def _ssh_process_logoff(self, session_id: str, *args, **kwargs) -> bool: """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" - # TODO: Should remove + # TODO: Should remove def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: """Receive Payload and process for a response.""" @@ -178,7 +180,7 @@ class Terminal(Service): return False if self.operating_state != ServiceOperatingState.RUNNING: - self.sys_log.warning(f"Cannot process message as not running") + self.sys_log.warning("Cannot process message as not running") return False self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") @@ -212,7 +214,7 @@ class Terminal(Service): """Remote login to terminal via SSH.""" if not user_account: # TODO: Generic hardcoded info, will need to be updated with UserManager. - user_account = f"Username: placeholder, Password: placeholder" + user_account = "Username: placeholder, Password: placeholder" # something like self.user_manager.get_user_details ? # Implement SSHPacket class @@ -242,8 +244,8 @@ class Terminal(Service): return False def disconnect(self, dest_ip_address: IPv4Address) -> bool: - """Disconnect from remote connection. - + """Disconnect from remote connection. + :param dest_ip_address: The IP address fo the connection we are terminating. :return: True if successful, False otherwise. """ @@ -263,15 +265,13 @@ class Terminal(Service): software_manager.send_payload_to_session_manager( payload={"type": "disconnect", "connection_id": self.connection_uuid}, dest_ip_address=dest_ip_address, - dest_port=self.port + dest_port=self.port, ) connection = self.user_connections.pop(self.connection_uuid) connection.is_active = False - self.sys_log.info( - f"{self.name}: Disconnected {self.connection_uuid}" - ) + self.sys_log.info(f"{self.name}: Disconnected {self.connection_uuid}") return True def send( @@ -289,6 +289,4 @@ class Terminal(Service): self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!") return False self.sys_log.debug(f"Sending payload: {payload}") - return super().send( - payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port - ) + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 62933b5c..6b0365ce 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -11,14 +11,18 @@ from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.software import SoftwareHealthState + @pytest.fixture(scope="function") def terminal_on_computer() -> Tuple[Terminal, Computer]: - computer: Computer = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + computer: Computer = Computer( + hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0 + ) computer.power_on() terminal: Terminal = computer.software_manager.software.get("Terminal") return [terminal, computer] + @pytest.fixture(scope="function") def basic_network() -> Network: network = Network() @@ -37,6 +41,7 @@ def test_terminal_creation(terminal_on_computer): terminal, computer = terminal_on_computer terminal.describe_state() + def test_terminal_install_default(): """Terminal should be auto installed onto Nodes""" computer = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) @@ -44,22 +49,25 @@ def test_terminal_install_default(): assert computer.software_manager.software.get("Terminal") + def test_terminal_not_on_switch(): """Ensure terminal does not auto-install to switch""" test_switch = Switch(hostname="Test") assert not test_switch.software_manager.software.get("Terminal") + def test_terminal_send(basic_network): - """Check that Terminal can send """ + """Check that Terminal can send""" network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") - payload: SSHPacket = SSHPacket(payload="Test_Payload", - transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN) - + payload: SSHPacket = SSHPacket( + payload="Test_Payload", + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + ) assert terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") @@ -91,6 +99,7 @@ def test_terminal_disconnect(basic_network): assert terminal.is_connected is False + def test_terminal_ignores_when_off(basic_network): """Terminal should ignore commands when not running""" network: Network = basic_network @@ -99,14 +108,16 @@ def test_terminal_ignores_when_off(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") - terminal_a.login(dest_ip_address="192.168.0.11") # login to computer_b + terminal_a.login(dest_ip_address="192.168.0.11") # login to computer_b assert terminal_a.is_connected is True terminal_a.operating_state = ServiceOperatingState.STOPPED - payload: SSHPacket = SSHPacket(payload="Test_Payload", - transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA) + payload: SSHPacket = SSHPacket( + payload="Test_Payload", + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA, + ) assert not terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") From fee7f202a66529564f422ea591bda3708bd04ee5 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 15 Jul 2024 10:06:28 +0100 Subject: [PATCH 017/206] #2711 - Amending some minor changes spotted whilst raising PR --- src/primaite/simulator/network/hardware/base.py | 1 + src/primaite/simulator/network/protocols/ssh.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 610dd071..6942d280 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -256,6 +256,7 @@ class NetworkInterface(SimComponent, ABC): """ # Determine the direction of the traffic direction = "inbound" if inbound else "outbound" + # Initialize protocol and port variables protocol = None port = None diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 7d1f915e..7be81982 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -56,9 +56,6 @@ class SSHConnectionMessage(IntEnum): SSH_MSG_CHANNEL_CLOSE = 87 """Closes the channel.""" - SSH_LOGOFF_ACK = 89 - """Logoff confirmation acknowledgement""" - class SSHPacket(DataPacket): """Represents an SSHPacket.""" From 34969c588b6a93134d77ecc518b64bed1988437d Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 16 Jul 2024 08:59:36 +0100 Subject: [PATCH 018/206] #2676: Fix mismerge. --- src/primaite/game/game.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index c2a1961b..3e129879 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -18,7 +18,7 @@ from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency -from primaite.simulator.network.hardware.base import NodeOperatingState +from primaite.simulator.network.hardware.base import NodeOperatingState, NetworkInterface from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Printer, Server From 07e736977ccefbe567cab88aed0c023c012c92d6 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 16 Jul 2024 16:58:11 +0100 Subject: [PATCH 019/206] #2676: Fix some more integration tests --- tests/assets/configs/bad_primaite_session.yaml | 2 +- tests/assets/configs/basic_switched_network.yaml | 2 +- tests/assets/configs/eval_only_primaite_session.yaml | 2 +- tests/assets/configs/firewall_actions_network.yaml | 2 +- tests/assets/configs/fix_duration_one_item.yaml | 2 +- tests/assets/configs/software_fix_duration.yaml | 2 +- tests/assets/configs/test_primaite_session.yaml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index 8cbd3ae9..c83cadc8 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -99,7 +99,7 @@ agents: num_files: 1 num_nics: 2 include_num_access: false - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/basic_switched_network.yaml b/tests/assets/configs/basic_switched_network.yaml index 69187fa3..fed0f52d 100644 --- a/tests/assets/configs/basic_switched_network.yaml +++ b/tests/assets/configs/basic_switched_network.yaml @@ -92,7 +92,7 @@ agents: - NONE tcp: - DNS - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index de861dcc..3d60eb6e 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -111,7 +111,7 @@ agents: num_files: 1 num_nics: 2 include_num_access: false - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/firewall_actions_network.yaml b/tests/assets/configs/firewall_actions_network.yaml index fd5b1bf8..2292616d 100644 --- a/tests/assets/configs/firewall_actions_network.yaml +++ b/tests/assets/configs/firewall_actions_network.yaml @@ -68,7 +68,7 @@ agents: num_files: 1 num_nics: 2 include_num_access: false - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/fix_duration_one_item.yaml b/tests/assets/configs/fix_duration_one_item.yaml index 59bc15f9..bd0fb61f 100644 --- a/tests/assets/configs/fix_duration_one_item.yaml +++ b/tests/assets/configs/fix_duration_one_item.yaml @@ -89,7 +89,7 @@ agents: - NONE tcp: - DNS - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/software_fix_duration.yaml b/tests/assets/configs/software_fix_duration.yaml index 1acb05a9..1a28258b 100644 --- a/tests/assets/configs/software_fix_duration.yaml +++ b/tests/assets/configs/software_fix_duration.yaml @@ -89,7 +89,7 @@ agents: - NONE tcp: - DNS - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index eb8103e8..27cfa240 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -120,7 +120,7 @@ agents: num_files: 1 num_nics: 2 include_num_access: false - include_nmne: true + include_nmne: false routers: - hostname: router_1 num_ports: 0 From 061509dffdd5d7f7fa4088bce2b082b487567f3b Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 17 Jul 2024 10:43:04 +0100 Subject: [PATCH 020/206] #2676: Further test fixes. --- src/primaite/game/game.py | 2 +- .../scenario_with_placeholders/scenario.yaml | 2 +- .../observations/test_nic_observations.py | 16 +++++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 3e129879..aca75b63 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -18,7 +18,7 @@ from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency -from primaite.simulator.network.hardware.base import NodeOperatingState, NetworkInterface +from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Printer, Server diff --git a/tests/assets/configs/scenario_with_placeholders/scenario.yaml b/tests/assets/configs/scenario_with_placeholders/scenario.yaml index 81848b2d..ef930a1a 100644 --- a/tests/assets/configs/scenario_with_placeholders/scenario.yaml +++ b/tests/assets/configs/scenario_with_placeholders/scenario.yaml @@ -44,7 +44,7 @@ agents: num_files: 1 num_nics: 1 include_num_access: false - include_nmne: true + include_nmne: false - type: LINKS label: LINKS diff --git a/tests/integration_tests/game_layer/observations/test_nic_observations.py b/tests/integration_tests/game_layer/observations/test_nic_observations.py index 88dd2bd5..dfad8b59 100644 --- a/tests/integration_tests/game_layer/observations/test_nic_observations.py +++ b/tests/integration_tests/game_layer/observations/test_nic_observations.py @@ -9,9 +9,11 @@ from gymnasium import spaces from primaite.game.agent.interface import ProxyAgent from primaite.game.agent.observations.nic_observations import NICObservation from primaite.game.game import PrimaiteGame +from primaite.simulator.network.hardware.base import NetworkInterface from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.web_browser import WebBrowser @@ -75,6 +77,18 @@ def test_nic(simulation): nic_obs = NICObservation(where=["network", "nodes", pc.hostname, "NICs", 1], include_nmne=True) + # Set the NMNE configuration to capture DELETE/ENCRYPT queries as MNEs + nmne_config = { + "capture_nmne": True, # Enable the capture of MNEs + "nmne_capture_keywords": [ + "DELETE", + "ENCRYPT", + ], # Specify "DELETE/ENCRYPT" SQL command as a keyword for MNE detection + } + + # Apply the NMNE configuration settings + NetworkInterface.nmne_config = store_nmne_config(nmne_config) + assert nic_obs.space["nic_status"] == spaces.Discrete(3) assert nic_obs.space["NMNE"]["inbound"] == spaces.Discrete(4) assert nic_obs.space["NMNE"]["outbound"] == spaces.Discrete(4) @@ -144,7 +158,7 @@ def test_nic_monitored_traffic(simulation): pc2: Computer = simulation.network.get_node_by_hostname("client_2") nic_obs = NICObservation( - where=["network", "nodes", pc.hostname, "NICs", 1], include_nmne=True, monitored_traffic=monitored_traffic + where=["network", "nodes", pc.hostname, "NICs", 1], include_nmne=False, monitored_traffic=monitored_traffic ) simulation.pre_timestep(0) # apply timestep to whole sim From f409d0c27c246c711d0dc372ffa36ffed2695692 Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 14:11:48 +0000 Subject: [PATCH 021/206] #2758 - Updated azure-benchmark-pipeline.yaml to use 'Imaginary Yak Pool' --- .azure/azure-benchmark-pipeline.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure/azure-benchmark-pipeline.yaml b/.azure/azure-benchmark-pipeline.yaml index 8bd7d08e..378187e4 100644 --- a/.azure/azure-benchmark-pipeline.yaml +++ b/.azure/azure-benchmark-pipeline.yaml @@ -19,7 +19,7 @@ jobs: - job: PrimAITE_Benchmark timeoutInMinutes: 360 # 6-hour maximum pool: - vmImage: ubuntu-latest + name: 'Imaginary Yak Pool' workspace: clean: all steps: From c7431fa0c81f69e269eae39a0a3519aa7045a45f Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 14:20:53 +0000 Subject: [PATCH 022/206] #2758 - Updated azure-benchmark-pipeline.yaml to use python3.10 on the yak pool vm --- .azure/azure-benchmark-pipeline.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.azure/azure-benchmark-pipeline.yaml b/.azure/azure-benchmark-pipeline.yaml index 378187e4..f29a85ef 100644 --- a/.azure/azure-benchmark-pipeline.yaml +++ b/.azure/azure-benchmark-pipeline.yaml @@ -47,15 +47,15 @@ jobs: addToPath: true - script: | - python -m pip install --upgrade pip - pip install -e .[dev,rl] + python3.10 -m pip install --upgrade pip + python3.10 -m pip install -e .[dev,rl] primaite setup displayName: 'Install Dependencies' - script: | set -e cd benchmark - python3 primaite_benchmark.py + python3.10 -m pip primaite_benchmark.py cd .. displayName: 'Run Benchmarking Script' From 2900ca9b2af36cd4ef438ad0f531f5dda009f81c Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 14:33:44 +0000 Subject: [PATCH 023/206] #2758 - Updated azure-benchmark-pipeline.yaml so that is created a venv on the vm --- .azure/azure-benchmark-pipeline.yaml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.azure/azure-benchmark-pipeline.yaml b/.azure/azure-benchmark-pipeline.yaml index f29a85ef..0d0575b7 100644 --- a/.azure/azure-benchmark-pipeline.yaml +++ b/.azure/azure-benchmark-pipeline.yaml @@ -22,10 +22,15 @@ jobs: name: 'Imaginary Yak Pool' workspace: clean: all + steps: - checkout: self persistCredentials: true + - script: | + $(python.version)-m venv venv + displayName: 'Create venv' + - script: | VERSION=$(cat src/primaite/VERSION | tr -d '\n') if [[ "$(Build.SourceBranch)" == "refs/heads/dev" ]]; then @@ -41,21 +46,18 @@ jobs: echo "##vso[task.setvariable variable=MAJOR_VERSION]$MAJOR_VERSION" displayName: 'Set Version Variables' - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.11' - addToPath: true - - script: | - python3.10 -m pip install --upgrade pip - python3.10 -m pip install -e .[dev,rl] + source venv/bin/activate + pip install --upgrade pip + pip install -e .[dev,rl] primaite setup displayName: 'Install Dependencies' - script: | set -e + source venv/bin/activate cd benchmark - python3.10 -m pip primaite_benchmark.py + python primaite_benchmark.py cd .. displayName: 'Run Benchmarking Script' From b83bab2e2e916b93862d621cecf0c0d43c8e71ba Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 14:34:51 +0000 Subject: [PATCH 024/206] #2758 - Updated azure-benchmark-pipeline.yaml --- .azure/azure-benchmark-pipeline.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure/azure-benchmark-pipeline.yaml b/.azure/azure-benchmark-pipeline.yaml index 0d0575b7..78c40bfd 100644 --- a/.azure/azure-benchmark-pipeline.yaml +++ b/.azure/azure-benchmark-pipeline.yaml @@ -28,7 +28,7 @@ jobs: persistCredentials: true - script: | - $(python.version)-m venv venv + python3.10 -m venv venv displayName: 'Create venv' - script: | From 6b14d6de4444e3a01a956af858727298daf7a8bb Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 15:45:25 +0000 Subject: [PATCH 025/206] Bumped version number to 3.3.0-dev0 --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index fd2a0186..68d02a53 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.1.0 +3.3.0-dev0 \ No newline at end of file From b651ee3837e3adcb1f16d44bc87743032159e6bc Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Wed, 17 Jul 2024 16:08:30 +0000 Subject: [PATCH 026/206] fixed line ending in VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 68d02a53..6d0e8e51 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.3.0-dev0 \ No newline at end of file +3.3.0-dev0 From 43617340148051973518f72b45dce01cbb6f40cf Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 17 Jul 2024 17:50:55 +0100 Subject: [PATCH 027/206] #2676: Code review changes --- src/primaite/game/game.py | 5 +---- src/primaite/simulator/network/hardware/base.py | 4 ++-- src/primaite/simulator/network/nmne.py | 11 +++++------ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index aca75b63..0c1b3192 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -26,7 +26,7 @@ from primaite.simulator.network.hardware.nodes.network.firewall import Firewall from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.nmne import NmneData, store_nmne_config +from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.application import Application @@ -110,9 +110,6 @@ class PrimaiteGame: self._reward_calculation_order: List[str] = [name for name in self.agents] """Agent order for reward evaluation, as some rewards can be dependent on other agents' rewards.""" - self.nmne_config: NmneData = None - """ Config data from Number of Malicious Network Events.""" - def step(self): """ Perform one step of the simulation/agent loop. diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index f85d3f2e..50549389 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -19,7 +19,7 @@ from primaite.simulator.core import RequestFormat, RequestManager, RequestPermis from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState -from primaite.simulator.network.nmne import NmneData +from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.system.applications.application import Application @@ -99,7 +99,7 @@ class NetworkInterface(SimComponent, ABC): pcap: Optional[PacketCapture] = None "A PacketCapture instance for capturing and analysing packets passing through this interface." - nmne_config: ClassVar[NmneData] = None + nmne_config: ClassVar[NMNEConfig] = None "A dataclass defining malicious network events to be captured." nmne: Dict = Field(default_factory=lambda: {}) diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py index 947f27ac..431ec07d 100644 --- a/src/primaite/simulator/network/nmne.py +++ b/src/primaite/simulator/network/nmne.py @@ -1,15 +1,14 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from dataclasses import dataclass, field +from pydantic import BaseModel from typing import Dict, List -@dataclass -class NmneData: +class NMNEConfig(BaseModel): """Store all the information to perform NMNE operations.""" capture_nmne: bool = True """Indicates whether Malicious Network Events (MNEs) should be captured.""" - nmne_capture_keywords: List[str] = field(default_factory=list) + nmne_capture_keywords: List[str] = [] """List of keywords to identify malicious network events.""" capture_by_direction: bool = True """Captures should be organized by traffic direction (inbound/outbound).""" @@ -23,7 +22,7 @@ class NmneData: """Captures should be filtered and categorised based on specific keywords.""" -def store_nmne_config(nmne_config: Dict) -> NmneData: +def store_nmne_config(nmne_config: Dict) -> NMNEConfig: """ Store configuration for capturing Malicious Network Events (MNEs). @@ -51,4 +50,4 @@ def store_nmne_config(nmne_config: Dict) -> NmneData: if not isinstance(nmne_capture_keywords, list): nmne_capture_keywords = [] # Reset to empty list if the provided value is not a list - return NmneData(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) + return NMNEConfig(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) From 8702dc706797ad7970ba7a5bed1a7fbff7175c04 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 19 Jul 2024 10:34:32 +0100 Subject: [PATCH 028/206] #2735 - tidies up some oif the api, temporarily integrated login checks to ping for testing, added temp test --- .../simulator/network/hardware/base.py | 357 +++++++++++++++++- .../network/hardware/nodes/host/host_node.py | 5 +- .../simulator/system/core/software_manager.py | 8 +- .../system/services/access/user_manager.py | 185 --------- .../services/access/user_session_manager.py | 97 ----- .../simulator/system/services/service.py | 2 +- .../system/test_local_accounts.py | 37 ++ 7 files changed, 391 insertions(+), 300 deletions(-) create mode 100644 tests/integration_tests/system/test_local_accounts.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 64fad264..9e6784c5 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,7 +6,7 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, ClassVar, Dict, Optional, TypeVar, Union +from typing import Any, ClassVar, Dict, List, Optional, TypeVar, Union from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel, Field @@ -31,14 +31,13 @@ from primaite.simulator.network.nmne import ( ) from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application from primaite.simulator.system.core.packet_capture import PacketCapture from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.processes.process import Process -from primaite.simulator.system.services.access.user_manager import UserManager -from primaite.simulator.system.services.access.user_session_manager import UserSessionManager from primaite.simulator.system.services.service import Service from primaite.simulator.system.software import IOSoftware from primaite.utils.converters import convert_dict_enum_keys_to_enum_values @@ -796,6 +795,330 @@ class Link(SimComponent): self.current_load = 0.0 +class User(SimComponent): + """ + Represents a user in the PrimAITE system. + + :param username: The username of the user + :param password: The password of the user + :param disabled: Boolean flag indicating whether the user is disabled + :param is_admin: Boolean flag indicating whether the user has admin privileges + """ + + username: str + password: str + disabled: bool = False + is_admin: bool = False + + def describe_state(self) -> Dict: + """ + Returns a dictionary representing the current state of the user. + + :return: A dict containing the state of the user + """ + return self.model_dump() + + +class UserManager(Service): + """ + Manages users within the PrimAITE system, handling creation, authentication, and administration. + + :param users: A dictionary of all users by their usernames + :param admins: A dictionary of admin users by their usernames + :param disabled_admins: A dictionary of currently disabled admin users by their usernames + """ + + users: Dict[str, User] = Field(default_factory=dict) + admins: Dict[str, User] = Field(default_factory=dict) + disabled_admins: Dict[str, User] = Field(default_factory=dict) + + def __init__(self, **kwargs): + """ + Initializes a UserManager instanc. + + :param username: The username for the default admin user + :param password: The password for the default admin user + """ + kwargs["name"] = "UserManager" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Returns the state of the UserManager along with the number of users and admins. + + :return: A dict containing detailed state information + """ + state = super().describe_state() + state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)}) + return state + + def show(self, markdown: bool = False): + """ + Display the Users. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["Username", "Admin", "Disabled"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} User Manager" + for user in self.users.values(): + table.add_row([user.username, user.is_admin, user.disabled]) + print(table.get_string(sortby="Username")) + + def _is_last_admin(self, username: str) -> bool: + return username in self.admins and len(self.admins) == 1 + + def add_user( + self, username: str, password: str, is_admin: bool = False, bypass_can_perform_action: bool = False + ) -> bool: + """ + Adds a new user to the system. + + :param username: The username for the new user + :param password: The password for the new user + :param is_admin: Flag indicating if the new user is an admin + :return: True if user was successfully added, False otherwise + """ + if not bypass_can_perform_action and not self._can_perform_action(): + return False + if username in self.users: + self.sys_log.info(f"{self.name}: Failed to create new user {username} as this user name already exists") + return False + user = User(username=username, password=password, is_admin=is_admin) + self.users[username] = user + if is_admin: + self.admins[username] = user + self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}") + return True + + def authenticate_user(self, username: str, password: str) -> Optional[User]: + """ + Authenticates a user's login attempt. + + :param username: The username of the user trying to log in + :param password: The password provided by the user + :return: The User object if authentication is successful, None otherwise + """ + if not self._can_perform_action(): + return None + user = self.users.get(username) + if user and not user.disabled and user.password == password: + self.sys_log.info(f"{self.name}: User authenticated: {username}") + return user + self.sys_log.info(f"{self.name}: Authentication failed for: {username}") + return None + + def change_user_password(self, username: str, current_password: str, new_password: str) -> bool: + """ + Changes a user's password. + + :param username: The username of the user changing their password + :param current_password: The current password of the user + :param new_password: The new password for the user + :return: True if the password was changed successfully, False otherwise + """ + if not self._can_perform_action(): + return False + user = self.users.get(username) + if user and user.password == current_password: + user.password = new_password + self.sys_log.info(f"{self.name}: Password changed for {username}") + return True + self.sys_log.info(f"{self.name}: Password change failed for {username}") + return False + + def disable_user(self, username: str) -> bool: + """ + Disables a user account, preventing them from logging in. + + :param username: The username of the user to disable + :return: True if the user was disabled successfully, False otherwise + """ + if not self._can_perform_action(): + return False + if username in self.users and not self.users[username].disabled: + if self._is_last_admin(username): + self.sys_log.info(f"{self.name}: Cannot disable User {username} as they are the only enabled admin") + return False + self.users[username].disabled = True + self.sys_log.info(f"{self.name}: User disabled: {username}") + if username in self.admins: + self.disabled_admins[username] = self.admins.pop(username) + return True + self.sys_log.info(f"{self.name}: Failed to disable user: {username}") + return False + + def enable_user(self, username: str) -> bool: + """ + Enables a previously disabled user account. + + :param username: The username of the user to enable + :return: True if the user was enabled successfully, False otherwise + """ + if username in self.users and self.users[username].disabled: + self.users[username].disabled = False + self.sys_log.info(f"{self.name}: User enabled: {username}") + if username in self.disabled_admins: + self.admins[username] = self.disabled_admins.pop(username) + return True + self.sys_log.info(f"{self.name}: Failed to enable user: {username}") + return False + + +class UserSession(SimComponent): + user: User + start_step: int + last_active_step: int + end_step: Optional[int] = None + local: bool = True + + @classmethod + def create(cls, user: User, timestep: int) -> UserSession: + return UserSession(user=user, start_step=timestep, last_active_step=timestep) + + def describe_state(self) -> Dict: + return self.model_dump() + + +class RemoteUserSession(UserSession): + remote_ip_address: IPV4Address + local: bool = False + + def describe_state(self) -> Dict: + state = super().describe_state() + state["remote_ip_address"] = str(self.remote_ip_address) + return state + + +class UserSessionManager(Service): + node: Node + local_session: Optional[UserSession] = None + remote_sessions: Dict[str, RemoteUserSession] = Field(default_factory=dict) + historic_sessions: List[UserSession] = Field(default_factory=list) + + local_session_timeout_steps: int = 30 + remote_session_timeout_steps: int = 5 + max_remote_sessions: int = 3 + + current_timestep: int = 0 + + def __init__(self, **kwargs): + """ + Initializes a UserSessionManager instance. + + :param username: The username for the default admin user + :param password: The password for the default admin user + """ + kwargs["name"] = "UserSessionManager" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) + self.start() + + def show(self, markdown: bool = False, include_session_id: bool = False, include_historic: bool = False): + """Prints a table of the user sessions on the Node.""" + headers = ["Session ID", "Username", "Type", "Remote IP", "Start Step", "Step Last Active", "End Step"] + + if not include_session_id: + headers = headers[1:] + + table = PrettyTable(headers) + + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.node.hostname} User Sessions" + + def _add_session_to_table(user_session: UserSession): + session_type = "local" + remote_ip = "" + if isinstance(user_session, RemoteUserSession): + session_type = "remote" + remote_ip = str(user_session.remote_ip_address) + data = [ + user_session.uuid, + user_session.user.username, + session_type, + remote_ip, + user_session.start_step, + user_session.last_active_step, + user_session.end_step if user_session.end_step else "", + ] + if not include_session_id: + data = data[1:] + table.add_row(data) + + if self.local_session is not None: + _add_session_to_table(self.local_session) + + for user_session in self.remote_sessions.values(): + _add_session_to_table(user_session) + + if include_historic: + for user_session in self.historic_sessions: + _add_session_to_table(user_session) + + print(table.get_string(sortby="Step Last Active", reversesort=True)) + + def describe_state(self) -> Dict: + return super().describe_state() + + @property + def _user_manager(self) -> UserManager: + return self.software_manager.software["UserManager"] # noqa + + def pre_timestep(self, timestep: int) -> None: + """Apply any pre-timestep logic that helps make sure we have the correct observations.""" + self.current_timestep = timestep + if self.local_session: + if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: + self._timeout_session(self.local_session) + + def _timeout_session(self, session: UserSession) -> None: + session.end_step = self.current_timestep + session_identity = session.user.username + if session.local: + self.local_session = None + session_type = "Local" + else: + self.remote_sessions.pop(session.uuid) + session_type = "Remote" + session_identity = f"{session_identity} {session.remote_ip_address}" + + self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") + + def login(self, username: str, password: str) -> Optional[str]: + if not self._can_perform_action(): + return None + user = self._user_manager.authenticate_user(username=username, password=password) + if user: + self.logout() + self.local_session = UserSession.create(user=user, timestep=self.current_timestep) + self.sys_log.info(f"{self.name}: User {user.username} logged in") + return self.local_session.uuid + else: + self.sys_log.info(f"{self.name}: Incorrect username or password") + + def logout(self): + if not self._can_perform_action(): + return False + if self.local_session: + session = self.local_session + session.end_step = self.current_timestep + self.historic_sessions.append(session) + self.local_session = None + self.sys_log.info(f"{self.name}: User {session.user.username} logged out") + + @property + def local_user_logged_in(self): + return self.local_session is not None + + class Node(SimComponent): """ A basic Node class that represents a node on the network. @@ -889,16 +1212,24 @@ class Node(SimComponent): super().__init__(**kwargs) self.session_manager.node = self self.session_manager.software_manager = self.software_manager - self.software_manager.install(UserSessionManager) + self.software_manager.install(UserSessionManager, node=self) self.software_manager.install(UserManager) + self.user_manager.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) + self._install_system_software() - # @property - # def user_manager(self) -> UserManager: - # return self.software_manager.software["UserManager"] # noqa - # - # @property - # def _user_session_manager(self) -> UserSessionManager: - # return self.software_manager.software["UserSessionManager"] # noqa + @property + def user_manager(self) -> UserManager: + return self.software_manager.software["UserManager"] # noqa + + @property + def user_session_manager(self) -> UserSessionManager: + return self.software_manager.software["UserSessionManager"] # noqa + + def login(self, username: str, password: str) -> Optional[str]: + return self.user_session_manager.login(username, password) + + def logout(self): + return self.user_session_manager.logout() def ip_is_network_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: """ @@ -1434,10 +1765,14 @@ class Node(SimComponent): :param pings: The number of pings to attempt, default is 4. :return: True if the ping is successful, otherwise False. """ + if not self.user_session_manager.local_user_logged_in: + return False if not isinstance(target_ip_address, IPv4Address): target_ip_address = IPv4Address(target_ip_address) if self.software_manager.icmp: + print("yes") return self.software_manager.icmp.ping(target_ip_address, pings) + print("no icmp") return False @abstractmethod diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index 80f80a04..aac57e95 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -11,8 +11,6 @@ from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.nmap import NMAP from primaite.simulator.system.applications.web_browser import WebBrowser -from primaite.simulator.system.services.access.user_manager import UserManager -from primaite.simulator.system.services.access.user_session_manager import UserSessionManager from primaite.simulator.system.services.arp.arp import ARP, ARPPacket from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.icmp.icmp import ICMP @@ -318,10 +316,9 @@ class HostNode(Node): network_interface: Dict[int, NIC] = {} "The NICs on the node by port id." - def __init__(self, ip_address: IPV4Address, subnet_mask: IPV4Address, username: str, password: str, **kwargs): + def __init__(self, ip_address: IPV4Address, subnet_mask: IPV4Address, **kwargs): super().__init__(**kwargs) self.connect_nic(NIC(ip_address=ip_address, subnet_mask=subnet_mask)) - self.user_manager.add_user(username=username, password=password, is_admin=True, bypass_can_perform_action=True) @property def nmap(self) -> Optional[NMAP]: diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index e2266c2d..c52e60ae 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -104,7 +104,7 @@ class SoftwareManager: return True return False - def install(self, software_class: Type[IOSoftwareClass]): + def install(self, software_class: Type[IOSoftwareClass], **install_kwargs) -> None: """ Install an Application or Service. @@ -116,7 +116,11 @@ class SoftwareManager: self.sys_log.warning(f"Cannot install {software_class} as it is already installed") return software = software_class( - software_manager=self, sys_log=self.sys_log, file_system=self.file_system, dns_server=self.dns_server + software_manager=self, + sys_log=self.sys_log, + file_system=self.file_system, + dns_server=self.dns_server, + **install_kwargs, ) if isinstance(software, Application): software.install() diff --git a/src/primaite/simulator/system/services/access/user_manager.py b/src/primaite/simulator/system/services/access/user_manager.py index 09f8950e..be6c00e7 100644 --- a/src/primaite/simulator/system/services/access/user_manager.py +++ b/src/primaite/simulator/system/services/access/user_manager.py @@ -1,186 +1 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from typing import Dict, Optional - -from prettytable import MARKDOWN, PrettyTable -from pydantic import Field - -from primaite.simulator.core import SimComponent -from primaite.simulator.network.transmission.network_layer import IPProtocol -from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.services.service import Service - - -class User(SimComponent): - """ - Represents a user in the PrimAITE system. - - :param username: The username of the user - :param password: The password of the user - :param disabled: Boolean flag indicating whether the user is disabled - :param is_admin: Boolean flag indicating whether the user has admin privileges - """ - - username: str - password: str - disabled: bool = False - is_admin: bool = False - - def describe_state(self) -> Dict: - """ - Returns a dictionary representing the current state of the user. - - :return: A dict containing the state of the user - """ - return self.model_dump() - - -class UserManager(Service): - """ - Manages users within the PrimAITE system, handling creation, authentication, and administration. - - :param users: A dictionary of all users by their usernames - :param admins: A dictionary of admin users by their usernames - :param disabled_admins: A dictionary of currently disabled admin users by their usernames - """ - - users: Dict[str, User] = Field(default_factory=dict) - admins: Dict[str, User] = Field(default_factory=dict) - disabled_admins: Dict[str, User] = Field(default_factory=dict) - - def __init__(self, **kwargs): - """ - Initializes a UserManager instanc. - - :param username: The username for the default admin user - :param password: The password for the default admin user - """ - kwargs["name"] = "UserManager" - kwargs["port"] = Port.NONE - kwargs["protocol"] = IPProtocol.NONE - super().__init__(**kwargs) - self.start() - - def describe_state(self) -> Dict: - """ - Returns the state of the UserManager along with the number of users and admins. - - :return: A dict containing detailed state information - """ - state = super().describe_state() - state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)}) - return state - - def show(self, markdown: bool = False): - """ - Display the Users. - - :param markdown: Whether to display the table in Markdown format or not. Default is `False`. - """ - table = PrettyTable(["Username", "Admin", "Enabled"]) - if markdown: - table.set_style(MARKDOWN) - table.align = "l" - table.title = f"{self.sys_log.hostname} User Manager)" - for user in self.users.values(): - table.add_row([user.username, user.is_admin, user.disabled]) - print(table.get_string(sortby="Username")) - - def _is_last_admin(self, username: str) -> bool: - return username in self.admins and len(self.admins) == 1 - - def add_user( - self, username: str, password: str, is_admin: bool = False, bypass_can_perform_action: bool = False - ) -> bool: - """ - Adds a new user to the system. - - :param username: The username for the new user - :param password: The password for the new user - :param is_admin: Flag indicating if the new user is an admin - :return: True if user was successfully added, False otherwise - """ - if not bypass_can_perform_action and not self._can_perform_action(): - return False - if username in self.users: - return False - user = User(username=username, password=password, is_admin=is_admin) - self.users[username] = user - if is_admin: - self.admins[username] = user - self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}") - return True - - def authenticate_user(self, username: str, password: str) -> Optional[User]: - """ - Authenticates a user's login attempt. - - :param username: The username of the user trying to log in - :param password: The password provided by the user - :return: The User object if authentication is successful, None otherwise - """ - if not self._can_perform_action(): - return None - user = self.users.get(username) - if user and not user.disabled and user.password == password: - self.sys_log.info(f"{self.name}: User authenticated: {username}") - return user - self.sys_log.info(f"{self.name}: Authentication failed for: {username}") - return None - - def change_user_password(self, username: str, current_password: str, new_password: str) -> bool: - """ - Changes a user's password. - - :param username: The username of the user changing their password - :param current_password: The current password of the user - :param new_password: The new password for the user - :return: True if the password was changed successfully, False otherwise - """ - if not self._can_perform_action(): - return False - user = self.users.get(username) - if user and user.password == current_password: - user.password = new_password - self.sys_log.info(f"{self.name}: Password changed for {username}") - return True - self.sys_log.info(f"{self.name}: Password change failed for {username}") - return False - - def disable_user(self, username: str) -> bool: - """ - Disables a user account, preventing them from logging in. - - :param username: The username of the user to disable - :return: True if the user was disabled successfully, False otherwise - """ - if not self._can_perform_action(): - return False - if username in self.users and not self.users[username].disabled: - if self._is_last_admin(username): - self.sys_log.info(f"{self.name}: Cannot disable User {username} as they are the only enabled admin") - return False - self.users[username].disabled = True - self.sys_log.info(f"{self.name}: User disabled: {username}") - if username in self.admins: - self.disabled_admins[username] = self.admins.pop(username) - return True - self.sys_log.info(f"{self.name}: Failed to disable user: {username}") - return False - - def enable_user(self, username: str) -> bool: - """ - Enables a previously disabled user account. - - :param username: The username of the user to enable - :return: True if the user was enabled successfully, False otherwise - """ - if not self._can_perform_action(): - return False - if username in self.users and self.users[username].disabled: - self.users[username].disabled = False - self.sys_log.info(f"{self.name}: User enabled: {username}") - if username in self.disabled_admins: - self.admins[username] = self.disabled_admins.pop(username) - return True - self.sys_log.info(f"{self.name}: Failed to enable user: {username}") - return False diff --git a/src/primaite/simulator/system/services/access/user_session_manager.py b/src/primaite/simulator/system/services/access/user_session_manager.py index 03d2dd93..be6c00e7 100644 --- a/src/primaite/simulator/system/services/access/user_session_manager.py +++ b/src/primaite/simulator/system/services/access/user_session_manager.py @@ -1,98 +1 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import Dict, List, Optional -from uuid import uuid4 - -from pydantic import BaseModel, Field - -from primaite.simulator.core import SimComponent -from primaite.simulator.network.transmission.network_layer import IPProtocol -from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.services.access.user_manager import User, UserManager -from primaite.simulator.system.services.service import Service -from primaite.utils.validators import IPV4Address - - -class UserSession(SimComponent): - user: User - start_step: int - last_active_step: int - end_step: Optional[int] = None - local: bool = True - - @classmethod - def create(cls, user: User, timestep: int) -> UserSession: - return UserSession(user=user, start_step=timestep, last_active_step=timestep) - def describe_state(self) -> Dict: - return self.model_dump() - - -class RemoteUserSession(UserSession): - remote_ip_address: IPV4Address - local: bool = False - - def describe_state(self) -> Dict: - state = super().describe_state() - state["remote_ip_address"] = str(self.remote_ip_address) - return state - - -class UserSessionManager(BaseModel): - node: - local_session: Optional[UserSession] = None - remote_sessions: Dict[str, RemoteUserSession] = Field(default_factory=dict) - historic_sessions: List[UserSession] = Field(default_factory=list) - - local_session_timeout_steps: int = 30 - remote_session_timeout_steps: int = 5 - max_remote_sessions: int = 3 - - current_timestep: int = 0 - - @property - def _user_manager(self) -> UserManager: - return self.software_manager.software["UserManager"] # noqa - - def pre_timestep(self, timestep: int) -> None: - """Apply any pre-timestep logic that helps make sure we have the correct observations.""" - self.current_timestep = timestep - if self.local_session: - if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: - self._timeout_session(self.local_session) - - def _timeout_session(self, session: UserSession) -> None: - session.end_step = self.current_timestep - session_identity = session.user.username - if session.local: - self.local_session = None - session_type = "Local" - else: - self.remote_sessions.pop(session.uuid) - session_type = "Remote" - session_identity = f"{session_identity} {session.remote_ip_address}" - - self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") - - def login(self, username: str, password: str) -> Optional[str]: - if not self._can_perform_action(): - return None - user = self._user_manager.authenticate_user(username=username, password=password) - if user: - self.logout() - self.local_session = UserSession.create(user=user, timestep=self.current_timestep) - self.sys_log.info(f"{self.name}: User {user.username} logged in") - return self.local_session.uuid - else: - self.sys_log.info(f"{self.name}: Incorrect username or password") - - def logout(self): - if not self._can_perform_action(): - return False - if self.local_session: - session = self.local_session - session.end_step = self.current_timestep - self.historic_sessions.append(session) - self.local_session = None - self.sys_log.info(f"{self.name}: User {session.user.username} logged out") diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 4227175b..5adea6e7 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -46,7 +46,7 @@ class Service(IOSoftware): restart_countdown: Optional[int] = None "If currently restarting, how many timesteps remain until the restart is finished." - def __init__(self, **kwargs):c + def __init__(self, **kwargs): super().__init__(**kwargs) def _can_perform_action(self) -> bool: diff --git a/tests/integration_tests/system/test_local_accounts.py b/tests/integration_tests/system/test_local_accounts.py new file mode 100644 index 00000000..dbdbf857 --- /dev/null +++ b/tests/integration_tests/system/test_local_accounts.py @@ -0,0 +1,37 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server + + +def test_local_accounts_ping_temp(): + network = Network() + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Connect Computer and Server + network.connect(computer.network_interface[1], server.network_interface[1]) + + assert not computer.ping(server.network_interface[1].ip_address) + + computer.user_session_manager.login(username="admin", password="admin") + + assert computer.ping(server.network_interface[1].ip_address) From 9fb3790c1a731779e368207cc02b1fe1f587b5de Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 19 Jul 2024 11:10:57 +0100 Subject: [PATCH 029/206] #2726: Resolve pydantic validators PR comment --- src/primaite/simulator/network/nmne.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py index 431ec07d..c9266fba 100644 --- a/src/primaite/simulator/network/nmne.py +++ b/src/primaite/simulator/network/nmne.py @@ -6,7 +6,7 @@ from typing import Dict, List class NMNEConfig(BaseModel): """Store all the information to perform NMNE operations.""" - capture_nmne: bool = True + capture_nmne: bool = False """Indicates whether Malicious Network Events (MNEs) should be captured.""" nmne_capture_keywords: List[str] = [] """List of keywords to identify malicious network events.""" @@ -42,12 +42,8 @@ def store_nmne_config(nmne_config: Dict) -> NMNEConfig: nmne_capture_keywords: List[str] = [] # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect capture_nmne = nmne_config.get("capture_nmne", False) - if not isinstance(capture_nmne, bool): - capture_nmne = True # Revert to default True if the provided value is not a boolean # Update the NMNE capture keywords, appending new keywords if provided nmne_capture_keywords += nmne_config.get("nmne_capture_keywords", []) - if not isinstance(nmne_capture_keywords, list): - nmne_capture_keywords = [] # Reset to empty list if the provided value is not a list return NMNEConfig(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) From 2104a7ec7d437153dd735c9d4fa95fffb87dc54a Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 19 Jul 2024 11:17:54 +0100 Subject: [PATCH 030/206] #2712 - Commit before merging in changes on dev --- .../simulator/network/protocols/ssh.py | 13 ++++++-- .../system/services/terminal/terminal.py | 32 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 7be81982..86544813 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -56,6 +56,15 @@ class SSHConnectionMessage(IntEnum): SSH_MSG_CHANNEL_CLOSE = 87 """Closes the channel.""" +class SSHUserCredentials(DataPacket): + """Hold Username and Password in SSH Packets""" + + username: str = None + """Username for login""" + + password: str = None + """Password for login""" + class SSHPacket(DataPacket): """Represents an SSHPacket.""" @@ -64,8 +73,8 @@ class SSHPacket(DataPacket): connection_message: SSHConnectionMessage = None - ssh_command: Optional[str] = None # This is the request string + connection_uuid: Optional[str] = None # The connection uuid used to validate the session ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse - user_account: Optional[Dict] = None # The user account we will use to login if we do not have a current connection. + ssh_command: Optional[str] = None # This is the request string \ No newline at end of file diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 589492ba..3cf9fc0d 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -7,8 +7,8 @@ from uuid import uuid4 from pydantic import BaseModel -from primaite.interface.request import RequestResponse -from primaite.simulator.core import RequestManager +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestPermissionValidator, RequestType from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol @@ -96,7 +96,11 @@ class Terminal(Service): def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" # TODO: Expand with a login validator? + + _login_valid = Terminal._LoginValidator(terminal=self) + rm = super()._init_request_manager() + rm.add_request("login", request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid)) return rm def _validate_login(self, user_account: Optional[str]) -> bool: @@ -109,10 +113,32 @@ class Terminal(Service): else: return True + class _LoginValidator(RequestPermissionValidator): + """ + When requests come in, this validator will only allow them through if the + User is logged into the Terminal. + + Login is required before making use of the Terminal. + """ + + terminal: Terminal + """Save a reference to the Terminal instance.""" + + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Return whether the Terminal has valid login credentials""" + return self.terminal.login_status + + @property + def fail_message(self) -> str: + """Message that is reported when a request is rejected by this validator""" + return ("Cannot perform request on terminal as not logged in.") + + # %% Inbound def _generate_connection_uuid(self) -> str: """Generate a unique connection ID.""" + # This might not be needed given user_manager.login() returns a UUID. return str(uuid4()) def login(self, dest_ip_address: IPv4Address, **kwargs) -> bool: @@ -136,7 +162,7 @@ class Terminal(Service): payload="login", transport_message=transport_message, connection_message=connection_message ) - self.sys_log.debug(f"Sending login request to {dest_ip_address}") + self.sys_log.info(f"Sending login request to {dest_ip_address}") self.send(payload=payload, dest_ip_address=dest_ip_address) def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: From 155562cb683fa6216eae02598528e2662f8ce0f7 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 19 Jul 2024 11:18:17 +0100 Subject: [PATCH 031/206] #2712 - Commit before merging in changes on dev --- src/primaite/simulator/network/protocols/ssh.py | 3 ++- .../simulator/system/services/terminal/terminal.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 86544813..af1c550a 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -56,6 +56,7 @@ class SSHConnectionMessage(IntEnum): SSH_MSG_CHANNEL_CLOSE = 87 """Closes the channel.""" + class SSHUserCredentials(DataPacket): """Hold Username and Password in SSH Packets""" @@ -77,4 +78,4 @@ class SSHPacket(DataPacket): ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse - ssh_command: Optional[str] = None # This is the request string \ No newline at end of file + ssh_command: Optional[str] = None # This is the request string diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 3cf9fc0d..9dd40edc 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -100,7 +100,12 @@ class Terminal(Service): _login_valid = Terminal._LoginValidator(terminal=self) rm = super()._init_request_manager() - rm.add_request("login", request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid)) + rm.add_request( + "login", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid + ), + ) return rm def _validate_login(self, user_account: Optional[str]) -> bool: @@ -127,12 +132,11 @@ class Terminal(Service): def __call__(self, request: RequestFormat, context: Dict) -> bool: """Return whether the Terminal has valid login credentials""" return self.terminal.login_status - + @property def fail_message(self) -> str: """Message that is reported when a request is rejected by this validator""" - return ("Cannot perform request on terminal as not logged in.") - + return "Cannot perform request on terminal as not logged in." # %% Inbound From e4ade6ba5484f70d2b9f1e5917513d4d698823eb Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 19 Jul 2024 12:02:43 +0100 Subject: [PATCH 032/206] #2676: Merge nmne.py with io.py --- src/primaite/game/game.py | 2 +- src/primaite/session/io.py | 46 +++++++++++++++++ .../simulator/network/hardware/base.py | 2 +- src/primaite/simulator/network/nmne.py | 49 ------------------- .../observations/test_nic_observations.py | 2 +- .../network/test_capture_nmne.py | 2 +- 6 files changed, 50 insertions(+), 53 deletions(-) delete mode 100644 src/primaite/simulator/network/nmne.py diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 0c1b3192..cd0180db 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -16,6 +16,7 @@ from primaite.game.agent.scripted_agents.probabilistic_agent import Probabilisti from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort +from primaite.session.io import store_nmne_config from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState @@ -26,7 +27,6 @@ from primaite.simulator.network.hardware.nodes.network.firewall import Firewall from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.application import Application diff --git a/src/primaite/session/io.py b/src/primaite/session/io.py index 78d7cb3c..2d0d5897 100644 --- a/src/primaite/session/io.py +++ b/src/primaite/session/io.py @@ -131,3 +131,49 @@ class PrimaiteIO: new = cls(settings=cls.Settings(**config)) return new + + +class NMNEConfig(BaseModel): + """Store all the information to perform NMNE operations.""" + + capture_nmne: bool = False + """Indicates whether Malicious Network Events (MNEs) should be captured.""" + nmne_capture_keywords: List[str] = [] + """List of keywords to identify malicious network events.""" + capture_by_direction: bool = True + """Captures should be organized by traffic direction (inbound/outbound).""" + capture_by_ip_address: bool = False + """Captures should be organized by source or destination IP address.""" + capture_by_protocol: bool = False + """Captures should be organized by network protocol (e.g., TCP, UDP).""" + capture_by_port: bool = False + """Captures should be organized by source or destination port.""" + capture_by_keyword: bool = False + """Captures should be filtered and categorised based on specific keywords.""" + + +def store_nmne_config(nmne_config: Dict) -> NMNEConfig: + """ + Store configuration for capturing Malicious Network Events (MNEs). + + This function updates global settings related to NMNE capture, including whether to capture + NMNEs and what keywords to use for identifying NMNEs. + + The function ensures that the settings are updated only if they are provided in the + `nmne_config` dictionary, and maintains type integrity by checking the types of the provided + values. + + :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys + include: + "capture_nmne" (bool) to indicate whether NMNEs should be captured; + "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. + :rvar dataclass with data read from config file. + """ + nmne_capture_keywords: List[str] = [] + # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect + capture_nmne = nmne_config.get("capture_nmne", False) + + # Update the NMNE capture keywords, appending new keywords if provided + nmne_capture_keywords += nmne_config.get("nmne_capture_keywords", []) + + return NMNEConfig(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 50549389..aafdbe5c 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -14,12 +14,12 @@ from pydantic import BaseModel, Field from primaite import getLogger from primaite.exceptions import NetworkError from primaite.interface.request import RequestResponse +from primaite.session.io import NMNEConfig from primaite.simulator import SIM_OUTPUT from primaite.simulator.core import RequestFormat, RequestManager, RequestPermissionValidator, RequestType, SimComponent from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState -from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.system.applications.application import Application diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py deleted file mode 100644 index c9266fba..00000000 --- a/src/primaite/simulator/network/nmne.py +++ /dev/null @@ -1,49 +0,0 @@ -# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from pydantic import BaseModel -from typing import Dict, List - - -class NMNEConfig(BaseModel): - """Store all the information to perform NMNE operations.""" - - capture_nmne: bool = False - """Indicates whether Malicious Network Events (MNEs) should be captured.""" - nmne_capture_keywords: List[str] = [] - """List of keywords to identify malicious network events.""" - capture_by_direction: bool = True - """Captures should be organized by traffic direction (inbound/outbound).""" - capture_by_ip_address: bool = False - """Captures should be organized by source or destination IP address.""" - capture_by_protocol: bool = False - """Captures should be organized by network protocol (e.g., TCP, UDP).""" - capture_by_port: bool = False - """Captures should be organized by source or destination port.""" - capture_by_keyword: bool = False - """Captures should be filtered and categorised based on specific keywords.""" - - -def store_nmne_config(nmne_config: Dict) -> NMNEConfig: - """ - Store configuration for capturing Malicious Network Events (MNEs). - - This function updates global settings related to NMNE capture, including whether to capture - NMNEs and what keywords to use for identifying NMNEs. - - The function ensures that the settings are updated only if they are provided in the - `nmne_config` dictionary, and maintains type integrity by checking the types of the provided - values. - - :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys - include: - "capture_nmne" (bool) to indicate whether NMNEs should be captured; - "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. - :rvar dataclass with data read from config file. - """ - nmne_capture_keywords: List[str] = [] - # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect - capture_nmne = nmne_config.get("capture_nmne", False) - - # Update the NMNE capture keywords, appending new keywords if provided - nmne_capture_keywords += nmne_config.get("nmne_capture_keywords", []) - - return NMNEConfig(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) diff --git a/tests/integration_tests/game_layer/observations/test_nic_observations.py b/tests/integration_tests/game_layer/observations/test_nic_observations.py index dfad8b59..7f86d26d 100644 --- a/tests/integration_tests/game_layer/observations/test_nic_observations.py +++ b/tests/integration_tests/game_layer/observations/test_nic_observations.py @@ -9,11 +9,11 @@ from gymnasium import spaces from primaite.game.agent.interface import ProxyAgent from primaite.game.agent.observations.nic_observations import NICObservation from primaite.game.game import PrimaiteGame +from primaite.session.io import store_nmne_config from primaite.simulator.network.hardware.base import NetworkInterface from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server -from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.web_browser import WebBrowser diff --git a/tests/integration_tests/network/test_capture_nmne.py b/tests/integration_tests/network/test_capture_nmne.py index f6e4c685..b4162e58 100644 --- a/tests/integration_tests/network/test_capture_nmne.py +++ b/tests/integration_tests/network/test_capture_nmne.py @@ -1,9 +1,9 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from primaite.game.agent.observations.nic_observations import NICObservation +from primaite.session.io import store_nmne_config from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server -from primaite.simulator.network.nmne import store_nmne_config from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection From 82a11b8b85c28bf90bbac3169f196b09fbfb8c4b Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 19 Jul 2024 12:54:01 +0100 Subject: [PATCH 033/206] #2676: Updated doc strings --- src/primaite/session/io.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/primaite/session/io.py b/src/primaite/session/io.py index 2d0d5897..c634e835 100644 --- a/src/primaite/session/io.py +++ b/src/primaite/session/io.py @@ -156,18 +156,17 @@ def store_nmne_config(nmne_config: Dict) -> NMNEConfig: """ Store configuration for capturing Malicious Network Events (MNEs). - This function updates global settings related to NMNE capture, including whether to capture - NMNEs and what keywords to use for identifying NMNEs. + This function updates settings related to NMNE capture, stored in NMNEConfig including whether + to capture NMNEs and the keywords to use for identifying NMNEs. The function ensures that the settings are updated only if they are provided in the - `nmne_config` dictionary, and maintains type integrity by checking the types of the provided - values. + `nmne_config` dictionary, and maintains type integrity by relying on pydantic validators. :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys include: "capture_nmne" (bool) to indicate whether NMNEs should be captured; "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. - :rvar dataclass with data read from config file. + :rvar class with data read from config file. """ nmne_capture_keywords: List[str] = [] # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect From 3c590a873340a99f4d47bf6b693d1b9716922d43 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 22 Jul 2024 09:58:09 +0100 Subject: [PATCH 034/206] #2712 - Commit before changing branches --- .../system/services/terminal/terminal.py | 153 +++++------------- .../_system/_services/test_terminal.py | 6 +- 2 files changed, 47 insertions(+), 112 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 9dd40edc..039fbeb3 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -17,6 +17,8 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState + +# TODO: This might not be needed now? class TerminalClientConnection(BaseModel): """ TerminalClientConnection Class. @@ -52,9 +54,6 @@ class TerminalClientConnection(BaseModel): class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" - user_account: Optional[str] = None - "The User Account used for login" - is_connected: bool = False "Boolean Value for whether connected" @@ -64,8 +63,6 @@ class Terminal(Service): operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING """Initial Operating State""" - user_connections: Dict[str, TerminalClientConnection] = {} - """List of authenticated connected users""" def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -85,38 +82,24 @@ class Terminal(Service): :rtype: Dict """ state = super().describe_state() - - state.update({"hostname": self.name}) return state def apply_request(self, request: List[str | int | float | Dict], context: Dict | None = None) -> RequestResponse: - """Apply Temrinal Request.""" + """Apply Terminal Request.""" return super().apply_request(request, context) def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" - # TODO: Expand with a login validator? - _login_valid = Terminal._LoginValidator(terminal=self) rm = super()._init_request_manager() - rm.add_request( - "login", - request_type=RequestType( - func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid - ), - ) + rm.add_request("login", request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid)) return rm - def _validate_login(self, user_account: Optional[str]) -> bool: + def _validate_login(self, connection_id: str) -> bool: """Validate login credentials are valid.""" - # TODO: Interact with UserManager to check user_account details - if len(self.user_connections) == 0: - # No current connections - self.sys_log.warning("Login Required!") - return False - else: - return True + return self.parent.UserSessionManager.validate_remote_session_uuid(connection_id) + class _LoginValidator(RequestPermissionValidator): """ @@ -132,77 +115,64 @@ class Terminal(Service): def __call__(self, request: RequestFormat, context: Dict) -> bool: """Return whether the Terminal has valid login credentials""" return self.terminal.login_status - + @property def fail_message(self) -> str: """Message that is reported when a request is rejected by this validator""" - return "Cannot perform request on terminal as not logged in." + return ("Cannot perform request on terminal as not logged in.") + # %% Inbound - def _generate_connection_uuid(self) -> str: - """Generate a unique connection ID.""" - # This might not be needed given user_manager.login() returns a UUID. - return str(uuid4()) - - def login(self, dest_ip_address: IPv4Address, **kwargs) -> bool: + def login(self, username: str, password: str, ip_address: Optional[IPv4Address]=None) -> bool: """Process User request to login to Terminal. :param dest_ip_address: The IP address of the node we want to connect to. + :param username: The username credential. + :param password: The user password component of credentials. :return: True if successful, False otherwise. """ if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning("Cannot process login as service is not running") return False - if self.connection_uuid in self.user_connections: - self.sys_log.debug("User authentication passed") + + # need to determine if this is a local or remote login + + if ip_address: + # ip_address has been given for remote login + return self._send_remote_login(username=username, password=password, ip_address=ip_address) + + return self._process_local_login(username=username, password=password) + + + def _process_local_login(self, username: str, password: str) -> bool: + """Local session login to terminal.""" + self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + if self.connection_uuid: + self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") return True else: - # Need to send a login request - # TODO: Refactor with UserManager changes to provide correct credentials and validate. - transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN - payload: SSHPacket = SSHPacket( - payload="login", transport_message=transport_message, connection_message=connection_message - ) + self.sys_log.warning("Login failed, incorrect Username or Password") + return False - self.sys_log.info(f"Sending login request to {dest_ip_address}") - self.send(payload=payload, dest_ip_address=dest_ip_address) + def _send_remote_login(self, username: str, password: str, ip_address: IPv4Address) -> bool: + """Attempt to login to a remote terminal.""" + pass - def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: - """Processes the login attempt. Returns a bool which either rejects the login or accepts it.""" - # we assume that the login fails unless we meet all the criteria. - transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED - # Hard coded at current - replace with another method to handle local accounts. - if user_account == "Username: placeholder, Password: placeholder": # hardcoded - self.connection_uuid = self._generate_connection_uuid() - if not self.add_connection(connection_id=self.connection_uuid): - self.sys_log.warning( - f"{self.name}: Connect request for {dest_ip_address} declined. Service is at capacity." - ) - return False - else: - self.sys_log.info(f"{self.name}: Connect request for ID: {self.connection_uuid} authorised") - transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS - connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION - new_connection = TerminalClientConnection( - parent_node=self.software_manager.node, - connection_id=self.connection_uuid, - dest_ip_address=dest_ip_address, - ) - self.user_connections[self.connection_uuid] = new_connection - self.is_connected = True - payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) + def _process_remote_login(self, username: str, password: str, ip_address:IPv4Address) -> bool: + """Processes a remote terminal requesting to login to this terminal.""" + self.connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) + if self.connection_uuid: + # Send uuid to remote + self.sys_log.info(f"Remote login authorised, connection ID {self.connection_uuid} for {username} on {ip_address}") + # send back to origin. + return True + else: + self.sys_log.warning("Login failed, incorrect Username or Password") + return False - self.send(payload=payload, dest_ip_address=dest_ip_address) - return True - - def _ssh_process_logoff(self, session_id: str, *args, **kwargs) -> bool: - """Process the logoff attempt. Return a bool if succesful or unsuccessful.""" - # TODO: Should remove def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: """Receive Payload and process for a response.""" @@ -213,12 +183,9 @@ class Terminal(Service): self.sys_log.warning("Cannot process message as not running") return False - self.sys_log.debug(f"Received payload: {payload} from session: {session_id}") - if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: connection_id = kwargs["connection_id"] dest_ip_address = kwargs["dest_ip_address"] - self._ssh_process_logoff(session_id=session_id) self.disconnect(dest_ip_address=dest_ip_address) self.sys_log.debug(f"Disconnecting {connection_id}") # We need to close on the other machine as well @@ -240,38 +207,6 @@ class Terminal(Service): return True # %% Outbound - def _ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: - """Remote login to terminal via SSH.""" - if not user_account: - # TODO: Generic hardcoded info, will need to be updated with UserManager. - user_account = "Username: placeholder, Password: placeholder" - # something like self.user_manager.get_user_details ? - - # Implement SSHPacket class - payload: SSHPacket = SSHPacket( - transport_message=SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, - user_account=user_account, - ) - if self.send(payload=payload, dest_ip_address=dest_ip_address): - if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: - self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") - # Need to confirm if self.uuid is correct. - self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) - return True - else: - self.sys_log.error("Login Failed. Incorrect credentials provided.") - return False - else: - self.sys_log.error("Login Failed. Incorrect credentials provided.") - return False - - def check_connection(self, connection_id: str) -> bool: - """Check whether the connection is valid.""" - if self.is_connected: - return self.send(dest_ip_address=self.dest_ip_address, connection_id=connection_id) - else: - return False def disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from remote connection. diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 6b0365ce..673b11a3 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -80,7 +80,7 @@ def test_terminal_fail_when_closed(basic_network): terminal.operating_state = ServiceOperatingState.STOPPED - assert terminal.login(dest_ip_address="192.168.0.11") is False + assert terminal.login(ip_address="192.168.0.11") is False def test_terminal_disconnect(basic_network): @@ -91,7 +91,7 @@ def test_terminal_disconnect(basic_network): assert terminal.is_connected is False - terminal.login(dest_ip_address="192.168.0.11") + terminal.login(ip_address="192.168.0.11") assert terminal.is_connected is True @@ -108,7 +108,7 @@ def test_terminal_ignores_when_off(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") - terminal_a.login(dest_ip_address="192.168.0.11") # login to computer_b + terminal_a.login(ip_address="192.168.0.11") # login to computer_b assert terminal_a.is_connected is True From e67b4b54cec61238c42f1a236e4184fae84914c0 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 22 Jul 2024 14:46:58 +0100 Subject: [PATCH 035/206] bumped version number and ran pre-commit --- benchmark/results/v3/v3.2.0/session_metadata/1.json | 2 +- benchmark/results/v3/v3.2.0/session_metadata/2.json | 2 +- benchmark/results/v3/v3.2.0/session_metadata/3.json | 2 +- benchmark/results/v3/v3.2.0/session_metadata/4.json | 2 +- benchmark/results/v3/v3.2.0/session_metadata/5.json | 2 +- benchmark/results/v3/v3.2.0/v3.2.0_benchmark_metadata.json | 2 +- src/primaite/VERSION | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/benchmark/results/v3/v3.2.0/session_metadata/1.json b/benchmark/results/v3/v3.2.0/session_metadata/1.json index 794f03e3..bfccfcdc 100644 --- a/benchmark/results/v3/v3.2.0/session_metadata/1.json +++ b/benchmark/results/v3/v3.2.0/session_metadata/1.json @@ -1006,4 +1006,4 @@ "999": 78.49999999999996, "1000": 84.69999999999993 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.2.0/session_metadata/2.json b/benchmark/results/v3/v3.2.0/session_metadata/2.json index e48c34b9..c35b5ae6 100644 --- a/benchmark/results/v3/v3.2.0/session_metadata/2.json +++ b/benchmark/results/v3/v3.2.0/session_metadata/2.json @@ -1006,4 +1006,4 @@ "999": 97.59999999999975, "1000": 103.34999999999978 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.2.0/session_metadata/3.json b/benchmark/results/v3/v3.2.0/session_metadata/3.json index 4e2d845c..342e0f7d 100644 --- a/benchmark/results/v3/v3.2.0/session_metadata/3.json +++ b/benchmark/results/v3/v3.2.0/session_metadata/3.json @@ -1006,4 +1006,4 @@ "999": 101.14999999999978, "1000": 80.94999999999976 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.2.0/session_metadata/4.json b/benchmark/results/v3/v3.2.0/session_metadata/4.json index 6e03a18f..6aaf9ab8 100644 --- a/benchmark/results/v3/v3.2.0/session_metadata/4.json +++ b/benchmark/results/v3/v3.2.0/session_metadata/4.json @@ -1006,4 +1006,4 @@ "999": 118.0500000000001, "1000": 77.95000000000005 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.2.0/session_metadata/5.json b/benchmark/results/v3/v3.2.0/session_metadata/5.json index ca7ad1e9..05cf76ed 100644 --- a/benchmark/results/v3/v3.2.0/session_metadata/5.json +++ b/benchmark/results/v3/v3.2.0/session_metadata/5.json @@ -1006,4 +1006,4 @@ "999": 55.849999999999916, "1000": 96.95000000000007 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.2.0/v3.2.0_benchmark_metadata.json b/benchmark/results/v3/v3.2.0/v3.2.0_benchmark_metadata.json index 830e980e..111ae25f 100644 --- a/benchmark/results/v3/v3.2.0/v3.2.0_benchmark_metadata.json +++ b/benchmark/results/v3/v3.2.0/v3.2.0_benchmark_metadata.json @@ -7442,4 +7442,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 944880fa..6d0e8e51 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.2.0 +3.3.0-dev0 From a7f9e4502edd85a905901d30ef7b20f0d114f33f Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 23 Jul 2024 15:18:20 +0100 Subject: [PATCH 036/206] #2712 - Updates to the login logic and fixing resultant test failures. Updates to terminal.rst and ssh.py --- .../system/services/terminal.rst | 26 +-- .../simulator/network/protocols/ssh.py | 24 ++- .../system/services/terminal/terminal.py | 148 +++++++++++------- .../_system/_services/test_terminal.py | 27 ++-- 4 files changed, 146 insertions(+), 79 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index afa79c0a..49dc941b 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -5,9 +5,16 @@ .. _Terminal: Terminal -######## +======== -The ``Terminal`` provides a generic terminal simulation, by extending the base Service class +The ``Terminal.py`` class provides a generic terminal simulation, by extending the base Service class within PrimAITE. The aim of this is to act as the primary entrypoint for Nodes within the environment. + + +Overview +-------- + +The Terminal service uses Secure Socket (SSH) as the communication method between terminals. They operate on port 22, and are part of the services automatically +installed on Nodes when they are instantiated. Key capabilities ================ @@ -17,21 +24,22 @@ Key capabilities - Simulates common Terminal commands - Leverages the Service base class for install/uninstall, status tracking etc. - Usage ===== - - Install on a node via the ``SoftwareManager`` to start the Terminal - - Terminal Clients connect, execute commands and disconnect. + - Pre-Installs on any `HostNode` component. See the below code example of how to access the terminal. + - Terminal Clients connect, execute commands and disconnect from remote components. + - Ensures that users are logged in to the component before executing any commands. - Service runs on SSH port 22 by default. Implementation ============== -- Manages SSH commands -- Ensures User login before sending commands -- Processes SSH commands -- Returns results in a ** format. +The terminal takes inspiration from the `Database Client` and `Database Service` classes, and leverages the `UserSessionManager` +to provide User Credential authentication when receiving/processing commands. + +Terminal acts as the interface between the user/component and both the Session and Requests Managers, facilitating +the passing of requests to both. Python diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index af1c550a..5eb181a6 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -1,7 +1,8 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from enum import IntEnum -from typing import Dict, Optional +from ipaddress import IPv4Address +from typing import Optional from primaite.interface.request import RequestResponse from primaite.simulator.network.protocols.packet import DataPacket @@ -58,21 +59,32 @@ class SSHConnectionMessage(IntEnum): class SSHUserCredentials(DataPacket): - """Hold Username and Password in SSH Packets""" + """Hold Username and Password in SSH Packets.""" - username: str = None + username: str """Username for login""" - password: str = None + password: str """Password for login""" class SSHPacket(DataPacket): """Represents an SSHPacket.""" - transport_message: SSHTransportMessage = None + sender_ip_address: IPv4Address + """Sender IP Address""" - connection_message: SSHConnectionMessage = None + target_ip_address: IPv4Address + """Target IP Address""" + + transport_message: SSHTransportMessage + """Message Transport Type""" + + connection_message: SSHConnectionMessage + """Message Connection Status""" + + user_account: Optional[SSHUserCredentials] = None + """User Account Credentials if passed""" connection_uuid: Optional[str] = None # The connection uuid used to validate the session diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 039fbeb3..7f37bc28 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -3,30 +3,33 @@ from __future__ import annotations from ipaddress import IPv4Address from typing import Dict, List, Optional -from uuid import uuid4 from pydantic import BaseModel from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestPermissionValidator, RequestType from primaite.simulator.network.hardware.base import Node -from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +from primaite.simulator.network.protocols.ssh import ( + SSHConnectionMessage, + SSHPacket, + SSHTransportMessage, + SSHUserCredentials, +) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState - # TODO: This might not be needed now? class TerminalClientConnection(BaseModel): """ TerminalClientConnection Class. - This class is used to record current User Connections within the Terminal class. + This class is used to record current remote User Connections to the Terminal class. """ - parent_node: Node # Technically I think this should be HostNode, but that causes a circular import. + parent_node: Node # Technically should be HostNode but this causes circular import error. """The parent Node that this connection was created on.""" is_active: bool = True @@ -35,6 +38,9 @@ class TerminalClientConnection(BaseModel): _dest_ip_address: IPv4Address """Destination IP address of connection""" + _connection_uuid: str = None + """Connection UUID""" + @property def dest_ip_address(self) -> Optional[IPv4Address]: """Destination IP Address.""" @@ -48,7 +54,7 @@ class TerminalClientConnection(BaseModel): def disconnect(self): """Disconnect the connection.""" if self.client and self.is_active: - self.client._disconnect(self.connection_id) # noqa + self.client._disconnect(self._connection_uuid) # noqa class Terminal(Service): @@ -63,6 +69,10 @@ class Terminal(Service): operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING """Initial Operating State""" + remote_connection: TerminalClientConnection = None + + parent: Node + """Parent component the terminal service is installed on.""" def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -93,18 +103,21 @@ class Terminal(Service): _login_valid = Terminal._LoginValidator(terminal=self) rm = super()._init_request_manager() - rm.add_request("login", request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid)) + rm.add_request( + "login", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid + ), + ) return rm - def _validate_login(self, connection_id: str) -> bool: + def _validate_login(self) -> bool: """Validate login credentials are valid.""" - return self.parent.UserSessionManager.validate_remote_session_uuid(connection_id) - + return self.parent.UserSessionManager.validate_remote_session_uuid(self.connection_uuid) class _LoginValidator(RequestPermissionValidator): """ - When requests come in, this validator will only allow them through if the - User is logged into the Terminal. + When requests come in, this validator will only allow them through if the User is logged into the Terminal. Login is required before making use of the Terminal. """ @@ -113,18 +126,17 @@ class Terminal(Service): """Save a reference to the Terminal instance.""" def __call__(self, request: RequestFormat, context: Dict) -> bool: - """Return whether the Terminal has valid login credentials""" - return self.terminal.login_status - + """Return whether the Terminal has valid login credentials.""" + return self.terminal.is_connected + @property def fail_message(self) -> str: - """Message that is reported when a request is rejected by this validator""" - return ("Cannot perform request on terminal as not logged in.") - + """Message that is reported when a request is rejected by this validator.""" + return "Cannot perform request on terminal as not logged in." # %% Inbound - def login(self, username: str, password: str, ip_address: Optional[IPv4Address]=None) -> bool: + def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: """Process User request to login to Terminal. :param dest_ip_address: The IP address of the node we want to connect to. @@ -136,15 +148,12 @@ class Terminal(Service): self.sys_log.warning("Cannot process login as service is not running") return False - # need to determine if this is a local or remote login - if ip_address: - # ip_address has been given for remote login + # if ip_address has been provided, we assume we are logging in to a remote terminal. return self._send_remote_login(username=username, password=password, ip_address=ip_address) return self._process_local_login(username=username, password=password) - def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) @@ -157,25 +166,54 @@ class Terminal(Service): def _send_remote_login(self, username: str, password: str, ip_address: IPv4Address) -> bool: """Attempt to login to a remote terminal.""" - pass + transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST + connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + user_account: SSHUserCredentials = SSHUserCredentials(username=username, password=password) + payload: SSHPacket = SSHPacket( + transport_message=transport_message, + connection_message=connection_message, + user_account=user_account, + target_ip_address=ip_address, + sender_ip_address=self.parent.network_interface[1].ip_address, + ) + self.sys_log.info(f"Sending remote login request to {ip_address}") + return self.send(payload=payload, dest_ip_address=ip_address) - def _process_remote_login(self, username: str, password: str, ip_address:IPv4Address) -> bool: + def _process_remote_login(self, payload: SSHPacket) -> bool: """Processes a remote terminal requesting to login to this terminal.""" + username: str = payload.user_account.username + password: str = payload.user_account.password self.connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) + self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") + if self.connection_uuid: # Send uuid to remote - self.sys_log.info(f"Remote login authorised, connection ID {self.connection_uuid} for {username} on {ip_address}") - # send back to origin. + self.sys_log.info( + f"Remote login authorised, connection ID {self.connection_uuid} for " + f"{username} on {payload.sender_ip_address}" + ) + transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + payload = SSHPacket( + transport_message=transport_message, + connection_message=connection_message, + connection_uuid=self.connection_uuid, + sender_ip_address=self.parent.network_interface[1].ip_address, + target_ip_address=payload.sender_ip_address, + ) + self.send(payload=payload, dest_ip_address=payload.target_ip_address) return True else: + # UserSessionManager has returned None self.sys_log.warning("Login failed, incorrect Username or Password") return False - - def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: + def receive(self, payload: SSHPacket, **kwargs) -> bool: """Receive Payload and process for a response.""" + self.sys_log.debug(f"Received payload: {payload}") + if not isinstance(payload, SSHPacket): return False @@ -184,6 +222,7 @@ class Terminal(Service): return False if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: + # Close the channel connection_id = kwargs["connection_id"] dest_ip_address = kwargs["dest_ip_address"] self.disconnect(dest_ip_address=dest_ip_address) @@ -191,12 +230,13 @@ class Terminal(Service): # We need to close on the other machine as well elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - # validate login - user_account = "Username: placeholder, Password: placeholder" - self._ssh_process_login(dest_ip_address="192.168.0.10", user_account=user_account) + """Login Request Received.""" + self._process_remote_login(payload=payload) + self.sys_log.info("User Auth Success!") elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: - self.sys_log.debug("Login Successful") + self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") + self.connection_uuid = payload.connection_uuid self.is_connected = True return True @@ -208,6 +248,26 @@ class Terminal(Service): # %% Outbound + def _disconnect(self, dest_ip_address: IPv4Address) -> bool: + """Disconnect from the remote.""" + if not self.is_connected: + self.sys_log.warning("Not currently connected to remote") + return False + + if not self.remote_connection: + self.sys_log.warning("No remote connection present") + return False + + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": self.remote_connection._connection_uuid}, + dest_ip_address=dest_ip_address, + dest_port=self.port, + ) + self.connection_uuid = None + self.sys_log.info(f"{self.name}: Disconnected {self.connection_uuid}") + return True + def disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from remote connection. @@ -217,28 +277,6 @@ class Terminal(Service): self._disconnect(dest_ip_address=dest_ip_address) self.is_connected = False - def _disconnect(self, dest_ip_address: IPv4Address) -> bool: - if not self.is_connected: - return False - - if len(self.user_connections) == 0: - self.sys_log.warning(f"{self.name}: Unable to disconnect, no active connections.") - return False - if not self.user_connections.get(self.connection_uuid): - return False - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": self.connection_uuid}, - dest_ip_address=dest_ip_address, - dest_port=self.port, - ) - connection = self.user_connections.pop(self.connection_uuid) - - connection.is_active = False - - self.sys_log.info(f"{self.name}: Disconnected {self.connection_uuid}") - return True - def send( self, payload: SSHPacket, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 673b11a3..65346b45 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -62,14 +62,17 @@ def test_terminal_send(basic_network): network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") payload: SSHPacket = SSHPacket( payload="Test_Payload", transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + sender_ip_address=computer_a.network_interface[1].ip_address, + target_ip_address=computer_b.network_interface[1].ip_address, ) - assert terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") + assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) def test_terminal_fail_when_closed(basic_network): @@ -77,27 +80,33 @@ def test_terminal_fail_when_closed(basic_network): network: Network = basic_network computer: Computer = network.get_node_by_hostname("node_a") terminal: Terminal = computer.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") terminal.operating_state = ServiceOperatingState.STOPPED - assert terminal.login(ip_address="192.168.0.11") is False + assert ( + terminal.login(username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address) + is False + ) def test_terminal_disconnect(basic_network): """Terminal should set is_connected to false on disconnect""" network: Network = basic_network - computer: Computer = network.get_node_by_hostname("node_a") - terminal: Terminal = computer.software_manager.software.get("Terminal") + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + terminal_b: Terminal = computer_b.software_manager.software.get("Terminal") - assert terminal.is_connected is False + assert terminal_a.is_connected is False - terminal.login(ip_address="192.168.0.11") + terminal_a.login(username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address) - assert terminal.is_connected is True + assert terminal_a.is_connected is True - terminal.disconnect(dest_ip_address="192.168.0.11") + terminal_a.disconnect(dest_ip_address=computer_b.network_interface[1].ip_address) - assert terminal.is_connected is False + assert terminal_a.is_connected is False def test_terminal_ignores_when_off(basic_network): From a36e34ee1d84175e5efb6ad1461797dc169beda4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 24 Jul 2024 08:31:24 +0100 Subject: [PATCH 037/206] #2712 - Prepping ahead of raising PR. --- .../simulation_components/system/services/terminal.rst | 2 +- .../simulator/system/services/terminal/terminal.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 49dc941b..4d1285d1 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -21,7 +21,7 @@ Key capabilities - Authenticates User connection by maintaining an active User account. - Ensures packets are matched to an existing session - - Simulates common Terminal commands + - Simulates common Terminal processes/commands. - Leverages the Service base class for install/uninstall, status tracking etc. Usage diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 7f37bc28..d3ff47da 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -71,9 +71,6 @@ class Terminal(Service): remote_connection: TerminalClientConnection = None - parent: Node - """Parent component the terminal service is installed on.""" - def __init__(self, **kwargs): kwargs["name"] = "Terminal" kwargs["port"] = Port.SSH @@ -196,14 +193,14 @@ class Terminal(Service): ) transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA - payload = SSHPacket( + return_payload = SSHPacket( transport_message=transport_message, connection_message=connection_message, connection_uuid=self.connection_uuid, sender_ip_address=self.parent.network_interface[1].ip_address, target_ip_address=payload.sender_ip_address, ) - self.send(payload=payload, dest_ip_address=payload.target_ip_address) + self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) return True else: # UserSessionManager has returned None @@ -232,7 +229,6 @@ class Terminal(Service): elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: """Login Request Received.""" self._process_remote_login(payload=payload) - self.sys_log.info("User Auth Success!") elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") From 1cb6ce02e001a4da1bac128b0f9fe282cba45402 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 24 Jul 2024 10:38:12 +0100 Subject: [PATCH 038/206] #2712 - Correcting the use of TerminalClientConnection for remote connections. Terminal should hold a list of active remote connections to itself with connection uuid for validation --- .../system/services/terminal/terminal.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index d3ff47da..9a71b63a 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -3,6 +3,7 @@ from __future__ import annotations from ipaddress import IPv4Address from typing import Dict, List, Optional +from uuid import uuid4 from pydantic import BaseModel @@ -21,7 +22,6 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState -# TODO: This might not be needed now? class TerminalClientConnection(BaseModel): """ TerminalClientConnection Class. @@ -69,7 +69,7 @@ class Terminal(Service): operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING """Initial Operating State""" - remote_connection: TerminalClientConnection = None + remote_connection: Dict[str, TerminalClientConnection] = {} def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -110,7 +110,8 @@ class Terminal(Service): def _validate_login(self) -> bool: """Validate login credentials are valid.""" - return self.parent.UserSessionManager.validate_remote_session_uuid(self.connection_uuid) + # return self.parent.UserSessionManager.validate_remote_session_uuid(self.connection_uuid) + return True class _LoginValidator(RequestPermissionValidator): """ @@ -153,7 +154,8 @@ class Terminal(Service): def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" - self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + # self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + self.connection_uuid = str(uuid4()) if self.connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") return True @@ -182,10 +184,11 @@ class Terminal(Service): """Processes a remote terminal requesting to login to this terminal.""" username: str = payload.user_account.username password: str = payload.user_account.password - self.connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") + # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) + connection_uuid = str(uuid4()) - if self.connection_uuid: + if connection_uuid: # Send uuid to remote self.sys_log.info( f"Remote login authorised, connection ID {self.connection_uuid} for " @@ -196,11 +199,18 @@ class Terminal(Service): return_payload = SSHPacket( transport_message=transport_message, connection_message=connection_message, - connection_uuid=self.connection_uuid, + connection_uuid=connection_uuid, sender_ip_address=self.parent.network_interface[1].ip_address, target_ip_address=payload.sender_ip_address, ) self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) + + self.remote_connection[connection_uuid] = TerminalClientConnection( + parent_node=self.software_manager.node, + _dest_ip_address=payload.sender_ip_address, + connection_uuid=connection_uuid, + ) + return True else: # UserSessionManager has returned None From a0e675a09a26116a335611e7f204d9ea93df88c6 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 24 Jul 2024 11:20:01 +0100 Subject: [PATCH 039/206] #2712 - Minor changes to login Validator --- src/primaite/simulator/system/services/terminal/terminal.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 9a71b63a..f01b44a2 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -101,9 +101,9 @@ class Terminal(Service): rm = super()._init_request_manager() rm.add_request( - "login", + "send", request_type=RequestType( - func=lambda request, context: RequestResponse.from_bool(self._validate_login()), validator=_login_valid + func=lambda request, context: RequestResponse.from_bool(self.send()), validator=_login_valid ), ) return rm @@ -124,7 +124,7 @@ class Terminal(Service): """Save a reference to the Terminal instance.""" def __call__(self, request: RequestFormat, context: Dict) -> bool: - """Return whether the Terminal has valid login credentials.""" + """Return whether the Terminal is connected.""" return self.terminal.is_connected @property From 173f110fb248911c7e5748e585e67c0fbbc04b15 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 24 Jul 2024 16:38:06 +0100 Subject: [PATCH 040/206] #2769: initial commit of user account actions --- pyproject.toml | 2 +- src/primaite/game/agent/actions.py | 36 ++++++++++++++++++ .../simulator/network/hardware/base.py | 9 +++++ tests/conftest.py | 3 ++ .../test_remote_user_account_actions.py | 38 +++++++++++++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py diff --git a/pyproject.toml b/pyproject.toml index 9e919604..f63ee4c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ license-files = ["LICENSE"] [project.optional-dependencies] rl = [ - "ray[rllib] >= 2.20.0, < 3", + "ray[rllib] >= 2.32.0, < 3", "tensorflow==2.12.0", "stable-baselines3[extra]==2.1.0", "sb3-contrib==2.1.0", diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 9a5fedc9..bf8e4323 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1071,6 +1071,39 @@ class NodeNetworkServiceReconAction(AbstractAction): ] +class NodeAccountsChangePasswordAction(AbstractAction): + """Action which changes the password for a user.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + pass + + +class NodeSessionsRemoteLoginAction(AbstractAction): + """Action which performs a remote session login.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + pass + + def form_request(self, node_id: str) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return ["network", "node", node_id, "remote_logon"] + + +class NodeSessionsRemoteLogoutAction(AbstractAction): + """Action which performs a remote session logout.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + pass + + def form_request(self, node_id: str) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return ["network", "node", node_id, "remote_logoff"] + + class ActionManager: """Class which manages the action space for an agent.""" @@ -1122,6 +1155,9 @@ class ActionManager: "CONFIGURE_DATABASE_CLIENT": ConfigureDatabaseClientAction, "CONFIGURE_RANSOMWARE_SCRIPT": ConfigureRansomwareScriptAction, "CONFIGURE_DOSBOT": ConfigureDoSBotAction, + "NODE_ACCOUNTS_CHANGEPASSWORD": NodeAccountsChangePasswordAction, + "NODE_SESSIONS_REMOTE_LOGIN": NodeSessionsRemoteLoginAction, + "NODE_SESSIONS_REMOTE_LOGOUT": NodeSessionsRemoteLogoutAction, } """Dictionary which maps action type strings to the corresponding action class.""" diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 15c44821..831a8539 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1072,6 +1072,15 @@ class Node(SimComponent): "logoff", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on) ) # TODO implement logoff request + rm.add_request( + "remote_logon", + RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), + ) # TODO implement remote_logon request + rm.add_request( + "remote_logoff", + RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), + ) # TODO implement remote_logoff request + self._os_request_manager = RequestManager() self._os_request_manager.add_request( "scan", diff --git a/tests/conftest.py b/tests/conftest.py index 54519e2b..e1ce41b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -458,6 +458,9 @@ def game_and_agent(): {"type": "HOST_NIC_DISABLE"}, {"type": "NETWORK_PORT_ENABLE"}, {"type": "NETWORK_PORT_DISABLE"}, + {"type": "NODE_ACCOUNTS_CHANGEPASSWORD"}, + {"type": "NODE_SESSIONS_REMOTE_LOGIN"}, + {"type": "NODE_SESSIONS_REMOTE_LOGOUT"}, ] action_space = ActionManager( diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py new file mode 100644 index 00000000..807715bb --- /dev/null +++ b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py @@ -0,0 +1,38 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + + +def test_remote_logon(game_and_agent): + """Test that the remote session login action works.""" + game, agent = game_and_agent + + action = ( + "NODE_SESSIONS_REMOTE_LOGIN", + {"node_id": 0}, + ) + agent.store_action(action) + game.step() + + # TODO Assert that there is a logged in user + + +def test_remote_logoff(game_and_agent): + """Test that the remote session logout action works.""" + game, agent = game_and_agent + + action = ( + "NODE_SESSIONS_REMOTE_LOGIN", + {"node_id": 0}, + ) + agent.store_action(action) + game.step() + + # TODO Assert that there is a logged in user + + action = ( + "NODE_SESSIONS_REMOTE_LOGOUT", + {"node_id": 0}, + ) + agent.store_action(action) + game.step() + + # TODO Assert the user has logged out From d0c8aeae301baa4d5f56506181a42955ff77b94d Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 24 Jul 2024 17:08:18 +0100 Subject: [PATCH 041/206] #2735 - implemented remote logins. Added action remote sessions to UserSessionManager describe_state. Added suite of tests for UserSessionManager logins --- .../simulator/network/hardware/base.py | 95 +++++-- .../system/test_local_accounts.py | 37 --- .../test_user_session_manager_logins.py | 250 ++++++++++++++++++ 3 files changed, 325 insertions(+), 57 deletions(-) delete mode 100644 tests/integration_tests/system/test_local_accounts.py create mode 100644 tests/integration_tests/system/test_user_session_manager_logins.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 9e6784c5..3ffc7b35 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, ClassVar, Dict, List, Optional, TypeVar, Union from prettytable import MARKDOWN, PrettyTable -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validate_call import primaite.simulator.network.nmne from primaite import getLogger @@ -989,6 +989,12 @@ class RemoteUserSession(UserSession): remote_ip_address: IPV4Address local: bool = False + @classmethod + def create(cls, user: User, timestep: int, remote_ip_address: IPV4Address) -> RemoteUserSession: # noqa + return RemoteUserSession( + user=user, start_step=timestep, last_active_step=timestep, remote_ip_address=remote_ip_address + ) + def describe_state(self) -> Dict: state = super().describe_state() state["remote_ip_address"] = str(self.remote_ip_address) @@ -1066,7 +1072,9 @@ class UserSessionManager(Service): print(table.get_string(sortby="Step Last Active", reversesort=True)) def describe_state(self) -> Dict: - return super().describe_state() + state = super().describe_state() + state["active_remote_logins"] = len(self.remote_sessions) + return state @property def _user_manager(self) -> UserManager: @@ -1092,27 +1100,78 @@ class UserSessionManager(Service): self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") - def login(self, username: str, password: str) -> Optional[str]: + @property + def remote_session_limit_reached(self) -> bool: + return len(self.remote_sessions) >= self.max_remote_sessions + + def validate_remote_session_uuid(self, remote_session_id: str) -> bool: + return remote_session_id in self.remote_sessions + + def _login( + self, username: str, password: str, local: bool = True, remote_ip_address: Optional[IPv4Address] = None + ) -> Optional[str]: if not self._can_perform_action(): return None - user = self._user_manager.authenticate_user(username=username, password=password) - if user: - self.logout() - self.local_session = UserSession.create(user=user, timestep=self.current_timestep) - self.sys_log.info(f"{self.name}: User {user.username} logged in") - return self.local_session.uuid - else: - self.sys_log.info(f"{self.name}: Incorrect username or password") - def logout(self): + user = self._user_manager.authenticate_user(username=username, password=password) + + if not user: + self.sys_log.info(f"{self.name}: Incorrect username or password") + return None + + session_id = None + if local: + create_new_session = True + if self.local_session: + if self.local_session.user != user: + # logout the current user + self.local_logout() + else: + # not required as existing logged-in user attempting to re-login + create_new_session = False + + if create_new_session: + self.local_session = UserSession.create(user=user, timestep=self.current_timestep) + + session_id = self.local_session.uuid + else: + if not self.remote_session_limit_reached: + remote_session = RemoteUserSession.create( + user=user, timestep=self.current_timestep, remote_ip_address=remote_ip_address + ) + session_id = remote_session.uuid + self.remote_sessions[session_id] = remote_session + self.sys_log.info(f"{self.name}: User {user.username} logged in") + return session_id + + def local_login(self, username: str, password: str) -> Optional[str]: + return self._login(username=username, password=password, local=True) + + @validate_call() + def remote_login(self, username: str, password: str, remote_ip_address: IPV4Address) -> Optional[str]: + return self._login(username=username, password=password, local=False, remote_ip_address=remote_ip_address) + + def _logout(self, local: bool = True, remote_session_id: Optional[str] = None): if not self._can_perform_action(): return False - if self.local_session: + session = None + if local and self.local_session: session = self.local_session session.end_step = self.current_timestep - self.historic_sessions.append(session) self.local_session = None + + if not local and remote_session_id: + session = self.remote_sessions.pop(remote_session_id) + if session: + self.historic_sessions.append(session) self.sys_log.info(f"{self.name}: User {session.user.username} logged out") + return + + def local_logout(self): + self._logout(local=True) + + def remote_logout(self, remote_session_id: str): + self._logout(local=False, remote_session_id=remote_session_id) @property def local_user_logged_in(self): @@ -1225,8 +1284,8 @@ class Node(SimComponent): def user_session_manager(self) -> UserSessionManager: return self.software_manager.software["UserSessionManager"] # noqa - def login(self, username: str, password: str) -> Optional[str]: - return self.user_session_manager.login(username, password) + def local_login(self, username: str, password: str) -> Optional[str]: + return self.user_session_manager.local_login(username, password) def logout(self): return self.user_session_manager.logout() @@ -1765,14 +1824,10 @@ class Node(SimComponent): :param pings: The number of pings to attempt, default is 4. :return: True if the ping is successful, otherwise False. """ - if not self.user_session_manager.local_user_logged_in: - return False if not isinstance(target_ip_address, IPv4Address): target_ip_address = IPv4Address(target_ip_address) if self.software_manager.icmp: - print("yes") return self.software_manager.icmp.ping(target_ip_address, pings) - print("no icmp") return False @abstractmethod diff --git a/tests/integration_tests/system/test_local_accounts.py b/tests/integration_tests/system/test_local_accounts.py deleted file mode 100644 index dbdbf857..00000000 --- a/tests/integration_tests/system/test_local_accounts.py +++ /dev/null @@ -1,37 +0,0 @@ -# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from primaite.simulator.network.container import Network -from primaite.simulator.network.hardware.nodes.host.computer import Computer -from primaite.simulator.network.hardware.nodes.host.server import Server - - -def test_local_accounts_ping_temp(): - network = Network() - - # Create Computer - computer = Computer( - hostname="computer", - ip_address="192.168.1.2", - subnet_mask="255.255.255.0", - default_gateway="192.168.1.1", - start_up_duration=0, - ) - computer.power_on() - - # Create Server - server = Server( - hostname="server", - ip_address="192.168.1.3", - subnet_mask="255.255.255.0", - default_gateway="192.168.1.1", - start_up_duration=0, - ) - server.power_on() - - # Connect Computer and Server - network.connect(computer.network_interface[1], server.network_interface[1]) - - assert not computer.ping(server.network_interface[1].ip_address) - - computer.user_session_manager.login(username="admin", password="admin") - - assert computer.ping(server.network_interface[1].ip_address) diff --git a/tests/integration_tests/system/test_user_session_manager_logins.py b/tests/integration_tests/system/test_user_session_manager_logins.py new file mode 100644 index 00000000..955408ad --- /dev/null +++ b/tests/integration_tests/system/test_user_session_manager_logins.py @@ -0,0 +1,250 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Tuple +from uuid import uuid4 + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server + + +@pytest.fixture(scope="function") +def client_server_network() -> Tuple[Computer, Server, Network]: + network = Network() + + client = Computer( + hostname="client", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client.power_on() + + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + network.connect(client.network_interface[1], server.network_interface[1]) + + return client, server, network + + +def test_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + +def test_local_login_failure(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert not client.user_session_manager.local_user_logged_in + + +def test_new_user_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_manager.add_user(username="jane.doe", password="12345") + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + +def test_new_local_login_clears_previous_login(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + client.user_manager.add_user(username="jane.doe", password="12345") + + new_session_id = client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "jane.doe" + + assert new_session_id != current_session_id + + +def test_new_local_login_attempt_same_uses_persists(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + new_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + assert new_session_id == current_session_id + + +def test_remote_login_success(client_server_network): + # partial test for now until we get the terminal application in so that amn actual remote connection can be made + client, server, network = client_server_network + + assert not server.user_session_manager.remote_sessions + + remote_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + assert server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + server.user_session_manager.remote_logout(remote_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_remote_login_failure(client_server_network): + # partial test for now until we get the terminal application in so that amn actual remote connection can be made + client, server, network = client_server_network + + assert not server.user_session_manager.remote_sessions + + remote_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_new_user_remote_login_success(client_server_network): + client, server, network = client_server_network + + server.user_manager.add_user(username="jane.doe", password="12345") + + remote_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + assert server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + server.user_session_manager.remote_logout(remote_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_max_remote_sessions_same_user(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [ + server.user_session_manager.remote_login(username="admin", password="admin", remote_ip_address="192.168.1.10") + for _ in range(server.user_session_manager.max_remote_sessions) + ] + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_max_remote_sessions_different_users(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [] + + for i in range(server.user_session_manager.max_remote_sessions): + username = str(uuid4()) + password = "12345" + server.user_manager.add_user(username=username, password=password) + + remote_session_ids.append( + server.user_session_manager.remote_login( + username=username, password=password, remote_ip_address="192.168.1.10" + ) + ) + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_max_remote_sessions_limit_reached(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [ + server.user_session_manager.remote_login(username="admin", password="admin", remote_ip_address="192.168.1.10") + for _ in range(server.user_session_manager.max_remote_sessions) + ] + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + assert len(server.user_session_manager.remote_sessions) == server.user_session_manager.max_remote_sessions + + fourth_attempt_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + assert not server.user_session_manager.validate_remote_session_uuid(fourth_attempt_session_id) + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_single_remote_logout_others_persist(client_server_network): + client, server, network = client_server_network + + server.user_manager.add_user(username="jane.doe", password="12345") + server.user_manager.add_user(username="john.doe", password="12345") + + admin_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + jane_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + john_session_id = server.user_session_manager.remote_login( + username="john.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + server.user_session_manager.remote_logout(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(john_session_id) + + server.user_session_manager.remote_logout(jane_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(john_session_id) + + server.user_session_manager.remote_logout(john_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(john_session_id) From df50ec8abc65d08961cc49716925e6296f68b787 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Thu, 25 Jul 2024 10:02:32 +0100 Subject: [PATCH 042/206] #2769: add change password action --- src/primaite/game/agent/actions.py | 8 ++++---- src/primaite/simulator/network/hardware/base.py | 5 ++++- .../test_user_account_change_password.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index bf8e4323..266c667b 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1077,16 +1077,16 @@ class NodeAccountsChangePasswordAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self) -> RequestFormat: + def form_request(self, node_id: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - pass + return ["network", "node", node_id, "change_password"] class NodeSessionsRemoteLoginAction(AbstractAction): """Action which performs a remote session login.""" def __init__(self, manager: "ActionManager", **kwargs) -> None: - pass + super().__init__(manager=manager) def form_request(self, node_id: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" @@ -1097,7 +1097,7 @@ class NodeSessionsRemoteLogoutAction(AbstractAction): """Action which performs a remote session logout.""" def __init__(self, manager: "ActionManager", **kwargs) -> None: - pass + super().__init__(manager=manager) def form_request(self, node_id: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 831a8539..3ef33ac3 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1071,7 +1071,10 @@ class Node(SimComponent): rm.add_request( "logoff", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on) ) # TODO implement logoff request - + rm.add_request( + "change_password", + RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), + ) # TODO implement change_password request rm.add_request( "remote_logon", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py new file mode 100644 index 00000000..27328100 --- /dev/null +++ b/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py @@ -0,0 +1,13 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +def test_remote_logon(game_and_agent): + """Test that the remote session login action works.""" + game, agent = game_and_agent + + action = ( + "NODE_ACCOUNTS_CHANGEPASSWORD", + {"node_id": 0}, + ) + agent.store_action(action) + game.step() + + # TODO Assert that the user account password is changed From 0ac1c6702c7369163562fa6015cf22f22f8e0412 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 26 Jul 2024 16:56:03 +0100 Subject: [PATCH 043/206] #2713 - eod commit. Initial RequestManager Test implemented, along with an initial setup of the additional Request Manager methods. --- CHANGELOG.md | 2 +- .../system/services/terminal/terminal.py | 97 +++++++++++--- .../_system/_services/test_terminal.py | 125 +++++++++++++++++- 3 files changed, 205 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ff83ed..b27244bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added ability to log each agent's action choices in each step to a JSON file. - Removal of Link bandwidth hardcoding. This can now be configured via the network configuration yaml. Will default to 100 if not present. - Added NMAP application to all host and layer-3 network nodes. -- Added Terminal Class for HostNode components +- Added Terminal Class for HostNode components. ### Bug Fixes diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index f01b44a2..559e234c 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -2,9 +2,10 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from uuid import uuid4 +from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel from primaite.interface.request import RequestFormat, RequestResponse @@ -35,17 +36,12 @@ class TerminalClientConnection(BaseModel): is_active: bool = True """Flag to state whether the connection is still active or not.""" - _dest_ip_address: IPv4Address + dest_ip_address: IPv4Address = None """Destination IP address of connection""" _connection_uuid: str = None """Connection UUID""" - @property - def dest_ip_address(self) -> Optional[IPv4Address]: - """Destination IP Address.""" - return self._dest_ip_address - @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -95,6 +91,21 @@ class Terminal(Service): """Apply Terminal Request.""" return super().apply_request(request, context) + def show(self, markdown: bool = False): + """ + Display the remote connections to this terminal instance in tabular format. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["Connection ID", "IP_Address", "Active"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} {self.name} Remote Connections" + for connection_id, connection in self.remote_connection.items(): + table.add_row([connection_id, connection.dest_ip_address, connection.is_active]) + print(table.get_string(sortby="Connection ID")) + def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" _login_valid = Terminal._LoginValidator(terminal=self) @@ -106,12 +117,52 @@ class Terminal(Service): func=lambda request, context: RequestResponse.from_bool(self.send()), validator=_login_valid ), ) - return rm - def _validate_login(self) -> bool: - """Validate login credentials are valid.""" - # return self.parent.UserSessionManager.validate_remote_session_uuid(self.connection_uuid) - return True + def _login(request: List[Any], context: Any) -> RequestResponse: + login = self._process_local_login(username=request[0], password=request[1]) + if login == True: + return RequestResponse(status="success", data={}) + else: + return RequestResponse(status="failure", data={}) + + def _remote_login(request: List[Any], context: Any) -> RequestResponse: + self._process_remote_login(username=request[0], password=request[1], ip_address=request[2]) + if self.is_connected: + return RequestResponse(status="success", data={}) + else: + return RequestResponse(status="failure", data={}) + + def _execute(request: List[Any], context: Any) -> RequestResponse: + """Execute an instruction.""" + command: str = request[0] + self.execute(command) + return RequestResponse(status="success", data={}) + + def _logoff() -> RequestResponse: + """Logoff from connection.""" + self.parent.UserSessionManager.logoff(self.connection_uuid) + self.disconnect(self.connection_uuid) + + return RequestResponse(status="success") + + rm.add_request( + "Login", + request_type=RequestType(func=_login), + ) + + rm.add_request( + "Remote Login", + request_type=RequestType(func=_remote_login), + ) + + rm.add_request( + "Execute", + request_type=RequestType(func=_execute), + ) + + rm.add_request("Logoff", request_type=RequestType(func=_logoff)) + + return rm class _LoginValidator(RequestPermissionValidator): """ @@ -155,7 +206,8 @@ class Terminal(Service): def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" # self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) - self.connection_uuid = str(uuid4()) + self.connection_uuid = str(uuid4()) # TODO: Remove following merging of UserSessionManager. + self.is_connected = True if self.connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") return True @@ -187,7 +239,7 @@ class Terminal(Service): self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) connection_uuid = str(uuid4()) - + self.is_connected = True if connection_uuid: # Send uuid to remote self.sys_log.info( @@ -203,14 +255,14 @@ class Terminal(Service): sender_ip_address=self.parent.network_interface[1].ip_address, target_ip_address=payload.sender_ip_address, ) - self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) self.remote_connection[connection_uuid] = TerminalClientConnection( parent_node=self.software_manager.node, - _dest_ip_address=payload.sender_ip_address, + dest_ip_address=payload.sender_ip_address, connection_uuid=connection_uuid, ) + self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) return True else: # UserSessionManager has returned None @@ -246,6 +298,9 @@ class Terminal(Service): self.is_connected = True return True + elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: + return self.execute(command=payload.payload) + else: self.sys_log.warning("Encounter unexpected message type, rejecting connection") return False @@ -254,6 +309,14 @@ class Terminal(Service): # %% Outbound + def execute(self, command: List[Any]) -> bool: + """Execute a passed ssh command via the request manager.""" + if command[0] == "install": + self.parent.software_manager.software.install(command[1]) + + return True + # TODO: Expand as necessary + def _disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from the remote.""" if not self.is_connected: @@ -266,7 +329,7 @@ class Terminal(Service): software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": self.remote_connection._connection_uuid}, + payload={"type": "disconnect", "connection_id": self.connection_uuid}, dest_ip_address=dest_ip_address, dest_port=self.port, ) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 65346b45..17af5699 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -3,12 +3,20 @@ from typing import Tuple import pytest +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.terminal.terminal import Terminal +from primaite.simulator.system.services.web_server.web_server import WebServer from primaite.simulator.system.software import SoftwareHealthState @@ -117,7 +125,7 @@ def test_terminal_ignores_when_off(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") - terminal_a.login(ip_address="192.168.0.11") # login to computer_b + terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") # login to computer_b assert terminal_a.is_connected is True @@ -127,6 +135,121 @@ def test_terminal_ignores_when_off(basic_network): payload="Test_Payload", transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA, + sender_ip_address=computer_a.network_interface[1].ip_address, + target_ip_address="192.168.0.11", ) assert not terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") + + +def test_terminal_acknowledges_acl_rules(basic_network): + """Test that Terminal messages""" + + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + + terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") + + router = Router(hostname="router", num_ports=3, start_up_duration=0) + router.power_on() + router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") + router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") + + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.SSH, dst_port=Port.SSH, position=22) + + +def test_network_simulation(basic_network): + # 0: Pull out the network + network = basic_network + + # 1: Set up network hardware + # 1.1: Configure the router + router = Router(hostname="router", num_ports=3, start_up_duration=0) + router.power_on() + router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") + router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") + + # 1.2: Create and connect switches + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) + router.enable_port(1) + switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) + switch_2.power_on() + network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router.enable_port(2) + + # 1.3: Create and connect computer + client_1 = Computer( + hostname="client_1", + ip_address="10.0.1.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.1.1", + start_up_duration=0, + ) + client_1.power_on() + network.connect( + endpoint_a=client_1.network_interface[1], + endpoint_b=switch_1.network_interface[1], + ) + + # 1.4: Create and connect servers + server_1 = Server( + hostname="server_1", + ip_address="10.0.2.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_1.power_on() + network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_2.network_interface[1]) + + server_2 = Server( + hostname="server_2", + ip_address="10.0.2.3", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_2.power_on() + network.connect(endpoint_a=server_2.network_interface[1], endpoint_b=switch_2.network_interface[2]) + + # 2: Configure base ACL + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router.acl.add_rule(action=ACLAction.DENY, protocol=IPProtocol.ICMP, position=23) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.DNS, dst_port=Port.DNS, position=1) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + + # 3: Install server software + server_1.software_manager.install(DNSServer) + dns_service: DNSServer = server_1.software_manager.software.get("DNSServer") # noqa + dns_service.dns_register("www.example.com", server_2.network_interface[1].ip_address) + server_2.software_manager.install(WebServer) + + # 3.1: Ensure that the dns clients are configured correctly + client_1.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address + server_2.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address + + terminal_1: Terminal = client_1.software_manager.software.get("Terminal") + + assert terminal_1.login(username="admin", password="Admin123!", ip_address="192.168.0.11") is False + + +def test_terminal_receives_requests(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + game, agent = game_and_agent_fixture + + network: Network = game.simulation.network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + + computer_b: Computer = network.get_node_by_hostname("node_b") + + assert terminal_a.is_connected is False + + action = ("TERMINAL_LOGIN", {"username": "admin", "password": "Admin123!"}) # TODO: Add Action to ActionManager ? + + agent.store_action(action) + game.step() + + assert terminal_a.is_connected is True From a0cfe8cdfa740be50b61a95034cd8eb2fc5e0e9d Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 29 Jul 2024 08:52:16 +0100 Subject: [PATCH 044/206] #2778 - fixed the mis-merge that was trying to call the old latex function instead of the new md function. removed the old threshold leftover stuff in the report too --- benchmark/primaite_benchmark.py | 4 ++-- benchmark/report.py | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/benchmark/primaite_benchmark.py b/benchmark/primaite_benchmark.py index 27e25a0c..542f46d7 100644 --- a/benchmark/primaite_benchmark.py +++ b/benchmark/primaite_benchmark.py @@ -5,12 +5,12 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Final, Tuple -from report import build_benchmark_latex_report from stable_baselines3 import PPO import primaite from benchmark import BenchmarkPrimaiteGymEnv from primaite.config.load import data_manipulation_config_path +from report import build_benchmark_md_report _LOGGER = primaite.getLogger(__name__) @@ -188,7 +188,7 @@ def run( with open(_SESSION_METADATA_ROOT / f"{i}.json", "r") as file: session_metadata_dict[i] = json.load(file) # generate report - build_benchmark_latex_report( + build_benchmark_md_report( benchmark_start_time=benchmark_start_time, session_metadata=session_metadata_dict, config_path=data_manipulation_config_path(), diff --git a/benchmark/report.py b/benchmark/report.py index 5eaaab9f..e1ff46b9 100644 --- a/benchmark/report.py +++ b/benchmark/report.py @@ -234,10 +234,7 @@ def _plot_av_s_per_100_steps_10_nodes( """ major_v = primaite.__version__.split(".")[0] title = f"Performance of Minor and Bugfix Releases for Major Version {major_v}" - subtitle = ( - f"Average Training Time per 100 Steps on 10 Nodes " - f"(target: <= {PLOT_CONFIG['av_s_per_100_steps_10_nodes_benchmark_threshold']} seconds)" - ) + subtitle = "Average Training Time per 100 Steps on 10 Nodes " title = f"{title}
{subtitle}" layout = go.Layout( @@ -250,10 +247,6 @@ def _plot_av_s_per_100_steps_10_nodes( versions = sorted(list(version_times_dict.keys())) times = [version_times_dict[version] for version in versions] - av_s_per_100_steps_10_nodes_benchmark_threshold = PLOT_CONFIG["av_s_per_100_steps_10_nodes_benchmark_threshold"] - - # Calculate the appropriate maximum y-axis value - max_y_axis_value = max(max(times), av_s_per_100_steps_10_nodes_benchmark_threshold) + 1 fig.add_trace( go.Bar( @@ -267,7 +260,6 @@ def _plot_av_s_per_100_steps_10_nodes( fig.update_layout( xaxis_title="PrimAITE Version", yaxis_title="Avg Time per 100 Steps on 10 Nodes (seconds)", - yaxis=dict(range=[0, max_y_axis_value]), title=title, ) From 2e35549c956ba33b32111f2714a4954b2ebfd532 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 29 Jul 2024 09:29:20 +0100 Subject: [PATCH 045/206] #2735 - added docstrings to the User, UserManager, and UserSessionManager classes --- .../simulator/network/hardware/base.py | 230 ++++++++++++++++-- 1 file changed, 213 insertions(+), 17 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 3ffc7b35..e33c6014 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,7 +6,7 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, ClassVar, Dict, List, Optional, TypeVar, Union +from typing import Any, Dict, List, Optional, TypeVar, Union from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel, Field, validate_call @@ -799,16 +799,23 @@ class User(SimComponent): """ Represents a user in the PrimAITE system. - :param username: The username of the user - :param password: The password of the user - :param disabled: Boolean flag indicating whether the user is disabled - :param is_admin: Boolean flag indicating whether the user has admin privileges + :ivar username: The username of the user + :ivar password: The password of the user + :ivar disabled: Boolean flag indicating whether the user is disabled + :ivar is_admin: Boolean flag indicating whether the user has admin privileges """ username: str + """The username of the user""" + password: str + """The password of the user""" + disabled: bool = False + """Boolean flag indicating whether the user is disabled""" + is_admin: bool = False + """Boolean flag indicating whether the user has admin privileges""" def describe_state(self) -> Dict: """ @@ -971,47 +978,131 @@ class UserManager(Service): class UserSession(SimComponent): + """ + Represents a user session on the Node. + + This class manages the state of a user session, including the user, session start, last active step, + and end step. It also indicates whether the session is local. + + :ivar user: The user associated with this session. + :ivar start_step: The timestep when the session was started. + :ivar last_active_step: The last timestep when the session was active. + :ivar end_step: The timestep when the session ended, if applicable. + :ivar local: Indicates if the session is local. Defaults to True. + """ + user: User + """The user associated with this session.""" + start_step: int + """The timestep when the session was started.""" + last_active_step: int + """The last timestep when the session was active.""" + end_step: Optional[int] = None + """The timestep when the session ended, if applicable.""" + local: bool = True + """Indicates if the session is local. Defaults to True.""" @classmethod def create(cls, user: User, timestep: int) -> UserSession: + """ + Creates a new instance of UserSession. + + This class method initialises a user session with the given user and timestep. + + :param user: The user associated with this session. + :param timestep: The timestep when the session is created. + :return: An instance of UserSession. + """ return UserSession(user=user, start_step=timestep, last_active_step=timestep) def describe_state(self) -> Dict: + """ + Describes the current state of the user session. + + :return: A dictionary representing the state of the user session. + """ return self.model_dump() class RemoteUserSession(UserSession): + """ + Represents a remote user session on the Node. + + This class extends the UserSession class to include additional attributes and methods specific to remote sessions. + + :ivar remote_ip_address: The IP address of the remote user. + :ivar local: Indicates that this is not a local session. Always set to False. + """ + remote_ip_address: IPV4Address + """The IP address of the remote user.""" + local: bool = False + """Indicates that this is not a local session. Always set to False.""" @classmethod def create(cls, user: User, timestep: int, remote_ip_address: IPV4Address) -> RemoteUserSession: # noqa + """ + Creates a new instance of RemoteUserSession. + + This class method initialises a remote user session with the given user, timestep, and remote IP address. + + :param user: The user associated with this session. + :param timestep: The timestep when the session is created. + :param remote_ip_address: The IP address of the remote user. + :return: An instance of RemoteUserSession. + """ return RemoteUserSession( user=user, start_step=timestep, last_active_step=timestep, remote_ip_address=remote_ip_address ) def describe_state(self) -> Dict: + """ + Describes the current state of the remote user session. + + This method extends the base describe_state method to include the remote IP address. + + :return: A dictionary representing the state of the remote user session. + """ state = super().describe_state() state["remote_ip_address"] = str(self.remote_ip_address) return state class UserSessionManager(Service): + """ + Manages user sessions on a Node, including local and remote sessions. + + This class handles authentication, session management, and session timeouts for users interacting with the Node. + """ + node: Node + """The node associated with this UserSessionManager.""" + local_session: Optional[UserSession] = None + """The current local user session, if any.""" + remote_sessions: Dict[str, RemoteUserSession] = Field(default_factory=dict) + """A dictionary of active remote user sessions.""" + historic_sessions: List[UserSession] = Field(default_factory=list) + """A list of historic user sessions.""" local_session_timeout_steps: int = 30 + """The number of steps before a local session times out due to inactivity.""" + remote_session_timeout_steps: int = 5 + """The number of steps before a remote session times out due to inactivity.""" + max_remote_sessions: int = 3 + """The maximum number of concurrent remote sessions allowed.""" current_timestep: int = 0 + """The current timestep in the simulation.""" def __init__(self, **kwargs): """ @@ -1027,7 +1118,13 @@ class UserSessionManager(Service): self.start() def show(self, markdown: bool = False, include_session_id: bool = False, include_historic: bool = False): - """Prints a table of the user sessions on the Node.""" + """ + Displays a table of the user sessions on the Node. + + :param markdown: Whether to display the table in markdown format. + :param include_session_id: Whether to include session IDs in the table. + :param include_historic: Whether to include historic sessions in the table. + """ headers = ["Session ID", "Username", "Type", "Remote IP", "Start Step", "Step Last Active", "End Step"] if not include_session_id: @@ -1041,6 +1138,14 @@ class UserSessionManager(Service): table.title = f"{self.node.hostname} User Sessions" def _add_session_to_table(user_session: UserSession): + """ + Adds a user session to the table for display. + + This helper function determines whether the session is local or remote and formats the session data + accordingly. It then adds the session data to the table. + + :param user_session: The user session to add to the table. + """ session_type = "local" remote_ip = "" if isinstance(user_session, RemoteUserSession): @@ -1072,12 +1177,22 @@ class UserSessionManager(Service): print(table.get_string(sortby="Step Last Active", reversesort=True)) def describe_state(self) -> Dict: + """ + Describes the current state of the UserSessionManager. + + :return: A dictionary representing the current state. + """ state = super().describe_state() state["active_remote_logins"] = len(self.remote_sessions) return state @property def _user_manager(self) -> UserManager: + """ + Returns the UserManager instance. + + :return: The UserManager instance. + """ return self.software_manager.software["UserManager"] # noqa def pre_timestep(self, timestep: int) -> None: @@ -1088,6 +1203,11 @@ class UserSessionManager(Service): self._timeout_session(self.local_session) def _timeout_session(self, session: UserSession) -> None: + """ + Handles session timeout logic. + + :param session: The session to be timed out. + """ session.end_step = self.current_timestep session_identity = session.user.username if session.local: @@ -1102,14 +1222,34 @@ class UserSessionManager(Service): @property def remote_session_limit_reached(self) -> bool: + """ + Checks if the maximum number of remote sessions has been reached. + + :return: True if the limit is reached, otherwise False. + """ return len(self.remote_sessions) >= self.max_remote_sessions def validate_remote_session_uuid(self, remote_session_id: str) -> bool: + """ + Validates if a given remote session ID exists. + + :param remote_session_id: The remote session ID to validate. + :return: True if the session ID exists, otherwise False. + """ return remote_session_id in self.remote_sessions def _login( - self, username: str, password: str, local: bool = True, remote_ip_address: Optional[IPv4Address] = None + self, username: str, password: str, local: bool = True, remote_ip_address: Optional[IPv4Address] = None ) -> Optional[str]: + """ + Logs a user in either locally or remotely. + + :param username: The username of the account. + :param password: The password of the account. + :param local: Whether the login is local or remote. + :param remote_ip_address: The remote IP address for remote login. + :return: The session ID if login is successful, otherwise None. + """ if not self._can_perform_action(): return None @@ -1145,13 +1285,35 @@ class UserSessionManager(Service): return session_id def local_login(self, username: str, password: str) -> Optional[str]: + """ + Logs a user in locally. + + :param username: The username of the account. + :param password: The password of the account. + :return: The session ID if login is successful, otherwise None. + """ return self._login(username=username, password=password, local=True) @validate_call() def remote_login(self, username: str, password: str, remote_ip_address: IPV4Address) -> Optional[str]: + """ + Logs a user in remotely. + + :param username: The username of the account. + :param password: The password of the account. + :param remote_ip_address: The remote IP address for the remote login. + :return: The session ID if login is successful, otherwise None. + """ return self._login(username=username, password=password, local=False, remote_ip_address=remote_ip_address) - def _logout(self, local: bool = True, remote_session_id: Optional[str] = None): + def _logout(self, local: bool = True, remote_session_id: Optional[str] = None) -> bool: + """ + Logs a user out either locally or remotely. + + :param local: Whether the logout is local or remote. + :param remote_session_id: The remote session ID for remote logout. + :return: True if logout successful, otherwise False. + """ if not self._can_perform_action(): return False session = None @@ -1165,16 +1327,33 @@ class UserSessionManager(Service): if session: self.historic_sessions.append(session) self.sys_log.info(f"{self.name}: User {session.user.username} logged out") - return + return True + return False - def local_logout(self): - self._logout(local=True) + def local_logout(self) -> bool: + """ + Logs out the current local user. - def remote_logout(self, remote_session_id: str): - self._logout(local=False, remote_session_id=remote_session_id) + :return: True if logout successful, otherwise False. + """ + return self._logout(local=True) + + def remote_logout(self, remote_session_id: str) -> bool: + """ + Logs out a remote user by session ID. + + :param remote_session_id: The remote session ID. + :return: True if logout successful, otherwise False. + """ + return self._logout(local=False, remote_session_id=remote_session_id) @property - def local_user_logged_in(self): + def local_user_logged_in(self) -> bool: + """ + Checks if a local user is currently logged in. + + :return: True if a local user is logged in, otherwise False. + """ return self.local_session is not None @@ -1249,7 +1428,7 @@ class Node(SimComponent): """ Initialize the Node with various components and managers. - This method initializes the ARP cache, ICMP handler, session manager, and software manager if they are not + This method initialises the ARP cache, ICMP handler, session manager, and software manager if they are not provided. """ if not kwargs.get("sys_log"): @@ -1278,17 +1457,34 @@ class Node(SimComponent): @property def user_manager(self) -> UserManager: + """The Nodes User Manager.""" return self.software_manager.software["UserManager"] # noqa @property def user_session_manager(self) -> UserSessionManager: + """The Nodes User Session Manager.""" return self.software_manager.software["UserSessionManager"] # noqa def local_login(self, username: str, password: str) -> Optional[str]: + """ + Attempt to log in to the node uas a local user. + + This method attempts to authenticate a local user with the given username and password. If successful, it + returns a session token. If authentication fails, it returns None. + + :param username: The username of the account attempting to log in. + :param password: The password of the account attempting to log in. + :return: A session token if the login is successful, otherwise None. + """ return self.user_session_manager.local_login(username, password) - def logout(self): - return self.user_session_manager.logout() + def local_logout(self) -> None: + """ + Log out the current local user from the node. + + This method ends the current local user's session and invalidates the session token. + """ + return self.user_session_manager.local_logout() def ip_is_network_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: """ From 8af7fc0ecd94e5f75805c059013c1aa5cfcba43d Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 29 Jul 2024 09:31:50 +0100 Subject: [PATCH 046/206] #2778 - ran pre-commit --- benchmark/primaite_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/primaite_benchmark.py b/benchmark/primaite_benchmark.py index 542f46d7..0e6c2acc 100644 --- a/benchmark/primaite_benchmark.py +++ b/benchmark/primaite_benchmark.py @@ -5,12 +5,12 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Final, Tuple +from report import build_benchmark_md_report from stable_baselines3 import PPO import primaite from benchmark import BenchmarkPrimaiteGymEnv from primaite.config.load import data_manipulation_config_path -from report import build_benchmark_md_report _LOGGER = primaite.getLogger(__name__) From 265632669ee9f947c0fca6916000899181e3b529 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 29 Jul 2024 10:29:12 +0100 Subject: [PATCH 047/206] #2778 - added request managers for USerManager and UserSessionManager classes --- .../simulator/network/hardware/base.py | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index e33c6014..0a561707 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -850,8 +850,29 @@ class UserManager(Service): kwargs["port"] = Port.NONE kwargs["protocol"] = IPProtocol.NONE super().__init__(**kwargs) + self._request_manager = None + self.start() + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + # todo add doc about requeest schemas + rm.add_request( + "change_password", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.change_user_password(username=request[0], current_password=request[1], new_password=request[2]) + ) + ), + ) + return rm + def describe_state(self) -> Dict: """ Returns the state of the UserManager along with the number of users and admins. @@ -1117,6 +1138,34 @@ class UserSessionManager(Service): super().__init__(**kwargs) self.start() + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + # todo add doc about requeest schemas + rm.add_request( + "remote_login", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.remote_login(username=request[0], password=request[1], remote_ip_address=request[2]) + ) + ), + ) + + rm.add_request( + "remote_logout", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.remote_logout(remote_session_id=request[0]) + ) + ), + ) + return rm + def show(self, markdown: bool = False, include_session_id: bool = False, include_historic: bool = False): """ Displays a table of the user sessions on the Node. @@ -1686,6 +1735,10 @@ class Node(SimComponent): self._application_manager.add_request(name="install", request_type=RequestType(func=_install_application)) self._application_manager.add_request(name="uninstall", request_type=RequestType(func=_uninstall_application)) + rm.add_request("accounts", RequestType(func=self.user_manager._request_manager)) # noqa + + rm.add_request("sessions", RequestType(func=self.user_session_manager._request_manager)) # noqa + return rm def describe_state(self) -> Dict: @@ -1868,7 +1921,6 @@ class Node(SimComponent): def pre_timestep(self, timestep: int) -> None: """Apply pre-timestep logic.""" super().pre_timestep(timestep) - self._ for network_interface in self.network_interfaces.values(): network_interface.pre_timestep(timestep=timestep) From cf7341a4fda5994c4000ae5730d11921b5658ed0 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 29 Jul 2024 10:50:32 +0100 Subject: [PATCH 048/206] #2713 - Minor changes before merging into main Terminal branch --- .../system/services/terminal/terminal.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 559e234c..cadc8853 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -3,7 +3,6 @@ from __future__ import annotations from ipaddress import IPv4Address from typing import Any, Dict, List, Optional -from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel @@ -157,10 +156,10 @@ class Terminal(Service): rm.add_request( "Execute", - request_type=RequestType(func=_execute), + request_type=RequestType(func=_execute, validator=_login_valid), ) - rm.add_request("Logoff", request_type=RequestType(func=_logoff)) + rm.add_request("Logoff", request_type=RequestType(func=_logoff, validator=_login_valid)) return rm @@ -205,8 +204,7 @@ class Terminal(Service): def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" - # self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) - self.connection_uuid = str(uuid4()) # TODO: Remove following merging of UserSessionManager. + self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) self.is_connected = True if self.connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") @@ -233,12 +231,15 @@ class Terminal(Service): return self.send(payload=payload, dest_ip_address=ip_address) def _process_remote_login(self, payload: SSHPacket) -> bool: - """Processes a remote terminal requesting to login to this terminal.""" + """Processes a remote terminal requesting to login to this terminal. + + :param payload: The SSH Payload Packet. + :return: True if successful, else False. + """ username: str = payload.user_account.username password: str = payload.user_account.password self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") - # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) - connection_uuid = str(uuid4()) + connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) self.is_connected = True if connection_uuid: # Send uuid to remote @@ -270,7 +271,11 @@ class Terminal(Service): return False def receive(self, payload: SSHPacket, **kwargs) -> bool: - """Receive Payload and process for a response.""" + """Receive Payload and process for a response. + + :param payload: The message contents received. + :return: True if successfull, else False. + """ self.sys_log.debug(f"Received payload: {payload}") if not isinstance(payload, SSHPacket): @@ -286,11 +291,9 @@ class Terminal(Service): dest_ip_address = kwargs["dest_ip_address"] self.disconnect(dest_ip_address=dest_ip_address) self.sys_log.debug(f"Disconnecting {connection_id}") - # We need to close on the other machine as well elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - """Login Request Received.""" - self._process_remote_login(payload=payload) + return self._process_remote_login(payload=payload) elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") @@ -311,11 +314,12 @@ class Terminal(Service): def execute(self, command: List[Any]) -> bool: """Execute a passed ssh command via the request manager.""" + # TODO: Expand as necessary, as new functionalilty is needed. if command[0] == "install": self.parent.software_manager.software.install(command[1]) - - return True - # TODO: Expand as necessary + return True + else: + return False def _disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from the remote.""" From f78cb24150ec8bac8f0be0970874df7e1836e850 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 29 Jul 2024 14:20:29 +0100 Subject: [PATCH 049/206] #2706 - Removed some un-necessary comments and changes to network used in terminal ACL unit test --- .../simulator/system/services/terminal/terminal.py | 6 ------ .../_simulator/_system/_services/test_terminal.py | 11 +++++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index cadc8853..3caf57be 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -72,8 +72,6 @@ class Terminal(Service): kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - # %% Util - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -182,8 +180,6 @@ class Terminal(Service): """Message that is reported when a request is rejected by this validator.""" return "Cannot perform request on terminal as not logged in." - # %% Inbound - def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: """Process User request to login to Terminal. @@ -310,8 +306,6 @@ class Terminal(Service): return True - # %% Outbound - def execute(self, command: List[Any]) -> bool: """Execute a passed ssh command via the request manager.""" # TODO: Expand as necessary, as new functionalilty is needed. diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 17af5699..e1241bbe 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -17,7 +17,6 @@ from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.services.web_server.web_server import WebServer -from primaite.simulator.system.software import SoftwareHealthState @pytest.fixture(scope="function") @@ -194,6 +193,14 @@ def test_network_simulation(basic_network): endpoint_b=switch_1.network_interface[1], ) + client_2 = Computer( + hostname="client_2", + ip_address="10.0.2.2", + subnet_mask="255.255.255.0", + ) + client_2.power_on() + network.connect(endpoint_a=client_2.network_interface[1], endpoint_b=switch_2.network_interface[1]) + # 1.4: Create and connect servers server_1 = Server( hostname="server_1", @@ -233,7 +240,7 @@ def test_network_simulation(basic_network): terminal_1: Terminal = client_1.software_manager.software.get("Terminal") - assert terminal_1.login(username="admin", password="Admin123!", ip_address="192.168.0.11") is False + assert terminal_1.login(username="admin", password="Admin123!", ip_address="10.0.2.2") is False def test_terminal_receives_requests(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): From 3d13669671403dbda1a60c07a65aab9f1e755328 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 29 Jul 2024 15:12:24 +0100 Subject: [PATCH 050/206] #2735: fixes to broken items --- src/primaite/simulator/network/hardware/base.py | 17 ++++++++++------- .../network/hardware/nodes/network/switch.py | 3 +++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 0a561707..08f14b7e 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -850,7 +850,6 @@ class UserManager(Service): kwargs["port"] = Port.NONE kwargs["protocol"] = IPProtocol.NONE super().__init__(**kwargs) - self._request_manager = None self.start() @@ -1499,20 +1498,28 @@ class Node(SimComponent): super().__init__(**kwargs) self.session_manager.node = self self.session_manager.software_manager = self.software_manager + self.software_manager.install(UserSessionManager, node=self) + self._request_manager.add_request( + "sessions", RequestType(func=self.user_session_manager._request_manager) + ) # noqa + self.software_manager.install(UserManager) + self._request_manager.add_request("accounts", RequestType(func=self.user_manager._request_manager)) # noqa + self.user_manager.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) + self._install_system_software() @property def user_manager(self) -> UserManager: """The Nodes User Manager.""" - return self.software_manager.software["UserManager"] # noqa + return self.software_manager.software.get("UserManager") # noqa @property def user_session_manager(self) -> UserSessionManager: """The Nodes User Session Manager.""" - return self.software_manager.software["UserSessionManager"] # noqa + return self.software_manager.software.get("UserSessionManager") # noqa def local_login(self, username: str, password: str) -> Optional[str]: """ @@ -1735,10 +1742,6 @@ class Node(SimComponent): self._application_manager.add_request(name="install", request_type=RequestType(func=_install_application)) self._application_manager.add_request(name="uninstall", request_type=RequestType(func=_uninstall_application)) - rm.add_request("accounts", RequestType(func=self.user_manager._request_manager)) # noqa - - rm.add_request("sessions", RequestType(func=self.user_session_manager._request_manager)) # noqa - return rm def describe_state(self) -> Dict: diff --git a/src/primaite/simulator/network/hardware/nodes/network/switch.py b/src/primaite/simulator/network/hardware/nodes/network/switch.py index 1a7da2e7..4324ac94 100644 --- a/src/primaite/simulator/network/hardware/nodes/network/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/network/switch.py @@ -108,6 +108,9 @@ class Switch(NetworkNode): for i in range(1, self.num_ports + 1): self.connect_nic(SwitchPort()) + def _install_system_software(self): + pass + def show(self, markdown: bool = False): """ Prints a table of the SwitchPorts on the Switch. From 0fad61eaea2d1d39c94fe5241125292c5686fc71 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 29 Jul 2024 15:15:15 +0100 Subject: [PATCH 051/206] #2735: pipeline build fail if test fails --- .azure/azure-ci-build-pipeline.yaml | 4 +--- run_test_and_coverage.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 run_test_and_coverage.py diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index 01111290..2375a391 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -102,9 +102,7 @@ stages: version: '2.1.x' - script: | - coverage run -m --source=primaite pytest -v -o junit_family=xunit2 --junitxml=junit/test-results.xml --cov-fail-under=80 - coverage xml -o coverage.xml -i - coverage html -d htmlcov -i + python run_test_and_coverage.py displayName: 'Run tests and code coverage' # Run the notebooks diff --git a/run_test_and_coverage.py b/run_test_and_coverage.py new file mode 100644 index 00000000..3bd9072d --- /dev/null +++ b/run_test_and_coverage.py @@ -0,0 +1,22 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import subprocess +import sys +from typing import Any + + +def run_command(command: Any): + """Runs a command and returns the exit code.""" + result = subprocess.run(command, shell=True) + if result.returncode != 0: + sys.exit(result.returncode) + + +# Run pytest with coverage +run_command( + "coverage run -m --source=primaite pytest -v -o junit_family=xunit2 " + "--junitxml=junit/test-results.xml --cov-fail-under=80" +) + +# Generate coverage reports if tests passed +run_command("coverage xml -o coverage.xml -i") +run_command("coverage html -d htmlcov -i") From e492f19a437b7aa119b524ac556ee91b99e1d900 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 29 Jul 2024 17:10:13 +0100 Subject: [PATCH 052/206] #2706 - Small change to execute method following feedback --- .../simulator/system/services/terminal/terminal.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 3caf57be..ca0d7c1f 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -306,14 +306,9 @@ class Terminal(Service): return True - def execute(self, command: List[Any]) -> bool: + def execute(self, command: List[Any]) -> RequestResponse: """Execute a passed ssh command via the request manager.""" - # TODO: Expand as necessary, as new functionalilty is needed. - if command[0] == "install": - self.parent.software_manager.software.install(command[1]) - return True - else: - return False + return self.parent.apply_request(command) def _disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from the remote.""" From c984d695cca3b2ac53d8ce7eff3fcce34aa43b94 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 29 Jul 2024 23:03:26 +0100 Subject: [PATCH 053/206] #2735: use ray version 2.32 until 2.33 is fixed --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e919604..01be8d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ license-files = ["LICENSE"] [project.optional-dependencies] rl = [ - "ray[rllib] >= 2.20.0, < 3", + "ray[rllib] == 2.32.0, < 3", "tensorflow==2.12.0", "stable-baselines3[extra]==2.1.0", "sb3-contrib==2.1.0", From bb0ecb93a4b9070b66da36f51c44bd4eb5f49d74 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 09:57:47 +0100 Subject: [PATCH 054/206] #2706 - Correcting whitespace change in database_service.py and actioning some review comments --- src/primaite/simulator/network/protocols/ssh.py | 3 --- .../simulator/system/services/database/database_service.py | 2 +- src/primaite/simulator/system/services/terminal/terminal.py | 4 ++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 5eb181a6..8671a1c8 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -7,9 +7,6 @@ from typing import Optional from primaite.interface.request import RequestResponse from primaite.simulator.network.protocols.packet import DataPacket -# TODO: Elaborate / Confirm / Validate - See 2709. -# Placeholder implementation for Terminal Class implementation. - class SSHTransportMessage(IntEnum): """ diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index f061b3c7..22ae0ff3 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -21,7 +21,7 @@ class DatabaseService(Service): """ A class for simulating a generic SQL Server service. - This class inherits from the `Service` class and provides methods to simulate a SQL database. + This class inherits from the `Service` class and provides methods to simulate a SQL database. """ password: Optional[str] = None diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index ca0d7c1f..884d3f5b 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -117,7 +117,7 @@ class Terminal(Service): def _login(request: List[Any], context: Any) -> RequestResponse: login = self._process_local_login(username=request[0], password=request[1]) - if login == True: + if login: return RequestResponse(status="success", data={}) else: return RequestResponse(status="failure", data={}) @@ -140,7 +140,7 @@ class Terminal(Service): self.parent.UserSessionManager.logoff(self.connection_uuid) self.disconnect(self.connection_uuid) - return RequestResponse(status="success") + return RequestResponse(status="success", data={}) rm.add_request( "Login", From 2e1d6222286a885ae48c498c8a7d691b23a7de32 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 30 Jul 2024 09:57:48 +0100 Subject: [PATCH 055/206] #2778 - pinned Ray version to <2.33 until they fix their bug --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e919604..c9b7c062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ license-files = ["LICENSE"] [project.optional-dependencies] rl = [ - "ray[rllib] >= 2.20.0, < 3", + "ray[rllib] >= 2.20.0, <2.33", "tensorflow==2.12.0", "stable-baselines3[extra]==2.1.0", "sb3-contrib==2.1.0", From ab267982404482907ade2f40af6a120a2d3bab24 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 10:23:34 +0100 Subject: [PATCH 056/206] #2706 - New test to check that the terminal can receive and process commmands. --- .../_system/_services/test_terminal.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index e1241bbe..7dd7c2b1 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -65,7 +65,7 @@ def test_terminal_not_on_switch(): def test_terminal_send(basic_network): - """Check that Terminal can send""" + """Test that Terminal can send valid commands.""" network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") @@ -82,6 +82,28 @@ def test_terminal_send(basic_network): assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) +def test_terminal_receive(basic_network): + """Test that terminal can receive and process commands""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + folder_name = "Downloads" + + payload: SSHPacket = SSHPacket( + payload=["file_system", "create", "folder", folder_name], + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + sender_ip_address=computer_a.network_interface[1].ip_address, + target_ip_address=computer_b.network_interface[1].ip_address, + ) + + assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) + + # Assert that the Folder has been correctly created + assert computer_b.file_system.get_folder(folder_name) + + def test_terminal_fail_when_closed(basic_network): """Ensure Terminal won't attempt to send/receive when off""" network: Network = basic_network @@ -254,7 +276,7 @@ def test_terminal_receives_requests(game_and_agent_fixture: Tuple[PrimaiteGame, assert terminal_a.is_connected is False - action = ("TERMINAL_LOGIN", {"username": "admin", "password": "Admin123!"}) # TODO: Add Action to ActionManager ? + action = ("TERMINAL_LOGIN", {"username": "admin", "password": "Admin123!"}) agent.store_action(action) game.step() From 2b33a6edb4fe63214421f9da9959718f74e493f2 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 11:04:55 +0100 Subject: [PATCH 057/206] #2706 - New unit test to show that Terminal is able to send/handle install commands --- .../_system/_services/test_terminal.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 7dd7c2b1..aad32863 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -13,6 +13,7 @@ from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.terminal.terminal import Terminal @@ -104,6 +105,26 @@ def test_terminal_receive(basic_network): assert computer_b.file_system.get_folder(folder_name) +def test_terminal_install(basic_network): + """Test that Terminal can successfully process an INSTALL request""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + + payload: SSHPacket = SSHPacket( + payload=["software_manager", "application", "install", "RansomwareScript"], + transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + sender_ip_address=computer_a.network_interface[1].ip_address, + target_ip_address=computer_b.network_interface[1].ip_address, + ) + + terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) + + assert computer_b.software_manager.software.get("RansomwareScript") + + def test_terminal_fail_when_closed(basic_network): """Ensure Terminal won't attempt to send/receive when off""" network: Network = basic_network From 2f50feb0a068171ec5afb7eb99391ad963c5b749 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 11:11:08 +0100 Subject: [PATCH 058/206] #2706 - Removing redundant unit test from --- .../_system/_services/test_terminal.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index aad32863..411f0ebe 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -184,23 +184,6 @@ def test_terminal_ignores_when_off(basic_network): assert not terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") -def test_terminal_acknowledges_acl_rules(basic_network): - """Test that Terminal messages""" - - network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") - - terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") - - router = Router(hostname="router", num_ports=3, start_up_duration=0) - router.power_on() - router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") - router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") - - router.acl.add_rule(action=ACLAction.DENY, src_port=Port.SSH, dst_port=Port.SSH, position=22) - - def test_network_simulation(basic_network): # 0: Pull out the network network = basic_network From 556239a535312e26775bba22147228b5a3e6f0ca Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 30 Jul 2024 11:17:10 +0100 Subject: [PATCH 059/206] #2689 Initial base class implementation --- .../simulator/network/protocols/masquerade.py | 30 +++ .../red_applications/c2/__init__.py | 1 + .../red_applications/c2/abstract_c2.py | 225 ++++++++++++++++++ .../red_applications/c2/c2_beacon.py | 1 + .../red_applications/c2/c2_server.py | 1 + 5 files changed, 258 insertions(+) create mode 100644 src/primaite/simulator/network/protocols/masquerade.py create mode 100644 src/primaite/simulator/system/applications/red_applications/c2/__init__.py create mode 100644 src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py create mode 100644 src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py create mode 100644 src/primaite/simulator/system/applications/red_applications/c2/c2_server.py diff --git a/src/primaite/simulator/network/protocols/masquerade.py b/src/primaite/simulator/network/protocols/masquerade.py new file mode 100644 index 00000000..93554f57 --- /dev/null +++ b/src/primaite/simulator/network/protocols/masquerade.py @@ -0,0 +1,30 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from enum import Enum +from typing import Optional + +from primaite.simulator.network.protocols.packet import DataPacket + + +class C2Payload(Enum): + """Represents the different types of command and control payloads.""" + + KEEP_ALIVE = "keep_alive" + """C2 Keep Alive payload. Used by the C2 beacon and C2 Server to confirm their connection.""" + + INPUT = "input_command" + """C2 Input Command payload. Used by the C2 Server to send a command to the c2 beacon.""" + + OUTPUT = "output_command" + """C2 Output Command. Used by the C2 Beacon to send the results of a Input command to the c2 server.""" + + +class MasqueradePacket(DataPacket): + """Represents an generic malicious packet that is masquerading as another protocol.""" + + masquerade_protocol: Enum # The 'Masquerade' protocol that is currently in use + + masquerade_port: Enum # The 'Masquerade' port that is currently in use + + payload_type: C2Payload # The type of C2 traffic (e.g keep alive, command or command out) + + command: Optional[str] # Used to pass the actual C2 Command in C2 INPUT diff --git a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py new file mode 100644 index 00000000..647bfcb5 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -0,0 +1,225 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from abc import abstractmethod +from enum import Enum +from ipaddress import IPv4Address +from typing import Dict, Optional + +from pydantic import validate_call + +from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application + + +class AbstractC2(Application): + """ + An abstract command and control (c2) application. + + Extends the Application class to provide base functionality for c2 suite applications + such as c2 beacons and c2 servers. + + Provides the base methods for handling ``Keep Alive`` connections, configuring masquerade ports and protocols + as well as providing the abstract methods for sending, receiving and parsing commands. + """ + + c2_connection_active: bool = False + """Indicates if the c2 server and c2 beacon are currently connected.""" + + c2_remote_connection: IPv4Address = None + """The IPv4 Address of the remote c2 connection. (Either the IP of the beacon or the server)""" + + keep_alive_sent: bool = False + """Indicates if a keep alive has been sent this timestep. Used to prevent packet storms.""" + + # We should set the application to NOT_RUNNING if the inactivity count reaches a certain thresh hold. + keep_alive_inactivity: int = 0 + """Indicates how many timesteps since the last time the c2 application received a keep alive.""" + + # These two attributes are set differently in the c2 server and c2 beacon. + # The c2 server parses the keep alive and sets these accordingly. + # The c2 beacon will set this attributes upon installation and configuration + + current_masquerade_protocol: Enum = IPProtocol.TCP + """The currently chosen protocol that the C2 traffic is masquerading as. Defaults as TCP.""" + + current_masquerade_port: Enum = Port.FTP + """The currently chosen port that the C2 traffic is masquerading as. Defaults at FTP.""" + + def __init__(self, **kwargs): + kwargs["name"] = "C2" + kwargs["port"] = self.current_masquerade_port + kwargs["protocol"] = self.current_masquerade_protocol + + # TODO: Move this duplicate method from NMAP class into 'Application' to adhere to DRY principle. + def _can_perform_network_action(self) -> bool: + """ + Checks if the C2 application can perform outbound network actions. + + This is done by checking the parent application can_per_action functionality. + Then checking if there is an enabled NIC that can be used for outbound traffic. + + :return: True if outbound network actions can be performed, otherwise False. + """ + if not super()._can_perform_action(): + return False + + for nic in self.software_manager.node.network_interface.values(): + if nic.enabled: + return True + return False + + def describe_state(self) -> Dict: + """ + Describe the state of the C2 application. + + :return: A dictionary representation of the C2 application's state. + :rtype: Dict + """ + return super().describe_state() + + # Validate call ensures we are only handling Masquerade Packets. + @validate_call + def _handle_c2_payload(self, payload: MasqueradePacket) -> bool: + """Handles masquerade payloads for both c2 beacons and c2 servers. + + Currently, the C2 application suite can handle the following payloads: + + KEEP ALIVE: + Establishes or confirms connection from the C2 Beacon to the C2 server. + Sent by both C2 beacons and C2 Servers. + + INPUT: + Contains a c2 command which must be executed by the C2 beacon. + Sent by C2 Servers and received by C2 Beacons. + + OUTPUT: + Contains the output of a c2 command which must be returned to the C2 Server. + Sent by C2 Beacons and received by C2 Servers + + The payload is passed to a different method dependant on the payload type. + + :param payload: The C2 Payload to be parsed and handled. + :return: True if the c2 payload was handled successfully, False otherwise. + """ + if payload.payload_type == C2Payload.KEEP_ALIVE: + self.sys_log.info(f"{self.name} received a KEEP ALIVE!") + return self._handle_keep_alive(payload) + + elif payload.payload_type == C2Payload.INPUT: + self.sys_log.info(f"{self.name} received an INPUT COMMAND!") + return self._handle_command_input(payload) + + elif payload.payload_type == C2Payload.OUTPUT: + self.sys_log.info(f"{self.name} received an OUTPUT COMMAND!") + return self._handle_command_input(payload) + + else: + self.sys_log.warning( + f"{self.name} received an unexpected c2 payload:{payload.payload_type}. Dropping Packet." + ) + return False + + # Abstract method + # Used in C2 server to prase and receive the output of commands sent to the c2 beacon. + @abstractmethod + def _handle_command_output(payload): + """Abstract Method: Used in C2 server to prase and receive the output of commands sent to the c2 beacon.""" + pass + + # Abstract method + # Used in C2 beacon to parse and handle commands received from the c2 server. + @abstractmethod + def _handle_command_input(payload): + """Abstract Method: Used in C2 beacon to parse and handle commands received from the c2 server.""" + pass + + def _handle_keep_alive(self) -> bool: + """ + Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. + + Returns False if a keep alive was unable to be sent. + Returns True if a keep alive was successfully sent or already has been sent this timestep. + """ + # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. + if self.keep_alive_sent: + self.c2_connection_active = True # Sets the connection to active + self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero + + # Return early without sending another keep alive and then setting keep alive_sent false for next timestep. + self.keep_alive_sent = False + return True + + # If we've reached this part of the method then we've received a keep alive but haven't sent a reply. + + # If this method returns true then we have sent successfully sent a keep alive. + if self._send_keep_alive(self): + # debugging/info logging that we successfully sent a keep alive + + # Now when the returning keep_alive comes back we won't send another keep alive + self.keep_alive_sent = True + return True + + else: + # debugging/info logging that we unsuccessfully sent a keep alive. + return False + + def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None) -> bool: + """Receives masquerade packets. Used by both c2 server and c2 client. + + :param payload: The Masquerade Packet to be received. + :param session: The transport session that the payload is originating from. + """ + return self._handle_c2_payload(payload, session_id) + + def _send_keep_alive(self) -> bool: + """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address.""" + # Checking that the c2 application is capable of performing both actions and has an enabled NIC + # (Using NOT to improve code readability) + if not self._can_perform_network_action(): + self.sys_log.warning(f"{self.name}: Unable to perform network actions.") + return False + + # We also Pass masquerade protocol/port so that the c2 server can reply on the correct protocol/port. + # (This also lays the foundations for switching masquerade port/protocols mid episode.) + keep_alive_packet = MasqueradePacket( + masquerade_protocol=self.current_masquerade_protocol, + masquerade_port=self.current_masquerade_port, + payload_type=C2Payload.KEEP_ALIVE, + ) + + # C2 Server will need to c2_remote_connection after it receives it's first keep alive. + if self.send( + self, + payload=keep_alive_packet, + dest_ip_address=self.c2_remote_connection, + port=self.current_masquerade_port, + protocol=self.current_masquerade_protocol, + ): + self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") + self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") + self.receive(payload=keep_alive_packet) + return True + else: + self.sys_log.warning( + f"{self.name}: failed to send a Keep Alive. The node may be unable to access the network." + ) + return False + + @abstractmethod + def configure( + self, + c2_server_ip_address: Optional[IPv4Address] = None, + keep_alive_frequency: Optional[int] = 5, + masquerade_protocol: Optional[Enum] = IPProtocol.TCP, + masquerade_port: Optional[Enum] = Port.FTP, + ) -> bool: + """ + Configures the C2 beacon to communicate with the C2 server with following additional parameters. + + :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. + :param keep_alive_frequency: The frequency (timesteps) at which the C2 beacon will send keep alives. + :param masquerade_protocol: The Protocol that C2 Traffic will masquerade as. Defaults as TCP. + :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. + """ + pass diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK From 7b523d9450a3e7fef252458d4fbe0fe9e0f4928c Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 30 Jul 2024 11:33:52 +0100 Subject: [PATCH 060/206] #2769: added changes which should align with 2735 once merged --- src/primaite/game/agent/actions.py | 12 +++++----- .../simulator/network/hardware/base.py | 12 ---------- .../test_remote_user_account_actions.py | 23 ++++++++++++++----- .../test_user_account_change_password.py | 14 +++++++++-- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 266c667b..19442818 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1077,9 +1077,9 @@ class NodeAccountsChangePasswordAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: str) -> RequestFormat: + def form_request(self, node_id: str, username: str, current_password: str, new_password: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "change_password"] + return ["network", "node", node_id, "accounts", "change_password", username, current_password, new_password] class NodeSessionsRemoteLoginAction(AbstractAction): @@ -1088,9 +1088,9 @@ class NodeSessionsRemoteLoginAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: str) -> RequestFormat: + def form_request(self, node_id: str, username: str, password: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "remote_logon"] + return ["network", "node", node_id, "sessions", "remote_login", username, password] class NodeSessionsRemoteLogoutAction(AbstractAction): @@ -1099,9 +1099,9 @@ class NodeSessionsRemoteLogoutAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: str) -> RequestFormat: + def form_request(self, node_id: str, remote_session_id: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "remote_logoff"] + return ["network", "node", node_id, "sessions", "remote_logout", remote_session_id] class ActionManager: diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 3ef33ac3..15c44821 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1071,18 +1071,6 @@ class Node(SimComponent): rm.add_request( "logoff", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on) ) # TODO implement logoff request - rm.add_request( - "change_password", - RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), - ) # TODO implement change_password request - rm.add_request( - "remote_logon", - RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), - ) # TODO implement remote_logon request - rm.add_request( - "remote_logoff", - RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on), - ) # TODO implement remote_logoff request self._os_request_manager = RequestManager() self._os_request_manager.add_request( diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py index 807715bb..2e282d77 100644 --- a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py +++ b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py @@ -1,38 +1,49 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from primaite.simulator.network.hardware.nodes.host.computer import Computer def test_remote_logon(game_and_agent): """Test that the remote session login action works.""" game, agent = game_and_agent + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) + action = ( "NODE_SESSIONS_REMOTE_LOGIN", - {"node_id": 0}, + {"node_id": 0, "username": "test_user", "password": "password"}, ) agent.store_action(action) game.step() - # TODO Assert that there is a logged in user + assert len(client_1.user_session_manager.remote_sessions) == 1 def test_remote_logoff(game_and_agent): """Test that the remote session logout action works.""" game, agent = game_and_agent + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) + action = ( "NODE_SESSIONS_REMOTE_LOGIN", - {"node_id": 0}, + {"node_id": 0, "username": "test_user", "password": "password"}, ) agent.store_action(action) game.step() - # TODO Assert that there is a logged in user + assert len(client_1.user_session_manager.remote_sessions) == 1 + + remote_session_id = client_1.user_session_manager.remote_sessions[0].uuid action = ( "NODE_SESSIONS_REMOTE_LOGOUT", - {"node_id": 0}, + {"node_id": 0, "remote_session_id": remote_session_id}, ) agent.store_action(action) game.step() - # TODO Assert the user has logged out + assert len(client_1.user_session_manager.remote_sessions) == 0 diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py index 27328100..3e6f55f6 100644 --- a/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py +++ b/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py @@ -1,13 +1,23 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from primaite.simulator.network.hardware.nodes.host.computer import Computer + + def test_remote_logon(game_and_agent): """Test that the remote session login action works.""" game, agent = game_and_agent + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) + user = next((user for user in client_1.user_manager.users.values() if user.username == "test_user"), None) + + assert user.password == "password" + action = ( "NODE_ACCOUNTS_CHANGEPASSWORD", - {"node_id": 0}, + {"node_id": 0, "username": user.username, "current_password": user.password, "new_password": "test_pass"}, ) agent.store_action(action) game.step() - # TODO Assert that the user account password is changed + assert user.password == "test_pass" From 09084574a87f22b6bd2aacc0766c3aa2c9b5a341 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 12:15:37 +0100 Subject: [PATCH 061/206] #2706 - Inclusion of health_state_actual attribute to the Terminal class. Started fleshing out a walkthrough notebook showing how to use the new component. --- .../notebooks/Terminal-Processing.ipynb | 157 ++++++++++++++++++ .../system/services/terminal/terminal.py | 6 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/primaite/notebooks/Terminal-Processing.ipynb diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb new file mode 100644 index 00000000..6a197b03 --- /dev/null +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -0,0 +1,157 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Terminal Processing\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This notebook serves as a guide on the functionality and use of the new Terminal simulation component.\n", + "\n", + "By default, the Terminal will come pre-installed on any simulation component which inherits from `HostNode`, and simulates the Secure Shell (SSH) protocol as the communication method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.system.services.terminal.terminal import Terminal\n", + "from primaite.simulator.network.container import Network\n", + "from primaite.simulator.network.hardware.nodes.host.computer import Computer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def basic_network() -> Network:\n", + " \"\"\"Utility function for creating a default network to demonstrate Terminal functionality\"\"\"\n", + " network = Network()\n", + " node_a = Computer(hostname=\"node_a\", ip_address=\"192.168.0.10\", subnet_mask=\"255.255.255.0\", start_up_duration=0)\n", + " node_a.power_on()\n", + " node_b = Computer(hostname=\"node_b\", ip_address=\"192.168.0.11\", subnet_mask=\"255.255.255.0\", start_up_duration=0)\n", + " node_b.power_on()\n", + " network.connect(node_a.network_interface[1], node_b.network_interface[1])\n", + " return network" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "demonstrate how we obtain the Terminal component" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "network: Network = basic_network()\n", + "computer_a: Computer = network.get_node_by_hostname(\"node_a\")\n", + "terminal_a: Terminal = computer_a.software_manager.software.get(\"Terminal\")\n", + "computer_b: Computer = network.get_node_by_hostname(\"node_b\")\n", + "\n", + "# The below can be un-commented when UserSessionManager is implemented. Will need to login before sending any SSH commands\n", + "# to remote.\n", + "# terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Terminal can be used to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n", + "\n", + "Once ran and the command sent, the `RansomwareScript` can be seen in the list of applications on the `node_b Software Manager`. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage\n", + "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", + "\n", + "computer_b.software_manager.show()\n", + "\n", + "payload: SSHPacket = SSHPacket(\n", + " payload=[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"],\n", + " transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST,\n", + " connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN,\n", + " sender_ip_address=computer_a.network_interface[1].ip_address,\n", + " target_ip_address=computer_b.network_interface[1].ip_address,\n", + ")\n", + "\n", + "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)\n", + "\n", + "computer_b.software_manager.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The below example shows how you can send a command via the terminal to create a folder on the target Node.\n", + "\n", + "Here, we send a command to `computer_b` to create a new folder titled \"Downloads\"." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.file_system.show()\n", + "\n", + "payload: SSHPacket = SSHPacket(\n", + " payload=[\"file_system\", \"create\", \"folder\", \"Downloads\"],\n", + " transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST,\n", + " connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN,\n", + " sender_ip_address=computer_a.network_interface[1].ip_address,\n", + " target_ip_address=computer_b.network_interface[1].ip_address,\n", + ")\n", + "\n", + "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)\n", + "\n", + "computer_b.file_system.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 884d3f5b..eae21804 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -20,6 +20,7 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState +from primaite.simulator.system.software import SoftwareHealthState class TerminalClientConnection(BaseModel): @@ -62,7 +63,10 @@ class Terminal(Service): "Uuid for connection requests" operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING - """Initial Operating State""" + "Initial Operating State" + + health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD + "Service Health State" remote_connection: Dict[str, TerminalClientConnection] = {} From 5e3a16999952aab47983f99175937da94a577826 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 30 Jul 2024 12:48:11 +0100 Subject: [PATCH 062/206] #2735: add usermanager and usersessionmanager into describe_state --- src/primaite/simulator/network/hardware/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 08f14b7e..05e52e32 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1767,6 +1767,8 @@ class Node(SimComponent): "services": {svc.name: svc.describe_state() for svc in self.services.values()}, "process": {proc.name: proc.describe_state() for proc in self.processes.values()}, "revealed_to_red": self.revealed_to_red, + "user_manager": self.user_manager.describe_state(), + "user_session_manager": self.user_session_manager.describe_state(), } ) return state From 8320ec524b35ca05eb0a43c009529ddbfb3069fc Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 30 Jul 2024 13:04:20 +0100 Subject: [PATCH 063/206] #2689 Initial C2 Beacon command handling functionality implemented. --- .../red_applications/c2/abstract_c2.py | 50 +++--- .../red_applications/c2/c2_beacon.py | 167 ++++++++++++++++++ .../red_applications/c2/c2_server.py | 9 + 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 647bfcb5..c7f90b9d 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -11,16 +11,34 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application +class C2Command(Enum): + """ + Enumerations representing the different commands the C2 suite currently supports. + """ + + RANSOMWARE_CONFIGURE = "Ransomware Configure" + "Instructs the c2 beacon to configure the ransomware with the provided options." + + RANSOMWARE_LAUNCH = "Ransomware Launch" + "Instructs the c2 beacon to execute the installed ransomware." + + TERMINAL = "Terminal" + "Instructs the c2 beacon to execute the provided terminal command." + + # The terminal command should also be able to pass a session which can be used for remote connections. + class AbstractC2(Application): """ An abstract command and control (c2) application. - Extends the Application class to provide base functionality for c2 suite applications - such as c2 beacons and c2 servers. + Extends the application class to provide base functionality for c2 suite applications + such as c2 beacons and c2 servers. Provides the base methods for handling ``Keep Alive`` connections, configuring masquerade ports and protocols as well as providing the abstract methods for sending, receiving and parsing commands. + + Defaults to masquerading as HTTP (Port 80) via TCP. """ c2_connection_active: bool = False @@ -43,13 +61,8 @@ class AbstractC2(Application): current_masquerade_protocol: Enum = IPProtocol.TCP """The currently chosen protocol that the C2 traffic is masquerading as. Defaults as TCP.""" - current_masquerade_port: Enum = Port.FTP - """The currently chosen port that the C2 traffic is masquerading as. Defaults at FTP.""" - - def __init__(self, **kwargs): - kwargs["name"] = "C2" - kwargs["port"] = self.current_masquerade_port - kwargs["protocol"] = self.current_masquerade_protocol + current_masquerade_port: Enum = Port.HTTP + """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" # TODO: Move this duplicate method from NMAP class into 'Application' to adhere to DRY principle. def _can_perform_network_action(self) -> bool: @@ -176,6 +189,9 @@ class AbstractC2(Application): """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address.""" # Checking that the c2 application is capable of performing both actions and has an enabled NIC # (Using NOT to improve code readability) + if self.c2_remote_connection == None: + self.sys_log.error(f"{self.name}: Unable to Establish connection as the C2 Server's IP Address has not been given.") + if not self._can_perform_network_action(): self.sys_log.warning(f"{self.name}: Unable to perform network actions.") return False @@ -206,20 +222,4 @@ class AbstractC2(Application): ) return False - @abstractmethod - def configure( - self, - c2_server_ip_address: Optional[IPv4Address] = None, - keep_alive_frequency: Optional[int] = 5, - masquerade_protocol: Optional[Enum] = IPProtocol.TCP, - masquerade_port: Optional[Enum] = Port.FTP, - ) -> bool: - """ - Configures the C2 beacon to communicate with the C2 server with following additional parameters. - :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. - :param keep_alive_frequency: The frequency (timesteps) at which the C2 beacon will send keep alives. - :param masquerade_protocol: The Protocol that C2 Traffic will masquerade as. Defaults as TCP. - :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. - """ - pass diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index be6c00e7..822e6ba9 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -1 +1,168 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command +#from primaite.simulator.system.services.terminal.terminal import Terminal +from primaite.simulator.core import RequestManager, RequestType +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from ipaddress import IPv4Address +from typing import Dict,Optional +from primaite.simulator.network.transmission.transport_layer import Port +from enum import Enum + +class C2Beacon(AbstractC2): + """ + C2 Beacon Application. + + Represents a generic C2 beacon which can be used in conjunction with the C2 Server + to simulate malicious communications within primAITE. + + Must be configured with the C2 Server's Ip Address upon installation. + + Extends the Abstract C2 application to include the following: + + 1. Receiving commands from the C2 Server (Command input) + 2. Leveraging the terminal application to execute requests (dependant on the command given) + 3. Sending the RequestResponse back to the C2 Server (Command output) + """ + + keep_alive_frequency: int = 5 + "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." + + + # Uncomment the Import and this Property after terminal PR + + #@property + #def _host_db_client(self) -> Terminal: + # """Return the database client that is installed on the same machine as the Ransomware Script.""" + # db_client: DatabaseClient = self.software_manager.software.get("DatabaseClient") + # if db_client is None: + # self.sys_log.warning(f"{self.__class__.__name__} cannot find a database client on its host.") + # return db_client + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + name="execute", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.establish())), + ) + + def _configure(request: RequestFormat, context: Dict) -> RequestResponse: + """ + Request for configuring the C2 Beacon. + + :param request: Request with one element containing a dict of parameters for the configure method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the configuration could be applied. + :rtype: RequestResponse + """ + server_ip = request[-1].get("c2_server_ip_address") + if server_ip == None: + self.sys_log.error(f"{self.name}: Did not receive C2 Server IP in configuration parameters.") + RequestResponse(status="failure", data={"No C2 Server IP given to C2 beacon. Unable to configure C2 Beacon"}) + + c2_remote_ip = IPv4Address(c2_remote_ip) + frequency = request[-1].get("keep_alive_frequency") + protocol= request[-1].get("masquerade_protocol") + port = request[-1].get("masquerade_port") + return RequestResponse.from_bool(self.configure(c2_server_ip_address=server_ip, + keep_alive_frequency=frequency, + masquerade_protocol=protocol, + masquerade_port=port)) + + rm.add_request("configure", request_type=RequestType(func=_configure)) + return rm + + def __init__(self, **kwargs): + self.name = "C2Beacon" + super.__init__(**kwargs) + + def configure( + self, + c2_server_ip_address: IPv4Address = None, + keep_alive_frequency: Optional[int] = 5, + masquerade_protocol: Optional[Enum] = IPProtocol.TCP, + masquerade_port: Optional[Enum] = Port.HTTP, + ) -> bool: + """ + Configures the C2 beacon to communicate with the C2 server with following additional parameters. + + + :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. + :type c2_server_ip_address: IPv4Address + :param keep_alive_frequency: The frequency (timesteps) at which the C2 beacon will send keep alive(s). + :type keep_alive_frequency: Int + :param masquerade_protocol: The Protocol that C2 Traffic will masquerade as. Defaults as TCP. + :type masquerade_protocol: Enum (IPProtocol) + :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. + :type masquerade_port: Enum (Port) + """ + self.c2_remote_connection = c2_server_ip_address + self.keep_alive_frequency = keep_alive_frequency + self.current_masquerade_port = masquerade_port + self.current_masquerade_protocol = masquerade_protocol + self.sys_log.info( + f"{self.name}: Configured {self.name} with remote C2 server connection: {c2_server_ip_address=}." + ) + self.sys_log.debug(f"{self.name}: configured with the following settings:" + f"Remote C2 Server: {c2_server_ip_address}" + f"Keep Alive Frequency {keep_alive_frequency}" + f"Masquerade Protocol: {masquerade_protocol}" + f"Masquerade Port: {masquerade_port}") + return True + + + def establish(self) -> bool: + """Establishes connection to the C2 server via a send alive. Must be called after the C2 Beacon is configured.""" + # I THINK that once the application is running it can respond to incoming traffic but I'll need to test this later. + self.run() + self._send_keep_alive() + self.num_executions += 1 + + + def _handle_command_input(self, payload: MasqueradePacket) -> RequestResponse: + """ + Handles C2 Commands and executes them via the terminal service. + + + :param payload: The INPUT C2 Payload + :type payload: MasqueradePacket + :return: The Request Response provided by the terminal execute method. + :rtype Request Response: + """ + command = payload.payload_type + if command != C2Payload: + self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to resolve command") + return RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to resolve command."}) + + if command == C2Command.RANSOMWARE_CONFIGURE: + self.sys_log.info(f"{self.name}: Received a ransomware configuration C2 command.") + return self._command_ransomware_config(payload) + + elif command == C2Command.RANSOMWARE_LAUNCH: + self.sys_log.info(f"{self.name}: Received a ransomware launch C2 command.") + return self._command_ransomware_launch(payload) + + elif payload.payload_type == C2Command.TERMINAL: + self.sys_log.info(f"{self.name} Received a terminal C2 command.") + return self._command_terminal(payload) + + else: + self.sys_log.error(f"{self.name} received an C2 command: {command} but was unable to resolve command.") + return RequestResponse(status="failure", data={"Unexpected Behaviour. Unable to resolve command."}) + + def _command_ransomware_config(self, payload: MasqueradePacket): + pass + + def _command_ransomware_launch(self, payload: MasqueradePacket): + pass + + def _command_terminal(self, payload: MasqueradePacket): + pass diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index be6c00e7..648ace47 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -1 +1,10 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command +from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket + + +class C2Server(AbstractC2): + + def _handle_command_output(payload): + """Abstract Method: Used in C2 server to prase and receive the output of commands sent to the c2 beacon.""" + pass From 8a00a2a29d990c39dbc2dd130a7a1da15964a811 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 30 Jul 2024 13:10:23 +0100 Subject: [PATCH 064/206] #2689 Added TODOs for future reference. --- .../system/applications/red_applications/c2/abstract_c2.py | 7 +++++++ .../system/applications/red_applications/c2/c2_beacon.py | 7 +++++-- .../system/applications/red_applications/c2/c2_server.py | 5 ++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index c7f90b9d..eabbf476 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -11,6 +11,13 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application +# TODO: +# Complete C2 Server and C2 Beacon TODOs +# Create test that leverage all the functionality needed for the different TAPs +# Create a .RST doc +# Potentially? A notebook which demonstrates a custom red agent using the c2 server for various means. + + class C2Command(Enum): """ Enumerations representing the different commands the C2 suite currently supports. diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 822e6ba9..ab5f47d4 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -29,8 +29,11 @@ class C2Beacon(AbstractC2): keep_alive_frequency: int = 5 "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." - - # Uncomment the Import and this Property after terminal PR + # TODO: + # Implement the placeholder command methods + # Implement the keep alive frequency. + # Implement a command output method that sends the RequestResponse to the C2 server. + # Uncomment the terminal Import and the terminal property after terminal PR #@property #def _host_db_client(self) -> Terminal: diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 648ace47..5f8824cd 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -4,7 +4,10 @@ from primaite.simulator.network.protocols.masquerade import C2Payload, Masquerad class C2Server(AbstractC2): + # TODO: + # Implement the request manager and agent actions. + # Implement the output handling methods. (These need to interface with the actions) def _handle_command_output(payload): - """Abstract Method: Used in C2 server to prase and receive the output of commands sent to the c2 beacon.""" + """Abstract Method: Used in C2 server to parse and receive the output of commands sent to the c2 beacon.""" pass From 3698e6ff5fd20316979ec2c6cbe374ca7331850e Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 15:24:37 +0100 Subject: [PATCH 065/206] #2706 - Commented out references to UserSessionManager to remove the dependency. --- src/primaite/notebooks/Terminal-Processing.ipynb | 9 ++++++++- .../system/services/terminal/terminal.py | 16 +++++++++------- .../_system/_services/test_terminal.py | 15 +++++++++++++-- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 6a197b03..4cb962ca 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -15,7 +15,7 @@ "source": [ "This notebook serves as a guide on the functionality and use of the new Terminal simulation component.\n", "\n", - "By default, the Terminal will come pre-installed on any simulation component which inherits from `HostNode`, and simulates the Secure Shell (SSH) protocol as the communication method." + "By default, the Terminal will come pre-installed on any simulation component which inherits from `HostNode` (Computer, Server, Printer), and simulates the Secure Shell (SSH) protocol as the communication method." ] }, { @@ -131,6 +131,13 @@ "\n", "computer_b.file_system.show()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The resultant call to `computer_b.file_system.show()` shows that the new folder has been created." + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index eae21804..50d30a34 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -3,6 +3,7 @@ from __future__ import annotations from ipaddress import IPv4Address from typing import Any, Dict, List, Optional +from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel @@ -88,10 +89,6 @@ class Terminal(Service): state = super().describe_state() return state - def apply_request(self, request: List[str | int | float | Dict], context: Dict | None = None) -> RequestResponse: - """Apply Terminal Request.""" - return super().apply_request(request, context) - def show(self, markdown: bool = False): """ Display the remote connections to this terminal instance in tabular format. @@ -141,7 +138,8 @@ class Terminal(Service): def _logoff() -> RequestResponse: """Logoff from connection.""" - self.parent.UserSessionManager.logoff(self.connection_uuid) + # TODO: Uncomment this when UserSessionManager merged. + # self.parent.UserSessionManager.logoff(self.connection_uuid) self.disconnect(self.connection_uuid) return RequestResponse(status="success", data={}) @@ -204,7 +202,9 @@ class Terminal(Service): def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" - self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + # TODO: Un-comment this when UserSessionManager is merged. + # self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + self.connection_uuid = str(uuid4()) self.is_connected = True if self.connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") @@ -239,7 +239,9 @@ class Terminal(Service): username: str = payload.user_account.username password: str = payload.user_account.password self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") - connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) + # TODO: Un-comment this when UserSessionManager is merged. + # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) + connection_uuid = str(uuid4()) self.is_connected = True if connection_uuid: # Send uuid to remote diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 411f0ebe..8ec20394 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -45,6 +45,17 @@ def basic_network() -> Network: return network +@pytest.fixture +def game_and_agent_fixture(game_and_agent): + """Create a game with a simple agent that can be controlled by the tests.""" + game, agent = game_and_agent + + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + client_1.start_up_duration = 3 + + return (game, agent) + + def test_terminal_creation(terminal_on_computer): terminal, computer = terminal_on_computer terminal.describe_state() @@ -273,10 +284,10 @@ def test_terminal_receives_requests(game_and_agent_fixture: Tuple[PrimaiteGame, game, agent = game_and_agent_fixture network: Network = game.simulation.network - computer_a: Computer = network.get_node_by_hostname("node_a") + computer_a: Computer = network.get_node_by_hostname("client_1") terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") - computer_b: Computer = network.get_node_by_hostname("node_b") + computer_b: Computer = network.get_node_by_hostname("client_2") assert terminal_a.is_connected is False From 0ed61ec79ba3f1a5f604949caecca26dfc7f80df Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 15:54:08 +0100 Subject: [PATCH 066/206] #2706 - Updates to terminal and host_node documentation, removal of redundant terminal unit test --- .../network/nodes/host_node.rst | 2 ++ .../system/services/terminal.rst | 1 + .../_system/_services/test_terminal.py | 19 ------------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/docs/source/simulation_components/network/nodes/host_node.rst b/docs/source/simulation_components/network/nodes/host_node.rst index 301cd783..b8aae098 100644 --- a/docs/source/simulation_components/network/nodes/host_node.rst +++ b/docs/source/simulation_components/network/nodes/host_node.rst @@ -49,3 +49,5 @@ fundamental network operations: 5. **NTP (Network Time Protocol) Client:** Synchronises the host's clock with network time servers. 6. **Web Browser:** A simulated application that allows the host to request and display web content. + +7. **Terminal:** A simulated service that allows the host to connect to remote hosts and execute commands. diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 4d1285d1..4b02a6db 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -41,6 +41,7 @@ to provide User Credential authentication when receiving/processing commands. Terminal acts as the interface between the user/component and both the Session and Requests Managers, facilitating the passing of requests to both. +A more detailed example of how to use the Terminal class can be found in the Terminal-Processing jupyter notebook. Python """""" diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 8ec20394..d4592228 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -278,22 +278,3 @@ def test_network_simulation(basic_network): terminal_1: Terminal = client_1.software_manager.software.get("Terminal") assert terminal_1.login(username="admin", password="Admin123!", ip_address="10.0.2.2") is False - - -def test_terminal_receives_requests(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): - game, agent = game_and_agent_fixture - - network: Network = game.simulation.network - computer_a: Computer = network.get_node_by_hostname("client_1") - terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") - - computer_b: Computer = network.get_node_by_hostname("client_2") - - assert terminal_a.is_connected is False - - action = ("TERMINAL_LOGIN", {"username": "admin", "password": "Admin123!"}) - - agent.store_action(action) - game.step() - - assert terminal_a.is_connected is True From 4c03a2015405073b4bbb1d1bdde4b0483f6b7602 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 30 Jul 2024 16:24:36 +0100 Subject: [PATCH 067/206] #2689 C2 Beacon command methods implemented. Additional docustrings also added. --- .../red_applications/c2/c2_beacon.py | 144 +++++++++++++++--- 1 file changed, 122 insertions(+), 22 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index ab5f47d4..bee00c5d 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -9,6 +9,8 @@ from ipaddress import IPv4Address from typing import Dict,Optional from primaite.simulator.network.transmission.transport_layer import Port from enum import Enum +from primaite.simulator.system.software import SoftwareHealthState +from primaite.simulator.system.applications.application import ApplicationOperatingState class C2Beacon(AbstractC2): """ @@ -36,12 +38,12 @@ class C2Beacon(AbstractC2): # Uncomment the terminal Import and the terminal property after terminal PR #@property - #def _host_db_client(self) -> Terminal: - # """Return the database client that is installed on the same machine as the Ransomware Script.""" - # db_client: DatabaseClient = self.software_manager.software.get("DatabaseClient") - # if db_client is None: - # self.sys_log.warning(f"{self.__class__.__name__} cannot find a database client on its host.") - # return db_client + #def _host_terminal(self) -> Terminal: + # """Return the Terminal that is installed on the same machine as the C2 Beacon.""" + # host_terminal: Terminal = self.software_manager.software.get("Terminal") + # if host_terminal: is None: + # self.sys_log.warning(f"{self.__class__.__name__} cannot find a terminal on its host.") + # return host_terminal def _init_request_manager(self) -> RequestManager: """ @@ -122,18 +124,18 @@ class C2Beacon(AbstractC2): return True + # I THINK that once the application is running it can respond to incoming traffic but I'll need to test this later. def establish(self) -> bool: """Establishes connection to the C2 server via a send alive. Must be called after the C2 Beacon is configured.""" - # I THINK that once the application is running it can respond to incoming traffic but I'll need to test this later. self.run() self._send_keep_alive() self.num_executions += 1 - def _handle_command_input(self, payload: MasqueradePacket) -> RequestResponse: + def _handle_command_input(self, payload: MasqueradePacket) -> bool: """ - Handles C2 Commands and executes them via the terminal service. - + Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets) + as well as then calling the relevant method dependant on the C2 Command. :param payload: The INPUT C2 Payload :type payload: MasqueradePacket @@ -143,29 +145,127 @@ class C2Beacon(AbstractC2): command = payload.payload_type if command != C2Payload: self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to resolve command") - return RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to resolve command."}) + return self._return_command_output(RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to resolve command."})) if command == C2Command.RANSOMWARE_CONFIGURE: self.sys_log.info(f"{self.name}: Received a ransomware configuration C2 command.") - return self._command_ransomware_config(payload) + return self._return_command_output(self._command_ransomware_config(payload)) elif command == C2Command.RANSOMWARE_LAUNCH: self.sys_log.info(f"{self.name}: Received a ransomware launch C2 command.") - return self._command_ransomware_launch(payload) + return self._return_command_output(self._command_ransomware_launch(payload)) elif payload.payload_type == C2Command.TERMINAL: - self.sys_log.info(f"{self.name} Received a terminal C2 command.") - return self._command_terminal(payload) + self.sys_log.info(f"{self.name}: Received a terminal C2 command.") + return self._return_command_output(self._command_terminal(payload)) else: - self.sys_log.error(f"{self.name} received an C2 command: {command} but was unable to resolve command.") - return RequestResponse(status="failure", data={"Unexpected Behaviour. Unable to resolve command."}) + self.sys_log.error(f"{self.name}: Received an C2 command: {command} but was unable to resolve command.") + return self._return_command_output(RequestResponse(status="failure", data={"Unexpected Behaviour. Unable to resolve command."})) - def _command_ransomware_config(self, payload: MasqueradePacket): - pass - def _command_ransomware_launch(self, payload: MasqueradePacket): - pass + def _return_command_output(self, command_output: RequestResponse) -> bool: + """Responsible for responding to the C2 Server with the output of the given command.""" + output_packet = MasqueradePacket( + masquerade_protocol=self.current_masquerade_protocol, + masquerade_port=self.current_masquerade_port, + payload_type=C2Payload.OUTPUT, + payload=command_output + ) + if self.send( + self, + payload=output_packet, + dest_ip_address=self.c2_remote_connection, + port=self.current_masquerade_port, + protocol=self.current_masquerade_protocol, + ): + self.sys_log.info(f"{self.name}: Command output sent to {self.c2_remote_connection}") + self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") + return True + else: + self.sys_log.warning( + f"{self.name}: failed to send a output packet. The node may be unable to access the network." + ) + return False - def _command_terminal(self, payload: MasqueradePacket): + def _command_ransomware_config(self, payload: MasqueradePacket) -> RequestResponse: + """ + C2 Command: Ransomware Configuration + + Creates a request that configures the ransomware based off the configuration options given. + This request is then sent to the terminal service in order to be executed. + + :return: Returns the Request Response returned by the Terminal execute method. + :rtype: Request Response + """ pass + #return self._host_terminal.execute(command) + + def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: + """ + C2 Command: Ransomware Execute + + Creates a request that executes the ransomware script. + This request is then sent to the terminal service in order to be executed. + + :return: Returns the Request Response returned by the Terminal execute method. + :rtype: Request Response + + Creates a Request that launches the ransomware. + """ + pass + #return self._host_terminal.execute(command) + + def _command_terminal(self, payload: MasqueradePacket) -> RequestResponse: + """ + C2 Command: Ransomware Execute + + Creates a request that executes the ransomware script. + This request is then sent to the terminal service in order to be executed. + + :return: Returns the Request Response returned by the Terminal execute method. + :rtype: Request Response + + Creates a Request that launches the ransomware. + """ + pass + #return self._host_terminal.execute(command) + + + # Not entirely sure if this actually works. + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the c2_beacon. + Used to keep track of when the c2 beacon should send another keep alive. + + The following logic is applied: + + 1. Each timestep the keep_alive_inactivity is increased. + + 2. If the keep alive inactivity eclipses that of the keep alive frequency then another keep alive is sent. + + 3. If the c2 beacon receives a keep alive response packet then the ``keep_alive_inactivity`` attribute is set to 0 + + Therefore, if ``keep_alive_inactivity`` attribute is not 0, then the connection is considered severed and c2 beacon will shut down. + + :param timestep: The current timestep of the simulation. + """ + super().apply_timestep(timestep=timestep) + if self.operating_state is ApplicationOperatingState.RUNNING and self.health_state_actual is SoftwareHealthState.GOOD: + self.keep_alive_inactivity += 1 + if not self._check_c2_connection(timestep): + self.sys_log.error(f"{self.name}: Connection Severed - Application Closing.") + self.clear_connections() + self.close() + return + + + def _check_c2_connection(self, timestep) -> bool: + """Checks the C2 Server connection. If a connection cannot be confirmed then the c2 beacon will halt and close.""" + if self.keep_alive_inactivity > self.keep_alive_frequency: + self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection} at timestep {timestep}.") + self._send_keep_alive() + if self.keep_alive_inactivity != 0: + self.sys_log.warning(f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed.") + return False + return True \ No newline at end of file From 06ac127f6bc90acbf40c7b4fb3b19248f9f95e65 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 30 Jul 2024 16:58:40 +0100 Subject: [PATCH 068/206] #2706 - Updates to Terminal Processing notebook to highlight utility function and improve formatting --- .../notebooks/Terminal-Processing.ipynb | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 4cb962ca..c9321b01 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -63,19 +63,33 @@ "computer_a: Computer = network.get_node_by_hostname(\"node_a\")\n", "terminal_a: Terminal = computer_a.software_manager.software.get(\"Terminal\")\n", "computer_b: Computer = network.get_node_by_hostname(\"node_b\")\n", + "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")\n", "\n", - "# The below can be un-commented when UserSessionManager is implemented. Will need to login before sending any SSH commands\n", - "# to remote.\n", - "# terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + "# Login to the remote (node_b) from local (node_a)\n", + "terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The Terminal can be used to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n", - "\n", - "Once ran and the command sent, the `RansomwareScript` can be seen in the list of applications on the `node_b Software Manager`. " + "You can view all remote connections to a terminal through use of the `show()` method" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "terminal_b.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The Terminal can be used to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" ] }, { @@ -97,8 +111,23 @@ " target_ip_address=computer_b.network_interface[1].ip_address,\n", ")\n", "\n", - "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)\n", - "\n", + "# Send commmand to install RansomwareScript\n", + "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `RansomwareScript` can then be seen in the list of applications on the `node_b Software Manager`. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "computer_b.software_manager.show()" ] }, From e4358b02bc262517934f8a4c72950182a58c95be Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 30 Jul 2024 17:18:28 +0100 Subject: [PATCH 069/206] #2689 Improving comments in abstract c2 --- .../applications/red_applications/c2/abstract_c2.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index eabbf476..bd9219c2 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -161,8 +161,10 @@ class AbstractC2(Application): Returns False if a keep alive was unable to be sent. Returns True if a keep alive was successfully sent or already has been sent this timestep. """ + self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}") # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. if self.keep_alive_sent: + self.sys_log.info(f"{self.name}: Connection successfully established with {self.c2_remote_connection}") self.c2_connection_active = True # Sets the connection to active self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero @@ -174,14 +176,11 @@ class AbstractC2(Application): # If this method returns true then we have sent successfully sent a keep alive. if self._send_keep_alive(self): - # debugging/info logging that we successfully sent a keep alive - - # Now when the returning keep_alive comes back we won't send another keep alive - self.keep_alive_sent = True + self.keep_alive_sent = True # Setting the guard clause to true (prevents packet storms.) return True + # Return false if we're unable to send handle the keep alive correctly. else: - # debugging/info logging that we unsuccessfully sent a keep alive. return False def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None) -> bool: From f097ed575dd23f7987c0c93ec016ce25268af3e9 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Wed, 31 Jul 2024 10:26:58 +0100 Subject: [PATCH 070/206] #2689 minor docustring and type hint change --- .../system/applications/red_applications/c2/abstract_c2.py | 3 ++- .../system/applications/red_applications/c2/c2_beacon.py | 2 +- .../system/applications/red_applications/c2/c2_server.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index bd9219c2..e45333e5 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -191,7 +191,7 @@ class AbstractC2(Application): """ return self._handle_c2_payload(payload, session_id) - def _send_keep_alive(self) -> bool: + def _send_keep_alive(self, session_id: Optional[str]) -> bool: """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address.""" # Checking that the c2 application is capable of performing both actions and has an enabled NIC # (Using NOT to improve code readability) @@ -217,6 +217,7 @@ class AbstractC2(Application): dest_ip_address=self.c2_remote_connection, port=self.current_masquerade_port, protocol=self.current_masquerade_protocol, + session_id=session_id, ): self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index bee00c5d..e0ad30f8 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -261,7 +261,7 @@ class C2Beacon(AbstractC2): def _check_c2_connection(self, timestep) -> bool: - """Checks the C2 Server connection. If a connection cannot be confirmed then the c2 beacon will halt and close.""" + """Checks the C2 Server connection. If a connection cannot be confirmed then this method will return false otherwise true.""" if self.keep_alive_inactivity > self.keep_alive_frequency: self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection} at timestep {timestep}.") self._send_keep_alive() diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 5f8824cd..05ff30d9 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -11,3 +11,4 @@ class C2Server(AbstractC2): def _handle_command_output(payload): """Abstract Method: Used in C2 server to parse and receive the output of commands sent to the c2 beacon.""" pass + \ No newline at end of file From 9bf8d0f8cbce18542622bf772fd9abb1edf50bc6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 31 Jul 2024 13:20:15 +0100 Subject: [PATCH 071/206] #2676 Put NMNE back into network module --- src/primaite/game/game.py | 4 +- src/primaite/session/io.py | 45 ------------------- .../simulator/network/hardware/base.py | 2 +- src/primaite/simulator/network/nmne.py | 25 +++++++++++ .../observations/test_nic_observations.py | 4 +- .../network/test_capture_nmne.py | 8 ++-- 6 files changed, 34 insertions(+), 54 deletions(-) create mode 100644 src/primaite/simulator/network/nmne.py diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index cd0180db..2e7ee735 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -16,7 +16,6 @@ from primaite.game.agent.scripted_agents.probabilistic_agent import Probabilisti from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort -from primaite.session.io import store_nmne_config from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState @@ -27,6 +26,7 @@ from primaite.simulator.network.hardware.nodes.network.firewall import Firewall from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter +from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.application import Application @@ -265,7 +265,7 @@ class PrimaiteGame: nodes_cfg = network_config.get("nodes", []) links_cfg = network_config.get("links", []) # Set the NMNE capture config - NetworkInterface.nmne_config = store_nmne_config(network_config.get("nmne_config", {})) + NetworkInterface.nmne_config = NMNEConfig(**network_config.get("nmne_config", {})) for node_cfg in nodes_cfg: n_type = node_cfg["type"] diff --git a/src/primaite/session/io.py b/src/primaite/session/io.py index c634e835..78d7cb3c 100644 --- a/src/primaite/session/io.py +++ b/src/primaite/session/io.py @@ -131,48 +131,3 @@ class PrimaiteIO: new = cls(settings=cls.Settings(**config)) return new - - -class NMNEConfig(BaseModel): - """Store all the information to perform NMNE operations.""" - - capture_nmne: bool = False - """Indicates whether Malicious Network Events (MNEs) should be captured.""" - nmne_capture_keywords: List[str] = [] - """List of keywords to identify malicious network events.""" - capture_by_direction: bool = True - """Captures should be organized by traffic direction (inbound/outbound).""" - capture_by_ip_address: bool = False - """Captures should be organized by source or destination IP address.""" - capture_by_protocol: bool = False - """Captures should be organized by network protocol (e.g., TCP, UDP).""" - capture_by_port: bool = False - """Captures should be organized by source or destination port.""" - capture_by_keyword: bool = False - """Captures should be filtered and categorised based on specific keywords.""" - - -def store_nmne_config(nmne_config: Dict) -> NMNEConfig: - """ - Store configuration for capturing Malicious Network Events (MNEs). - - This function updates settings related to NMNE capture, stored in NMNEConfig including whether - to capture NMNEs and the keywords to use for identifying NMNEs. - - The function ensures that the settings are updated only if they are provided in the - `nmne_config` dictionary, and maintains type integrity by relying on pydantic validators. - - :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys - include: - "capture_nmne" (bool) to indicate whether NMNEs should be captured; - "nmne_capture_keywords" (list of strings) to specify keywords for NMNE identification. - :rvar class with data read from config file. - """ - nmne_capture_keywords: List[str] = [] - # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect - capture_nmne = nmne_config.get("capture_nmne", False) - - # Update the NMNE capture keywords, appending new keywords if provided - nmne_capture_keywords += nmne_config.get("nmne_capture_keywords", []) - - return NMNEConfig(capture_nmne=capture_nmne, nmne_capture_keywords=nmne_capture_keywords) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index aafdbe5c..50549389 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -14,12 +14,12 @@ from pydantic import BaseModel, Field from primaite import getLogger from primaite.exceptions import NetworkError from primaite.interface.request import RequestResponse -from primaite.session.io import NMNEConfig from primaite.simulator import SIM_OUTPUT from primaite.simulator.core import RequestFormat, RequestManager, RequestPermissionValidator, RequestType, SimComponent from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.system.applications.application import Application diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py new file mode 100644 index 00000000..c9cff5de --- /dev/null +++ b/src/primaite/simulator/network/nmne.py @@ -0,0 +1,25 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import List + +from pydantic import BaseModel, ConfigDict + + +class NMNEConfig(BaseModel): + """Store all the information to perform NMNE operations.""" + + model_config = ConfigDict(extra="forbid") + + capture_nmne: bool = False + """Indicates whether Malicious Network Events (MNEs) should be captured.""" + nmne_capture_keywords: List[str] = [] + """List of keywords to identify malicious network events.""" + capture_by_direction: bool = True + """Captures should be organized by traffic direction (inbound/outbound).""" + capture_by_ip_address: bool = False + """Captures should be organized by source or destination IP address.""" + capture_by_protocol: bool = False + """Captures should be organized by network protocol (e.g., TCP, UDP).""" + capture_by_port: bool = False + """Captures should be organized by source or destination port.""" + capture_by_keyword: bool = False + """Captures should be filtered and categorised based on specific keywords.""" diff --git a/tests/integration_tests/game_layer/observations/test_nic_observations.py b/tests/integration_tests/game_layer/observations/test_nic_observations.py index 7f86d26d..ef789ba7 100644 --- a/tests/integration_tests/game_layer/observations/test_nic_observations.py +++ b/tests/integration_tests/game_layer/observations/test_nic_observations.py @@ -9,11 +9,11 @@ from gymnasium import spaces from primaite.game.agent.interface import ProxyAgent from primaite.game.agent.observations.nic_observations import NICObservation from primaite.game.game import PrimaiteGame -from primaite.session.io import store_nmne_config from primaite.simulator.network.hardware.base import NetworkInterface from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.web_browser import WebBrowser @@ -87,7 +87,7 @@ def test_nic(simulation): } # Apply the NMNE configuration settings - NetworkInterface.nmne_config = store_nmne_config(nmne_config) + NetworkInterface.nmne_config = NMNEConfig(**nmne_config) assert nic_obs.space["nic_status"] == spaces.Discrete(3) assert nic_obs.space["NMNE"]["inbound"] == spaces.Discrete(4) diff --git a/tests/integration_tests/network/test_capture_nmne.py b/tests/integration_tests/network/test_capture_nmne.py index b4162e58..debf5b1c 100644 --- a/tests/integration_tests/network/test_capture_nmne.py +++ b/tests/integration_tests/network/test_capture_nmne.py @@ -1,9 +1,9 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from primaite.game.agent.observations.nic_observations import NICObservation -from primaite.session.io import store_nmne_config from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection @@ -35,7 +35,7 @@ def test_capture_nmne(uc2_network: Network): } # Apply the NMNE configuration settings - NIC.nmne_config = store_nmne_config(nmne_config) + NIC.nmne_config = NMNEConfig(**nmne_config) # Assert that initially, there are no captured MNEs on both web and database servers assert web_server_nic.nmne == {} @@ -112,7 +112,7 @@ def test_describe_state_nmne(uc2_network: Network): } # Apply the NMNE configuration settings - NIC.nmne_config = store_nmne_config(nmne_config) + NIC.nmne_config = NMNEConfig(**nmne_config) # Assert that initially, there are no captured MNEs on both web and database servers web_server_nic_state = web_server_nic.describe_state() @@ -221,7 +221,7 @@ def test_capture_nmne_observations(uc2_network: Network): } # Apply the NMNE configuration settings - NIC.nmne_config = store_nmne_config(nmne_config) + NIC.nmne_config = NMNEConfig(**nmne_config) # Define observations for the NICs of the database and web servers db_server_nic_obs = NICObservation(where=["network", "nodes", "database_server", "NICs", 1], include_nmne=True) From bd1e23db7df686e1e50a5e5850a0a45c4dc509d5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 31 Jul 2024 15:25:02 +0100 Subject: [PATCH 072/206] 2676 - make ntwk intf use default nmne config --- src/primaite/simulator/network/hardware/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 50549389..6a25cbef 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -99,7 +99,7 @@ class NetworkInterface(SimComponent, ABC): pcap: Optional[PacketCapture] = None "A PacketCapture instance for capturing and analysing packets passing through this interface." - nmne_config: ClassVar[NMNEConfig] = None + nmne_config: ClassVar[NMNEConfig] = NMNEConfig() "A dataclass defining malicious network events to be captured." nmne: Dict = Field(default_factory=lambda: {}) @@ -1167,7 +1167,7 @@ class Node(SimComponent): ip_address, network_interface.speed, "Enabled" if network_interface.enabled else "Disabled", - network_interface.nmne if self.nmne_config.capture_nmne else "Disabled", + network_interface.nmne if network_interface.nmne_config.capture_nmne else "Disabled", ] ) print(table) From 0f3fa79ffea3adeeecdfbe00e60526bcf8b2f773 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 31 Jul 2024 15:47:18 +0100 Subject: [PATCH 073/206] #2706 - Actioning review comments on example notebook and terminal class --- src/primaite/notebooks/Terminal-Processing.ipynb | 8 +++++--- .../simulator/system/services/terminal/terminal.py | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index c9321b01..75b92422 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -50,7 +50,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "demonstrate how we obtain the Terminal component" + "The terminal can be accessed from a `HostNode` via the `software_manager` as demonstrated below. \n", + "\n", + "In the example, we have a basic network consisting of two computers " ] }, { @@ -89,7 +91,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The Terminal can be used to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" + "The Terminal can be used to send requests to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" ] }, { @@ -111,7 +113,7 @@ " target_ip_address=computer_b.network_interface[1].ip_address,\n", ")\n", "\n", - "# Send commmand to install RansomwareScript\n", + "# Send command to install RansomwareScript\n", "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)" ] }, diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 50d30a34..b6999694 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -124,13 +124,13 @@ class Terminal(Service): return RequestResponse(status="failure", data={}) def _remote_login(request: List[Any], context: Any) -> RequestResponse: - self._process_remote_login(username=request[0], password=request[1], ip_address=request[2]) - if self.is_connected: + login = self._process_remote_login(username=request[0], password=request[1], ip_address=request[2]) + if login: return RequestResponse(status="success", data={}) else: return RequestResponse(status="failure", data={}) - def _execute(request: List[Any], context: Any) -> RequestResponse: + def _execute_request(request: List[Any], context: Any) -> RequestResponse: """Execute an instruction.""" command: str = request[0] self.execute(command) @@ -156,7 +156,7 @@ class Terminal(Service): rm.add_request( "Execute", - request_type=RequestType(func=_execute, validator=_login_valid), + request_type=RequestType(func=_execute_request, validator=_login_valid), ) rm.add_request("Logoff", request_type=RequestType(func=_logoff, validator=_login_valid)) From 2abd1969fe618160df7e77b2899c7e0ab0c4f5bd Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 31 Jul 2024 16:41:59 +0100 Subject: [PATCH 074/206] #2800 - Consolidate software install and uninstall to a single method --- .../simulator/network/hardware/base.py | 68 ------------------ .../simulator/system/core/software_manager.py | 70 ++++++++++--------- tests/conftest.py | 12 ++-- .../test_action_integration.py | 3 +- .../system/test_service_on_node.py | 4 +- .../test_simulation/test_request_response.py | 6 +- .../_network/_hardware/test_node_actions.py | 17 +++-- 7 files changed, 61 insertions(+), 119 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 15c44821..fd3f369d 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1455,74 +1455,6 @@ class Node(SimComponent): else: return - def install_service(self, service: Service) -> None: - """ - Install a service on this node. - - :param service: Service instance that has not been installed on any node yet. - :type service: Service - """ - if service in self: - _LOGGER.warning(f"Can't add service {service.name} to node {self.hostname}. It's already installed.") - return - self.services[service.uuid] = service - service.parent = self - service.install() # Perform any additional setup, such as creating files for this service on the node. - self.sys_log.info(f"Installed service {service.name}") - _LOGGER.debug(f"Added service {service.name} to node {self.hostname}") - self._service_request_manager.add_request(service.name, RequestType(func=service._request_manager)) - - def uninstall_service(self, service: Service) -> None: - """ - Uninstall and completely remove service from this node. - - :param service: Service object that is currently associated with this node. - :type service: Service - """ - if service not in self: - _LOGGER.warning(f"Can't remove service {service.name} from node {self.hostname}. It's not installed.") - return - service.uninstall() # Perform additional teardown, such as removing files or restarting the machine. - self.services.pop(service.uuid) - service.parent = None - self.sys_log.info(f"Uninstalled service {service.name}") - self._service_request_manager.remove_request(service.name) - - def install_application(self, application: Application) -> None: - """ - Install an application on this node. - - :param application: Application instance that has not been installed on any node yet. - :type application: Application - """ - if application in self: - _LOGGER.warning( - f"Can't add application {application.name} to node {self.hostname}. It's already installed." - ) - return - self.applications[application.uuid] = application - application.parent = self - self.sys_log.info(f"Installed application {application.name}") - _LOGGER.debug(f"Added application {application.name} to node {self.hostname}") - self._application_request_manager.add_request(application.name, RequestType(func=application._request_manager)) - - def uninstall_application(self, application: Application) -> None: - """ - Uninstall and completely remove application from this node. - - :param application: Application object that is currently associated with this node. - :type application: Application - """ - if application not in self: - _LOGGER.warning( - f"Can't remove application {application.name} from node {self.hostname}. It's not installed." - ) - return - self.applications.pop(application.uuid) - application.parent = None - self.sys_log.info(f"Uninstalled application {application.name}") - self._application_request_manager.remove_request(application.name) - def _shut_down_actions(self): """Actions to perform when the node is shut down.""" # Turn off all the services in the node diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index e2266c2d..9c4d7cf6 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable +from primaite.simulator.core import RequestType from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol @@ -20,9 +21,7 @@ if TYPE_CHECKING: from primaite.simulator.system.services.arp.arp import ARP from primaite.simulator.system.services.icmp.icmp import ICMP -from typing import Type, TypeVar - -IOSoftwareClass = TypeVar("IOSoftwareClass", bound=IOSoftware) +from typing import Type class SoftwareManager: @@ -51,7 +50,7 @@ class SoftwareManager: self.node = parent_node self.session_manager = session_manager self.software: Dict[str, Union[Service, Application]] = {} - self._software_class_to_name_map: Dict[Type[IOSoftwareClass], str] = {} + self._software_class_to_name_map: Dict[Type[IOSoftware], str] = {} self.port_protocol_mapping: Dict[Tuple[Port, IPProtocol], Union[Service, Application]] = {} self.sys_log: SysLog = sys_log self.file_system: FileSystem = file_system @@ -104,33 +103,34 @@ class SoftwareManager: return True return False - def install(self, software_class: Type[IOSoftwareClass]): + def install(self, software_class: Type[IOSoftware]): """ Install an Application or Service. :param software_class: The software class. """ - # TODO: Software manager and node itself both have an install method. Need to refactor to have more logical - # separation of concerns. if software_class in self._software_class_to_name_map: self.sys_log.warning(f"Cannot install {software_class} as it is already installed") return software = software_class( software_manager=self, sys_log=self.sys_log, file_system=self.file_system, dns_server=self.dns_server ) + software.parent = self.node if isinstance(software, Application): - software.install() + self.node.applications[software.uuid] = software + self.node._application_request_manager.add_request( + software.name, RequestType(func=software._request_manager) + ) + elif isinstance(software, Service): + self.node.services[software.uuid] = software + self.node._service_request_manager.add_request(software.name, RequestType(func=software._request_manager)) + software.install() software.software_manager = self self.software[software.name] = software self.port_protocol_mapping[(software.port, software.protocol)] = software if isinstance(software, Application): software.operating_state = ApplicationOperatingState.CLOSED - - # add the software to the node's registry after it has been fully initialized - if isinstance(software, Service): - self.node.install_service(software) - elif isinstance(software, Application): - self.node.install_application(software) + self.node.sys_log.info(f"Installed {software.name}") def uninstall(self, software_name: str): """ @@ -138,25 +138,31 @@ class SoftwareManager: :param software_name: The software name. """ - if software_name in self.software: - self.software[software_name].uninstall() - software = self.software.pop(software_name) # noqa - if isinstance(software, Application): - self.node.uninstall_application(software) - elif isinstance(software, Service): - self.node.uninstall_service(software) - for key, value in self.port_protocol_mapping.items(): - if value.name == software_name: - self.port_protocol_mapping.pop(key) - break - for key, value in self._software_class_to_name_map.items(): - if value == software_name: - self._software_class_to_name_map.pop(key) - break - del software - self.sys_log.info(f"Uninstalled {software_name}") + if software_name not in self.software: + self.sys_log.error(f"Cannot uninstall {software_name} as it is not installed") return - self.sys_log.error(f"Cannot uninstall {software_name} as it is not installed") + + self.software[software_name].uninstall() + software = self.software.pop(software_name) # noqa + if isinstance(software, Application): + self.node.applications.pop(software.uuid) + self.node._application_request_manager.remove_request(software.name) + elif isinstance(software, Service): + self.node.services.pop(software.uuid) + software.uninstall() + self.node._service_request_manager.remove_request(software.name) + software.parent = None + for key, value in self.port_protocol_mapping.items(): + if value.name == software_name: + self.port_protocol_mapping.pop(key) + break + for key, value in self._software_class_to_name_map.items(): + if value == software_name: + self._software_class_to_name_map.pop(key) + break + del software + self.sys_log.info(f"Uninstalled {software_name}") + return def send_internal_payload(self, target_software: str, payload: Any): """ diff --git a/tests/conftest.py b/tests/conftest.py index 54519e2b..ca704461 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,14 +37,14 @@ ACTION_SPACE_NODE_ACTION_VALUES = 1 _LOGGER = getLogger(__name__) -class TestService(Service): +class DummyService(Service): """Test Service class""" def describe_state(self) -> Dict: return super().describe_state() def __init__(self, **kwargs): - kwargs["name"] = "TestService" + kwargs["name"] = "DummyService" kwargs["port"] = Port.HTTP kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) @@ -75,15 +75,15 @@ def uc2_network() -> Network: @pytest.fixture(scope="function") -def service(file_system) -> TestService: - return TestService( - name="TestService", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="test_service") +def service(file_system) -> DummyService: + return DummyService( + name="DummyService", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="dummy_service") ) @pytest.fixture(scope="function") def service_class(): - return TestService + return DummyService @pytest.fixture(scope="function") diff --git a/tests/integration_tests/component_creation/test_action_integration.py b/tests/integration_tests/component_creation/test_action_integration.py index a6f09436..7bdc80fc 100644 --- a/tests/integration_tests/component_creation/test_action_integration.py +++ b/tests/integration_tests/component_creation/test_action_integration.py @@ -22,8 +22,7 @@ def test_passing_actions_down(monkeypatch) -> None: for n in [pc1, pc2, srv, s1]: sim.network.add_node(n) - database_service = DatabaseService(file_system=srv.file_system) - srv.install_service(database_service) + srv.software_manager.install(DatabaseService) downloads_folder = pc1.file_system.create_folder("downloads") pc1.file_system.create_file("bermuda_triangle.png", folder_name="downloads") diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py index 15dbaf1d..cf9728ce 100644 --- a/tests/integration_tests/system/test_service_on_node.py +++ b/tests/integration_tests/system/test_service_on_node.py @@ -23,7 +23,7 @@ def populated_node( server.power_on() server.software_manager.install(service_class) - service = server.software_manager.software.get("TestService") + service = server.software_manager.software.get("DummyService") service.start() return server, service @@ -42,7 +42,7 @@ def test_service_on_offline_node(service_class): computer.power_on() computer.software_manager.install(service_class) - service: Service = computer.software_manager.software.get("TestService") + service: Service = computer.software_manager.software.get("DummyService") computer.power_off() diff --git a/tests/integration_tests/test_simulation/test_request_response.py b/tests/integration_tests/test_simulation/test_request_response.py index a9f0b58d..95634cf1 100644 --- a/tests/integration_tests/test_simulation/test_request_response.py +++ b/tests/integration_tests/test_simulation/test_request_response.py @@ -13,7 +13,7 @@ from primaite.simulator.network.hardware.node_operating_state import NodeOperati from primaite.simulator.network.hardware.nodes.host.host_node import HostNode from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router from primaite.simulator.network.transmission.transport_layer import Port -from tests.conftest import DummyApplication, TestService +from tests.conftest import DummyApplication, DummyService def test_successful_node_file_system_creation_request(example_network): @@ -61,7 +61,7 @@ def test_successful_application_requests(example_network): def test_successful_service_requests(example_network): net = example_network server_1 = net.get_node_by_hostname("server_1") - server_1.software_manager.install(TestService) + server_1.software_manager.install(DummyService) # Careful: the order here is important, for example we cannot run "stop" unless we run "start" first for verb in [ @@ -77,7 +77,7 @@ def test_successful_service_requests(example_network): "scan", "fix", ]: - resp_1 = net.apply_request(["node", "server_1", "service", "TestService", verb]) + resp_1 = net.apply_request(["node", "server_1", "service", "DummyService", verb]) assert resp_1 == RequestResponse(status="success", data={}) server_1.apply_timestep(timestep=1) server_1.apply_timestep(timestep=1) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py index 9b37ac80..44c5c781 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py @@ -7,6 +7,7 @@ from primaite.simulator.file_system.folder import Folder from primaite.simulator.network.hardware.base import Node, NodeOperatingState from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.system.software import SoftwareHealthState +from tests.conftest import DummyApplication, DummyService @pytest.fixture @@ -47,7 +48,7 @@ def test_node_shutdown(node): assert node.operating_state == NodeOperatingState.OFF -def test_node_os_scan(node, service, application): +def test_node_os_scan(node): """Test OS Scanning.""" node.operating_state = NodeOperatingState.ON @@ -55,13 +56,15 @@ def test_node_os_scan(node, service, application): # TODO implement processes # add services to node + node.software_manager.install(DummyService) + service = node.software_manager.software.get("DummyService") service.set_health_state(SoftwareHealthState.COMPROMISED) - node.install_service(service=service) assert service.health_state_visible == SoftwareHealthState.UNUSED # add application to node + node.software_manager.install(DummyApplication) + application = node.software_manager.software.get("DummyApplication") application.set_health_state(SoftwareHealthState.COMPROMISED) - node.install_application(application=application) assert application.health_state_visible == SoftwareHealthState.UNUSED # add folder and file to node @@ -91,7 +94,7 @@ def test_node_os_scan(node, service, application): assert file2.visible_health_status == FileSystemItemHealthStatus.CORRUPT -def test_node_red_scan(node, service, application): +def test_node_red_scan(node): """Test revealing to red""" node.operating_state = NodeOperatingState.ON @@ -99,12 +102,14 @@ def test_node_red_scan(node, service, application): # TODO implement processes # add services to node - node.install_service(service=service) + node.software_manager.install(DummyService) + service = node.software_manager.software.get("DummyService") assert service.revealed_to_red is False # add application to node + node.software_manager.install(DummyApplication) + application = node.software_manager.software.get("DummyApplication") application.set_health_state(SoftwareHealthState.COMPROMISED) - node.install_application(application=application) assert application.revealed_to_red is False # add folder and file to node From 4c7e465f0df5c7496f43096f9f723b43fa4ffeea Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Wed, 31 Jul 2024 16:43:17 +0100 Subject: [PATCH 075/206] #2689 Initial Implementation of C2 Server. --- src/primaite/game/game.py | 2 + .../red_applications/c2/abstract_c2.py | 50 +++++-- .../red_applications/c2/c2_beacon.py | 2 +- .../red_applications/c2/c2_server.py | 138 +++++++++++++++++- .../_red_applications/test_c2_suite.py | 56 +++++++ 5 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 5ef8c14c..8cddbcda 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -36,6 +36,8 @@ from primaite.simulator.system.applications.red_applications.data_manipulation_b ) from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot # noqa: F401 from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript # noqa: F401 +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server from primaite.simulator.system.applications.web_browser import WebBrowser # noqa: F401 from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_client import DNSClient diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index e45333e5..1cea972f 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -100,7 +100,7 @@ class AbstractC2(Application): # Validate call ensures we are only handling Masquerade Packets. @validate_call - def _handle_c2_payload(self, payload: MasqueradePacket) -> bool: + def _handle_c2_payload(self, payload: MasqueradePacket, session_id: Optional[str] = None) -> bool: """Handles masquerade payloads for both c2 beacons and c2 servers. Currently, the C2 application suite can handle the following payloads: @@ -121,18 +121,19 @@ class AbstractC2(Application): :param payload: The C2 Payload to be parsed and handled. :return: True if the c2 payload was handled successfully, False otherwise. + :rtype: Bool """ if payload.payload_type == C2Payload.KEEP_ALIVE: - self.sys_log.info(f"{self.name} received a KEEP ALIVE!") - return self._handle_keep_alive(payload) + self.sys_log.info(f"{self.name} received a KEEP ALIVE payload.") + return self._handle_keep_alive(payload, session_id) elif payload.payload_type == C2Payload.INPUT: - self.sys_log.info(f"{self.name} received an INPUT COMMAND!") - return self._handle_command_input(payload) + self.sys_log.info(f"{self.name} received an INPUT COMMAND payload.") + return self._handle_command_input(payload, session_id) elif payload.payload_type == C2Payload.OUTPUT: - self.sys_log.info(f"{self.name} received an OUTPUT COMMAND!") - return self._handle_command_input(payload) + self.sys_log.info(f"{self.name} received an OUTPUT COMMAND payload.") + return self._handle_command_input(payload, session_id) else: self.sys_log.warning( @@ -154,12 +155,15 @@ class AbstractC2(Application): """Abstract Method: Used in C2 beacon to parse and handle commands received from the c2 server.""" pass - def _handle_keep_alive(self) -> bool: + def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. Returns False if a keep alive was unable to be sent. Returns True if a keep alive was successfully sent or already has been sent this timestep. + + :return: True if successfully handled, false otherwise. + :rtype: Bool """ self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}") # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. @@ -173,9 +177,12 @@ class AbstractC2(Application): return True # If we've reached this part of the method then we've received a keep alive but haven't sent a reply. + # Therefore we also need to configure the masquerade attributes based off the keep alive sent. + if not self._resolve_keep_alive(self, payload): + return False # If this method returns true then we have sent successfully sent a keep alive. - if self._send_keep_alive(self): + if self._send_keep_alive(self, session_id): self.keep_alive_sent = True # Setting the guard clause to true (prevents packet storms.) return True @@ -230,3 +237,28 @@ class AbstractC2(Application): return False + def _resolve_keep_alive(self, payload: MasqueradePacket) -> bool: + """ + Parses the Masquerade Port/Protocol within the received Keep Alive packet. + + Used to dynamically set the Masquerade Port and Protocol based on incoming traffic. + + Returns True on successfully extracting and configuring the masquerade port/protocols. + Returns False otherwise. + + :param payload: The Keep Alive payload received. + :type payload: MasqueradePacket + :return: True on successful configuration, false otherwise. + :rtype: bool + """ + # Validating that they are valid Enums. + if payload.masquerade_port or payload.masquerade_protocol != Enum: + self.sys_log.warning(f"{self.name}: Received invalid Masquerade type. Port: {type(payload.masquerade_port)} Protocol: {type(payload.masquerade_protocol)}") + return False + # TODO: Validation on Ports (E.g only allow HTTP, FTP etc) + # Potentially compare to IPProtocol & Port children (Same way that abstract TAP does it with kill chains) + + # Setting the Ports + self.current_masquerade_port = payload.masquerade_port + self.current_masquerade_protocol = payload.masquerade_protocol + return True \ No newline at end of file diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index e0ad30f8..f8db5398 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -155,7 +155,7 @@ class C2Beacon(AbstractC2): self.sys_log.info(f"{self.name}: Received a ransomware launch C2 command.") return self._return_command_output(self._command_ransomware_launch(payload)) - elif payload.payload_type == C2Command.TERMINAL: + elif command == C2Command.TERMINAL: self.sys_log.info(f"{self.name}: Received a terminal C2 command.") return self._return_command_output(self._command_terminal(payload)) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 05ff30d9..8ab10d22 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -1,14 +1,142 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket - +from primaite.simulator.core import RequestManager, RequestType +from primaite.interface.request import RequestFormat, RequestResponse +from typing import Dict,Optional class C2Server(AbstractC2): # TODO: # Implement the request manager and agent actions. # Implement the output handling methods. (These need to interface with the actions) + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + name="c2_ransomware_configure", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_configure_ransomware_action())), + ) + rm.add_request( + name="c2_ransomware_launch", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_launch_ransomware_action())), + ) + rm.add_request( + name="c2_terminal_command", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_remote_terminal_action())), + ) + + def _configure_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: + """Requests - Sends a RANSOMWARE_CONFIGURE C2Command to the C2 Beacon with the given parameters. + + :param request: Request with one element containing a dict of parameters for the configure method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the configuration could be applied. + :rtype: RequestResponse + """ + # TODO: Parse the parameters from the request to get the parameters + placeholder: dict = {} + return self._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=placeholder) + + def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: + """Agent Action - Sends a RANSOMWARE_LAUNCH C2Command to the C2 Beacon with the given parameters. + + :param request: Request with one element containing a dict of parameters for the configure method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the ransomware was launched. + :rtype: RequestResponse + """ + # TODO: Parse the parameters from the request to get the parameters + placeholder: dict = {} + return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options=placeholder) + + def _remote_terminal_action(request: RequestFormat, context: Dict) -> RequestResponse: + """Agent Action - Sends a TERMINAL C2Command to the C2 Beacon with the given parameters. + + :param request: Request with one element containing a dict of parameters for the configure method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the ransomware was launched. + :rtype: RequestResponse + """ + # TODO: Parse the parameters from the request to get the parameters + placeholder: dict = {} + return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options=placeholder) + + + def _handle_command_output(self, payload: MasqueradePacket) -> RequestResponse: + """ + Handles the parsing of C2 Command Output from C2 Traffic (Masquerade Packets) + as well as then calling the relevant method dependant on the C2 Command. + + :param payload: The OUTPUT C2 Payload + :type payload: MasqueradePacket + :return: Returns the Request Response of the C2 Beacon's host terminal service execute method. + :rtype Request Response: + """ + self.sys_log.info(f"{self.name}: Received command response from C2 Beacon: {payload}.") + command_output = payload.payload + if command_output != MasqueradePacket: + self.sys_log.warning(f"{self.name}: Received invalid command response: {command_output}.") + return RequestResponse(status="failure", data={"Received unexpected C2 Response."}) + return command_output - def _handle_command_output(payload): - """Abstract Method: Used in C2 server to parse and receive the output of commands sent to the c2 beacon.""" - pass - \ No newline at end of file + def _send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: + """ + Sends a command to the C2 Beacon. + + # TODO: Expand this docustring. + + :param given_command: The C2 command to be sent to the C2 Beacon. + :type given_command: C2Command. + :param command_options: The relevant C2 Beacon parameters. + :type command_options: Dict + :return: Returns the Request Response of the C2 Beacon's host terminal service execute method. + :rtype: RequestResponse + """ + if given_command != C2Payload: + self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to send command.") + return RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to send command."}) + + self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") + command_packet = self._craft_packet(given_command=given_command, command_options=command_options) + + # Need to investigate if this is correct. + if self.send(self, payload=command_packet,dest_ip_address=self.c2_remote_connection, + port=self.current_masquerade_port, protocol=self.current_masquerade_protocol,session_id=None): + self.sys_log.info(f"{self.name}: Successfully sent {given_command}.") + self.sys_log.info(f"{self.name}: Awaiting command response {given_command}.") + return self._handle_command_output(command_packet) + + + # TODO: Perhaps make a new pydantic base model for command_options? + # TODO: Perhaps make the return optional? Returns False is the packet was unable to be crafted. + def _craft_packet(self, given_command: C2Command, command_options: Dict) -> MasqueradePacket: + """ + Creates a Masquerade Packet based off the command parameter and the arguments given. + + :param given_command: The C2 command to be sent to the C2 Beacon. + :type given_command: C2Command. + :param command_options: The relevant C2 Beacon parameters. + :type command_options: Dict + :return: Returns the construct MasqueradePacket + :rtype: MasqueradePacket + """ + # TODO: Validation on command_options. + constructed_packet = MasqueradePacket( + masquerade_protocol=self.current_masquerade_protocol, + masquerade_port=self.current_masquerade_port, + payload_type=C2Payload.INPUT, + command=given_command, + payload=command_options + ) + return constructed_packet + \ No newline at end of file diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py new file mode 100644 index 00000000..e5fee496 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -0,0 +1,56 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Tuple + +import pytest + +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.services.web_server.web_server import WebServer +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server + +# TODO: Update these tests. + +@pytest.fixture(scope="function") +def c2_server_on_computer() -> Tuple[C2Beacon, Computer]: + computer: Computer = Computer( + hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0 + ) + computer.power_on() + c2_beacon = computer.software_manager.software.get("C2Beacon") + + return [c2_beacon, computer] + +@pytest.fixture(scope="function") +def c2_server_on_computer() -> Tuple[C2Server, Computer]: + computer: Computer = Computer( + hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0 + ) + computer.power_on() + c2_server = computer.software_manager.software.get("C2Server") + + return [c2_server, computer] + + + +@pytest.fixture(scope="function") +def basic_network() -> Network: + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_a.software_manager.get_open_ports() + + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + return network \ No newline at end of file From 2648614f97d2424c31e4fc1c208ebe0ce12dbd69 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 31 Jul 2024 16:44:25 +0100 Subject: [PATCH 076/206] 2800 update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 515be435..cebc2569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Transmission Feasibility Check**: Updated `_can_transmit` function in `Link` to account for current load and total bandwidth capacity, ensuring transmissions do not exceed limits. - **Frame Size Details**: Frame `size` attribute now includes both core size and payload size in bytes. - **Transmission Blocking**: Enhanced `AirSpace` logic to block transmissions that would exceed the available capacity. +- **Software (un)install refactored**: Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. ### Fixed From e4e3e17f511322ce1f5a5735a071d4518ff5a2f5 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 1 Aug 2024 07:57:01 +0100 Subject: [PATCH 077/206] #2706 - commit minor changes from review comments --- src/primaite/simulator/system/services/terminal/terminal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index b6999694..6df21618 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -185,9 +185,11 @@ class Terminal(Service): def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: """Process User request to login to Terminal. - :param dest_ip_address: The IP address of the node we want to connect to. + If ip_address is passed, login will attempt a remote login to the terminal + :param username: The username credential. :param password: The user password component of credentials. + :param dest_ip_address: The IP address of the node we want to connect to. :return: True if successful, False otherwise. """ if self.operating_state != ServiceOperatingState.RUNNING: @@ -196,6 +198,8 @@ class Terminal(Service): if ip_address: # if ip_address has been provided, we assume we are logging in to a remote terminal. + if ip_address == self.parent.network_interface[1].ip_address: + return self._process_local_login(username=username, password=password) return self._send_remote_login(username=username, password=password, ip_address=ip_address) return self._process_local_login(username=username, password=password) From 5ef9e78a448192ec58f66429c80588bda84f93f7 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 1 Aug 2024 08:37:51 +0100 Subject: [PATCH 078/206] #2706 - Elaborated on terminal login within notebook --- .../notebooks/Terminal-Processing.ipynb | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 75b92422..fc795794 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -65,10 +65,25 @@ "computer_a: Computer = network.get_node_by_hostname(\"node_a\")\n", "terminal_a: Terminal = computer_a.software_manager.software.get(\"Terminal\")\n", "computer_b: Computer = network.get_node_by_hostname(\"node_b\")\n", - "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")\n", - "\n", + "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To be able to send commands from `node_a` to `node_b`, you will need to `login` to `node_b` first, using valid user credentials. In the example below, we are logging in to the 'admin' account on `node_b`. \n", + "If you are not logged in, any commands sent will be rejected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ "# Login to the remote (node_b) from local (node_a)\n", - "terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)\n" + "terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" ] }, { From b5992574339c2d28b5ab954d566d478317c4fc4e Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 1 Aug 2024 09:06:35 +0100 Subject: [PATCH 079/206] #2676 - update configs to use new nmne schema; fix test and warnings --- .../_package_data/scenario_with_placeholders/scenario.yaml | 4 ++++ src/primaite/simulator/network/protocols/icmp.py | 4 ++-- tests/conftest.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml index 81848b2d..dfd200f3 100644 --- a/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml +++ b/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml @@ -129,6 +129,10 @@ agents: simulation: network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE nodes: - hostname: client type: computer diff --git a/src/primaite/simulator/network/protocols/icmp.py b/src/primaite/simulator/network/protocols/icmp.py index 743e2375..9f0626f0 100644 --- a/src/primaite/simulator/network/protocols/icmp.py +++ b/src/primaite/simulator/network/protocols/icmp.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Union from pydantic import BaseModel, field_validator, validate_call -from pydantic_core.core_schema import FieldValidationInfo +from pydantic_core.core_schema import ValidationInfo from primaite import getLogger @@ -96,7 +96,7 @@ class ICMPPacket(BaseModel): @field_validator("icmp_code") # noqa @classmethod - def _icmp_type_must_have_icmp_code(cls, v: int, info: FieldValidationInfo) -> int: + def _icmp_type_must_have_icmp_code(cls, v: int, info: ValidationInfo) -> int: """Validates the icmp_type and icmp_code.""" icmp_type = info.data["icmp_type"] if get_icmp_type_code_description(icmp_type, v): diff --git a/tests/conftest.py b/tests/conftest.py index 54519e2b..2996e953 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ from primaite.simulator.system.services.service import Service from primaite.simulator.system.services.web_server.web_server import WebServer from tests import TEST_ASSETS_ROOT -rayinit(local_mode=True) +rayinit() ACTION_SPACE_NODE_VALUES = 1 ACTION_SPACE_NODE_ACTION_VALUES = 1 From e09c0ad4ac49a5de34e2b4b9dea719acfe56ddd7 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Thu, 1 Aug 2024 10:11:03 +0100 Subject: [PATCH 080/206] #2689 added test template and fixed class instancing issues. --- .../red_applications/c2/abstract_c2.py | 7 +++- .../red_applications/c2/c2_beacon.py | 16 ++++++-- .../red_applications/c2/c2_server.py | 40 ++++++++++++------- .../_red_applications/test_c2_suite.py | 12 +++++- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 1cea972f..af5c37b9 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -35,7 +35,7 @@ class C2Command(Enum): # The terminal command should also be able to pass a session which can be used for remote connections. -class AbstractC2(Application): +class AbstractC2(Application, identifier="AbstractC2"): """ An abstract command and control (c2) application. @@ -97,6 +97,11 @@ class AbstractC2(Application): :rtype: Dict """ return super().describe_state() + + def __init__(self, **kwargs): + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) # Validate call ensures we are only handling Masquerade Packets. @validate_call diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index f8db5398..1d61e3b1 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -12,7 +12,7 @@ from enum import Enum from primaite.simulator.system.software import SoftwareHealthState from primaite.simulator.system.applications.application import ApplicationOperatingState -class C2Beacon(AbstractC2): +class C2Beacon(AbstractC2, identifier="C2Beacon"): """ C2 Beacon Application. @@ -86,8 +86,8 @@ class C2Beacon(AbstractC2): return rm def __init__(self, **kwargs): - self.name = "C2Beacon" - super.__init__(**kwargs) + kwargs["name"] = "C2Beacon" + super().__init__(**kwargs) def configure( self, @@ -268,4 +268,12 @@ class C2Beacon(AbstractC2): if self.keep_alive_inactivity != 0: self.sys_log.warning(f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed.") return False - return True \ No newline at end of file + return True + + + # Defining this abstract method from Abstract C2 + def _handle_command_output(self, payload): + """C2 Beacons currently do not need to handle output commands coming from the C2 Servers.""" + self.sys_log.warning(f"{self.name}: C2 Beacon received an unexpected OUTPUT payload: {payload}") + pass + \ No newline at end of file diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 8ab10d22..6cff1972 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -5,7 +5,7 @@ from primaite.simulator.core import RequestManager, RequestType from primaite.interface.request import RequestFormat, RequestResponse from typing import Dict,Optional -class C2Server(AbstractC2): +class C2Server(AbstractC2, identifier="C2 Server"): # TODO: # Implement the request manager and agent actions. # Implement the output handling methods. (These need to interface with the actions) @@ -16,18 +16,6 @@ class C2Server(AbstractC2): More information in user guide and docstring for SimComponent._init_request_manager. """ rm = super()._init_request_manager() - rm.add_request( - name="c2_ransomware_configure", - request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_configure_ransomware_action())), - ) - rm.add_request( - name="c2_ransomware_launch", - request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_launch_ransomware_action())), - ) - rm.add_request( - name="c2_terminal_command", - request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(_remote_terminal_action())), - ) def _configure_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: """Requests - Sends a RANSOMWARE_CONFIGURE C2Command to the C2 Beacon with the given parameters. @@ -71,6 +59,23 @@ class C2Server(AbstractC2): placeholder: dict = {} return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options=placeholder) + rm.add_request( + name="c2_ransomware_configure", + request_type=RequestType(func=_configure_ransomware_action), + ) + rm.add_request( + name="c2_ransomware_launch", + request_type=RequestType(func=_launch_ransomware_action), + ) + rm.add_request( + name="c2_terminal_command", + request_type=RequestType(func=_remote_terminal_action), + ) + return rm + + def __init__(self, **kwargs): + kwargs["name"] = "C2Server" + super().__init__(**kwargs) def _handle_command_output(self, payload: MasqueradePacket) -> RequestResponse: """ @@ -125,7 +130,7 @@ class C2Server(AbstractC2): :param given_command: The C2 command to be sent to the C2 Beacon. :type given_command: C2Command. - :param command_options: The relevant C2 Beacon parameters. + :param command_options: The relevant C2 Beacon parameters.F :type command_options: Dict :return: Returns the construct MasqueradePacket :rtype: MasqueradePacket @@ -139,4 +144,9 @@ class C2Server(AbstractC2): payload=command_options ) return constructed_packet - \ No newline at end of file + + # Defining this abstract method + def _handle_command_input(self, payload): + """C2 Servers currently do not receive input commands coming from the C2 Beacons.""" + self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") + pass diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index e5fee496..20da4140 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -48,9 +48,19 @@ def basic_network() -> Network: node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) node_a.power_on() node_a.software_manager.get_open_ports() + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) node_b.power_on() network.connect(node_a.network_interface[1], node_b.network_interface[1]) - return network \ No newline at end of file + return network + +def test_c2_suite_setup_receive(basic_network): + """Test that C2 Beacon can successfully establish connection with the c2 Server""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = network.get_node_by_hostname("node_b") + c2_beacon: C2Server = computer_a.software_manager.software.get("C2Beacon") From 19d7774440c2e11b5bfef3fc55a6daa3bb40c88a Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 1 Aug 2024 12:34:21 +0100 Subject: [PATCH 081/206] #2706 - Changed how Terminal Class handles its connections. Terminal now has a list of TerminalClientConnection objects that holds all active connections. Corrected a typo in ssh.py --- .../simulator/network/protocols/ssh.py | 2 +- .../system/services/terminal/terminal.py | 74 +++++++++++-------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 8671a1c8..495a2a2b 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -22,7 +22,7 @@ class SSHTransportMessage(IntEnum): """Indicates User Authentication failed.""" SSH_MSG_USERAUTH_SUCCESS = 52 - """Indicates User Authentication failed was successful.""" + """Indicates User Authentication was successful.""" SSH_MSG_SERVICE_REQUEST = 24 """Requests a service - such as executing a command.""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 6df21618..998238a9 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -43,6 +43,17 @@ class TerminalClientConnection(BaseModel): _connection_uuid: str = None """Connection UUID""" + @property + def is_local(self) -> bool: + """Indicates if connection is remote or local. + + Returns True if local, False if remote. + """ + for interface in self.parent_node.network_interface: + if self.dest_ip_address == self.parent_node.network_interface[interface].ip_address: + return True + return False + @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -57,9 +68,6 @@ class TerminalClientConnection(BaseModel): class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" - is_connected: bool = False - "Boolean Value for whether connected" - connection_uuid: Optional[str] = None "Uuid for connection requests" @@ -69,7 +77,8 @@ class Terminal(Service): health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD "Service Health State" - remote_connection: Dict[str, TerminalClientConnection] = {} + _connections: Dict[str, TerminalClientConnection] = {} + "List of active connections held on this terminal." def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -95,13 +104,13 @@ class Terminal(Service): :param markdown: Whether to display the table in Markdown format or not. Default is `False`. """ - table = PrettyTable(["Connection ID", "IP_Address", "Active"]) + table = PrettyTable(["Connection ID", "IP_Address", "Active", "Local"]) if markdown: table.set_style(MARKDOWN) table.align = "l" - table.title = f"{self.sys_log.hostname} {self.name} Remote Connections" - for connection_id, connection in self.remote_connection.items(): - table.add_row([connection_id, connection.dest_ip_address, connection.is_active]) + table.title = f"{self.sys_log.hostname} {self.name} Connections" + for connection_id, connection in self._connections.items(): + table.add_row([connection_id, connection.dest_ip_address, connection.is_active, connection.is_local]) print(table.get_string(sortby="Connection ID")) def _init_request_manager(self) -> RequestManager: @@ -182,11 +191,18 @@ class Terminal(Service): """Message that is reported when a request is rejected by this validator.""" return "Cannot perform request on terminal as not logged in." + def _add_new_connection(self, connection_uuid: str, dest_ip_address: IPv4Address): + """Create a new connection object and amend to list of active connections.""" + self._connections[connection_uuid] = TerminalClientConnection( + parent_node=self.software_manager.node, + dest_ip_address=dest_ip_address, + connection_uuid=connection_uuid, + ) + def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: """Process User request to login to Terminal. - If ip_address is passed, login will attempt a remote login to the terminal - + If ip_address is passed, login will attempt a remote login to the node at that address. :param username: The username credential. :param password: The user password component of credentials. :param dest_ip_address: The IP address of the node we want to connect to. @@ -198,8 +214,6 @@ class Terminal(Service): if ip_address: # if ip_address has been provided, we assume we are logging in to a remote terminal. - if ip_address == self.parent.network_interface[1].ip_address: - return self._process_local_login(username=username, password=password) return self._send_remote_login(username=username, password=password, ip_address=ip_address) return self._process_local_login(username=username, password=password) @@ -207,11 +221,14 @@ class Terminal(Service): def _process_local_login(self, username: str, password: str) -> bool: """Local session login to terminal.""" # TODO: Un-comment this when UserSessionManager is merged. - # self.connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) - self.connection_uuid = str(uuid4()) - self.is_connected = True - if self.connection_uuid: + # connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) + connection_uuid = str(uuid4()) + if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") + # Add new local session to list of connections + self._add_new_connection( + connection_uuid=connection_uuid, dest_ip_address=self.parent.network_interface[1].ip_address + ) return True else: self.sys_log.warning("Login failed, incorrect Username or Password") @@ -246,11 +263,10 @@ class Terminal(Service): # TODO: Un-comment this when UserSessionManager is merged. # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) connection_uuid = str(uuid4()) - self.is_connected = True if connection_uuid: # Send uuid to remote self.sys_log.info( - f"Remote login authorised, connection ID {self.connection_uuid} for " + f"Remote login authorised, connection ID {connection_uuid} for " f"{username} on {payload.sender_ip_address}" ) transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS @@ -262,12 +278,7 @@ class Terminal(Service): sender_ip_address=self.parent.network_interface[1].ip_address, target_ip_address=payload.sender_ip_address, ) - - self.remote_connection[connection_uuid] = TerminalClientConnection( - parent_node=self.software_manager.node, - dest_ip_address=payload.sender_ip_address, - connection_uuid=connection_uuid, - ) + self._add_new_connection(connection_uuid=connection_uuid, dest_ip_address=payload.sender_ip_address) self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) return True @@ -280,7 +291,7 @@ class Terminal(Service): """Receive Payload and process for a response. :param payload: The message contents received. - :return: True if successfull, else False. + :return: True if successful, else False. """ self.sys_log.debug(f"Received payload: {payload}") @@ -304,7 +315,6 @@ class Terminal(Service): elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") self.connection_uuid = payload.connection_uuid - self.is_connected = True return True elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: @@ -322,14 +332,15 @@ class Terminal(Service): def _disconnect(self, dest_ip_address: IPv4Address) -> bool: """Disconnect from the remote.""" - if not self.is_connected: - self.sys_log.warning("Not currently connected to remote") - return False - - if not self.remote_connection: + if not self._connections: self.sys_log.warning("No remote connection present") return False + # TODO: This should probably be done entirely by connection uuid and not IP_address. + for connection in self._connections: + if dest_ip_address == self._connections[connection].dest_ip_address: + self._connections.pop(connection) + software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( payload={"type": "disconnect", "connection_id": self.connection_uuid}, @@ -347,7 +358,6 @@ class Terminal(Service): :return: True if successful, False otherwise. """ self._disconnect(dest_ip_address=dest_ip_address) - self.is_connected = False def send( self, From 78ad95fcef835b2b62f03a3ab724cf564a2e400f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 1 Aug 2024 13:58:35 +0100 Subject: [PATCH 082/206] #2735 - fix up node request manager and system software --- .../simulator/network/hardware/base.py | 36 +++++++++---------- .../network/hardware/nodes/host/host_node.py | 22 +++++------- .../network/hardware/nodes/network/router.py | 10 ++++-- 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index cbe8db64..d2aa4604 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,7 +6,7 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, Dict, List, Optional, TypeVar, Union +from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel, Field, validate_call @@ -39,7 +39,7 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.processes.process import Process from primaite.simulator.system.services.service import Service -from primaite.simulator.system.software import IOSoftware +from primaite.simulator.system.software import IOSoftware, Software from primaite.utils.converters import convert_dict_enum_keys_to_enum_values from primaite.utils.validators import IPV4Address @@ -897,6 +897,10 @@ class UserManager(Service): table.add_row([user.username, user.is_admin, user.disabled]) print(table.get_string(sortby="Username")) + def install(self) -> None: + """Setup default user during first-time installation.""" + self.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) + def _is_last_admin(self, username: str) -> bool: return username in self.admins and len(self.admins) == 1 @@ -1100,9 +1104,6 @@ class UserSessionManager(Service): This class handles authentication, session management, and session timeouts for users interacting with the Node. """ - node: Node - """The node associated with this UserSessionManager.""" - local_session: Optional[UserSession] = None """The current local user session, if any.""" @@ -1183,7 +1184,7 @@ class UserSessionManager(Service): if markdown: table.set_style(MARKDOWN) table.align = "l" - table.title = f"{self.node.hostname} User Sessions" + table.title = f"{self.parent.hostname} User Sessions" def _add_session_to_table(user_session: UserSession): """ @@ -1472,6 +1473,9 @@ class Node(SimComponent): red_scan_countdown: int = 0 "Time steps until reveal to red scan is complete." + SYSTEM_SOFTWARE: ClassVar[Dict[str, Type[Software]]] = {} + "Base system software that must be preinstalled." + def __init__(self, **kwargs): """ Initialize the Node with various components and managers. @@ -1496,21 +1500,10 @@ class Node(SimComponent): dns_server=kwargs.get("dns_server"), ) super().__init__(**kwargs) + self._install_system_software() self.session_manager.node = self self.session_manager.software_manager = self.software_manager - self.software_manager.install(UserSessionManager, node=self) - self._request_manager.add_request( - "sessions", RequestType(func=self.user_session_manager._request_manager) - ) # noqa - - self.software_manager.install(UserManager) - self._request_manager.add_request("accounts", RequestType(func=self.user_manager._request_manager)) # noqa - - self.user_manager.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) - - self._install_system_software() - @property def user_manager(self) -> UserManager: """The Nodes User Manager.""" @@ -1767,8 +1760,6 @@ class Node(SimComponent): "services": {svc.name: svc.describe_state() for svc in self.services.values()}, "process": {proc.name: proc.describe_state() for proc in self.processes.values()}, "revealed_to_red": self.revealed_to_red, - "user_manager": self.user_manager.describe_state(), - "user_session_manager": self.user_session_manager.describe_state(), } ) return state @@ -2134,6 +2125,11 @@ class Node(SimComponent): # for process_id in self.processes: # self.processes[process_id] + def _install_system_software(self) -> None: + """Preinstall required software.""" + for _, software_class in self.SYSTEM_SOFTWARE.items(): + self.software_manager.install(software_class) + def __contains__(self, item: Any) -> bool: if isinstance(item, Service): return item.uuid in self.services diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index aac57e95..22c50bef 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -5,7 +5,13 @@ from ipaddress import IPv4Address from typing import Any, ClassVar, Dict, Optional from primaite import getLogger -from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, Link, Node +from primaite.simulator.network.hardware.base import ( + IPWiredNetworkInterface, + Link, + Node, + UserManager, + UserSessionManager, +) from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.system.applications.application import ApplicationOperatingState @@ -306,8 +312,8 @@ class HostNode(Node): "NTPClient": NTPClient, "WebBrowser": WebBrowser, "NMAP": NMAP, - # "UserSessionManager": UserSessionManager, - # "UserManager": UserManager, + "UserSessionManager": UserSessionManager, + "UserManager": UserManager, } """List of system software that is automatically installed on nodes.""" @@ -340,16 +346,6 @@ class HostNode(Node): """ return self.software_manager.software.get("ARP") - def _install_system_software(self): - """ - Installs the system software and network services typically found on an operating system. - - This method equips the host with essential network services and applications, preparing it for various - network-related tasks and operations. - """ - for _, software_class in self.SYSTEM_SOFTWARE.items(): - self.software_manager.install(software_class) - def default_gateway_hello(self): """ Sends a hello message to the default gateway to establish connectivity and resolve the gateway's MAC address. diff --git a/src/primaite/simulator/network/hardware/nodes/network/router.py b/src/primaite/simulator/network/hardware/nodes/network/router.py index 61b7b96a..42821120 100644 --- a/src/primaite/simulator/network/hardware/nodes/network/router.py +++ b/src/primaite/simulator/network/hardware/nodes/network/router.py @@ -4,14 +4,14 @@ from __future__ import annotations import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from prettytable import MARKDOWN, PrettyTable from pydantic import validate_call from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType, SimComponent -from primaite.simulator.network.hardware.base import IPWiredNetworkInterface +from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, UserManager, UserSessionManager from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.network.network_node import NetworkNode from primaite.simulator.network.protocols.arp import ARPPacket @@ -1200,6 +1200,11 @@ class Router(NetworkNode): RouteTable, RouterARP, and RouterICMP services. """ + SYSTEM_SOFTWARE: ClassVar[Dict] = { + "UserSessionManager": UserSessionManager, + "UserManager": UserManager, + } + num_ports: int network_interfaces: Dict[str, RouterInterface] = {} "The Router Interfaces on the node." @@ -1235,6 +1240,7 @@ class Router(NetworkNode): resolution within the network. These services are crucial for the router's operation, enabling it to manage network traffic efficiently. """ + super()._install_system_software() self.software_manager.install(RouterICMP) icmp: RouterICMP = self.software_manager.icmp # noqa icmp.router = self From e554a2d2241523a2f2047d4c278261ddee17e837 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Thu, 1 Aug 2024 17:18:10 +0100 Subject: [PATCH 083/206] #2689 Remote connections now successfully establishing however current issues with keep alive inactivity causing the c2 beacon to close even when it does have connection to the c2 server. --- .../red_applications/c2/abstract_c2.py | 80 ++++++++++++------- .../red_applications/c2/c2_beacon.py | 45 +++++++---- .../red_applications/c2/c2_server.py | 25 +++++- .../_red_applications/test_c2_suite.py | 12 ++- 4 files changed, 111 insertions(+), 51 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index af5c37b9..9c840616 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -10,6 +10,7 @@ from primaite.simulator.network.protocols.masquerade import C2Payload, Masquerad from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.core.session_manager import Session # TODO: # Complete C2 Server and C2 Beacon TODOs @@ -52,7 +53,7 @@ class AbstractC2(Application, identifier="AbstractC2"): """Indicates if the c2 server and c2 beacon are currently connected.""" c2_remote_connection: IPv4Address = None - """The IPv4 Address of the remote c2 connection. (Either the IP of the beacon or the server)""" + """The IPv4 Address of the remote c2 connection. (Either the IP of the beacon or the server).""" keep_alive_sent: bool = False """Indicates if a keep alive has been sent this timestep. Used to prevent packet storms.""" @@ -65,12 +66,18 @@ class AbstractC2(Application, identifier="AbstractC2"): # The c2 server parses the keep alive and sets these accordingly. # The c2 beacon will set this attributes upon installation and configuration - current_masquerade_protocol: Enum = IPProtocol.TCP + current_masquerade_protocol: IPProtocol = IPProtocol.TCP """The currently chosen protocol that the C2 traffic is masquerading as. Defaults as TCP.""" - current_masquerade_port: Enum = Port.HTTP + current_masquerade_port: Port = Port.HTTP """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" + current_c2_session: Session = None + """The currently active session that the C2 Traffic is using. Set after establishing connection.""" + + # TODO: Create a attribute call 'LISTENER' which indicates whenever the c2 application should listen for incoming connections or establish connections. + # This in order to simulate a blind shell (the current implementation is more akin to a reverse shell) + # TODO: Move this duplicate method from NMAP class into 'Application' to adhere to DRY principle. def _can_perform_network_action(self) -> bool: """ @@ -99,8 +106,8 @@ class AbstractC2(Application, identifier="AbstractC2"): return super().describe_state() def __init__(self, **kwargs): - kwargs["port"] = Port.NONE - kwargs["protocol"] = IPProtocol.NONE + kwargs["port"] = Port.HTTP # TODO: Update this post application/services requiring to listen to multiple ports + kwargs["protocol"] = IPProtocol.TCP # Update this as well super().__init__(**kwargs) # Validate call ensures we are only handling Masquerade Packets. @@ -147,7 +154,7 @@ class AbstractC2(Application, identifier="AbstractC2"): return False # Abstract method - # Used in C2 server to prase and receive the output of commands sent to the c2 beacon. + # Used in C2 server to parse and receive the output of commands sent to the c2 beacon. @abstractmethod def _handle_command_output(payload): """Abstract Method: Used in C2 server to prase and receive the output of commands sent to the c2 beacon.""" @@ -170,32 +177,35 @@ class AbstractC2(Application, identifier="AbstractC2"): :return: True if successfully handled, false otherwise. :rtype: Bool """ - self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}") - # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. - if self.keep_alive_sent: - self.sys_log.info(f"{self.name}: Connection successfully established with {self.c2_remote_connection}") - self.c2_connection_active = True # Sets the connection to active - self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero + self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}.") + + self.c2_connection_active = True # Sets the connection to active + self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero + self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] + + # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. + # This guard clause triggers on the c2 suite that establishes connection. + if self.keep_alive_sent == True: # Return early without sending another keep alive and then setting keep alive_sent false for next timestep. self.keep_alive_sent = False return True # If we've reached this part of the method then we've received a keep alive but haven't sent a reply. # Therefore we also need to configure the masquerade attributes based off the keep alive sent. - if not self._resolve_keep_alive(self, payload): + if self._resolve_keep_alive(payload, session_id) == False: + self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") return False # If this method returns true then we have sent successfully sent a keep alive. - if self._send_keep_alive(self, session_id): - self.keep_alive_sent = True # Setting the guard clause to true (prevents packet storms.) - return True - # Return false if we're unable to send handle the keep alive correctly. - else: - return False + self.sys_log.info(f"{self.name}: Connection successfully established with {self.c2_remote_connection}.") + + return self._send_keep_alive(session_id) - def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None) -> bool: + + # from_network_interface=from_network_interface + def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None, **kwargs) -> bool: """Receives masquerade packets. Used by both c2 server and c2 client. :param payload: The Masquerade Packet to be received. @@ -220,20 +230,20 @@ class AbstractC2(Application, identifier="AbstractC2"): masquerade_protocol=self.current_masquerade_protocol, masquerade_port=self.current_masquerade_port, payload_type=C2Payload.KEEP_ALIVE, + command=None ) - - # C2 Server will need to c2_remote_connection after it receives it's first keep alive. + # We need to set this guard clause to true before sending the keep alive (prevents packet storms.) + self.keep_alive_sent = True + # C2 Server will need to configure c2_remote_connection after it receives it's first keep alive. if self.send( - self, payload=keep_alive_packet, dest_ip_address=self.c2_remote_connection, - port=self.current_masquerade_port, - protocol=self.current_masquerade_protocol, + dest_port=self.current_masquerade_port, + ip_protocol=self.current_masquerade_protocol, session_id=session_id, ): self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") - self.receive(payload=keep_alive_packet) return True else: self.sys_log.warning( @@ -242,7 +252,7 @@ class AbstractC2(Application, identifier="AbstractC2"): return False - def _resolve_keep_alive(self, payload: MasqueradePacket) -> bool: + def _resolve_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ Parses the Masquerade Port/Protocol within the received Keep Alive packet. @@ -257,8 +267,8 @@ class AbstractC2(Application, identifier="AbstractC2"): :rtype: bool """ # Validating that they are valid Enums. - if payload.masquerade_port or payload.masquerade_protocol != Enum: - self.sys_log.warning(f"{self.name}: Received invalid Masquerade type. Port: {type(payload.masquerade_port)} Protocol: {type(payload.masquerade_protocol)}") + if not isinstance(payload.masquerade_port, Port) or not isinstance(payload.masquerade_protocol, IPProtocol): + self.sys_log.warning(f"{self.name}: Received invalid Masquerade type. Port: {type(payload.masquerade_port)} Protocol: {type(payload.masquerade_protocol)}.") return False # TODO: Validation on Ports (E.g only allow HTTP, FTP etc) # Potentially compare to IPProtocol & Port children (Same way that abstract TAP does it with kill chains) @@ -266,4 +276,14 @@ class AbstractC2(Application, identifier="AbstractC2"): # Setting the Ports self.current_masquerade_port = payload.masquerade_port self.current_masquerade_protocol = payload.masquerade_protocol - return True \ No newline at end of file + + # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) + if self.c2_remote_connection == None: + self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") + self.c2_remote_connection = self.current_c2_session.with_ip_address + + self.c2_connection_active = True # Sets the connection to active + self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zeroW + + return True + \ No newline at end of file diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 1d61e3b1..14c7af02 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -1,6 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command #from primaite.simulator.system.services.terminal.terminal import Terminal +from prettytable import MARKDOWN, PrettyTable from primaite.simulator.core import RequestManager, RequestType from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket @@ -12,7 +13,7 @@ from enum import Enum from primaite.simulator.system.software import SoftwareHealthState from primaite.simulator.system.applications.application import ApplicationOperatingState -class C2Beacon(AbstractC2, identifier="C2Beacon"): +class C2Beacon(AbstractC2, identifier="C2 Beacon"): """ C2 Beacon Application. @@ -128,11 +129,10 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): def establish(self) -> bool: """Establishes connection to the C2 server via a send alive. Must be called after the C2 Beacon is configured.""" self.run() - self._send_keep_alive() self.num_executions += 1 - + return self._send_keep_alive(session_id=None) - def _handle_command_input(self, payload: MasqueradePacket) -> bool: + def _handle_command_input(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets) as well as then calling the relevant method dependant on the C2 Command. @@ -149,22 +149,22 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): if command == C2Command.RANSOMWARE_CONFIGURE: self.sys_log.info(f"{self.name}: Received a ransomware configuration C2 command.") - return self._return_command_output(self._command_ransomware_config(payload)) + return self._return_command_output(command_output=self._command_ransomware_config(payload), session_id=session_id) elif command == C2Command.RANSOMWARE_LAUNCH: self.sys_log.info(f"{self.name}: Received a ransomware launch C2 command.") - return self._return_command_output(self._command_ransomware_launch(payload)) + return self._return_command_output(command_output=self._command_ransomware_launch(payload), session_id=session_id) elif command == C2Command.TERMINAL: self.sys_log.info(f"{self.name}: Received a terminal C2 command.") - return self._return_command_output(self._command_terminal(payload)) + return self._return_command_output(command_output=self._command_terminal(payload), session_id=session_id) else: self.sys_log.error(f"{self.name}: Received an C2 command: {command} but was unable to resolve command.") return self._return_command_output(RequestResponse(status="failure", data={"Unexpected Behaviour. Unable to resolve command."})) - def _return_command_output(self, command_output: RequestResponse) -> bool: + def _return_command_output(self, command_output: RequestResponse, session_id) -> bool: """Responsible for responding to the C2 Server with the output of the given command.""" output_packet = MasqueradePacket( masquerade_protocol=self.current_masquerade_protocol, @@ -173,11 +173,10 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): payload=command_output ) if self.send( - self, payload=output_packet, dest_ip_address=self.c2_remote_connection, - port=self.current_masquerade_port, - protocol=self.current_masquerade_protocol, + dest_port=self.current_masquerade_port, + ip_protocol=self.current_masquerade_protocol, ): self.sys_log.info(f"{self.name}: Command output sent to {self.c2_remote_connection}") self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") @@ -256,15 +255,16 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): if not self._check_c2_connection(timestep): self.sys_log.error(f"{self.name}: Connection Severed - Application Closing.") self.clear_connections() + # TODO: Shouldn't this close() method also set the health state to 'UNUSED'? self.close() return def _check_c2_connection(self, timestep) -> bool: """Checks the C2 Server connection. If a connection cannot be confirmed then this method will return false otherwise true.""" - if self.keep_alive_inactivity > self.keep_alive_frequency: - self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection} at timestep {timestep}.") - self._send_keep_alive() + if self.keep_alive_inactivity == self.keep_alive_frequency: + self.sys_log.info(f"{self.name}: Attempting to Send Keep Alive to {self.c2_remote_connection} at timestep {timestep}.") + self._send_keep_alive(session_id=self.current_c2_session.uuid) if self.keep_alive_inactivity != 0: self.sys_log.warning(f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed.") return False @@ -274,6 +274,19 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # Defining this abstract method from Abstract C2 def _handle_command_output(self, payload): """C2 Beacons currently do not need to handle output commands coming from the C2 Servers.""" - self.sys_log.warning(f"{self.name}: C2 Beacon received an unexpected OUTPUT payload: {payload}") + self.sys_log.warning(f"{self.name}: C2 Beacon received an unexpected OUTPUT payload: {payload}.") pass - \ No newline at end of file + + def show(self, markdown: bool = False): + """ + Prints a table of the current C2 attributes on a C2 Beacon. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity", "Keep Alive Frequency"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.name} Running Status" + table.add_row([self.c2_connection_active, self.c2_remote_connection, self.keep_alive_inactivity, self.keep_alive_frequency]) + print(table) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 6cff1972..8fe8c00c 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -3,6 +3,7 @@ from primaite.simulator.system.applications.red_applications.c2.abstract_c2 impo from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket from primaite.simulator.core import RequestManager, RequestType from primaite.interface.request import RequestFormat, RequestResponse +from prettytable import MARKDOWN, PrettyTable from typing import Dict,Optional class C2Server(AbstractC2, identifier="C2 Server"): @@ -94,6 +95,7 @@ class C2Server(AbstractC2, identifier="C2 Server"): return RequestResponse(status="failure", data={"Received unexpected C2 Response."}) return command_output + def _send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: """ Sends a command to the C2 Beacon. @@ -115,8 +117,12 @@ class C2Server(AbstractC2, identifier="C2 Server"): command_packet = self._craft_packet(given_command=given_command, command_options=command_options) # Need to investigate if this is correct. - if self.send(self, payload=command_packet,dest_ip_address=self.c2_remote_connection, - port=self.current_masquerade_port, protocol=self.current_masquerade_protocol,session_id=None): + if self.send(payload=command_packet, + dest_ip_address=self.c2_remote_connection, + src_port=self.current_masquerade_port, + dst_port=self.current_masquerade_port, + ip_protocol=self.current_masquerade_protocol, + session_id=None): self.sys_log.info(f"{self.name}: Successfully sent {given_command}.") self.sys_log.info(f"{self.name}: Awaiting command response {given_command}.") return self._handle_command_output(command_packet) @@ -150,3 +156,18 @@ class C2Server(AbstractC2, identifier="C2 Server"): """C2 Servers currently do not receive input commands coming from the C2 Beacons.""" self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") pass + + + def show(self, markdown: bool = False): + """ + Prints a table of the current C2 attributes on a C2 Server. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.name} Running Status" + table.add_row([self.c2_connection_active, self.c2_remote_connection, self.keep_alive_inactivity]) + print(table) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 20da4140..3014cd19 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -48,19 +48,25 @@ def basic_network() -> Network: node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) node_a.power_on() node_a.software_manager.get_open_ports() - + node_a.software_manager.install(software_class=C2Server) node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.software_manager.install(software_class=C2Beacon) node_b.power_on() network.connect(node_a.network_interface[1], node_b.network_interface[1]) return network def test_c2_suite_setup_receive(basic_network): - """Test that C2 Beacon can successfully establish connection with the c2 Server""" + """Test that C2 Beacon can successfully establish connection with the c2 Server.""" network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") c2_server: C2Server = computer_a.software_manager.software.get("C2Server") computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Server = computer_a.software_manager.software.get("C2Beacon") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + c2_beacon.configure(c2_server_ip_address="192.168.0.10") + c2_beacon.establish() + + c2_beacon.sys_log.show() \ No newline at end of file From 0fe61576c768839429a4802ba5ec89b4ac8f48ba Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 2 Aug 2024 09:13:31 +0100 Subject: [PATCH 084/206] #2706 - Removed source and target ip_address attributes from the SSHPacket Class. Terminal now uses session_id to send login outcome. No more network_interface[1].ip_address. --- .../simulator/network/protocols/ssh.py | 7 -- .../system/services/terminal/terminal.py | 112 +++++++++--------- 2 files changed, 53 insertions(+), 66 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 495a2a2b..4ec043b8 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -1,7 +1,6 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from enum import IntEnum -from ipaddress import IPv4Address from typing import Optional from primaite.interface.request import RequestResponse @@ -68,12 +67,6 @@ class SSHUserCredentials(DataPacket): class SSHPacket(DataPacket): """Represents an SSHPacket.""" - sender_ip_address: IPv4Address - """Sender IP Address""" - - target_ip_address: IPv4Address - """Target IP Address""" - transport_message: SSHTransportMessage """Message Transport Type""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 998238a9..192f0551 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -28,32 +28,21 @@ class TerminalClientConnection(BaseModel): """ TerminalClientConnection Class. - This class is used to record current remote User Connections to the Terminal class. + This class is used to record current User Connections to the Terminal class. """ parent_node: Node # Technically should be HostNode but this causes circular import error. """The parent Node that this connection was created on.""" - is_active: bool = True - """Flag to state whether the connection is still active or not.""" - dest_ip_address: IPv4Address = None """Destination IP address of connection""" + session_id: str = None + """Session ID that connection is linked to""" + _connection_uuid: str = None """Connection UUID""" - @property - def is_local(self) -> bool: - """Indicates if connection is remote or local. - - Returns True if local, False if remote. - """ - for interface in self.parent_node.network_interface: - if self.dest_ip_address == self.parent_node.network_interface[interface].ip_address: - return True - return False - @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -68,9 +57,6 @@ class TerminalClientConnection(BaseModel): class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" - connection_uuid: Optional[str] = None - "Uuid for connection requests" - operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING "Initial Operating State" @@ -104,13 +90,13 @@ class Terminal(Service): :param markdown: Whether to display the table in Markdown format or not. Default is `False`. """ - table = PrettyTable(["Connection ID", "IP_Address", "Active", "Local"]) + table = PrettyTable(["Connection ID", "Session_ID"]) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.sys_log.hostname} {self.name} Connections" for connection_id, connection in self._connections.items(): - table.add_row([connection_id, connection.dest_ip_address, connection.is_active, connection.is_local]) + table.add_row([connection_id, connection.session_id]) print(table.get_string(sortby="Connection ID")) def _init_request_manager(self) -> RequestManager: @@ -145,11 +131,12 @@ class Terminal(Service): self.execute(command) return RequestResponse(status="success", data={}) - def _logoff() -> RequestResponse: + def _logoff(request: List[Any]) -> RequestResponse: """Logoff from connection.""" + connection_uuid = request[0] # TODO: Uncomment this when UserSessionManager merged. - # self.parent.UserSessionManager.logoff(self.connection_uuid) - self.disconnect(self.connection_uuid) + # self.parent.UserSessionManager.logoff(connection_uuid) + self.disconnect(connection_uuid) return RequestResponse(status="success", data={}) @@ -191,12 +178,12 @@ class Terminal(Service): """Message that is reported when a request is rejected by this validator.""" return "Cannot perform request on terminal as not logged in." - def _add_new_connection(self, connection_uuid: str, dest_ip_address: IPv4Address): + def _add_new_connection(self, connection_uuid: str, session_id: str): """Create a new connection object and amend to list of active connections.""" self._connections[connection_uuid] = TerminalClientConnection( parent_node=self.software_manager.node, - dest_ip_address=dest_ip_address, connection_uuid=connection_uuid, + session_id=session_id, ) def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: @@ -219,23 +206,31 @@ class Terminal(Service): return self._process_local_login(username=username, password=password) def _process_local_login(self, username: str, password: str) -> bool: - """Local session login to terminal.""" + """Local session login to terminal. + + :param username: Username for login. + :param password: Password for login. + :return: boolean, True if successful, else False + """ # TODO: Un-comment this when UserSessionManager is merged. # connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) connection_uuid = str(uuid4()) if connection_uuid: - self.sys_log.info(f"Login request authorised, connection uuid: {self.connection_uuid}") + self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections - self._add_new_connection( - connection_uuid=connection_uuid, dest_ip_address=self.parent.network_interface[1].ip_address - ) + self._add_new_connection(connection_uuid=connection_uuid) return True else: self.sys_log.warning("Login failed, incorrect Username or Password") return False def _send_remote_login(self, username: str, password: str, ip_address: IPv4Address) -> bool: - """Attempt to login to a remote terminal.""" + """Attempt to login to a remote terminal. + + :param username: username for login. + :param password: password for login. + :ip_address: IP address of the target node for login. + """ transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA user_account: SSHUserCredentials = SSHUserCredentials(username=username, password=password) @@ -244,14 +239,12 @@ class Terminal(Service): transport_message=transport_message, connection_message=connection_message, user_account=user_account, - target_ip_address=ip_address, - sender_ip_address=self.parent.network_interface[1].ip_address, ) self.sys_log.info(f"Sending remote login request to {ip_address}") return self.send(payload=payload, dest_ip_address=ip_address) - def _process_remote_login(self, payload: SSHPacket) -> bool: + def _process_remote_login(self, payload: SSHPacket, session_id: str) -> bool: """Processes a remote terminal requesting to login to this terminal. :param payload: The SSH Payload Packet. @@ -266,8 +259,7 @@ class Terminal(Service): if connection_uuid: # Send uuid to remote self.sys_log.info( - f"Remote login authorised, connection ID {connection_uuid} for " - f"{username} on {payload.sender_ip_address}" + f"Remote login authorised, connection ID {connection_uuid} for " f"{username} in session {session_id}" ) transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA @@ -275,19 +267,17 @@ class Terminal(Service): transport_message=transport_message, connection_message=connection_message, connection_uuid=connection_uuid, - sender_ip_address=self.parent.network_interface[1].ip_address, - target_ip_address=payload.sender_ip_address, ) - self._add_new_connection(connection_uuid=connection_uuid, dest_ip_address=payload.sender_ip_address) + self._add_new_connection(connection_uuid=connection_uuid, session_id=session_id) - self.send(payload=return_payload, dest_ip_address=return_payload.target_ip_address) + self.send(payload=return_payload, session_id=session_id) return True else: # UserSessionManager has returned None self.sys_log.warning("Login failed, incorrect Username or Password") return False - def receive(self, payload: SSHPacket, **kwargs) -> bool: + def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: """Receive Payload and process for a response. :param payload: The message contents received. @@ -310,11 +300,10 @@ class Terminal(Service): self.sys_log.debug(f"Disconnecting {connection_id}") elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - return self._process_remote_login(payload=payload) + return self._process_remote_login(payload=payload, session_id=session_id) elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") - self.connection_uuid = payload.connection_uuid return True elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: @@ -330,16 +319,18 @@ class Terminal(Service): """Execute a passed ssh command via the request manager.""" return self.parent.apply_request(command) - def _disconnect(self, dest_ip_address: IPv4Address) -> bool: - """Disconnect from the remote.""" + def _disconnect(self, connection_uuid: str) -> bool: + """Disconnect from the remote. + + :param connection_uuid: Connection ID that we want to disconnect. + :return True if successful, False otherwise. + """ if not self._connections: self.sys_log.warning("No remote connection present") return False - # TODO: This should probably be done entirely by connection uuid and not IP_address. - for connection in self._connections: - if dest_ip_address == self._connections[connection].dest_ip_address: - self._connections.pop(connection) + dest_ip_address = self._connections[connection_uuid].dest_ip_address + self._connections.pop(connection_uuid) software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( @@ -347,22 +338,23 @@ class Terminal(Service): dest_ip_address=dest_ip_address, dest_port=self.port, ) - self.connection_uuid = None - self.sys_log.info(f"{self.name}: Disconnected {self.connection_uuid}") + self.sys_log.info(f"{self.name}: Disconnected {connection_uuid}") return True - def disconnect(self, dest_ip_address: IPv4Address) -> bool: - """Disconnect from remote connection. + def disconnect(self, connection_uuid: Optional[str]) -> bool: + """Disconnect the terminal. - :param dest_ip_address: The IP address fo the connection we are terminating. + If no connection id has been supplied, disconnects the first connection. + :param connection_uuid: Connection ID that we want to disconnect. :return: True if successful, False otherwise. """ - self._disconnect(dest_ip_address=dest_ip_address) + if not connection_uuid: + connection_uuid = next(iter(self._connections)) + + return self._disconnect(connection_uuid=connection_uuid) def send( - self, - payload: SSHPacket, - dest_ip_address: IPv4Address, + self, payload: SSHPacket, dest_ip_address: Optional[IPv4Address] = None, session_id: Optional[str] = None ) -> bool: """ Send a payload out from the Terminal. @@ -374,4 +366,6 @@ class Terminal(Service): self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!") return False self.sys_log.debug(f"Sending payload: {payload}") - return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port) + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id + ) From c2a19af6fa259d9cf4ac4525075b8b8900936ac3 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 2 Aug 2024 09:20:00 +0100 Subject: [PATCH 085/206] #2735 - added documentation for users, usermanager and usersessionmanager. Added the ability to add additional users from config and documented this. also tested additional users from config. --- .../nodes/common/common_node_attributes.rst | 27 +++ .../network/base_hardware.rst | 206 +++++++++++++++++- src/primaite/game/game.py | 8 +- .../assets/configs/basic_node_with_users.yaml | 34 +++ .../test_users_creation_from_config.py | 26 +++ 5 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 tests/assets/configs/basic_node_with_users.yaml create mode 100644 tests/integration_tests/network/test_users_creation_from_config.py diff --git a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst index e648e4a1..7cf11eb4 100644 --- a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst +++ b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst @@ -53,3 +53,30 @@ The number of time steps required to occur in order for the node to cycle from ` Optional. Default value is ``3``. The number of time steps required to occur in order for the node to cycle from ``ON`` to ``SHUTTING_DOWN`` and then finally ``OFF``. + +``users`` +--------- + +The list of pre-existing users that are additional to the default admin user (``username=admin``, ``password=admin``). +Additional users are configured as an array nd must contain a ``username``, ``password``, and can contain an optional +boolean ``is_admin``. + +Example of adding two additional users to a node: + +.. code-block:: yaml + + simulation: + network: + nodes: + - hostname: client_1 + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + users: + - username: jane.doe + password: '1234' + is_admin: true + - username: john.doe + password: password_1 + is_admin: false diff --git a/docs/source/simulation_components/network/base_hardware.rst b/docs/source/simulation_components/network/base_hardware.rst index 9e42b1de..ce1e5c74 100644 --- a/docs/source/simulation_components/network/base_hardware.rst +++ b/docs/source/simulation_components/network/base_hardware.rst @@ -97,8 +97,8 @@ Node Behaviours/Functions - **receive_frame()**: Handles the processing of incoming network frames. - **apply_timestep()**: Advances the state of the node according to the simulation timestep. - **power_on()**: Initiates the node, enabling all connected Network Interfaces and starting all Services and - Applications, taking into account the `start_up_duration`. -- **power_off()**: Stops the node's operations, adhering to the `shut_down_duration`. + Applications, taking into account the ``start_up_duration``. +- **power_off()**: Stops the node's operations, adhering to the ``shut_down_duration``. - **ping()**: Sends ICMP echo requests to a specified IP address to test connectivity. - **has_enabled_network_interface()**: Checks if the node has any network interfaces enabled, facilitating network communication. @@ -109,3 +109,205 @@ Node Behaviours/Functions The Node class handles installation of system software, network connectivity, frame processing, system logging, and power states. It establishes baseline functionality while allowing subclassing to model specific node types like hosts, routers, firewalls etc. The flexible architecture enables composing complex network topologies. + +User, UserManager, and UserSessionManager +========================================= + +The ``base.py`` module also includes essential classes for managing users and their sessions within the PrimAITE +simulation. These are the ``User``, ``UserManager``, and ``UserSessionManager`` classes. The base ``Node`` class comes +with ``UserManager``, and ``UserSessionManager`` classes pre-installed. + +User Class +---------- + +The ``User`` class represents a user in the system. It includes attributes such as ``username``, ``password``, +``disabled``, and ``is_admin`` to define the user's credentials and status. + +Example Usage +^^^^^^^^^^^^^ + +Creating a user: + .. code-block:: python + + user = User(username="john_doe", password="12345") + +UserManager Class +----------------- + +The ``UserManager`` class handles user management tasks such as creating users, authenticating them, changing passwords, +and enabling or disabling user accounts. It maintains a dictionary of users and provides methods to manage them +effectively. + +Example Usage +^^^^^^^^^^^^^ + +Creating a ``UserManager`` instance and adding a user: + .. code-block:: python + + user_manager = UserManager() + user_manager.add_user(username="john_doe", password="12345") + +Authenticating a user: + .. code-block:: python + + user = user_manager.authenticate_user(username="john_doe", password="12345") + +UserSessionManager Class +------------------------ + +The ``UserSessionManager`` class manages user sessions, including local and remote sessions. It handles session creation, +timeouts, and provides methods for logging users in and out. + +Example Usage +^^^^^^^^^^^^^ + +Creating a ``UserSessionManager`` instance and logging a user in locally: + .. code-block:: python + + session_manager = UserSessionManager() + session_id = session_manager.local_login(username="john_doe", password="12345") + +Logging a user out: + .. code-block:: python + + session_manager.local_logout() + +Practical Examples +------------------ + +Below are unit tests which act as practical examples illustrating how to use the ``User``, ``UserManager``, and +``UserSessionManager`` classes within the context of a client-server network simulation. + +Setting up a Client-Server Network +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from typing import Tuple + from uuid import uuid4 + + import pytest + + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.network.hardware.nodes.host.server import Server + + @pytest.fixture(scope="function") + def client_server_network() -> Tuple[Computer, Server, Network]: + network = Network() + + client = Computer( + hostname="client", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client.power_on() + + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + network.connect(client.network_interface[1], server.network_interface[1]) + + return client, server, network + +Local Login Success +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + +Local Login Failure +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_local_login_failure(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert not client.user_session_manager.local_user_logged_in + +Adding a New User and Successful Local Login +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_user_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_manager.add_user(username="jane.doe", password="12345") + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + +Clearing Previous Login on New Local Login +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_local_login_clears_previous_login(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + client.user_manager.add_user(username="jane.doe", password="12345") + + new_session_id = client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "jane.doe" + + assert new_session_id != current_session_id + +Persistent Login for the Same User +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_local_login_attempt_same_uses_persists(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + new_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + assert new_session_id == current_session_id diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 5ef8c14c..68abf9f2 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -18,7 +18,7 @@ from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency -from primaite.simulator.network.hardware.base import NodeOperatingState +from primaite.simulator.network.hardware.base import NodeOperatingState, UserManager from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Printer, Server @@ -267,6 +267,7 @@ class PrimaiteGame: for node_cfg in nodes_cfg: n_type = node_cfg["type"] + new_node = None if n_type == "computer": new_node = Computer( hostname=node_cfg["hostname"], @@ -316,6 +317,11 @@ class PrimaiteGame: msg = f"invalid node type {n_type} in config" _LOGGER.error(msg) raise ValueError(msg) + + if "users" in node_cfg and new_node.software_manager.software.get("UserManager"): + user_manager: UserManager = new_node.software_manager.software["UserManager"] # noqa + for user_cfg in node_cfg["users"]: + user_manager.add_user(**user_cfg, bypass_can_perform_action=True) if "services" in node_cfg: for service_cfg in node_cfg["services"]: new_service = None diff --git a/tests/assets/configs/basic_node_with_users.yaml b/tests/assets/configs/basic_node_with_users.yaml new file mode 100644 index 00000000..064519dd --- /dev/null +++ b/tests/assets/configs/basic_node_with_users.yaml @@ -0,0 +1,34 @@ +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + sys_log_level: WARNING + agent_log_level: INFO + save_agent_logs: true + write_agent_log_to_terminal: True + + +game: + max_episode_length: 256 + ports: + - ARP + protocols: + - ICMP + - UDP + + +simulation: + network: + nodes: + - hostname: client_1 + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + users: + - username: jane.doe + password: '1234' + is_admin: true + - username: john.doe + password: password_1 + is_admin: false diff --git a/tests/integration_tests/network/test_users_creation_from_config.py b/tests/integration_tests/network/test_users_creation_from_config.py new file mode 100644 index 00000000..8cd3b037 --- /dev/null +++ b/tests/integration_tests/network/test_users_creation_from_config.py @@ -0,0 +1,26 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import yaml + +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.hardware.base import UserManager +from tests import TEST_ASSETS_ROOT + + +def test_users_from_config(): + config_path = TEST_ASSETS_ROOT / "configs" / "basic_node_with_users.yaml" + + with open(config_path, "r") as f: + config_dict = yaml.safe_load(f) + network = PrimaiteGame.from_config(cfg=config_dict).simulation.network + + client_1 = network.get_node_by_hostname("client_1") + + user_manager: UserManager = client_1.software_manager.software["UserManager"] + + assert len(user_manager.users) == 3 + + assert user_manager.users["jane.doe"].password == "1234" + assert user_manager.users["jane.doe"].is_admin + + assert user_manager.users["john.doe"].password == "password_1" + assert not user_manager.users["john.doe"].is_admin From ab4931463f211891efca84e082f9aab1ebb428ef Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 2 Aug 2024 09:21:55 +0100 Subject: [PATCH 086/206] #2706 - Minor change following the session_id changes as local_login failed to pass a session_id when creating a new TerminalClientConnection object --- src/primaite/simulator/system/services/terminal/terminal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 192f0551..92893b14 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -218,7 +218,8 @@ class Terminal(Service): if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections - self._add_new_connection(connection_uuid=connection_uuid) + session_id = str(uuid4()) + self._add_new_connection(connection_uuid=connection_uuid, session_id=session_id) return True else: self.sys_log.warning("Login failed, incorrect Username or Password") From 5dcc0189a0655a47cd5e51dc17f98a06e890c117 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 2 Aug 2024 11:30:45 +0100 Subject: [PATCH 087/206] #2777: Implementation of RNG seed --- .../scripted_agents/probabilistic_agent.py | 14 ++++---- src/primaite/game/game.py | 2 ++ src/primaite/session/environment.py | 36 +++++++++++++++++++ src/primaite/session/ray_envs.py | 2 ++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py index f5905ad0..ce1da3f2 100644 --- a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py +++ b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py @@ -22,8 +22,6 @@ class ProbabilisticAgent(AbstractScriptedAgent): """Strict validation.""" action_probabilities: Dict[int, float] """Probability to perform each action in the action map. The sum of probabilities should sum to 1.""" - random_seed: Optional[int] = None - """Random seed. If set, each episode the agent will choose the same random sequence of actions.""" # TODO: give the option to still set a random seed, but have it vary each episode in a predictable way # for example if the user sets seed 123, have it be 123 + episode_num, so that each ep it's the next seed. @@ -59,17 +57,19 @@ class ProbabilisticAgent(AbstractScriptedAgent): num_actions = len(action_space.action_map) settings = {"action_probabilities": {i: 1 / num_actions for i in range(num_actions)}} - # If seed not specified, set it to None so that numpy chooses a random one. - settings.setdefault("random_seed") - + # The random number seed for np.random is dependent on whether a random number seed is set + # in the config file. If there is one it is processed by set_random_seed() in environment.py + # and as a consequence the the sequence of rng_seed's used here will be repeatable. self.settings = ProbabilisticAgent.Settings(**settings) - - self.rng = np.random.default_rng(self.settings.random_seed) + rng_seed = np.random.randint(0, 65535) + self.rng = np.random.default_rng(rng_seed) + print(f"Probabilistic Agent - rng_seed: {rng_seed}") # convert probabilities from self.probabilities = np.asarray(list(self.settings.action_probabilities.values())) super().__init__(agent_name, action_space, observation_space, reward_function) + self.logger.info(f"ProbabilisticAgent RNG seed: {rng_seed}") def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: """ diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 5ef8c14c..a4325b3e 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -70,6 +70,8 @@ class PrimaiteGameOptions(BaseModel): model_config = ConfigDict(extra="forbid") + seed: int = None + """Random number seed for RNGs.""" max_episode_length: int = 256 """Maximum number of episodes for the PrimAITE game.""" ports: List[str] diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index a87f0cde..359932c7 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -1,5 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK import json +import random +import sys from os import PathLike from typing import Any, Dict, Optional, SupportsFloat, Tuple, Union @@ -17,6 +19,33 @@ from primaite.simulator.system.core.packet_capture import PacketCapture _LOGGER = getLogger(__name__) +# Check torch is installed +try: + import torch as th +except ModuleNotFoundError: + _LOGGER.debug("Torch not available for importing") + + +def set_random_seed(seed: int) -> Union[None, int]: + """ + Set random number generators. + + :param seed: int + """ + if seed is None or seed == -1: + return None + elif seed < -1: + raise ValueError("Invalid random number seed") + # Seed python RNG + random.seed(seed) + # Seed numpy RNG + np.random.seed(seed) + # Seed the RNG for all devices (both CPU and CUDA) + # if torch not installed don't set random seed. + if sys.modules["torch"]: + th.manual_seed(seed) + return seed + class PrimaiteGymEnv(gymnasium.Env): """ @@ -31,6 +60,9 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.episode_scheduler: EpisodeScheduler = build_scheduler(env_config) """Object that returns a config corresponding to the current episode.""" + self.seed = self.episode_scheduler(0).get("game").get("seed") + """Get RNG seed from config file. NB: Must be before game instantiation.""" + self.seed = set_random_seed(self.seed) self.io = PrimaiteIO.from_config(self.episode_scheduler(0).get("io_settings", {})) """Handles IO for the environment. This produces sys logs, agent logs, etc.""" self.game: PrimaiteGame = PrimaiteGame.from_config(self.episode_scheduler(0)) @@ -42,6 +74,8 @@ class PrimaiteGymEnv(gymnasium.Env): self.total_reward_per_episode: Dict[int, float] = {} """Average rewards of agents per episode.""" + _LOGGER.info(f"PrimaiteGymEnv RNG seed = {self.seed}") + def action_masks(self) -> np.ndarray: """ Return the action mask for the agent. @@ -108,6 +142,8 @@ class PrimaiteGymEnv(gymnasium.Env): f"Resetting environment, episode {self.episode_counter}, " f"avg. reward: {self.agent.reward_function.total_reward}" ) + if seed is not None: + set_random_seed(seed) self.total_reward_per_episode[self.episode_counter] = self.agent.reward_function.total_reward if self.io.settings.save_agent_actions: diff --git a/src/primaite/session/ray_envs.py b/src/primaite/session/ray_envs.py index 1adc324c..33c74b0e 100644 --- a/src/primaite/session/ray_envs.py +++ b/src/primaite/session/ray_envs.py @@ -63,6 +63,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv): def reset(self, *, seed: int = None, options: dict = None) -> Tuple[ObsType, Dict]: """Reset the environment.""" + super().reset() # Ensure PRNG seed is set everywhere rewards = {name: agent.reward_function.total_reward for name, agent in self.agents.items()} _LOGGER.info(f"Resetting environment, episode {self.episode_counter}, " f"avg. reward: {rewards}") @@ -176,6 +177,7 @@ class PrimaiteRayEnv(gymnasium.Env): def reset(self, *, seed: int = None, options: dict = None) -> Tuple[ObsType, Dict]: """Reset the environment.""" + super().reset() # Ensure PRNG seed is set everywhere if self.env.agent.action_masking: obs, *_ = self.env.reset(seed=seed) new_obs = {"action_mask": self.env.action_masks(), "observations": obs} From 61c7cc2da37eb8c16ac612cd8c7466dcc2c8c197 Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Fri, 2 Aug 2024 10:57:51 +0000 Subject: [PATCH 088/206] Apply suggestions from code review --- src/primaite/simulator/network/hardware/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index d2aa4604..c2b0ecc4 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1505,12 +1505,12 @@ class Node(SimComponent): self.session_manager.software_manager = self.software_manager @property - def user_manager(self) -> UserManager: + def user_manager(self) -> Optional[UserManager]: """The Nodes User Manager.""" return self.software_manager.software.get("UserManager") # noqa @property - def user_session_manager(self) -> UserSessionManager: + def user_session_manager(self) -> Optional[UserSessionManager]: """The Nodes User Session Manager.""" return self.software_manager.software.get("UserSessionManager") # noqa From 696236aa6162283f4cfa96e3642f36f1a43901d2 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 2 Aug 2024 12:47:02 +0100 Subject: [PATCH 089/206] #2735 - make the disabled/enabled admins/non-admins dynamic properties for simplicity. Added num_of_logins to User. Added additional test for counting user logins. Added all users to the UserManager describe_state function. Refactored model fields with empty dict as default value to have direct instantiation instead of using Field(default_factory=dict) or Field(default_factory=: lambda: {}). --- .../simulator/network/hardware/base.py | 55 +++++++++++++++---- .../test_user_session_manager_logins.py | 24 ++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index c2b0ecc4..1d320824 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -817,6 +817,9 @@ class User(SimComponent): is_admin: bool = False """Boolean flag indicating whether the user has admin privileges""" + num_of_logins: int = 0 + """Counts the number of the User has logged in""" + def describe_state(self) -> Dict: """ Returns a dictionary representing the current state of the user. @@ -835,9 +838,7 @@ class UserManager(Service): :param disabled_admins: A dictionary of currently disabled admin users by their usernames """ - users: Dict[str, User] = Field(default_factory=dict) - admins: Dict[str, User] = Field(default_factory=dict) - disabled_admins: Dict[str, User] = Field(default_factory=dict) + users: Dict[str, User] = {} def __init__(self, **kwargs): """ @@ -880,6 +881,7 @@ class UserManager(Service): """ state = super().describe_state() state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)}) + state["users"] = {k: v.describe_state() for k, v in self.users.items()} return state def show(self, markdown: bool = False): @@ -897,6 +899,42 @@ class UserManager(Service): table.add_row([user.username, user.is_admin, user.disabled]) print(table.get_string(sortby="Username")) + @property + def non_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all enabled non-admin users. + + :return: A dictionary with usernames as keys and User objects as values for non-admin, non-disabled users. + """ + return {k: v for k, v in self.users.items() if not v.is_admin and not v.disabled} + + @property + def disabled_non_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all disabled non-admin users. + + :return: A dictionary with usernames as keys and User objects as values for non-admin, disabled users. + """ + return {k: v for k, v in self.users.items() if not v.is_admin and v.disabled} + + @property + def admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all enabled admin users. + + :return: A dictionary with usernames as keys and User objects as values for admin, non-disabled users. + """ + return {k: v for k, v in self.users.items() if v.is_admin and not v.disabled} + + @property + def disabled_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all disabled admin users. + + :return: A dictionary with usernames as keys and User objects as values for admin, disabled users. + """ + return {k: v for k, v in self.users.items() if v.is_admin and v.disabled} + def install(self) -> None: """Setup default user during first-time installation.""" self.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) @@ -922,8 +960,6 @@ class UserManager(Service): return False user = User(username=username, password=password, is_admin=is_admin) self.users[username] = user - if is_admin: - self.admins[username] = user self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}") return True @@ -978,8 +1014,6 @@ class UserManager(Service): return False self.users[username].disabled = True self.sys_log.info(f"{self.name}: User disabled: {username}") - if username in self.admins: - self.disabled_admins[username] = self.admins.pop(username) return True self.sys_log.info(f"{self.name}: Failed to disable user: {username}") return False @@ -994,8 +1028,6 @@ class UserManager(Service): if username in self.users and self.users[username].disabled: self.users[username].disabled = False self.sys_log.info(f"{self.name}: User enabled: {username}") - if username in self.disabled_admins: - self.admins[username] = self.disabled_admins.pop(username) return True self.sys_log.info(f"{self.name}: Failed to enable user: {username}") return False @@ -1028,7 +1060,7 @@ class UserSession(SimComponent): """The timestep when the session ended, if applicable.""" local: bool = True - """Indicates if the session is local. Defaults to True.""" + """Indicates if the session is a local session or a remote session. Defaults to True as a local session.""" @classmethod def create(cls, user: User, timestep: int) -> UserSession: @@ -1041,6 +1073,7 @@ class UserSession(SimComponent): :param timestep: The timestep when the session is created. :return: An instance of UserSession. """ + user.num_of_logins += 1 return UserSession(user=user, start_step=timestep, last_active_step=timestep) def describe_state(self) -> Dict: @@ -1107,7 +1140,7 @@ class UserSessionManager(Service): local_session: Optional[UserSession] = None """The current local user session, if any.""" - remote_sessions: Dict[str, RemoteUserSession] = Field(default_factory=dict) + remote_sessions: Dict[str, RemoteUserSession] = {} """A dictionary of active remote user sessions.""" historic_sessions: List[UserSession] = Field(default_factory=list) diff --git a/tests/integration_tests/system/test_user_session_manager_logins.py b/tests/integration_tests/system/test_user_session_manager_logins.py index 955408ad..4318530c 100644 --- a/tests/integration_tests/system/test_user_session_manager_logins.py +++ b/tests/integration_tests/system/test_user_session_manager_logins.py @@ -5,6 +5,7 @@ from uuid import uuid4 import pytest from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import User from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server @@ -46,6 +47,29 @@ def test_local_login_success(client_server_network): assert client.user_session_manager.local_user_logged_in +def test_login_count_increases(client_server_network): + client, server, network = client_server_network + + admin_user: User = client.user_manager.users["admin"] + + assert admin_user.num_of_logins == 0 + + client.user_session_manager.local_login(username="admin", password="admin") + + assert admin_user.num_of_logins == 1 + + client.user_session_manager.local_login(username="admin", password="admin") + + # shouldn't change as user is already logged in + assert admin_user.num_of_logins == 1 + + client.user_session_manager.local_logout() + + client.user_session_manager.local_login(username="admin", password="admin") + + assert admin_user.num_of_logins == 2 + + def test_local_login_failure(client_server_network): client, server, network = client_server_network From a1e1a17c2a9fe87099b8bfcd9e3c3c0eab3bc408 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 2 Aug 2024 12:49:17 +0100 Subject: [PATCH 090/206] #2777: Add RNG test --- .../game_layer/test_RNG_seed.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/integration_tests/game_layer/test_RNG_seed.py diff --git a/tests/integration_tests/game_layer/test_RNG_seed.py b/tests/integration_tests/game_layer/test_RNG_seed.py new file mode 100644 index 00000000..c1bb7bb0 --- /dev/null +++ b/tests/integration_tests/game_layer/test_RNG_seed.py @@ -0,0 +1,43 @@ +from primaite.config.load import data_manipulation_config_path +from primaite.session.environment import PrimaiteGymEnv +from primaite.game.agent.interface import AgentHistoryItem +import yaml +from pprint import pprint +import pytest + +@pytest.fixture() +def create_env(): + with open(data_manipulation_config_path(), 'r') as f: + cfg = yaml.safe_load(f) + + env = PrimaiteGymEnv(env_config = cfg) + return env + +def test_rng_seed_set(create_env): + env = create_env + env.reset(seed=3) + for i in range(100): + env.step(0) + a = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + + env.reset(seed=3) + for i in range(100): + env.step(0) + b = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + + assert a==b + +def test_rng_seed_unset(create_env): + env = create_env + env.reset() + for i in range(100): + env.step(0) + a = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + + env.reset() + for i in range(100): + env.step(0) + b = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + + assert a!=b + From 0cc724be605fff5e65a893d9ddebd5ed2517f342 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 2 Aug 2024 12:50:40 +0100 Subject: [PATCH 091/206] #2777: Updated CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cebc2569..7d7ba9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Bandwidth Tracking**: Tracks data transmission across each frequency. - **New Tests**: Added to validate the respect of bandwidth capacities and the correct parsing of airspace configurations from YAML files. - **New Logging**: Added a new agent behaviour log which are more human friendly than agent history. These Logs are found in session log directory and can be enabled in the I/O settings in a yaml configuration file. - +- **Random Number Generator Seeding**: Added support for specifying a random number seed in the config file. ### Changed - **NetworkInterface Speed Type**: The `speed` attribute of `NetworkInterface` has been changed from `int` to `float`. From 2339dabac13b348998479e6f512ba0e449fa97ee Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Fri, 2 Aug 2024 13:25:08 +0100 Subject: [PATCH 092/206] #2689 Overhauled .receive method. Keep Alive and initial implementation of commands working. (also Updated docustrings + pre-commit) --- src/primaite/game/game.py | 2 - .../simulator/network/protocols/masquerade.py | 17 +- .../red_applications/c2/abstract_c2.py | 109 +++---- .../red_applications/c2/c2_beacon.py | 302 +++++++++++++----- .../red_applications/c2/c2_server.py | 152 ++++++--- .../_red_applications/test_c2_suite.py | 12 +- 6 files changed, 382 insertions(+), 212 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 8cddbcda..5ef8c14c 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -36,8 +36,6 @@ from primaite.simulator.system.applications.red_applications.data_manipulation_b ) from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot # noqa: F401 from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript # noqa: F401 -from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon -from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server from primaite.simulator.system.applications.web_browser import WebBrowser # noqa: F401 from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_client import DNSClient diff --git a/src/primaite/simulator/network/protocols/masquerade.py b/src/primaite/simulator/network/protocols/masquerade.py index 93554f57..7ef17fc0 100644 --- a/src/primaite/simulator/network/protocols/masquerade.py +++ b/src/primaite/simulator/network/protocols/masquerade.py @@ -5,19 +5,6 @@ from typing import Optional from primaite.simulator.network.protocols.packet import DataPacket -class C2Payload(Enum): - """Represents the different types of command and control payloads.""" - - KEEP_ALIVE = "keep_alive" - """C2 Keep Alive payload. Used by the C2 beacon and C2 Server to confirm their connection.""" - - INPUT = "input_command" - """C2 Input Command payload. Used by the C2 Server to send a command to the c2 beacon.""" - - OUTPUT = "output_command" - """C2 Output Command. Used by the C2 Beacon to send the results of a Input command to the c2 server.""" - - class MasqueradePacket(DataPacket): """Represents an generic malicious packet that is masquerading as another protocol.""" @@ -25,6 +12,6 @@ class MasqueradePacket(DataPacket): masquerade_port: Enum # The 'Masquerade' port that is currently in use - payload_type: C2Payload # The type of C2 traffic (e.g keep alive, command or command out) + payload_type: Enum # The type of C2 traffic (e.g keep alive, command or command out) - command: Optional[str] # Used to pass the actual C2 Command in C2 INPUT + command: Optional[Enum] = None # Used to pass the actual C2 Command in C2 INPUT diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 9c840616..af701e8c 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -6,7 +6,7 @@ from typing import Dict, Optional from pydantic import validate_call -from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket +from primaite.simulator.network.protocols.masquerade import MasqueradePacket from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application @@ -20,9 +20,7 @@ from primaite.simulator.system.core.session_manager import Session class C2Command(Enum): - """ - Enumerations representing the different commands the C2 suite currently supports. - """ + """Enumerations representing the different commands the C2 suite currently supports.""" RANSOMWARE_CONFIGURE = "Ransomware Configure" "Instructs the c2 beacon to configure the ransomware with the provided options." @@ -36,12 +34,25 @@ class C2Command(Enum): # The terminal command should also be able to pass a session which can be used for remote connections. +class C2Payload(Enum): + """Represents the different types of command and control payloads.""" + + KEEP_ALIVE = "keep_alive" + """C2 Keep Alive payload. Used by the C2 beacon and C2 Server to confirm their connection.""" + + INPUT = "input_command" + """C2 Input Command payload. Used by the C2 Server to send a command to the c2 beacon.""" + + OUTPUT = "output_command" + """C2 Output Command. Used by the C2 Beacon to send the results of a Input command to the c2 server.""" + + class AbstractC2(Application, identifier="AbstractC2"): """ An abstract command and control (c2) application. Extends the application class to provide base functionality for c2 suite applications - such as c2 beacons and c2 servers. + such as c2 beacons and c2 servers. Provides the base methods for handling ``Keep Alive`` connections, configuring masquerade ports and protocols as well as providing the abstract methods for sending, receiving and parsing commands. @@ -55,13 +66,6 @@ class AbstractC2(Application, identifier="AbstractC2"): c2_remote_connection: IPv4Address = None """The IPv4 Address of the remote c2 connection. (Either the IP of the beacon or the server).""" - keep_alive_sent: bool = False - """Indicates if a keep alive has been sent this timestep. Used to prevent packet storms.""" - - # We should set the application to NOT_RUNNING if the inactivity count reaches a certain thresh hold. - keep_alive_inactivity: int = 0 - """Indicates how many timesteps since the last time the c2 application received a keep alive.""" - # These two attributes are set differently in the c2 server and c2 beacon. # The c2 server parses the keep alive and sets these accordingly. # The c2 beacon will set this attributes upon installation and configuration @@ -75,9 +79,6 @@ class AbstractC2(Application, identifier="AbstractC2"): current_c2_session: Session = None """The currently active session that the C2 Traffic is using. Set after establishing connection.""" - # TODO: Create a attribute call 'LISTENER' which indicates whenever the c2 application should listen for incoming connections or establish connections. - # This in order to simulate a blind shell (the current implementation is more akin to a reverse shell) - # TODO: Move this duplicate method from NMAP class into 'Application' to adhere to DRY principle. def _can_perform_network_action(self) -> bool: """ @@ -104,10 +105,10 @@ class AbstractC2(Application, identifier="AbstractC2"): :rtype: Dict """ return super().describe_state() - + def __init__(self, **kwargs): - kwargs["port"] = Port.HTTP # TODO: Update this post application/services requiring to listen to multiple ports - kwargs["protocol"] = IPProtocol.TCP # Update this as well + kwargs["port"] = Port.HTTP # TODO: Update this post application/services requiring to listen to multiple ports + kwargs["protocol"] = IPProtocol.TCP # Update this as well super().__init__(**kwargs) # Validate call ensures we are only handling Masquerade Packets. @@ -145,7 +146,7 @@ class AbstractC2(Application, identifier="AbstractC2"): elif payload.payload_type == C2Payload.OUTPUT: self.sys_log.info(f"{self.name} received an OUTPUT COMMAND payload.") - return self._handle_command_input(payload, session_id) + return self._handle_command_output(payload) else: self.sys_log.warning( @@ -168,41 +169,7 @@ class AbstractC2(Application, identifier="AbstractC2"): pass def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: - """ - Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. - - Returns False if a keep alive was unable to be sent. - Returns True if a keep alive was successfully sent or already has been sent this timestep. - - :return: True if successfully handled, false otherwise. - :rtype: Bool - """ - self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}.") - - self.c2_connection_active = True # Sets the connection to active - self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero - self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] - - - # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. - # This guard clause triggers on the c2 suite that establishes connection. - if self.keep_alive_sent == True: - # Return early without sending another keep alive and then setting keep alive_sent false for next timestep. - self.keep_alive_sent = False - return True - - # If we've reached this part of the method then we've received a keep alive but haven't sent a reply. - # Therefore we also need to configure the masquerade attributes based off the keep alive sent. - if self._resolve_keep_alive(payload, session_id) == False: - self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") - return False - - # If this method returns true then we have sent successfully sent a keep alive. - - self.sys_log.info(f"{self.name}: Connection successfully established with {self.c2_remote_connection}.") - - return self._send_keep_alive(session_id) - + """Abstract Method: The C2 Server and the C2 Beacon handle the KEEP ALIVEs differently.""" # from_network_interface=from_network_interface def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None, **kwargs) -> bool: @@ -217,23 +184,23 @@ class AbstractC2(Application, identifier="AbstractC2"): """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address.""" # Checking that the c2 application is capable of performing both actions and has an enabled NIC # (Using NOT to improve code readability) - if self.c2_remote_connection == None: - self.sys_log.error(f"{self.name}: Unable to Establish connection as the C2 Server's IP Address has not been given.") - + if self.c2_remote_connection is None: + self.sys_log.error( + f"{self.name}: Unable to Establish connection as the C2 Server's IP Address has not been given." + ) + if not self._can_perform_network_action(): self.sys_log.warning(f"{self.name}: Unable to perform network actions.") return False - # We also Pass masquerade protocol/port so that the c2 server can reply on the correct protocol/port. + # We also Pass masquerade proto`col/port so that the c2 server can reply on the correct protocol/port. # (This also lays the foundations for switching masquerade port/protocols mid episode.) keep_alive_packet = MasqueradePacket( masquerade_protocol=self.current_masquerade_protocol, masquerade_port=self.current_masquerade_port, payload_type=C2Payload.KEEP_ALIVE, - command=None + command=None, ) - # We need to set this guard clause to true before sending the keep alive (prevents packet storms.) - self.keep_alive_sent = True # C2 Server will need to configure c2_remote_connection after it receives it's first keep alive. if self.send( payload=keep_alive_packet, @@ -242,16 +209,16 @@ class AbstractC2(Application, identifier="AbstractC2"): ip_protocol=self.current_masquerade_protocol, session_id=session_id, ): + self.keep_alive_sent = True self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") return True else: self.sys_log.warning( - f"{self.name}: failed to send a Keep Alive. The node may be unable to access the network." + f"{self.name}: failed to send a Keep Alive. The node may be unable to access the ``network." ) return False - def _resolve_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ Parses the Masquerade Port/Protocol within the received Keep Alive packet. @@ -260,7 +227,7 @@ class AbstractC2(Application, identifier="AbstractC2"): Returns True on successfully extracting and configuring the masquerade port/protocols. Returns False otherwise. - + :param payload: The Keep Alive payload received. :type payload: MasqueradePacket :return: True on successful configuration, false otherwise. @@ -268,22 +235,24 @@ class AbstractC2(Application, identifier="AbstractC2"): """ # Validating that they are valid Enums. if not isinstance(payload.masquerade_port, Port) or not isinstance(payload.masquerade_protocol, IPProtocol): - self.sys_log.warning(f"{self.name}: Received invalid Masquerade type. Port: {type(payload.masquerade_port)} Protocol: {type(payload.masquerade_protocol)}.") + self.sys_log.warning( + f"{self.name}: Received invalid Masquerade Values within Keep Alive." + f"Port: {payload.masquerade_port} Protocol: {payload.masquerade_protocol}." + ) return False # TODO: Validation on Ports (E.g only allow HTTP, FTP etc) # Potentially compare to IPProtocol & Port children (Same way that abstract TAP does it with kill chains) - + # Setting the Ports self.current_masquerade_port = payload.masquerade_port self.current_masquerade_protocol = payload.masquerade_protocol # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) - if self.c2_remote_connection == None: + if self.c2_remote_connection is None: self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") self.c2_remote_connection = self.current_c2_session.with_ip_address - + self.c2_connection_active = True # Sets the connection to active self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zeroW - + return True - \ No newline at end of file diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 14c7af02..b00b7c57 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -1,34 +1,44 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command -#from primaite.simulator.system.services.terminal.terminal import Terminal -from prettytable import MARKDOWN, PrettyTable -from primaite.simulator.core import RequestManager, RequestType -from primaite.interface.request import RequestFormat, RequestResponse -from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket -from primaite.simulator.network.transmission.network_layer import IPProtocol -from ipaddress import IPv4Address -from typing import Dict,Optional -from primaite.simulator.network.transmission.transport_layer import Port from enum import Enum -from primaite.simulator.system.software import SoftwareHealthState +from ipaddress import IPv4Address +from typing import Dict, Optional + +# from primaite.simulator.system.services.terminal.terminal import Terminal +from prettytable import MARKDOWN, PrettyTable + +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.protocols.masquerade import MasqueradePacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload +from primaite.simulator.system.software import SoftwareHealthState + class C2Beacon(AbstractC2, identifier="C2 Beacon"): """ C2 Beacon Application. - Represents a generic C2 beacon which can be used in conjunction with the C2 Server - to simulate malicious communications within primAITE. + Represents a vendor generic C2 beacon is used in conjunction with the C2 Server + to simulate malicious communications and infrastructure within primAITE. + + Must be configured with the C2 Server's IP Address upon installation. - Must be configured with the C2 Server's Ip Address upon installation. - Extends the Abstract C2 application to include the following: 1. Receiving commands from the C2 Server (Command input) 2. Leveraging the terminal application to execute requests (dependant on the command given) 3. Sending the RequestResponse back to the C2 Server (Command output) """ - + + keep_alive_attempted: bool = False + """Indicates if a keep alive has been attempted to be sent this timestep. Used to prevent packet storms.""" + + # We should set the application to NOT_RUNNING if the inactivity count reaches a certain thresh hold. + keep_alive_inactivity: int = 0 + """Indicates how many timesteps since the last time the c2 application received a keep alive.""" + keep_alive_frequency: int = 5 "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." @@ -38,8 +48,8 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): # Implement a command output method that sends the RequestResponse to the C2 server. # Uncomment the terminal Import and the terminal property after terminal PR - #@property - #def _host_terminal(self) -> Terminal: + # @property + # def _host_terminal(self) -> Terminal: # """Return the Terminal that is installed on the same machine as the C2 Beacon.""" # host_terminal: Terminal = self.software_manager.software.get("Terminal") # if host_terminal: is None: @@ -69,27 +79,33 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): :return: RequestResponse object with a success code reflecting whether the configuration could be applied. :rtype: RequestResponse """ - server_ip = request[-1].get("c2_server_ip_address") - if server_ip == None: + c2_remote_ip = request[-1].get("c2_server_ip_address") + if c2_remote_ip is None: self.sys_log.error(f"{self.name}: Did not receive C2 Server IP in configuration parameters.") - RequestResponse(status="failure", data={"No C2 Server IP given to C2 beacon. Unable to configure C2 Beacon"}) + RequestResponse( + status="failure", data={"No C2 Server IP given to C2 beacon. Unable to configure C2 Beacon"} + ) c2_remote_ip = IPv4Address(c2_remote_ip) frequency = request[-1].get("keep_alive_frequency") - protocol= request[-1].get("masquerade_protocol") + protocol = request[-1].get("masquerade_protocol") port = request[-1].get("masquerade_port") - return RequestResponse.from_bool(self.configure(c2_server_ip_address=server_ip, - keep_alive_frequency=frequency, - masquerade_protocol=protocol, - masquerade_port=port)) + return RequestResponse.from_bool( + self.configure( + c2_server_ip_address=c2_remote_ip, + keep_alive_frequency=frequency, + masquerade_protocol=protocol, + masquerade_port=port, + ) + ) rm.add_request("configure", request_type=RequestType(func=_configure)) return rm - + def __init__(self, **kwargs): kwargs["name"] = "C2Beacon" super().__init__(**kwargs) - + def configure( self, c2_server_ip_address: IPv4Address = None, @@ -100,6 +116,7 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): """ Configures the C2 beacon to communicate with the C2 server with following additional parameters. + # TODO: Expand docustring. :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. :type c2_server_ip_address: IPv4Address @@ -117,43 +134,70 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): self.sys_log.info( f"{self.name}: Configured {self.name} with remote C2 server connection: {c2_server_ip_address=}." ) - self.sys_log.debug(f"{self.name}: configured with the following settings:" - f"Remote C2 Server: {c2_server_ip_address}" - f"Keep Alive Frequency {keep_alive_frequency}" - f"Masquerade Protocol: {masquerade_protocol}" - f"Masquerade Port: {masquerade_port}") + self.sys_log.debug( + f"{self.name}: configured with the following settings:" + f"Remote C2 Server: {c2_server_ip_address}" + f"Keep Alive Frequency {keep_alive_frequency}" + f"Masquerade Protocol: {masquerade_protocol}" + f"Masquerade Port: {masquerade_port}" + ) return True - # I THINK that once the application is running it can respond to incoming traffic but I'll need to test this later. def establish(self) -> bool: - """Establishes connection to the C2 server via a send alive. Must be called after the C2 Beacon is configured.""" + """Establishes connection to the C2 server via a send alive. The C2 Beacon must already be configured.""" + if self.c2_remote_connection is None: + self.sys_log.info(f"{self.name}: Failed to establish connection. C2 Beacon has not been configured.") + return False self.run() self.num_executions += 1 return self._send_keep_alive(session_id=None) def _handle_command_input(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ - Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets) - as well as then calling the relevant method dependant on the C2 Command. - - :param payload: The INPUT C2 Payload + Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets). + + Dependant the C2 Command contained within the payload. + The following methods are called and returned. + + C2 Command | Internal Method + ---------------------|------------------------ + RANSOMWARE_CONFIGURE | self._command_ransomware_config() + RANSOMWARE_LAUNCH | self._command_ransomware_launch() + Terminal | self._command_terminal() + + Please see each method individually for further information regarding + the implementation of these commands. + + :param payload: The INPUT C2 Payload :type payload: MasqueradePacket :return: The Request Response provided by the terminal execute method. :rtype Request Response: """ - command = payload.payload_type - if command != C2Payload: + # TODO: Probably could refactor this to be a more clean. + # The elif's are a bit ugly when they are all calling the same method. + command = payload.command + if not isinstance(command, C2Command): self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to resolve command") - return self._return_command_output(RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to resolve command."})) + return self._return_command_output( + command_output=RequestResponse( + status="failure", + data={"Reason": "C2 Beacon received unexpected C2Command. Unable to resolve command."}, + ), + session_id=session_id, + ) if command == C2Command.RANSOMWARE_CONFIGURE: self.sys_log.info(f"{self.name}: Received a ransomware configuration C2 command.") - return self._return_command_output(command_output=self._command_ransomware_config(payload), session_id=session_id) + return self._return_command_output( + command_output=self._command_ransomware_config(payload), session_id=session_id + ) elif command == C2Command.RANSOMWARE_LAUNCH: self.sys_log.info(f"{self.name}: Received a ransomware launch C2 command.") - return self._return_command_output(command_output=self._command_ransomware_launch(payload), session_id=session_id) + return self._return_command_output( + command_output=self._command_ransomware_launch(payload), session_id=session_id + ) elif command == C2Command.TERMINAL: self.sys_log.info(f"{self.name}: Received a terminal C2 command.") @@ -161,22 +205,30 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): else: self.sys_log.error(f"{self.name}: Received an C2 command: {command} but was unable to resolve command.") - return self._return_command_output(RequestResponse(status="failure", data={"Unexpected Behaviour. Unable to resolve command."})) + return self._return_command_output( + RequestResponse(status="failure", data={"Reason": "Unexpected Behaviour. Unable to resolve command."}) + ) + def _return_command_output(self, command_output: RequestResponse, session_id: Optional[str] = None) -> bool: + """Responsible for responding to the C2 Server with the output of the given command. - def _return_command_output(self, command_output: RequestResponse, session_id) -> bool: - """Responsible for responding to the C2 Server with the output of the given command.""" + :param command_output: The RequestResponse returned by the terminal application's execute method. + :type command_output: Request Response + :param session_id: The current session established with the C2 Server. + :type session_id: Str + """ output_packet = MasqueradePacket( masquerade_protocol=self.current_masquerade_protocol, masquerade_port=self.current_masquerade_port, payload_type=C2Payload.OUTPUT, - payload=command_output + payload=command_output, ) if self.send( payload=output_packet, dest_ip_address=self.c2_remote_connection, dest_port=self.current_masquerade_port, ip_protocol=self.current_masquerade_protocol, + session_id=session_id, ): self.sys_log.info(f"{self.name}: Command output sent to {self.c2_remote_connection}") self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") @@ -189,68 +241,121 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): def _command_ransomware_config(self, payload: MasqueradePacket) -> RequestResponse: """ - C2 Command: Ransomware Configuration + C2 Command: Ransomware Configuration. Creates a request that configures the ransomware based off the configuration options given. This request is then sent to the terminal service in order to be executed. + :payload MasqueradePacket: The incoming INPUT command. + :type Masquerade Packet: MasqueradePacket. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - pass - #return self._host_terminal.execute(command) + # TODO: replace and use terminal + return RequestResponse(status="success", data={"Reason": "Placeholder."}) def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: """ - C2 Command: Ransomware Execute + C2 Command: Ransomware Launch. Creates a request that executes the ransomware script. This request is then sent to the terminal service in order to be executed. + + :payload MasqueradePacket: The incoming INPUT command. + :type Masquerade Packet: MasqueradePacket. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response - - Creates a Request that launches the ransomware. """ - pass - #return self._host_terminal.execute(command) + # TODO: replace and use terminal + return RequestResponse(status="success", data={"Reason": "Placeholder."}) def _command_terminal(self, payload: MasqueradePacket) -> RequestResponse: """ - C2 Command: Ransomware Execute + C2 Command: Terminal. - Creates a request that executes the ransomware script. + Creates a request that executes a terminal command. This request is then sent to the terminal service in order to be executed. + :payload MasqueradePacket: The incoming INPUT command. + :type Masquerade Packet: MasqueradePacket. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response - - Creates a Request that launches the ransomware. """ - pass - #return self._host_terminal.execute(command) + # TODO: uncomment and replace (uses terminal) + return RequestResponse(status="success", data={"Reason": "Placeholder."}) + # return self._host_terminal.execute(command) + def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + """ + Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. + + In the C2 Beacon implementation of this method the c2 connection active boolean + is set to true and the keep alive inactivity is reset only after sending a keep alive + as wel as receiving a response back from the C2 Server. + + This is because the C2 Server is the listener and thus will only ever receive packets from + the C2 Beacon rather than the other way around. (The C2 Beacon is akin to a reverse shell) + + Therefore, we need a response back from the listener (C2 Server) + before the C2 beacon is able to confirm it's connection. + + Returns False if a keep alive was unable to be sent. + Returns True if a keep alive was successfully sent or already has been sent this timestep. + + :return: True if successfully handled, false otherwise. + :rtype: Bool + """ + self.sys_log.info(f"{self.name}: Keep Alive Received from {self.c2_remote_connection}.") + + # Using this guard clause to prevent packet storms and recognise that we've achieved a connection. + # This guard clause triggers on the c2 suite that establishes connection. + if self.keep_alive_attempted is True: + self.c2_connection_active = True # Sets the connection to active + self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero + self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] + + # We set keep alive_attempted here to show that we've achieved connection. + self.keep_alive_attempted = False + self.sys_log.warning(f"{self.name}: Connection successfully Established with C2 Server.") + return True + + # If we've reached this part of the method then we've received a keep alive but haven't sent a reply. + # Therefore we also need to configure the masquerade attributes based off the keep alive sent. + if self._resolve_keep_alive(payload, session_id) is False: + self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") + return False + + self.keep_alive_attempted = True + # If this method returns true then we have sent successfully sent a keep alive. + return self._send_keep_alive(session_id) - # Not entirely sure if this actually works. def apply_timestep(self, timestep: int) -> None: - """ - Apply a timestep to the c2_beacon. - Used to keep track of when the c2 beacon should send another keep alive. + """Apply a timestep to the c2_beacon. + Used to keep track of when the c2 beacon should send another keep alive. The following logic is applied: 1. Each timestep the keep_alive_inactivity is increased. 2. If the keep alive inactivity eclipses that of the keep alive frequency then another keep alive is sent. - 3. If the c2 beacon receives a keep alive response packet then the ``keep_alive_inactivity`` attribute is set to 0 - - Therefore, if ``keep_alive_inactivity`` attribute is not 0, then the connection is considered severed and c2 beacon will shut down. - + 3. If a keep alive response packet is received then the ``keep_alive_inactivity`` attribute is reset. + + Therefore, if ``keep_alive_inactivity`` attribute is not 0 after a keep alive is sent + then the connection is considered severed and c2 beacon will shut down. + :param timestep: The current timestep of the simulation. + :type timestep: Int + :return bool: Returns false if connection was lost. Returns True if connection is active or re-established. + :rtype bool: """ super().apply_timestep(timestep=timestep) - if self.operating_state is ApplicationOperatingState.RUNNING and self.health_state_actual is SoftwareHealthState.GOOD: + self.keep_alive_attempted = False # Resetting keep alive sent. + if ( + self.operating_state is ApplicationOperatingState.RUNNING + and self.health_state_actual is SoftwareHealthState.GOOD + ): self.keep_alive_inactivity += 1 if not self._check_c2_connection(timestep): self.sys_log.error(f"{self.name}: Connection Severed - Application Closing.") @@ -259,34 +364,67 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): self.close() return + def _check_c2_connection(self, timestep: int) -> bool: + """Checks the suitability of the current C2 Server connection. - def _check_c2_connection(self, timestep) -> bool: - """Checks the C2 Server connection. If a connection cannot be confirmed then this method will return false otherwise true.""" + If a connection cannot be confirmed then this method will return false otherwise true. + + :param timestep: The current timestep of the simulation. + :type timestep: Int + :return: Returns False if connection was lost. Returns True if connection is active or re-established. + :rtype bool: + """ if self.keep_alive_inactivity == self.keep_alive_frequency: - self.sys_log.info(f"{self.name}: Attempting to Send Keep Alive to {self.c2_remote_connection} at timestep {timestep}.") + self.sys_log.info( + f"{self.name}: Attempting to Send Keep Alive to {self.c2_remote_connection} at timestep {timestep}." + ) self._send_keep_alive(session_id=self.current_c2_session.uuid) if self.keep_alive_inactivity != 0: - self.sys_log.warning(f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed.") + self.sys_log.warning( + f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed." + ) return False return True - # Defining this abstract method from Abstract C2 - def _handle_command_output(self, payload): - """C2 Beacons currently do not need to handle output commands coming from the C2 Servers.""" + def _handle_command_output(self, payload: MasqueradePacket): + """C2 Beacons currently does not need to handle output commands coming from the C2 Servers.""" self.sys_log.warning(f"{self.name}: C2 Beacon received an unexpected OUTPUT payload: {payload}.") pass - + def show(self, markdown: bool = False): """ - Prints a table of the current C2 attributes on a C2 Beacon. + Prints a table of the current status of the C2 Beacon. + + Displays the current values of the following C2 attributes: + + ``C2 Connection Active``: + If the C2 Beacon is currently connected to the C2 Server + + ``C2 Remote Connection``: + The IP of the C2 Server. (Configured by upon installation) + + ``Keep Alive Inactivity``: + How many timesteps have occurred since the last keep alive. + + ``Keep Alive Frequency``: + How often should the C2 Beacon attempt a keep alive? :param markdown: If True, outputs the table in markdown format. Default is False. """ - table = PrettyTable(["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity", "Keep Alive Frequency"]) + table = PrettyTable( + ["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity", "Keep Alive Frequency"] + ) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.name} Running Status" - table.add_row([self.c2_connection_active, self.c2_remote_connection, self.keep_alive_inactivity, self.keep_alive_frequency]) + table.add_row( + [ + self.c2_connection_active, + self.c2_remote_connection, + self.keep_alive_inactivity, + self.keep_alive_frequency, + ] + ) print(table) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 8fe8c00c..9d02224e 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -1,15 +1,34 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command -from primaite.simulator.network.protocols.masquerade import C2Payload, MasqueradePacket -from primaite.simulator.core import RequestManager, RequestType -from primaite.interface.request import RequestFormat, RequestResponse +from typing import Dict, Optional + from prettytable import MARKDOWN, PrettyTable -from typing import Dict,Optional +from pydantic import validate_call + +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.protocols.masquerade import MasqueradePacket +from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload + class C2Server(AbstractC2, identifier="C2 Server"): - # TODO: - # Implement the request manager and agent actions. - # Implement the output handling methods. (These need to interface with the actions) + """ + C2 Server Application. + + Represents a vendor generic C2 Server is used in conjunction with the C2 beacon + to simulate malicious communications and infrastructure within primAITE. + + The C2 Server must be installed and be in a running state before it's able to receive + red agent actions and send commands to the C2 beacon. + + Extends the Abstract C2 application to include the following: + + 1. Sending commands to the C2 Beacon. (Command input) + 2. Parsing terminal RequestResponses back to the Agent. + """ + + current_command_output: RequestResponse = None + """The Request Response by the last command send. This attribute is updated by the method _handle_command_output.""" + def _init_request_manager(self) -> RequestManager: """ Initialise the request manager. @@ -34,7 +53,7 @@ class C2Server(AbstractC2, identifier="C2 Server"): def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a RANSOMWARE_LAUNCH C2Command to the C2 Beacon with the given parameters. - + :param request: Request with one element containing a dict of parameters for the configure method. :type request: RequestFormat :param context: additional context for resolving this action, currently unused @@ -48,7 +67,7 @@ class C2Server(AbstractC2, identifier="C2 Server"): def _remote_terminal_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a TERMINAL C2Command to the C2 Beacon with the given parameters. - + :param request: Request with one element containing a dict of parameters for the configure method. :type request: RequestFormat :param context: additional context for resolving this action, currently unused @@ -78,28 +97,69 @@ class C2Server(AbstractC2, identifier="C2 Server"): kwargs["name"] = "C2Server" super().__init__(**kwargs) - def _handle_command_output(self, payload: MasqueradePacket) -> RequestResponse: + def _handle_command_output(self, payload: MasqueradePacket) -> bool: """ - Handles the parsing of C2 Command Output from C2 Traffic (Masquerade Packets) - as well as then calling the relevant method dependant on the C2 Command. - - :param payload: The OUTPUT C2 Payload + Handles the parsing of C2 Command Output from C2 Traffic (Masquerade Packets). + + Parses the Request Response within MasqueradePacket's payload attribute (Inherited from Data packet). + The class attribute self.current_command_output is then set to this Request Response. + + If the payload attribute does not contain a RequestResponse, then an error will be raised in syslog and + the self.current_command_output is updated to reflect the error. + + :param payload: The OUTPUT C2 Payload :type payload: MasqueradePacket - :return: Returns the Request Response of the C2 Beacon's host terminal service execute method. - :rtype Request Response: + :return: Returns True if the self.current_command_output is currently updated, false otherwise. + :rtype Bool: """ self.sys_log.info(f"{self.name}: Received command response from C2 Beacon: {payload}.") command_output = payload.payload - if command_output != MasqueradePacket: - self.sys_log.warning(f"{self.name}: Received invalid command response: {command_output}.") - return RequestResponse(status="failure", data={"Received unexpected C2 Response."}) - return command_output - + if not isinstance(command_output, RequestResponse): + self.sys_log.warning(f"{self.name}: C2 Server received invalid command response: {command_output}.") + self.current_command_output = RequestResponse( + status="failure", data={"Reason": "Received unexpected C2 Response."} + ) + return False + self.current_command_output = command_output + return True + + def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + """ + Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. + + In the C2 Server implementation of this method the c2 connection active boolean + is set to true and the keep alive inactivity is reset after receiving one keep alive. + + This is because the C2 Server is the listener and thus will only ever receive packets from + the C2 Beacon rather than the other way around. (The C2 Beacon is akin to a reverse shell) + + Returns False if a keep alive was unable to be sent. + Returns True if a keep alive was successfully sent or already has been sent this timestep. + + :return: True if successfully handled, false otherwise. + :rtype: Bool + """ + self.sys_log.info(f"{self.name}: Keep Alive Received. Attempting to resolve the remote connection details.") + + self.c2_connection_active = True # Sets the connection to active + self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] + + if self._resolve_keep_alive(payload, session_id) == False: + self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") + return False + + # If this method returns true then we have sent successfully sent a keep alive. + self.sys_log.info(f"{self.name}: Remote connection successfully established: {self.c2_remote_connection}.") + self.sys_log.debug(f"{self.name}: Attempting to send Keep Alive response back to {self.c2_remote_connection}.") + + return self._send_keep_alive(session_id) + + @validate_call def _send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: """ Sends a command to the C2 Beacon. - + # TODO: Expand this docustring. :param given_command: The C2 command to be sent to the C2 Beacon. @@ -109,31 +169,41 @@ class C2Server(AbstractC2, identifier="C2 Server"): :return: Returns the Request Response of the C2 Beacon's host terminal service execute method. :rtype: RequestResponse """ - if given_command != C2Payload: + if not isinstance(given_command, C2Command): self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to send command.") - return RequestResponse(status="failure", data={"Received unexpected C2Command. Unable to send command."}) + return RequestResponse( + status="failure", data={"Reason": "Received unexpected C2Command. Unable to send command."} + ) + + if self._can_perform_network_action == False: + self.sys_log.warning(f"{self.name}: Unable to make leverage networking resources. Rejecting Command.") + return RequestResponse( + status="failure", data={"Reason": "Unable to access networking resources. Unable to send command."} + ) self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") command_packet = self._craft_packet(given_command=given_command, command_options=command_options) # Need to investigate if this is correct. - if self.send(payload=command_packet, + if self.send( + payload=command_packet, dest_ip_address=self.c2_remote_connection, - src_port=self.current_masquerade_port, - dst_port=self.current_masquerade_port, + session_id=self.current_c2_session.uuid, + dest_port=self.current_masquerade_port, ip_protocol=self.current_masquerade_protocol, - session_id=None): + ): self.sys_log.info(f"{self.name}: Successfully sent {given_command}.") self.sys_log.info(f"{self.name}: Awaiting command response {given_command}.") - return self._handle_command_output(command_packet) + # If the command output was handled currently, the self.current_command_output will contain the RequestResponse. + return self.current_command_output # TODO: Perhaps make a new pydantic base model for command_options? # TODO: Perhaps make the return optional? Returns False is the packet was unable to be crafted. def _craft_packet(self, given_command: C2Command, command_options: Dict) -> MasqueradePacket: """ Creates a Masquerade Packet based off the command parameter and the arguments given. - + :param given_command: The C2 command to be sent to the C2 Beacon. :type given_command: C2Command. :param command_options: The relevant C2 Beacon parameters.F @@ -147,27 +217,33 @@ class C2Server(AbstractC2, identifier="C2 Server"): masquerade_port=self.current_masquerade_port, payload_type=C2Payload.INPUT, command=given_command, - payload=command_options + payload=command_options, ) return constructed_packet - + + # TODO: I think I can just overload the methods rather than setting it as abstract_method? # Defining this abstract method - def _handle_command_input(self, payload): - """C2 Servers currently do not receive input commands coming from the C2 Beacons.""" + def _handle_command_input(self, payload: MasqueradePacket): + """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. + + C2 Servers currently do not receive input commands coming from the C2 Beacons. + + :param payload: The incoming MasqueradePacket + :type payload: MasqueradePacket. + """ self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") pass - def show(self, markdown: bool = False): """ Prints a table of the current C2 attributes on a C2 Server. :param markdown: If True, outputs the table in markdown format. Default is False. """ - table = PrettyTable(["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity"]) + table = PrettyTable(["C2 Connection Active", "C2 Remote Connection"]) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.name} Running Status" - table.add_row([self.c2_connection_active, self.c2_remote_connection, self.keep_alive_inactivity]) + table.add_row([self.c2_connection_active, self.c2_remote_connection]) print(table) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 3014cd19..7f869e92 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -12,14 +12,15 @@ from primaite.simulator.network.hardware.nodes.network.router import ACLAction, from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.web_server.web_server import WebServer -from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon -from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server # TODO: Update these tests. + @pytest.fixture(scope="function") def c2_server_on_computer() -> Tuple[C2Beacon, Computer]: computer: Computer = Computer( @@ -30,6 +31,7 @@ def c2_server_on_computer() -> Tuple[C2Beacon, Computer]: return [c2_beacon, computer] + @pytest.fixture(scope="function") def c2_server_on_computer() -> Tuple[C2Server, Computer]: computer: Computer = Computer( @@ -41,7 +43,6 @@ def c2_server_on_computer() -> Tuple[C2Server, Computer]: return [c2_server, computer] - @pytest.fixture(scope="function") def basic_network() -> Network: network = Network() @@ -57,6 +58,7 @@ def basic_network() -> Network: return network + def test_c2_suite_setup_receive(basic_network): """Test that C2 Beacon can successfully establish connection with the c2 Server.""" network: Network = basic_network @@ -68,5 +70,5 @@ def test_c2_suite_setup_receive(basic_network): c2_beacon.configure(c2_server_ip_address="192.168.0.10") c2_beacon.establish() - - c2_beacon.sys_log.show() \ No newline at end of file + + c2_beacon.sys_log.show() From e132c52121a874d735118b03bc211431f9bcc8f0 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 2 Aug 2024 13:32:34 +0100 Subject: [PATCH 093/206] #2706 - Removed the LoginValidator. Will be handled by UserSessionManager. Updated some missing variables in method definitions/ --- .../system/services/terminal/terminal.py | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 92893b14..1b8497d0 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -8,8 +8,8 @@ from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel -from primaite.interface.request import RequestFormat, RequestResponse -from primaite.simulator.core import RequestManager, RequestPermissionValidator, RequestType +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.protocols.ssh import ( SSHConnectionMessage, @@ -50,7 +50,7 @@ class TerminalClientConnection(BaseModel): def disconnect(self): """Disconnect the connection.""" - if self.client and self.is_active: + if self.client: self.client._disconnect(self._connection_uuid) # noqa @@ -101,14 +101,10 @@ class Terminal(Service): def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" - _login_valid = Terminal._LoginValidator(terminal=self) - rm = super()._init_request_manager() rm.add_request( "send", - request_type=RequestType( - func=lambda request, context: RequestResponse.from_bool(self.send()), validator=_login_valid - ), + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())), ) def _login(request: List[Any], context: Any) -> RequestResponse: @@ -119,7 +115,7 @@ class Terminal(Service): return RequestResponse(status="failure", data={}) def _remote_login(request: List[Any], context: Any) -> RequestResponse: - login = self._process_remote_login(username=request[0], password=request[1], ip_address=request[2]) + login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2]) if login: return RequestResponse(status="success", data={}) else: @@ -152,32 +148,13 @@ class Terminal(Service): rm.add_request( "Execute", - request_type=RequestType(func=_execute_request, validator=_login_valid), + request_type=RequestType(func=_execute_request), ) - rm.add_request("Logoff", request_type=RequestType(func=_logoff, validator=_login_valid)) + rm.add_request("Logoff", request_type=RequestType(func=_logoff)) return rm - class _LoginValidator(RequestPermissionValidator): - """ - When requests come in, this validator will only allow them through if the User is logged into the Terminal. - - Login is required before making use of the Terminal. - """ - - terminal: Terminal - """Save a reference to the Terminal instance.""" - - def __call__(self, request: RequestFormat, context: Dict) -> bool: - """Return whether the Terminal is connected.""" - return self.terminal.is_connected - - @property - def fail_message(self) -> str: - """Message that is reported when a request is rejected by this validator.""" - return "Cannot perform request on terminal as not logged in." - def _add_new_connection(self, connection_uuid: str, session_id: str): """Create a new connection object and amend to list of active connections.""" self._connections[connection_uuid] = TerminalClientConnection( @@ -249,6 +226,7 @@ class Terminal(Service): """Processes a remote terminal requesting to login to this terminal. :param payload: The SSH Payload Packet. + :param session_id: Session ID for sending login response. :return: True if successful, else False. """ username: str = payload.user_account.username @@ -282,6 +260,7 @@ class Terminal(Service): """Receive Payload and process for a response. :param payload: The message contents received. + :param session_id: Session ID of received message. :return: True if successful, else False. """ self.sys_log.debug(f"Received payload: {payload}") @@ -335,7 +314,7 @@ class Terminal(Service): software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": self.connection_uuid}, + payload={"type": "disconnect", "connection_id": connection_uuid}, dest_ip_address=dest_ip_address, dest_port=self.port, ) From 1933522e8928d948ff081529ad774da48e638ffc Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Fri, 2 Aug 2024 16:13:59 +0100 Subject: [PATCH 094/206] #2689 Updated docustrings and general quality improvements. --- .../system/applications/application.py | 18 ++++++ .../red_applications/c2/abstract_c2.py | 51 ++++++++-------- .../red_applications/c2/c2_server.py | 61 +++++++++++++------ 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index dc16a725..741f491d 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -214,3 +214,21 @@ class Application(IOSoftware): f"Cannot perform request on application '{self.application.name}' because it is not in the " f"{self.state.name} state." ) + + def _can_perform_network_action(self) -> bool: + """ + Checks if the application can perform outbound network actions. + + First confirms application suitability via the can_perform_action method. + Then confirms that the host has an enabled NIC that can be used for outbound traffic. + + :return: True if outbound network actions can be performed, otherwise False. + :rtype bool: + """ + if not super()._can_perform_action(): + return False + + for nic in self.software_manager.node.network_interface.values(): + if nic.enabled: + return True + return False diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index af701e8c..89ab7953 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -13,7 +13,6 @@ from primaite.simulator.system.applications.application import Application from primaite.simulator.system.core.session_manager import Session # TODO: -# Complete C2 Server and C2 Beacon TODOs # Create test that leverage all the functionality needed for the different TAPs # Create a .RST doc # Potentially? A notebook which demonstrates a custom red agent using the c2 server for various means. @@ -79,24 +78,6 @@ class AbstractC2(Application, identifier="AbstractC2"): current_c2_session: Session = None """The currently active session that the C2 Traffic is using. Set after establishing connection.""" - # TODO: Move this duplicate method from NMAP class into 'Application' to adhere to DRY principle. - def _can_perform_network_action(self) -> bool: - """ - Checks if the C2 application can perform outbound network actions. - - This is done by checking the parent application can_per_action functionality. - Then checking if there is an enabled NIC that can be used for outbound traffic. - - :return: True if outbound network actions can be performed, otherwise False. - """ - if not super()._can_perform_action(): - return False - - for nic in self.software_manager.node.network_interface.values(): - if nic.enabled: - return True - return False - def describe_state(self) -> Dict: """ Describe the state of the C2 application. @@ -106,9 +87,11 @@ class AbstractC2(Application, identifier="AbstractC2"): """ return super().describe_state() + # TODO: Update this post application/services requiring to listen to multiple ports def __init__(self, **kwargs): + """Initialise the C2 applications to by default listen for HTTP traffic.""" kwargs["port"] = Port.HTTP # TODO: Update this post application/services requiring to listen to multiple ports - kwargs["protocol"] = IPProtocol.TCP # Update this as well + kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) # Validate call ensures we are only handling Masquerade Packets. @@ -173,15 +156,30 @@ class AbstractC2(Application, identifier="AbstractC2"): # from_network_interface=from_network_interface def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None, **kwargs) -> bool: - """Receives masquerade packets. Used by both c2 server and c2 client. + """Receives masquerade packets. Used by both c2 server and c2 beacon. + + Defining the `Receive` method so that the application can receive packets via the session manager. + These packets are then immediately handed to ._handle_c2_payload. :param payload: The Masquerade Packet to be received. - :param session: The transport session that the payload is originating from. + :type payload: MasqueradePacket + :param session_id: The transport session_id that the payload is originating from. + :type session_id: str """ return self._handle_c2_payload(payload, session_id) def _send_keep_alive(self, session_id: Optional[str]) -> bool: - """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address.""" + """Sends a C2 keep alive payload to the self.remote_connection IPv4 Address. + + Used by both the c2 client and the s2 server for establishing and confirming connection. + This method also contains some additional validation to ensure that the C2 applications + are correctly configured before sending any traffic. + + :param session_id: The transport session_id that the payload is originating from. + :type session_id: str + :returns: Returns True if a send alive was successfully sent. False otherwise. + :rtype bool: + """ # Checking that the c2 application is capable of performing both actions and has an enabled NIC # (Using NOT to improve code readability) if self.c2_remote_connection is None: @@ -230,6 +228,8 @@ class AbstractC2(Application, identifier="AbstractC2"): :param payload: The Keep Alive payload received. :type payload: MasqueradePacket + :param session_id: The transport session_id that the payload is originating from. + :type session_id: str :return: True on successful configuration, false otherwise. :rtype: bool """ @@ -240,8 +240,9 @@ class AbstractC2(Application, identifier="AbstractC2"): f"Port: {payload.masquerade_port} Protocol: {payload.masquerade_protocol}." ) return False + # TODO: Validation on Ports (E.g only allow HTTP, FTP etc) - # Potentially compare to IPProtocol & Port children (Same way that abstract TAP does it with kill chains) + # Potentially compare to IPProtocol & Port children? Depends on how listening on multiple ports is implemented. # Setting the Ports self.current_masquerade_port = payload.masquerade_port @@ -253,6 +254,6 @@ class AbstractC2(Application, identifier="AbstractC2"): self.c2_remote_connection = self.current_c2_session.with_ip_address self.c2_connection_active = True # Sets the connection to active - self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zeroW + self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero return True diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 9d02224e..c29cd271 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -137,6 +137,10 @@ class C2Server(AbstractC2, identifier="C2 Server"): Returns False if a keep alive was unable to be sent. Returns True if a keep alive was successfully sent or already has been sent this timestep. + :param payload: The Keep Alive payload received. + :type payload: MasqueradePacket + :param session_id: The transport session_id that the payload is originating from. + :type session_id: str :return: True if successfully handled, false otherwise. :rtype: Bool """ @@ -160,7 +164,22 @@ class C2Server(AbstractC2, identifier="C2 Server"): """ Sends a command to the C2 Beacon. - # TODO: Expand this docustring. + Currently, these commands leverage the pre-existing capability of other applications. + However, the commands are sent via the network rather than the game layer which + grants more opportunity to the blue agent to prevent attacks. + + Additionally, future editions of primAITE may expand the C2 repertoire to allow for + more complex red agent behaviour such as file extraction, establishing further fall back channels + or introduce red applications that are only installable via C2 Servers. (T1105) + + C2 Command | Meaning + ---------------------|------------------------ + RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. + RANSOMWARE_LAUNCH | Launches the installed ransomware script. + Terminal | Executes a command via the terminal installed on the C2 Beacons Host. + + For more information on the impact of these commands please refer to the terminal + and the ransomware applications. :param given_command: The C2 command to be sent to the C2 Beacon. :type given_command: C2Command. @@ -198,11 +217,12 @@ class C2Server(AbstractC2, identifier="C2 Server"): # If the command output was handled currently, the self.current_command_output will contain the RequestResponse. return self.current_command_output - # TODO: Perhaps make a new pydantic base model for command_options? - # TODO: Perhaps make the return optional? Returns False is the packet was unable to be crafted. + # TODO: Probably could move this as a class method in MasqueradePacket. def _craft_packet(self, given_command: C2Command, command_options: Dict) -> MasqueradePacket: """ - Creates a Masquerade Packet based off the command parameter and the arguments given. + Creates and returns a Masquerade Packet using the arguments given. + + Creates Masquerade Packet with a payload_type INPUT C2Payload :param given_command: The C2 command to be sent to the C2 Beacon. :type given_command: C2Command. @@ -221,23 +241,18 @@ class C2Server(AbstractC2, identifier="C2 Server"): ) return constructed_packet - # TODO: I think I can just overload the methods rather than setting it as abstract_method? - # Defining this abstract method - def _handle_command_input(self, payload: MasqueradePacket): - """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. - - C2 Servers currently do not receive input commands coming from the C2 Beacons. - - :param payload: The incoming MasqueradePacket - :type payload: MasqueradePacket. - """ - self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") - pass - def show(self, markdown: bool = False): """ Prints a table of the current C2 attributes on a C2 Server. + Displays the current values of the following C2 attributes: + + ``C2 Connection Active``: + If the C2 Server has established connection with a C2 Beacon. + + ``C2 Remote Connection``: + The IP of the C2 Beacon. (Configured by upon receiving a keep alive.) + :param markdown: If True, outputs the table in markdown format. Default is False. """ table = PrettyTable(["C2 Connection Active", "C2 Remote Connection"]) @@ -247,3 +262,15 @@ class C2Server(AbstractC2, identifier="C2 Server"): table.title = f"{self.name} Running Status" table.add_row([self.c2_connection_active, self.c2_remote_connection]) print(table) + + # Abstract method inherited from abstract C2 - Not currently utilised. + def _handle_command_input(self, payload: MasqueradePacket) -> None: + """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. + + C2 Servers currently do not receive input commands coming from the C2 Beacons. + + :param payload: The incoming MasqueradePacket + :type payload: MasqueradePacket. + """ + self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") + pass From 322a691e53f5658398a2e778d2b820b604ffa04a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 2 Aug 2024 23:21:35 +0100 Subject: [PATCH 095/206] #2768 - Added listen_on_ports attribute to IOSoftware. updated software manager so that it sends copies of payloads to listening ports too. Added integration test that installs a listening service to snoop on DB traffic. --- .../simulator/system/core/software_manager.py | 23 +++++-- .../services/database/database_service.py | 2 + src/primaite/simulator/system/software.py | 5 +- .../system/test_service_listening_on_ports.py | 64 +++++++++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 tests/integration_tests/system/test_service_listening_on_ports.py diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index e00afba6..7b36097b 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -1,4 +1,5 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from copy import deepcopy from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union @@ -76,6 +77,8 @@ class SoftwareManager: for software in self.port_protocol_mapping.values(): if software.operating_state in {ApplicationOperatingState.RUNNING, ServiceOperatingState.RUNNING}: open_ports.append(software.port) + if software.listen_on_ports: + open_ports += list(software.listen_on_ports) return open_ports def check_port_is_open(self, port: Port, protocol: IPProtocol) -> bool: @@ -223,7 +226,9 @@ class SoftwareManager: frame: Frame, ): """ - Receive a payload from the SessionManager and forward it to the corresponding service or application. + Receive a payload from the SessionManager and forward it to the corresponding service or applications. + + This function handles both software assigned a specific port, and software listening in on other ports. :param payload: The payload being received. :param session: The transport session the payload originates from. @@ -231,11 +236,17 @@ class SoftwareManager: if payload.__class__.__name__ == "PortScanPayload": self.software.get("NMAP").receive(payload=payload, session_id=session_id) return - receiver: Optional[Union[Service, Application]] = self.port_protocol_mapping.get((port, protocol), None) - if receiver: - receiver.receive( - payload=payload, session_id=session_id, from_network_interface=from_network_interface, frame=frame - ) + main_receiver = self.port_protocol_mapping.get((port, protocol), None) + listening_receivers = [software for software in self.software.values() if port in software.listen_on_ports] + receivers = [main_receiver] + listening_receivers if main_receiver else listening_receivers + if receivers: + for receiver in receivers: + receiver.receive( + payload=deepcopy(payload), + session_id=session_id, + from_network_interface=from_network_interface, + frame=frame, + ) else: self.sys_log.warning(f"No service or application found for port {port} and protocol {protocol}") pass diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 22ae0ff3..56edcf89 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -377,6 +377,8 @@ class DatabaseService(Service): ) else: result = {"status_code": 401, "type": "sql"} + else: + self.sys_log.info(f"{self.name}: Ignoring payload as it is not a Database payload") self.send(payload=result, session_id=session_id) return True diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7c27534a..7a3d675c 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -4,9 +4,10 @@ from abc import abstractmethod from datetime import datetime from enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, Optional, TYPE_CHECKING, Union +from typing import Any, Dict, Optional, Set, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable +from pydantic import Field from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType, SimComponent @@ -252,6 +253,8 @@ class IOSoftware(Software): "Indicates if the software uses UDP protocol for communication. Default is True." port: Port "The port to which the software is connected." + listen_on_ports: Set[Port] = Field(default_factory=set) + "The set of ports to listen on." protocol: IPProtocol "The IP Protocol the Software operates on." _connections: Dict[str, Dict] = {} diff --git a/tests/integration_tests/system/test_service_listening_on_ports.py b/tests/integration_tests/system/test_service_listening_on_ports.py new file mode 100644 index 00000000..0cb1ad54 --- /dev/null +++ b/tests/integration_tests/system/test_service_listening_on_ports.py @@ -0,0 +1,64 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Any, Dict, List, Set + +from pydantic import Field + +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.service import Service + + +class _DatabaseListener(Service): + name: str = "DatabaseListener" + protocol: IPProtocol = IPProtocol.TCP + port: Port = Port.NONE + listen_on_ports: Set[Port] = {Port.POSTGRES_SERVER} + payloads_received: List[Any] = Field(default_factory=list) + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + self.payloads_received.append(payload) + self.sys_log.info(f"{self.name}: received payload {payload}") + return True + + def describe_state(self) -> Dict: + return super().describe_state() + + +def test_http_listener(client_server): + computer, server = client_server + + server.software_manager.install(DatabaseService) + server_db = server.software_manager.software["DatabaseService"] + server_db.start() + + server.software_manager.install(_DatabaseListener) + server_db_listener: _DatabaseListener = server.software_manager.software["DatabaseListener"] + server_db_listener.start() + + computer.software_manager.install(DatabaseClient) + computer_db_client: DatabaseClient = computer.software_manager.software["DatabaseClient"] + + computer_db_client.run() + computer_db_client.server_ip_address = server.network_interface[1].ip_address + + assert len(server_db_listener.payloads_received) == 0 + computer.session_manager.receive_payload_from_software_manager( + payload="masquerade as Database traffic", + dst_ip_address=server.network_interface[1].ip_address, + dst_port=Port.POSTGRES_SERVER, + ip_protocol=IPProtocol.TCP, + ) + + assert len(server_db_listener.payloads_received) == 1 + + db_connection = computer_db_client.get_new_connection() + + assert db_connection + + assert len(server_db_listener.payloads_received) == 2 + + assert db_connection.query("SELECT") + + assert len(server_db_listener.payloads_received) == 3 From 4bddf72cd335fd52da74cc193dbc1471cf111684 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 09:29:17 +0100 Subject: [PATCH 096/206] #2706 - Initial refactor of Terminal Class following review discussion on Friday. Terminal will now return a TerminalConnection/RemoteTerminalConnection object on login. The new connection object can then be used to pass commands to the target node, without needing to form a full payload item. --- .../notebooks/Terminal-Processing.ipynb | 96 ++---- .../simulator/network/protocols/ssh.py | 2 + .../system/services/terminal/terminal.py | 293 +++++++++--------- .../_system/_services/test_terminal.py | 55 ++-- 4 files changed, 205 insertions(+), 241 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index fc795794..77be3822 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -26,7 +26,8 @@ "source": [ "from primaite.simulator.system.services.terminal.terminal import Terminal\n", "from primaite.simulator.network.container import Network\n", - "from primaite.simulator.network.hardware.nodes.host.computer import Computer" + "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", + "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript" ] }, { @@ -83,7 +84,38 @@ "outputs": [], "source": [ "# Login to the remote (node_b) from local (node_a)\n", - "terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + "from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection\n", + "\n", + "\n", + "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.software_manager.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(type(term_a_term_b_remote_connection))\n", + "term_a_term_b_remote_connection.execute([\"software_manager\", \"application\", \"install\", \"RansomwareScript\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.software_manager.show()" ] }, { @@ -109,45 +141,6 @@ "The Terminal can be used to send requests to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage\n", - "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", - "\n", - "computer_b.software_manager.show()\n", - "\n", - "payload: SSHPacket = SSHPacket(\n", - " payload=[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"],\n", - " transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST,\n", - " connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN,\n", - " sender_ip_address=computer_a.network_interface[1].ip_address,\n", - " target_ip_address=computer_b.network_interface[1].ip_address,\n", - ")\n", - "\n", - "# Send command to install RansomwareScript\n", - "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The `RansomwareScript` can then be seen in the list of applications on the `node_b Software Manager`. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "computer_b.software_manager.show()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -157,27 +150,6 @@ "Here, we send a command to `computer_b` to create a new folder titled \"Downloads\"." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "computer_b.file_system.show()\n", - "\n", - "payload: SSHPacket = SSHPacket(\n", - " payload=[\"file_system\", \"create\", \"folder\", \"Downloads\"],\n", - " transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST,\n", - " connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN,\n", - " sender_ip_address=computer_a.network_interface[1].ip_address,\n", - " target_ip_address=computer_b.network_interface[1].ip_address,\n", - ")\n", - "\n", - "terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address)\n", - "\n", - "computer_b.file_system.show()" - ] - }, { "cell_type": "markdown", "metadata": {}, diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 4ec043b8..7ba629f8 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -76,6 +76,8 @@ class SSHPacket(DataPacket): user_account: Optional[SSHUserCredentials] = None """User Account Credentials if passed""" + connection_request_uuid: Optional[str] = None # Connection Request uuid. + connection_uuid: Optional[str] = None # The connection uuid used to validate the session ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 1b8497d0..b7bc5287 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -10,18 +10,11 @@ from pydantic import BaseModel from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.network.hardware.base import Node -from primaite.simulator.network.protocols.ssh import ( - SSHConnectionMessage, - SSHPacket, - SSHTransportMessage, - SSHUserCredentials, -) +from primaite.simulator.network.protocols.ssh import SSHPacket from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState -from primaite.simulator.system.software import SoftwareHealthState class TerminalClientConnection(BaseModel): @@ -31,40 +24,45 @@ class TerminalClientConnection(BaseModel): This class is used to record current User Connections to the Terminal class. """ - parent_node: Node # Technically should be HostNode but this causes circular import error. + parent_terminal: Terminal """The parent Node that this connection was created on.""" - dest_ip_address: IPv4Address = None - """Destination IP address of connection""" - session_id: str = None """Session ID that connection is linked to""" - _connection_uuid: str = None + connection_uuid: str = None """Connection UUID""" @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" - return self.parent_node.software_manager.software.get("Terminal") + return self.parent_terminal - def disconnect(self): - """Disconnect the connection.""" - if self.client: - self.client._disconnect(self._connection_uuid) # noqa + def disconnect(self) -> bool: + """Disconnect the session.""" + return self.parent_terminal._disconnect(connection_uuid=self.connection_uuid) + + +class RemoteTerminalConnection(TerminalClientConnection): + """ + RemoteTerminalConnection Class. + + This class acts as broker between the terminal and remote. + + """ + + def execute(self, command: Any) -> bool: + """Execute a given command on the remote Terminal.""" + if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: + self.parent_terminal.sys_log.warning("Cannot process command as system not running") + # Send command to remote terminal to process. + return self.parent_terminal.send(payload=command, session_id=self.session_id) class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" - operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING - "Initial Operating State" - - health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD - "Service Health State" - - _connections: Dict[str, TerminalClientConnection] = {} - "List of active connections held on this terminal." + _client_connection_requests: Dict[str, Optional[str]] = {} def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -155,34 +153,40 @@ class Terminal(Service): return rm - def _add_new_connection(self, connection_uuid: str, session_id: str): + def execute(self, command: List[Any]) -> RequestResponse: + """Execute a passed ssh command via the request manager.""" + return self.parent.apply_request(command) + + def _create_local_connection(self, connection_uuid: str, session_id: str) -> RemoteTerminalConnection: """Create a new connection object and amend to list of active connections.""" - self._connections[connection_uuid] = TerminalClientConnection( - parent_node=self.software_manager.node, + new_connection = TerminalClientConnection( + parent_terminal=self, connection_uuid=connection_uuid, session_id=session_id, ) + self._connections[connection_uuid] = new_connection + self._client_connection_requests[connection_uuid] = new_connection - def login(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: - """Process User request to login to Terminal. + return new_connection - If ip_address is passed, login will attempt a remote login to the node at that address. - :param username: The username credential. - :param password: The user password component of credentials. - :param dest_ip_address: The IP address of the node we want to connect to. - :return: True if successful, False otherwise. - """ + def login( + self, username: str, password: str, ip_address: Optional[IPv4Address] = None + ) -> Optional[TerminalClientConnection]: + """Login to the terminal. Will attempt a remote login if ip_address is given, else local.""" if self.operating_state != ServiceOperatingState.RUNNING: - self.sys_log.warning("Cannot process login as service is not running") - return False - + self.sys_log.warning("Cannot login as service is not running.") + return None + connection_request_id = str(uuid4()) + self._client_connection_requests[connection_request_id] = None if ip_address: - # if ip_address has been provided, we assume we are logging in to a remote terminal. - return self._send_remote_login(username=username, password=password, ip_address=ip_address) + # Assuming that if IP is passed we are connecting to remote + return self._send_remote_login( + username=username, password=password, ip_address=ip_address, connection_request_id=connection_request_id + ) + else: + return self._process_local_login(username=username, password=password) - return self._process_local_login(username=username, password=password) - - def _process_local_login(self, username: str, password: str) -> bool: + def _process_local_login(self, username: str, password: str) -> Optional[TerminalClientConnection]: """Local session login to terminal. :param username: Username for login. @@ -195,110 +199,114 @@ class Terminal(Service): if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections - session_id = str(uuid4()) - self._add_new_connection(connection_uuid=connection_uuid, session_id=session_id) - return True + self._create_local_connection(connection_uuid=connection_uuid, session_id="") + return TerminalClientConnection(parent_terminal=self, session_id="", connection_uuid=connection_uuid) else: self.sys_log.warning("Login failed, incorrect Username or Password") - return False + return None - def _send_remote_login(self, username: str, password: str, ip_address: IPv4Address) -> bool: - """Attempt to login to a remote terminal. + def _check_client_connection(self, connection_id: str) -> bool: + """Check that client_connection_id is valid.""" + return True if connection_id in self._client_connection_requests else False - :param username: username for login. - :param password: password for login. - :ip_address: IP address of the target node for login. - """ - transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST - connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA - user_account: SSHUserCredentials = SSHUserCredentials(username=username, password=password) + def _send_remote_login( + self, + username: str, + password: str, + ip_address: IPv4Address, + connection_request_id: str, + is_reattempt: bool = False, + ) -> Optional[RemoteTerminalConnection]: + """Process a remote login attempt.""" + self.sys_log.info(f"Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}") + if is_reattempt: + valid_connection = self._check_client_connection(connection_id=connection_request_id) + if valid_connection: + remote_terminal_connection = self._client_connection_requests.pop(connection_request_id) + self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.") + return remote_terminal_connection + else: + self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") + return None - payload: SSHPacket = SSHPacket( - transport_message=transport_message, - connection_message=connection_message, - user_account=user_account, + payload = { + "type": "login_request", + "username": username, + "password": password, + "connection_request_id": connection_request_id, + } + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=ip_address, dest_port=self.port + ) + return self._send_remote_login( + username=username, + password=password, + ip_address=ip_address, + is_reattempt=True, + connection_request_id=connection_request_id, ) - self.sys_log.info(f"Sending remote login request to {ip_address}") - return self.send(payload=payload, dest_ip_address=ip_address) + def _create_remote_connection(self, connection_id: str, connection_request_id: str, session_id: str) -> None: + """Create a new TerminalClientConnection Object.""" + client_connection = RemoteTerminalConnection( + parent_terminal=self, session_id=session_id, connection_uuid=connection_id + ) + self._connections[connection_id] = client_connection + self._client_connection_requests[connection_request_id] = client_connection - def _process_remote_login(self, payload: SSHPacket, session_id: str) -> bool: - """Processes a remote terminal requesting to login to this terminal. - - :param payload: The SSH Payload Packet. - :param session_id: Session ID for sending login response. - :return: True if successful, else False. + def receive(self, session_id: str, payload: Any, **kwargs) -> bool: """ - username: str = payload.user_account.username - password: str = payload.user_account.password - self.sys_log.info(f"Sending UserAuth request to UserSessionManager, username={username}, password={password}") - # TODO: Un-comment this when UserSessionManager is merged. - # connection_uuid = self.parent.UserSessionManager.remote_login(username=username, password=password) - connection_uuid = str(uuid4()) - if connection_uuid: - # Send uuid to remote - self.sys_log.info( - f"Remote login authorised, connection ID {connection_uuid} for " f"{username} in session {session_id}" - ) - transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS - connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA - return_payload = SSHPacket( - transport_message=transport_message, - connection_message=connection_message, - connection_uuid=connection_uuid, - ) - self._add_new_connection(connection_uuid=connection_uuid, session_id=session_id) + Receive a payload from the Software Manager. - self.send(payload=return_payload, session_id=session_id) - return True - else: - # UserSessionManager has returned None - self.sys_log.warning("Login failed, incorrect Username or Password") - return False - - def receive(self, payload: SSHPacket, session_id: str, **kwargs) -> bool: - """Receive Payload and process for a response. - - :param payload: The message contents received. - :param session_id: Session ID of received message. - :return: True if successful, else False. + :param payload: A payload to receive. + :param session_id: The session id the payload relates to. + :return: True. """ - self.sys_log.debug(f"Received payload: {payload}") + self.sys_log.info(f"Received payload: {payload}") + if isinstance(payload, dict) and payload.get("type"): + if payload["type"] == "login_request": + # add connection + connection_request_id = payload["connection_request_id"] + username = payload["username"] + password = payload["password"] + print(f"Connection ID is: {connection_request_id}") + self.sys_log.info(f"Connection authorised, session_id: {session_id}") + self._create_remote_connection( + connection_id=connection_request_id, + connection_request_id=payload["connection_request_id"], + session_id=session_id, + ) + payload = { + "type": "login_success", + "username": username, + "password": password, + "connection_request_id": connection_request_id, + } + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_port=self.port, session_id=session_id + ) + elif payload["type"] == "login_success": + self.sys_log.info(f"Login was successful! session_id is:{session_id}") + connection_request_id = payload["connection_request_id"] + self._create_remote_connection( + connection_id=connection_request_id, + session_id=session_id, + connection_request_id=connection_request_id, + ) - if not isinstance(payload, SSHPacket): - return False + elif payload["type"] == "disconnect": + connection_id = payload["connection_id"] + self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from the server") + self._disconnect(payload["connection_id"]) - if self.operating_state != ServiceOperatingState.RUNNING: - self.sys_log.warning("Cannot process message as not running") - return False - - if payload.connection_message == SSHConnectionMessage.SSH_MSG_CHANNEL_CLOSE: - # Close the channel - connection_id = kwargs["connection_id"] - dest_ip_address = kwargs["dest_ip_address"] - self.disconnect(dest_ip_address=dest_ip_address) - self.sys_log.debug(f"Disconnecting {connection_id}") - - elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: - return self._process_remote_login(payload=payload, session_id=session_id) - - elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: - self.sys_log.info(f"Login Successful, connection ID is {payload.connection_uuid}") - return True - - elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: - return self.execute(command=payload.payload) - - else: - self.sys_log.warning("Encounter unexpected message type, rejecting connection") - return False + if isinstance(payload, list): + # A request? For me? + self.execute(payload) return True - def execute(self, command: List[Any]) -> RequestResponse: - """Execute a passed ssh command via the request manager.""" - return self.parent.apply_request(command) - def _disconnect(self, connection_uuid: str) -> bool: """Disconnect from the remote. @@ -309,30 +317,16 @@ class Terminal(Service): self.sys_log.warning("No remote connection present") return False - dest_ip_address = self._connections[connection_uuid].dest_ip_address + session_id = self._connections[connection_uuid].session_id self._connections.pop(connection_uuid) software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": connection_uuid}, - dest_ip_address=dest_ip_address, - dest_port=self.port, + payload={"type": "disconnect", "connection_id": connection_uuid}, dest_port=self.port, session_id=session_id ) self.sys_log.info(f"{self.name}: Disconnected {connection_uuid}") return True - def disconnect(self, connection_uuid: Optional[str]) -> bool: - """Disconnect the terminal. - - If no connection id has been supplied, disconnects the first connection. - :param connection_uuid: Connection ID that we want to disconnect. - :return: True if successful, False otherwise. - """ - if not connection_uuid: - connection_uuid = next(iter(self._connections)) - - return self._disconnect(connection_uuid=connection_uuid) - def send( self, payload: SSHPacket, dest_ip_address: Optional[IPv4Address] = None, session_id: Optional[str] = None ) -> bool: @@ -345,6 +339,7 @@ class Terminal(Service): if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!") return False + self.sys_log.debug(f"Sending payload: {payload}") return super().send( payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index d4592228..2f093dae 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -16,7 +16,7 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.service import ServiceOperatingState -from primaite.simulator.system.services.terminal.terminal import Terminal +from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection, Terminal from primaite.simulator.system.services.web_server.web_server import WebServer @@ -87,8 +87,6 @@ def test_terminal_send(basic_network): payload="Test_Payload", transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, - sender_ip_address=computer_a.network_interface[1].ip_address, - target_ip_address=computer_b.network_interface[1].ip_address, ) assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) @@ -106,11 +104,13 @@ def test_terminal_receive(basic_network): payload=["file_system", "create", "folder", folder_name], transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, - sender_ip_address=computer_a.network_interface[1].ip_address, - target_ip_address=computer_b.network_interface[1].ip_address, ) - assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) + term_a_on_node_b: RemoteTerminalConnection = terminal_a.login( + username="username", password="password", ip_address="192.168.0.11" + ) + + term_a_on_node_b.execute(["file_system", "create", "folder", folder_name]) # Assert that the Folder has been correctly created assert computer_b.file_system.get_folder(folder_name) @@ -127,11 +127,13 @@ def test_terminal_install(basic_network): payload=["software_manager", "application", "install", "RansomwareScript"], transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, - sender_ip_address=computer_a.network_interface[1].ip_address, - target_ip_address=computer_b.network_interface[1].ip_address, ) - terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) + term_a_on_node_b: RemoteTerminalConnection = terminal_a.login( + username="username", password="password", ip_address="192.168.0.11" + ) + + term_a_on_node_b.execute(["software_manager", "application", "install", "RansomwareScript"]) assert computer_b.software_manager.software.get("RansomwareScript") @@ -145,29 +147,30 @@ def test_terminal_fail_when_closed(basic_network): terminal.operating_state = ServiceOperatingState.STOPPED - assert ( - terminal.login(username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address) - is False + assert not terminal.login( + username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address ) def test_terminal_disconnect(basic_network): - """Terminal should set is_connected to false on disconnect""" + """Test Terminal disconnects""" network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") computer_b: Computer = network.get_node_by_hostname("node_b") terminal_b: Terminal = computer_b.software_manager.software.get("Terminal") - assert terminal_a.is_connected is False + assert len(terminal_b._connections) == 0 - terminal_a.login(username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address) + term_a_on_term_b = terminal_a.login( + username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address + ) - assert terminal_a.is_connected is True + assert len(terminal_b._connections) == 1 - terminal_a.disconnect(dest_ip_address=computer_b.network_interface[1].ip_address) + term_a_on_term_b.disconnect() - assert terminal_a.is_connected is False + assert len(terminal_b._connections) == 0 def test_terminal_ignores_when_off(basic_network): @@ -178,21 +181,13 @@ def test_terminal_ignores_when_off(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") - terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") # login to computer_b - - assert terminal_a.is_connected is True + term_a_on_term_b: RemoteTerminalConnection = terminal_a.login( + username="admin", password="Admin123!", ip_address="192.168.0.11" + ) # login to computer_b terminal_a.operating_state = ServiceOperatingState.STOPPED - payload: SSHPacket = SSHPacket( - payload="Test_Payload", - transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA, - sender_ip_address=computer_a.network_interface[1].ip_address, - target_ip_address="192.168.0.11", - ) - - assert not terminal_a.send(payload=payload, dest_ip_address="192.168.0.11") + assert not term_a_on_term_b.execute(["software_manager", "application", "install", "RansomwareScript"]) def test_network_simulation(basic_network): From 814663cf2c2ab4136efe2572aa08d7b756d899ea Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 10:04:23 +0100 Subject: [PATCH 097/206] #2706 - Terminal now installs on a Router --- src/primaite/simulator/network/hardware/base.py | 6 ++++++ .../simulator/network/hardware/nodes/host/host_node.py | 2 +- .../simulator/network/hardware/nodes/network/router.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 4994e7d3..9230dd47 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -30,6 +30,7 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.processes.process import Process from primaite.simulator.system.services.service import Service +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.software import IOSoftware, Software from primaite.utils.converters import convert_dict_enum_keys_to_enum_values from primaite.utils.validators import IPV4Address @@ -1541,6 +1542,11 @@ class Node(SimComponent): """The Nodes User Session Manager.""" return self.software_manager.software.get("UserSessionManager") # noqa + @property + def terminal(self) -> Optional[Terminal]: + """The Nodes Terminal.""" + return self.software_manager.software.get("Terminal") + def local_login(self, username: str, password: str) -> Optional[str]: """ Attempt to log in to the node uas a local user. diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index 7393490b..c197d30b 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -314,9 +314,9 @@ class HostNode(Node): "NTPClient": NTPClient, "WebBrowser": WebBrowser, "NMAP": NMAP, - "Terminal": Terminal, "UserSessionManager": UserSessionManager, "UserManager": UserManager, + "Terminal": Terminal, } """List of system software that is automatically installed on nodes.""" diff --git a/src/primaite/simulator/network/hardware/nodes/network/router.py b/src/primaite/simulator/network/hardware/nodes/network/router.py index 42821120..ceb91695 100644 --- a/src/primaite/simulator/network/hardware/nodes/network/router.py +++ b/src/primaite/simulator/network/hardware/nodes/network/router.py @@ -24,6 +24,7 @@ from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.services.arp.arp import ARP from primaite.simulator.system.services.icmp.icmp import ICMP +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.utils.validators import IPV4Address @@ -1203,6 +1204,7 @@ class Router(NetworkNode): SYSTEM_SOFTWARE: ClassVar[Dict] = { "UserSessionManager": UserSessionManager, "UserManager": UserManager, + "Terminal": Terminal, } num_ports: int From 2e4a1c37d1708ba7d01e3c16005f81938e1f9796 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 10:34:06 +0100 Subject: [PATCH 098/206] #2777: Pre-commit fixes to test --- .../game_layer/test_RNG_seed.py | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/integration_tests/game_layer/test_RNG_seed.py b/tests/integration_tests/game_layer/test_RNG_seed.py index c1bb7bb0..0c6d567d 100644 --- a/tests/integration_tests/game_layer/test_RNG_seed.py +++ b/tests/integration_tests/game_layer/test_RNG_seed.py @@ -1,43 +1,50 @@ -from primaite.config.load import data_manipulation_config_path -from primaite.session.environment import PrimaiteGymEnv -from primaite.game.agent.interface import AgentHistoryItem -import yaml +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from pprint import pprint + import pytest +import yaml + +from primaite.config.load import data_manipulation_config_path +from primaite.game.agent.interface import AgentHistoryItem +from primaite.session.environment import PrimaiteGymEnv + @pytest.fixture() def create_env(): - with open(data_manipulation_config_path(), 'r') as f: + with open(data_manipulation_config_path(), "r") as f: cfg = yaml.safe_load(f) - env = PrimaiteGymEnv(env_config = cfg) + env = PrimaiteGymEnv(env_config=cfg) return env + def test_rng_seed_set(create_env): + """Test with RNG seed set.""" env = create_env env.reset(seed=3) for i in range(100): env.step(0) - a = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + a = [item.timestep for item in env.game.agents["client_2_green_user"].history if item.action != "DONOTHING"] env.reset(seed=3) for i in range(100): env.step(0) - b = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + b = [item.timestep for item in env.game.agents["client_2_green_user"].history if item.action != "DONOTHING"] + + assert a == b - assert a==b def test_rng_seed_unset(create_env): + """Test with no RNG seed.""" env = create_env env.reset() for i in range(100): env.step(0) - a = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] + a = [item.timestep for item in env.game.agents["client_2_green_user"].history if item.action != "DONOTHING"] env.reset() for i in range(100): env.step(0) - b = [item.timestep for item in env.game.agents['client_2_green_user'].history if item.action!="DONOTHING"] - - assert a!=b + b = [item.timestep for item in env.game.agents["client_2_green_user"].history if item.action != "DONOTHING"] + assert a != b From ca8e56873440eaffe09db6c037c434d478c91029 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 10:58:23 +0100 Subject: [PATCH 099/206] #2706 - Additional tests to check terminal login to/from networknodes. Redo of test to check that a router will block SSH traffic if no ACL rule. --- .../_system/_services/test_terminal.py | 189 +++++++++++------- 1 file changed, 117 insertions(+), 72 deletions(-) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 2f093dae..5010cd8f 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -10,6 +10,7 @@ from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -45,6 +46,72 @@ def basic_network() -> Network: return network +@pytest.fixture(scope="function") +def wireless_wan_network(): + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = WirelessRouter(hostname="router_1", start_up_duration=0, airspace=network.airspace) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_router_interface("192.168.0.1", "255.255.255.0") + network.connect(pc_a.network_interface[1], router_1.network_interface[2]) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = WirelessRouter(hostname="router_2", start_up_duration=0, airspace=network.airspace) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_router_interface("192.168.2.1", "255.255.255.0") + network.connect(pc_b.network_interface[1], router_2.network_interface[2]) + + # Configure Router 2 ACLs + + # Configure the wireless connection between Router 1 port 1 and Router 2 port 1 + router_1.configure_wireless_access_point("192.168.1.1", "255.255.255.0") + router_2.configure_wireless_access_point("192.168.1.2", "255.255.255.0") + + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + return pc_a, pc_b, router_1, router_2 + + @pytest.fixture def game_and_agent_fixture(game_and_agent): """Create a game with a simple agent that can be controlled by the tests.""" @@ -190,86 +257,64 @@ def test_terminal_ignores_when_off(basic_network): assert not term_a_on_term_b.execute(["software_manager", "application", "install", "RansomwareScript"]) -def test_network_simulation(basic_network): - # 0: Pull out the network - network = basic_network +def test_computer_remote_login_to_router(wireless_wan_network): + """Test to confirm that a computer can SSH into a router.""" + pc_a, pc_b, router_1, router_2 = wireless_wan_network - # 1: Set up network hardware - # 1.1: Configure the router - router = Router(hostname="router", num_ports=3, start_up_duration=0) - router.power_on() - router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") - router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) - # 1.2: Create and connect switches - switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) - switch_1.power_on() - network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) - router.enable_port(1) - switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) - switch_2.power_on() - network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) - router.enable_port(2) + pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") + pc_b_terminal: Terminal = pc_b.software_manager.software.get("Terminal") - # 1.3: Create and connect computer - client_1 = Computer( - hostname="client_1", - ip_address="10.0.1.2", - subnet_mask="255.255.255.0", - default_gateway="10.0.1.1", - start_up_duration=0, - ) - client_1.power_on() - network.connect( - endpoint_a=client_1.network_interface[1], - endpoint_b=switch_1.network_interface[1], - ) + router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") + router_2_terminal: Terminal = router_2.software_manager.software.get("Terminal") - client_2 = Computer( - hostname="client_2", - ip_address="10.0.2.2", - subnet_mask="255.255.255.0", - ) - client_2.power_on() - network.connect(endpoint_a=client_2.network_interface[1], endpoint_b=switch_2.network_interface[1]) + assert len(pc_a_terminal._connections) == 0 - # 1.4: Create and connect servers - server_1 = Server( - hostname="server_1", - ip_address="10.0.2.2", - subnet_mask="255.255.255.0", - default_gateway="10.0.2.1", - start_up_duration=0, - ) - server_1.power_on() - network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_2.network_interface[1]) + pc_a_on_router_1 = pc_a_terminal.login(username="username", password="password", ip_address="192.168.1.1") - server_2 = Server( - hostname="server_2", - ip_address="10.0.2.3", - subnet_mask="255.255.255.0", - default_gateway="10.0.2.1", - start_up_duration=0, - ) - server_2.power_on() - network.connect(endpoint_a=server_2.network_interface[1], endpoint_b=switch_2.network_interface[2]) + assert len(pc_a_terminal._connections) == 1 - # 2: Configure base ACL - router.acl.add_rule(action=ACLAction.DENY, src_port=Port.ARP, dst_port=Port.ARP, position=22) - router.acl.add_rule(action=ACLAction.DENY, protocol=IPProtocol.ICMP, position=23) - router.acl.add_rule(action=ACLAction.DENY, src_port=Port.DNS, dst_port=Port.DNS, position=1) - router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + payload = ["software_manager", "application", "install", "RansomwareScript"] - # 3: Install server software - server_1.software_manager.install(DNSServer) - dns_service: DNSServer = server_1.software_manager.software.get("DNSServer") # noqa - dns_service.dns_register("www.example.com", server_2.network_interface[1].ip_address) - server_2.software_manager.install(WebServer) + pc_a_on_router_1.execute(payload) - # 3.1: Ensure that the dns clients are configured correctly - client_1.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address - server_2.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address + assert router_1.software_manager.software.get("RansomwareScript") - terminal_1: Terminal = client_1.software_manager.software.get("Terminal") - assert terminal_1.login(username="admin", password="Admin123!", ip_address="10.0.2.2") is False +def test_router_remote_login_to_computer(wireless_wan_network): + """Test to confirm that a router can ssh into a computer.""" + pc_a, pc_b, router_1, router_2 = wireless_wan_network + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) + + pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") + pc_b_terminal: Terminal = pc_b.software_manager.software.get("Terminal") + + router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") + router_2_terminal: Terminal = router_2.software_manager.software.get("Terminal") + + assert len(router_1_terminal._connections) == 0 + + router_1_on_pc_a = router_1_terminal.login(username="username", password="password", ip_address="192.168.0.2") + + assert len(router_1_terminal._connections) == 1 + + payload = ["software_manager", "application", "install", "RansomwareScript"] + + router_1_on_pc_a.execute(payload) + + assert pc_a.software_manager.software.get("RansomwareScript") + + +def test_router_blocks_SSH_traffic(wireless_wan_network): + """Test to check that router will block SSH traffic if no ACL rule.""" + pc_a, _, _, router_2 = wireless_wan_network + + pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") + + assert len(pc_a_terminal._connections) == 0 + + pc_a_terminal.login(username="username", password="password", ip_address="192.168.0.2") + + assert len(pc_a_terminal._connections) == 0 From 7d7117e6246d96a46bae4a1a0c6c619c219a44b5 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 11:13:32 +0100 Subject: [PATCH 100/206] #2777: Merge with dev --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68745913..c52f4678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tests to verify that airspace bandwidth is applied correctly and can be configured via YAML - Agent logging for agents' internal decision logic - Action masking in all PrimAITE environments -- **Random Number Generator Seeding**: Added support for specifying a random number seed in the config file. +- Random Number Generator Seeding by specifying a random number seed in the config file. ### Changed - Application registry was moved to the `Application` class and now updates automatically when Application is subclassed - Databases can no longer respond to request while performing a backup From 972b0b9712e7f3f95da5a7e28b5e7c05289b4817 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 11:19:27 +0100 Subject: [PATCH 101/206] #2706 - Added another test demonstrating an SSH connection across a network. Actioned some review comments and a minor change to other ACL Terminal tests --- .../_system/_services/test_terminal.py | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 5010cd8f..794e88bf 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -29,7 +29,7 @@ def terminal_on_computer() -> Tuple[Terminal, Computer]: computer.power_on() terminal: Terminal = computer.software_manager.software.get("Terminal") - return [terminal, computer] + return terminal, computer @pytest.fixture(scope="function") @@ -74,6 +74,9 @@ def wireless_wan_network(): router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + # add ACL rule to allow SSH traffic + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) + # Configure PC B pc_b = Computer( hostname="pc_b", @@ -120,7 +123,7 @@ def game_and_agent_fixture(game_and_agent): client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") client_1.start_up_duration = 3 - return (game, agent) + return game, agent def test_terminal_creation(terminal_on_computer): @@ -259,15 +262,9 @@ def test_terminal_ignores_when_off(basic_network): def test_computer_remote_login_to_router(wireless_wan_network): """Test to confirm that a computer can SSH into a router.""" - pc_a, pc_b, router_1, router_2 = wireless_wan_network - - router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) + pc_a, _, router_1, _ = wireless_wan_network pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") - pc_b_terminal: Terminal = pc_b.software_manager.software.get("Terminal") - - router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") - router_2_terminal: Terminal = router_2.software_manager.software.get("Terminal") assert len(pc_a_terminal._connections) == 0 @@ -284,15 +281,11 @@ def test_computer_remote_login_to_router(wireless_wan_network): def test_router_remote_login_to_computer(wireless_wan_network): """Test to confirm that a router can ssh into a computer.""" - pc_a, pc_b, router_1, router_2 = wireless_wan_network + pc_a, _, router_1, _ = wireless_wan_network - router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) - - pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") - pc_b_terminal: Terminal = pc_b.software_manager.software.get("Terminal") + router_1: Router = router_1 router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") - router_2_terminal: Terminal = router_2.software_manager.software.get("Terminal") assert len(router_1_terminal._connections) == 0 @@ -309,7 +302,12 @@ def test_router_remote_login_to_computer(wireless_wan_network): def test_router_blocks_SSH_traffic(wireless_wan_network): """Test to check that router will block SSH traffic if no ACL rule.""" - pc_a, _, _, router_2 = wireless_wan_network + pc_a, _, router_1, _ = wireless_wan_network + + router_1: Router = router_1 + + # Remove rule that allows SSH traffic. + router_1.acl.remove_rule(position=21) pc_a_terminal: Terminal = pc_a.software_manager.software.get("Terminal") @@ -318,3 +316,19 @@ def test_router_blocks_SSH_traffic(wireless_wan_network): pc_a_terminal.login(username="username", password="password", ip_address="192.168.0.2") assert len(pc_a_terminal._connections) == 0 + + +def test_SSH_across_network(wireless_wan_network): + """Test to show ability to SSH across a network.""" + pc_a, pc_b, router_1, router_2 = wireless_wan_network + + terminal_a: Terminal = pc_a.software_manager.software.get("Terminal") + terminal_b: Terminal = pc_b.software_manager.software.get("Terminal") + + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=21) + + assert len(terminal_a._connections) == 0 + + terminal_b_on_terminal_a = terminal_b.login(username="username", password="password", ip_address="192.168.0.2") + + assert len(terminal_a._connections) == 1 From 966542c2ca1b00d128594ae4afdd638d45160972 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 15:08:31 +0100 Subject: [PATCH 102/206] #2777: Add determinism to torch backends when seed set. --- src/primaite/session/environment.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 359932c7..a12d2eb7 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -44,6 +44,10 @@ def set_random_seed(seed: int) -> Union[None, int]: # if torch not installed don't set random seed. if sys.modules["torch"]: th.manual_seed(seed) + + th.backends.cudnn.deterministic = True + th.backends.cudnn.benchmark = False + return seed From d059ddceaba77ac60ed9f24b4120e3375bfc384c Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 15:11:57 +0100 Subject: [PATCH 103/206] #2777: Remove debug print statement --- src/primaite/game/agent/scripted_agents/probabilistic_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py index ce1da3f2..ab2e69ef 100644 --- a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py +++ b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py @@ -63,7 +63,6 @@ class ProbabilisticAgent(AbstractScriptedAgent): self.settings = ProbabilisticAgent.Settings(**settings) rng_seed = np.random.randint(0, 65535) self.rng = np.random.default_rng(rng_seed) - print(f"Probabilistic Agent - rng_seed: {rng_seed}") # convert probabilities from self.probabilities = np.asarray(list(self.settings.action_probabilities.values())) From 4fe9753fcf5f80b776ebcb893492eca56e566556 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 15:44:52 +0100 Subject: [PATCH 104/206] #2706 - Updated terminal.receive() to work with SSHPacket class, fixed some tests and updated RemoteTerminalConnection to hold Source_IP for easier reading --- .../system/services/terminal.rst | 16 +- .../notebooks/Terminal-Processing.ipynb | 103 ++++++---- .../system/services/terminal/terminal.py | 186 +++++++++++++----- tests/integration_tests/system/test_nmap.py | 2 +- .../_system/_services/test_terminal.py | 18 +- 5 files changed, 222 insertions(+), 103 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 4b02a6db..37872b5b 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -19,7 +19,6 @@ installed on Nodes when they are instantiated. Key capabilities ================ - - Authenticates User connection by maintaining an active User account. - Ensures packets are matched to an existing session - Simulates common Terminal processes/commands. - Leverages the Service base class for install/uninstall, status tracking etc. @@ -27,21 +26,18 @@ Key capabilities Usage ===== - - Pre-Installs on any `HostNode` component. See the below code example of how to access the terminal. - - Terminal Clients connect, execute commands and disconnect from remote components. + - Pre-Installs on any `Node` (component with the exception of `Switches`). + - Terminal Clients connect, execute commands and disconnect from remote nodes. - Ensures that users are logged in to the component before executing any commands. - Service runs on SSH port 22 by default. Implementation ============== -The terminal takes inspiration from the `Database Client` and `Database Service` classes, and leverages the `UserSessionManager` -to provide User Credential authentication when receiving/processing commands. - -Terminal acts as the interface between the user/component and both the Session and Requests Managers, facilitating -the passing of requests to both. - -A more detailed example of how to use the Terminal class can be found in the Terminal-Processing jupyter notebook. + - Manages remote connections in a dictionary by session ID. + - Processes commands, forwarding to the ``RequestManager`` or ``SessionManager`` where appropriate. + - Extends Service class. + - A detailed guide on the implementation and functionality of the Terminal class can be found in the "Terminal-Processing" jupyter notebook. Python """""" diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 77be3822..30b1a5e7 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -15,7 +15,7 @@ "source": [ "This notebook serves as a guide on the functionality and use of the new Terminal simulation component.\n", "\n", - "By default, the Terminal will come pre-installed on any simulation component which inherits from `HostNode` (Computer, Server, Printer), and simulates the Secure Shell (SSH) protocol as the communication method." + "The Terminal service comes pre-installed on most Nodes (The exception being Switches, as these are currently dumb). " ] }, { @@ -27,15 +27,9 @@ "from primaite.simulator.system.services.terminal.terminal import Terminal\n", "from primaite.simulator.network.container import Network\n", "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", - "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", + "from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection\n", + "\n", "def basic_network() -> Network:\n", " \"\"\"Utility function for creating a default network to demonstrate Terminal functionality\"\"\"\n", " network = Network()\n", @@ -51,9 +45,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The terminal can be accessed from a `HostNode` via the `software_manager` as demonstrated below. \n", + "The terminal can be accessed from a `Node` via the `software_manager` as demonstrated below. \n", "\n", - "In the example, we have a basic network consisting of two computers " + "In the example, we have a basic network consisting of two computers, connected to form a basic network." ] }, { @@ -66,15 +60,17 @@ "computer_a: Computer = network.get_node_by_hostname(\"node_a\")\n", "terminal_a: Terminal = computer_a.software_manager.software.get(\"Terminal\")\n", "computer_b: Computer = network.get_node_by_hostname(\"node_b\")\n", - "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")\n" + "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To be able to send commands from `node_a` to `node_b`, you will need to `login` to `node_b` first, using valid user credentials. In the example below, we are logging in to the 'admin' account on `node_b`. \n", - "If you are not logged in, any commands sent will be rejected." + "To be able to send commands from `node_a` to `node_b`, you will need to `login` to `node_b` first, using valid user credentials. In the example below, we are remotely logging in to the 'admin' account on `node_b`, from `node_a`. \n", + "If you are not logged in, any commands sent will be rejected by the remote.\n", + "\n", + "Remote Logins return a RemoteTerminalConnection object, which can be used for sending commands to the remote node. " ] }, { @@ -84,10 +80,14 @@ "outputs": [], "source": [ "# Login to the remote (node_b) from local (node_a)\n", - "from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection\n", - "\n", - "\n", - "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=\"192.168.0.11\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can view all active connections to a terminal through use of the `show()` method" ] }, { @@ -96,7 +96,14 @@ "metadata": {}, "outputs": [], "source": [ - "computer_b.software_manager.show()" + "terminal_b.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The new connection object allows us to forward commands to be executed on the target node. The example below demonstrates how you can remotely install an application on the target node." ] }, { @@ -105,7 +112,6 @@ "metadata": {}, "outputs": [], "source": [ - "print(type(term_a_term_b_remote_connection))\n", "term_a_term_b_remote_connection.execute([\"software_manager\", \"application\", \"install\", \"RansomwareScript\"])" ] }, @@ -122,7 +128,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can view all remote connections to a terminal through use of the `show()` method" + "The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to create a downloads folder. \n" ] }, { @@ -131,23 +137,11 @@ "metadata": {}, "outputs": [], "source": [ - "terminal_b.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The Terminal can be used to send requests to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The below example shows how you can send a command via the terminal to create a folder on the target Node.\n", + "# Display the current state of the file system on computer_b\n", + "computer_b.file_system.show()\n", "\n", - "Here, we send a command to `computer_b` to create a new folder titled \"Downloads\"." + "# Send command\n", + "term_a_term_b_remote_connection.execute([\"file_system\", \"create\", \"folder\", \"downloads\"])" ] }, { @@ -156,6 +150,39 @@ "source": [ "The resultant call to `computer_b.file_system.show()` shows that the new folder has been created." ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.file_system.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When finished, the connection can be closed by calling the `disconnect` function of the Remote Client object" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display active connection\n", + "terminal_a.show()\n", + "terminal_b.show()\n", + "\n", + "term_a_term_b_remote_connection.disconnect()\n", + "\n", + "terminal_a.show()\n", + "\n", + "terminal_b.show()" + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index b7bc5287..0f8e180e 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -2,7 +2,7 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable @@ -10,7 +10,12 @@ from pydantic import BaseModel from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.network.protocols.ssh import SSHPacket +from primaite.simulator.network.protocols.ssh import ( + SSHConnectionMessage, + SSHPacket, + SSHTransportMessage, + SSHUserCredentials, +) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager @@ -33,6 +38,9 @@ class TerminalClientConnection(BaseModel): connection_uuid: str = None """Connection UUID""" + connection_request_id: str = None + """Connection request ID""" + @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -51,6 +59,9 @@ class RemoteTerminalConnection(TerminalClientConnection): """ + source_ip: IPv4Address + """Source IP of Connection""" + def execute(self, command: Any) -> bool: """Execute a given command on the remote Terminal.""" if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: @@ -88,13 +99,13 @@ class Terminal(Service): :param markdown: Whether to display the table in Markdown format or not. Default is `False`. """ - table = PrettyTable(["Connection ID", "Session_ID"]) + table = PrettyTable(["Connection ID", "Connection request ID", "Source IP"]) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.sys_log.hostname} {self.name} Connections" for connection_id, connection in self._connections.items(): - table.add_row([connection_id, connection.session_id]) + table.add_row([connection_id, connection.connection_request_id, connection.source_ip]) print(table.get_string(sortby="Connection ID")) def _init_request_manager(self) -> RequestManager: @@ -130,7 +141,7 @@ class Terminal(Service): connection_uuid = request[0] # TODO: Uncomment this when UserSessionManager merged. # self.parent.UserSessionManager.logoff(connection_uuid) - self.disconnect(connection_uuid) + self._disconnect(connection_uuid) return RequestResponse(status="success", data={}) @@ -157,8 +168,13 @@ class Terminal(Service): """Execute a passed ssh command via the request manager.""" return self.parent.apply_request(command) - def _create_local_connection(self, connection_uuid: str, session_id: str) -> RemoteTerminalConnection: - """Create a new connection object and amend to list of active connections.""" + def _create_local_connection(self, connection_uuid: str, session_id: str) -> TerminalClientConnection: + """Create a new connection object and amend to list of active connections. + + :param connection_uuid: Connection ID of the new local connection + :param session_id: Session ID of the new local connection + :return: TerminalClientConnection object + """ new_connection = TerminalClientConnection( parent_terminal=self, connection_uuid=connection_uuid, @@ -172,7 +188,17 @@ class Terminal(Service): def login( self, username: str, password: str, ip_address: Optional[IPv4Address] = None ) -> Optional[TerminalClientConnection]: - """Login to the terminal. Will attempt a remote login if ip_address is given, else local.""" + """Login to the terminal. Will attempt a remote login if ip_address is given, else local. + + :param: username: Username used to connect to the remote node. + :type: username: str + + :param: password: Password used to connect to the remote node + :type: password: str + + :param: ip_address: Target Node IP address for login attempt. If None, login is assumed local. + :type: ip_address: Optional[IPv4Address] + """ if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning("Cannot login as service is not running.") return None @@ -199,8 +225,10 @@ class Terminal(Service): if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections - self._create_local_connection(connection_uuid=connection_uuid, session_id="") - return TerminalClientConnection(parent_terminal=self, session_id="", connection_uuid=connection_uuid) + self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection") + return TerminalClientConnection( + parent_terminal=self, session_id="Local_Connection", connection_uuid=connection_uuid + ) else: self.sys_log.warning("Login failed, incorrect Username or Password") return None @@ -217,7 +245,26 @@ class Terminal(Service): connection_request_id: str, is_reattempt: bool = False, ) -> Optional[RemoteTerminalConnection]: - """Process a remote login attempt.""" + """Send a remote login attempt and connect to Node. + + :param: username: Username used to connect to the remote node. + :type: username: str + + :param: password: Password used to connect to the remote node + :type: password: str + + :param: ip_address: Target Node IP address for login attempt. + :type: ip_address: IPv4Address + + :param: connection_request_id: Connection Request ID + :type: connection_request_id: str + + :param: is_reattempt: True if the request has been reattempted. Default False. + :type: is_reattempt: Optional[bool] + + :return: RemoteTerminalConnection: Connection Object for sending further commands if successful, else False. + + """ self.sys_log.info(f"Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}") if is_reattempt: valid_connection = self._check_client_connection(connection_id=connection_request_id) @@ -229,12 +276,24 @@ class Terminal(Service): self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") return None - payload = { + transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST + connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + user_details: SSHUserCredentials = SSHUserCredentials(username=username, password=password) + + payload_contents = { "type": "login_request", "username": username, "password": password, "connection_request_id": connection_request_id, } + + payload: SSHPacket = SSHPacket( + payload=payload_contents, + transport_message=transport_message, + connection_message=connection_message, + user_account=user_details, + ) + software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( payload=payload, dest_ip_address=ip_address, dest_port=self.port @@ -247,15 +306,28 @@ class Terminal(Service): connection_request_id=connection_request_id, ) - def _create_remote_connection(self, connection_id: str, connection_request_id: str, session_id: str) -> None: - """Create a new TerminalClientConnection Object.""" + def _create_remote_connection( + self, connection_id: str, connection_request_id: str, session_id: str, source_ip: str + ) -> None: + """Create a new TerminalClientConnection Object. + + :param: connection_request_id: Connection Request ID + :type: connection_request_id: str + + :param: session_id: Session ID of connection. + :type: session_id: str + """ client_connection = RemoteTerminalConnection( - parent_terminal=self, session_id=session_id, connection_uuid=connection_id + parent_terminal=self, + session_id=session_id, + connection_uuid=connection_id, + source_ip=source_ip, + connection_request_id=connection_request_id, ) self._connections[connection_id] = client_connection self._client_connection_requests[connection_request_id] = client_connection - def receive(self, session_id: str, payload: Any, **kwargs) -> bool: + def receive(self, session_id: str, payload: Union[SSHPacket, Dict, List], **kwargs) -> bool: """ Receive a payload from the Software Manager. @@ -263,42 +335,62 @@ class Terminal(Service): :param session_id: The session id the payload relates to. :return: True. """ - self.sys_log.info(f"Received payload: {payload}") - if isinstance(payload, dict) and payload.get("type"): - if payload["type"] == "login_request": - # add connection - connection_request_id = payload["connection_request_id"] - username = payload["username"] - password = payload["password"] - print(f"Connection ID is: {connection_request_id}") - self.sys_log.info(f"Connection authorised, session_id: {session_id}") + source_ip = kwargs["from_network_interface"].ip_address + self.sys_log.info(f"Received payload: {payload}. Source: {source_ip}") + if isinstance(payload, SSHPacket): + if payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: + # validate & add connection + # TODO: uncomment this as part of 2781 + # connection_id = self.parent.UserSessionManager.login(username=username, password=password) + connection_id = str(uuid4()) + if connection_id: + connection_request_id = payload.connection_request_uuid + username = payload.user_account.username + password = payload.user_account.password + print(f"Connection ID is: {connection_request_id}") + self.sys_log.info(f"Connection authorised, session_id: {session_id}") + self._create_remote_connection( + connection_id=connection_id, + connection_request_id=connection_request_id, + session_id=session_id, + source_ip=source_ip, + ) + + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + + payload_contents = { + "type": "login_success", + "username": username, + "password": password, + "connection_request_id": connection_request_id, + "connection_id": connection_id, + } + payload: SSHPacket = SSHPacket( + payload=payload_contents, + transport_message=transport_message, + connection_message=connection_message, + connection_request_uuid=connection_request_id, + connection_uuid=connection_id, + ) + + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_port=self.port, session_id=session_id + ) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + self.sys_log.info("Login Successful") self._create_remote_connection( - connection_id=connection_request_id, - connection_request_id=payload["connection_request_id"], + connection_id=payload.connection_uuid, + connection_request_id=payload.connection_request_uuid, session_id=session_id, - ) - payload = { - "type": "login_success", - "username": username, - "password": password, - "connection_request_id": connection_request_id, - } - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload=payload, dest_port=self.port, session_id=session_id - ) - elif payload["type"] == "login_success": - self.sys_log.info(f"Login was successful! session_id is:{session_id}") - connection_request_id = payload["connection_request_id"] - self._create_remote_connection( - connection_id=connection_request_id, - session_id=session_id, - connection_request_id=connection_request_id, + source_ip=source_ip, ) - elif payload["type"] == "disconnect": + if isinstance(payload, dict) and payload.get("type"): + if payload["type"] == "disconnect": connection_id = payload["connection_id"] - self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from the server") + self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.") self._disconnect(payload["connection_id"]) if isinstance(payload, list): diff --git a/tests/integration_tests/system/test_nmap.py b/tests/integration_tests/system/test_nmap.py index 08251d71..2b8691cc 100644 --- a/tests/integration_tests/system/test_nmap.py +++ b/tests/integration_tests/system/test_nmap.py @@ -107,7 +107,7 @@ def test_port_scan_full_subnet_all_ports_and_protocols(example_network): expected_result = { IPv4Address("192.168.10.1"): {IPProtocol.UDP: [Port.ARP]}, IPv4Address("192.168.10.22"): { - IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS, Port.SSH], + IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS], IPProtocol.UDP: [Port.ARP, Port.NTP], }, } diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 794e88bf..c86d6466 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -1,5 +1,6 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from typing import Tuple +from uuid import uuid4 import pytest @@ -11,7 +12,12 @@ from primaite.simulator.network.hardware.nodes.host.server import Server from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +from primaite.simulator.network.protocols.ssh import ( + SSHConnectionMessage, + SSHPacket, + SSHTransportMessage, + SSHUserCredentials, +) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript @@ -155,8 +161,10 @@ def test_terminal_send(basic_network): payload: SSHPacket = SSHPacket( payload="Test_Payload", - transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + transport_message=SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA, + user_account=SSHUserCredentials(username="username", password="password"), + connection_request_uuid=str(uuid4()), ) assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) @@ -283,8 +291,6 @@ def test_router_remote_login_to_computer(wireless_wan_network): """Test to confirm that a router can ssh into a computer.""" pc_a, _, router_1, _ = wireless_wan_network - router_1: Router = router_1 - router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") assert len(router_1_terminal._connections) == 0 @@ -304,8 +310,6 @@ def test_router_blocks_SSH_traffic(wireless_wan_network): """Test to check that router will block SSH traffic if no ACL rule.""" pc_a, _, router_1, _ = wireless_wan_network - router_1: Router = router_1 - # Remove rule that allows SSH traffic. router_1.acl.remove_rule(position=21) From 63a689d94afa88ada4085984deaa2db8235e55a1 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 5 Aug 2024 16:25:35 +0100 Subject: [PATCH 105/206] #2706 - correcting test failures --- src/primaite/simulator/system/services/terminal/terminal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 0f8e180e..274353ed 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -292,6 +292,7 @@ class Terminal(Service): transport_message=transport_message, connection_message=connection_message, user_account=user_details, + connection_request_uuid=connection_request_id, ) software_manager: SoftwareManager = self.software_manager From b4893c44989ba498e31ca1e47fcd94685e0f9301 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 5 Aug 2024 16:27:53 +0100 Subject: [PATCH 106/206] #2769 - Add remote ip as action parameter --- src/primaite/game/agent/actions.py | 33 ++++++++++++++++--- .../test_remote_user_account_actions.py | 2 +- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 7c908f42..2ddeff3d 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1079,7 +1079,18 @@ class NodeAccountsChangePasswordAction(AbstractAction): def form_request(self, node_id: str, username: str, current_password: str, new_password: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "accounts", "change_password", username, current_password, new_password] + node_name = self.manager.get_node_name_by_idx(node_id) + return [ + "network", + "node", + node_name, + "service", + "UserManager", + "change_password", + username, + current_password, + new_password, + ] class NodeSessionsRemoteLoginAction(AbstractAction): @@ -1088,9 +1099,21 @@ class NodeSessionsRemoteLoginAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: str, username: str, password: str) -> RequestFormat: + def form_request(self, node_id: str, username: str, password: str, remote_ip: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "sessions", "remote_login", username, password] + # TODO: change this so it creates a remote connection using terminal rather than a local remote login + node_name = self.manager.get_node_name_by_idx(node_id) + return [ + "network", + "node", + node_name, + "service", + "UserSessionManager", + "remote_login", + username, + password, + remote_ip, + ] class NodeSessionsRemoteLogoutAction(AbstractAction): @@ -1101,7 +1124,9 @@ class NodeSessionsRemoteLogoutAction(AbstractAction): def form_request(self, node_id: str, remote_session_id: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - return ["network", "node", node_id, "sessions", "remote_logout", remote_session_id] + # TODO: change this so it destroys a remote connection using terminal rather than a local remote login + node_name = self.manager.get_node_name_by_idx(node_id) + return ["network", "node", node_name, "service", "UserSessionManager", "remote_logout", remote_session_id] class ActionManager: diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py index 2e282d77..25079226 100644 --- a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py +++ b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py @@ -12,7 +12,7 @@ def test_remote_logon(game_and_agent): action = ( "NODE_SESSIONS_REMOTE_LOGIN", - {"node_id": 0, "username": "test_user", "password": "password"}, + {"node_id": 0, "username": "test_user", "password": "password", "remote_ip": "10.0.2.2"}, ) agent.store_action(action) game.step() From 3253dd80547125635c8c13693689f15bbafc6e67 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 16:27:54 +0100 Subject: [PATCH 107/206] #2777: Update test --- .../_primaite/_game/_agent/test_probabilistic_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py b/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py index f3b3c6eb..ec18f1fb 100644 --- a/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py +++ b/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py @@ -62,7 +62,6 @@ def test_probabilistic_agent(): reward_function=reward_function, settings={ "action_probabilities": {0: P_DO_NOTHING, 1: P_NODE_APPLICATION_EXECUTE, 2: P_NODE_FILE_DELETE}, - "random_seed": 120, }, ) From 4ae0275dc9e6b6a46ab4aa6515d056f0d4f96c88 Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Mon, 5 Aug 2024 16:53:48 +0100 Subject: [PATCH 108/206] #2689 Implemented initial agent actions and started on documentations. A few TODO's left to do such as validation and expanding unit tests. --- .../system/applications/c2_suite.rst | 145 ++++++++++++ .../red_applications/c2/abstract_c2.py | 4 +- .../red_applications/c2/c2_beacon.py | 19 +- .../red_applications/c2/c2_server.py | 17 +- .../red_applications/ransomware_script.py | 24 ++ .../_red_applications/test_c2_suite.py | 224 +++++++++++++++++- 6 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 docs/source/simulation_components/system/applications/c2_suite.rst diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst new file mode 100644 index 00000000..c360d0be --- /dev/null +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -0,0 +1,145 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +.. _C2_Suite: + +Command and Control Application Suite +##################################### + +Comprising of two applications, the command and control (C2) suites intends to introduce +malicious network architecture and begin to further the realism of red agents within primAITE. + +Overview: +========= + +These two new classes intend to Red Agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application. +Whilst introducing both more oppourtinies for the blue agent to notice and subvert Red Agents during an episode. + +For a more in-depth look at the command and control applications then please refer to the ``C2-E2E-Notebook``. + +``C2 Server`` +"""""""""""" + +The C2 Server application is intended to represent the malicious infrastructure already under the control of an adversary. + +The C2 Server is configured to listen and await ``keep alive`` traffic from a c2 beacon. Once received the C2 Server is able to send and receive c2 commands. + +Currently, the C2 Server offers three commands: + ++---------------------+---------------------------------------------------------------------------+ +|C2 Command | Meaning | ++=====================+===========================================================================+ +|RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. | ++---------------------+---------------------------------------------------------------------------+ +|RANSOMWARE_LAUNCH | Launches the installed ransomware script. | ++---------------------+---------------------------------------------------------------------------+ +|TERMINAL_COMMAND | Executes a command via the terminal installed on the C2 Beacons Host. | ++---------------------+---------------------------------------------------------------------------+ + + +It's important to note that in order to keep the PrimAITE realistic from a cyber perspective, +The C2 Server application should never be visible or actionable upon directly by the blue agent. + +This is because in the real world, C2 servers are hosted on ephemeral public domains that would not be accessible by private network blue agent. +Therefore granting a blue agent's the ability to perform counter measures directly against the application would be unrealistic. + +It is more accurate to see the host that the C2 Server is installed on as being able to route to the C2 Server (Internet Access). + +``C2 Beacon`` +""""""""""""" + +The C2 Beacon application is intended to represent malware that is used to establish and maintain contact to a C2 Server within a compromised network. + +A C2 Beacon will need to be first configured with the C2 Server IP Address which can be done via the ``configure`` method. + +Once installed and configured; the c2 beacon can establish connection with the C2 Server via executing the application. + +This will send an initial ``keep alive`` to the given C2 Server (The C2 Server IPv4Address must be given upon C2 Beacon configuration). +Which is then resolved and responded by another ``Keep Alive`` by the c2 server back to the C2 beacon to confirm connection. + +The C2 Beacon will send out periodic keep alive based on it's configuration parameters to configure it's active connection with the c2 server. + +It's recommended that a C2 Beacon is installed and configured mid episode by a Red Agent for a more cyber realistic simulation. + +Usage +===== + +As mentioned, the C2 Suite is intended to grant Red Agents further flexibility whilst also expanding a blue agent's observation_space. + +Adding to this, the following behaviour of the C2 beacon can be configured by users for increased domain randomisation: + +- Frequency of C2 ``Keep Alive `` Communication`` +- C2 Communication Port +- C2 Communication Protocol + + +Implementation +============== + +Both applications inherit from an abstract C2 which handles the keep alive functionality and main logic. +However, each host implements it's receive methods individually. + +- The ``C2 Beacon`` is responsible for the following logic: + - Establishes and confirms connection to the C2 Server via sending ``C2Payload.KEEP_ALIVE``. + - Receives and executes C2 Commands given by the C2 Server via ``C2Payload.INPUT``. + - Returns the RequestResponse of the C2 Commands executed back the C2 Server via ``C2Payload.OUTPUT``. + +- The ``C2 Server`` is responsible for the following logic: + - Listens and resolves connection to a C2 Beacon via responding to ``C2Payload.KEEP_ALIVE``. + - Sends C2 Commands to the C2 Beacon via ``C2Payload.INPUT``. + - Receives the RequestResponse of the C2 Commands executed by C2 Beacon via ``C2Payload.OUTPUT``. + + + +Examples +======== + +Python +"""""" +.. code-block:: python + from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon + from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server + from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + # Network Setup + + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_a.software_manager.install(software_class=C2Server) + node_a.software_manager.get_open_ports() + + + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + node_b.software_manager.install(software_class=C2Beacon) + node_b.software_manager.install(software_class=RansomwareScript) + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + + # C2 Application objects + + c2_server_host = simulation_testing_network.get_node_by_hostname("node_a") + c2_beacon_host = simulation_testing_network.get_node_by_hostname("node_b") + + + c2_server: C2Server = c2_server_host.software_manager.software["C2Server"] + c2_beacon: C2Beacon = c2_beacon_host.software_manager.software["C2Beacon"] + + # Configuring the C2 Beacon + c2_beacon.configure(c2_server_ip_address="192.168.0.10", keep_alive_frequency=5) + + # Launching the C2 Server (Needs to be running in order to listen for connections) + c2_server.run() + + # Establishing connection + c2_beacon.establish() + + # Example command: Configuring Ransomware + + ransomware_config = {"server_ip_address": "1.1.1.1"} + c2_server._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) + + +For a more in-depth look at the command and control applications then please refer to the ``C2-Suite-E2E-Notebook``. diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 89ab7953..9158d80f 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -213,7 +213,7 @@ class AbstractC2(Application, identifier="AbstractC2"): return True else: self.sys_log.warning( - f"{self.name}: failed to send a Keep Alive. The node may be unable to access the ``network." + f"{self.name}: failed to send a Keep Alive. The node may be unable to access networking resources." ) return False @@ -251,7 +251,7 @@ class AbstractC2(Application, identifier="AbstractC2"): # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) if self.c2_remote_connection is None: self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") - self.c2_remote_connection = self.current_c2_session.with_ip_address + self.c2_remote_connection = IPv4Address(self.current_c2_session.with_ip_address) self.c2_connection_active = True # Sets the connection to active self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index b00b7c57..16420164 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -13,6 +13,7 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload +from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.software import SoftwareHealthState @@ -44,8 +45,6 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): # TODO: # Implement the placeholder command methods - # Implement the keep alive frequency. - # Implement a command output method that sends the RequestResponse to the C2 server. # Uncomment the terminal Import and the terminal property after terminal PR # @property @@ -56,6 +55,14 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): # self.sys_log.warning(f"{self.__class__.__name__} cannot find a terminal on its host.") # return host_terminal + @property + def _host_ransomware_script(self) -> RansomwareScript: + """Return the RansomwareScript that is installed on the same machine as the C2 Beacon.""" + ransomware_script: RansomwareScript = self.software_manager.software.get("RansomwareScript") + if ransomware_script is None: + self.sys_log.warning(f"{self.__class__.__name__} cannot find installed ransomware on its host.") + return ransomware_script + def _init_request_manager(self) -> RequestManager: """ Initialise the request manager. @@ -87,6 +94,7 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): ) c2_remote_ip = IPv4Address(c2_remote_ip) + # TODO: validation. frequency = request[-1].get("keep_alive_frequency") protocol = request[-1].get("masquerade_protocol") port = request[-1].get("masquerade_port") @@ -127,7 +135,7 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. :type masquerade_port: Enum (Port) """ - self.c2_remote_connection = c2_server_ip_address + self.c2_remote_connection = IPv4Address(c2_server_ip_address) self.keep_alive_frequency = keep_alive_frequency self.current_masquerade_port = masquerade_port self.current_masquerade_protocol = masquerade_protocol @@ -252,7 +260,10 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): :rtype: Request Response """ # TODO: replace and use terminal - return RequestResponse(status="success", data={"Reason": "Placeholder."}) + # return RequestResponse(status="success", data={"Reason": "Placeholder."}) + given_config = payload.payload + host_ransomware = self._host_ransomware_script + return RequestResponse.from_bool(host_ransomware.configure(server_ip_address=given_config["server_ip_address"])) def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: """ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index c29cd271..d01cd412 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -48,8 +48,8 @@ class C2Server(AbstractC2, identifier="C2 Server"): :rtype: RequestResponse """ # TODO: Parse the parameters from the request to get the parameters - placeholder: dict = {} - return self._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=placeholder) + ransomware_config = {"server_ip_address": request[-1].get("server_ip_address")} + return self._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a RANSOMWARE_LAUNCH C2Command to the C2 Beacon with the given parameters. @@ -61,9 +61,7 @@ class C2Server(AbstractC2, identifier="C2 Server"): :return: RequestResponse object with a success code reflecting whether the ransomware was launched. :rtype: RequestResponse """ - # TODO: Parse the parameters from the request to get the parameters - placeholder: dict = {} - return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options=placeholder) + return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options={}) def _remote_terminal_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a TERMINAL C2Command to the C2 Beacon with the given parameters. @@ -77,18 +75,18 @@ class C2Server(AbstractC2, identifier="C2 Server"): """ # TODO: Parse the parameters from the request to get the parameters placeholder: dict = {} - return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options=placeholder) + return self._send_command(given_command=C2Command.TERMINAL, command_options=placeholder) rm.add_request( - name="c2_ransomware_configure", + name="ransomware_configure", request_type=RequestType(func=_configure_ransomware_action), ) rm.add_request( - name="c2_ransomware_launch", + name="ransomware_launch", request_type=RequestType(func=_launch_ransomware_action), ) rm.add_request( - name="c2_terminal_command", + name="terminal_command", request_type=RequestType(func=_remote_terminal_action), ) return rm @@ -203,7 +201,6 @@ class C2Server(AbstractC2, identifier="C2 Server"): self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") command_packet = self._craft_packet(given_command=given_command, command_options=command_options) - # Need to investigate if this is correct. if self.send( payload=command_packet, dest_ip_address=self.c2_remote_connection, diff --git a/src/primaite/simulator/system/applications/red_applications/ransomware_script.py b/src/primaite/simulator/system/applications/red_applications/ransomware_script.py index 77a6bf2c..2046affc 100644 --- a/src/primaite/simulator/system/applications/red_applications/ransomware_script.py +++ b/src/primaite/simulator/system/applications/red_applications/ransomware_script.py @@ -2,6 +2,8 @@ from ipaddress import IPv4Address from typing import Dict, Optional +from prettytable import MARKDOWN, PrettyTable + from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.transmission.network_layer import IPProtocol @@ -169,3 +171,25 @@ class RansomwareScript(Application, identifier="RansomwareScript"): else: self.sys_log.warning("Attack Attempted to launch too quickly") return False + + def show(self, markdown: bool = False): + """ + Prints a table of the current status of the Ransomware Script. + + Displays the current values of the following Ransomware Attributes: + + ``server_ip_address`: + The IP of the target database. + + ``payload``: + The payload (type of attack) to be sent to the database. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["Target Server IP Address", "Payload"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.name} Running Status" + table.add_row([self.server_ip_address, self.payload]) + print(table) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 7f869e92..064ef57d 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -1,4 +1,5 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from ipaddress import IPv4Address from typing import Tuple import pytest @@ -12,14 +13,13 @@ from primaite.simulator.network.hardware.nodes.network.router import ACLAction, from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server +from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.dns.dns_server import DNSServer -from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.web_server.web_server import WebServer -# TODO: Update these tests. - @pytest.fixture(scope="function") def c2_server_on_computer() -> Tuple[C2Beacon, Computer]: @@ -60,7 +60,7 @@ def basic_network() -> Network: def test_c2_suite_setup_receive(basic_network): - """Test that C2 Beacon can successfully establish connection with the c2 Server.""" + """Test that C2 Beacon can successfully establish connection with the C2 Server.""" network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") c2_server: C2Server = computer_a.software_manager.software.get("C2Server") @@ -68,7 +68,221 @@ def test_c2_suite_setup_receive(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + # Assert that the c2 beacon configure correctly. c2_beacon.configure(c2_server_ip_address="192.168.0.10") + assert c2_beacon.c2_remote_connection == IPv4Address("192.168.0.10") + + c2_server.run() c2_beacon.establish() - c2_beacon.sys_log.show() + # Asserting that the c2 beacon has established a c2 connection + assert c2_beacon.c2_connection_active is True + + # Asserting that the c2 server has established a c2 connection. + assert c2_server.c2_connection_active is True + assert c2_server.c2_remote_connection == IPv4Address("192.168.0.11") + + +def test_c2_suite_keep_alive_inactivity(basic_network): + """Tests that C2 Beacon disconnects from the C2 Server after inactivity.""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = network.get_node_by_hostname("node_b") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + # Initial config (#TODO: Make this a function) + c2_beacon.configure(c2_server_ip_address="192.168.0.10", keep_alive_frequency=2) + c2_server.run() + c2_beacon.establish() + + c2_beacon.apply_timestep(0) + assert c2_beacon.keep_alive_inactivity == 1 + + # Keep Alive successfully sent and received upon the 2nd timestep. + c2_beacon.apply_timestep(1) + assert c2_beacon.keep_alive_inactivity == 0 + assert c2_beacon.c2_connection_active == True + + # Now we turn off the c2 server (Thus preventing a keep alive) + c2_server.close() + c2_beacon.apply_timestep(2) + c2_beacon.apply_timestep(3) + assert c2_beacon.keep_alive_inactivity == 2 + assert c2_beacon.c2_connection_active == False + assert c2_beacon.health_state_actual == ApplicationOperatingState.CLOSED + + +# TODO: Flesh out these tests. +def test_c2_suite_configure_via_actions(basic_network): + """Tests that a red agent is able to configure the c2 beacon and c2 server via Actions.""" + # Setting up the network: + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = network.get_node_by_hostname("node_b") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + # Testing Via Requests: + network.apply_request(["node", "node_a", "application", "C2Server", "run"]) + + c2_beacon_config = { + "c2_server_ip_address": "192.168.0.10", + "keep_alive_frequency": 5, + "masquerade_protocol": IPProtocol.TCP, + "masquerade_port": Port.HTTP, + } + + network.apply_request(["node", "node_b", "application", "C2Beacon", "configure", c2_beacon_config]) + network.apply_request(["node", "node_b", "application", "C2Beacon", "execute"]) + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + assert c2_server.c2_remote_connection == IPv4Address("192.168.0.11") + + # Testing Via Agents: + # TODO: + + +def test_c2_suite_configure_ransomware(basic_network): + """Tests that a red agent is able to configure ransomware via C2 Server Actions.""" + # Setting up the network: + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = network.get_node_by_hostname("node_b") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + c2_beacon.configure(c2_server_ip_address="192.168.0.10", keep_alive_frequency=2) + c2_server.run() + c2_beacon.establish() + + # Testing Via Requests: + computer_b.software_manager.install(software_class=RansomwareScript) + ransomware_config = {"server_ip_address": "1.1.1.1"} + network.apply_request(["node", "node_a", "application", "C2Server", "ransomware_configure", ransomware_config]) + + ransomware_script: RansomwareScript = computer_b.software_manager.software["RansomwareScript"] + + assert ransomware_script.server_ip_address == "1.1.1.1" + + # Testing Via Agents: + # TODO: + + +def test_c2_suite_terminal(basic_network): + """Tests that a red agent is able to execute terminal commands via C2 Server Actions.""" + + +@pytest.fixture(scope="function") +def acl_network() -> Network: + # 0: Pull out the network + network = Network() + + # 1: Set up network hardware + # 1.1: Configure the router + router = Router(hostname="router", num_ports=3, start_up_duration=0) + router.power_on() + router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") + router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") + + # 1.2: Create and connect switches + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) + router.enable_port(1) + switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) + switch_2.power_on() + network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router.enable_port(2) + + # 1.3: Create and connect computer + client_1 = Computer( + hostname="client_1", + ip_address="10.0.1.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.1.1", + start_up_duration=0, + ) + client_1.power_on() + client_1.software_manager.install(software_class=C2Server) + network.connect( + endpoint_a=client_1.network_interface[1], + endpoint_b=switch_1.network_interface[1], + ) + + client_2 = Computer( + hostname="client_2", + ip_address="10.0.1.3", + subnet_mask="255.255.255.0", + default_gateway="10.0.1.1", + start_up_duration=0, + ) + client_2.power_on() + client_2.software_manager.install(software_class=C2Beacon) + network.connect(endpoint_a=client_2.network_interface[1], endpoint_b=switch_2.network_interface[1]) + + # 1.4: Create and connect servers + server_1 = Server( + hostname="server_1", + ip_address="10.0.2.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_1.power_on() + network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_2.network_interface[1]) + + server_2 = Server( + hostname="server_2", + ip_address="10.0.2.3", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_2.power_on() + network.connect(endpoint_a=server_2.network_interface[1], endpoint_b=switch_2.network_interface[2]) + + return network + + +# TODO: Fix this test: Not sure why this isn't working + + +def test_c2_suite_acl_block(acl_network): + """Tests that C2 Beacon disconnects from the C2 Server after blocking ACL rules.""" + network: Network = acl_network + computer_a: Computer = network.get_node_by_hostname("client_1") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = network.get_node_by_hostname("client_2") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + router: Router = network.get_node_by_hostname("router") + + network.apply_timestep(0) + # Initial config (#TODO: Make this a function) + c2_beacon.configure(c2_server_ip_address="10.0.1.2", keep_alive_frequency=2) + + c2_server.run() + c2_beacon.establish() + + assert c2_beacon.keep_alive_inactivity == 0 + assert c2_beacon.c2_connection_active == True + assert c2_server.c2_connection_active == True + + # Now we add a HTTP blocking acl (Thus preventing a keep alive) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=1) + + c2_beacon.apply_timestep(1) + c2_beacon.apply_timestep(2) + assert c2_beacon.keep_alive_inactivity == 2 + assert c2_beacon.c2_connection_active == False + assert c2_beacon.health_state_actual == ApplicationOperatingState.CLOSED + + +def test_c2_suite_launch_ransomware(basic_network): + """Tests that a red agent is able to launch ransomware via C2 Server Actions.""" From 3441dd25092aff65c7c9f5e9e0d11855f7bad8d7 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 5 Aug 2024 17:45:01 +0100 Subject: [PATCH 109/206] #2777: Code review changes. --- CHANGELOG.md | 4 ++-- .../game/agent/scripted_agents/probabilistic_agent.py | 2 +- src/primaite/session/environment.py | 7 +++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52f4678..8b3cfbb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - +### Added +- Random Number Generator Seeding by specifying a random number seed in the config file. ### Changed - Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. @@ -22,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tests to verify that airspace bandwidth is applied correctly and can be configured via YAML - Agent logging for agents' internal decision logic - Action masking in all PrimAITE environments -- Random Number Generator Seeding by specifying a random number seed in the config file. ### Changed - Application registry was moved to the `Application` class and now updates automatically when Application is subclassed - Databases can no longer respond to request while performing a backup diff --git a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py index ab2e69ef..cd44644f 100644 --- a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py +++ b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py @@ -68,7 +68,7 @@ class ProbabilisticAgent(AbstractScriptedAgent): self.probabilities = np.asarray(list(self.settings.action_probabilities.values())) super().__init__(agent_name, action_space, observation_space, reward_function) - self.logger.info(f"ProbabilisticAgent RNG seed: {rng_seed}") + self.logger.debug(f"ProbabilisticAgent RNG seed: {rng_seed}") def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: """ diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index a12d2eb7..c66663e3 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -44,9 +44,8 @@ def set_random_seed(seed: int) -> Union[None, int]: # if torch not installed don't set random seed. if sys.modules["torch"]: th.manual_seed(seed) - - th.backends.cudnn.deterministic = True - th.backends.cudnn.benchmark = False + th.backends.cudnn.deterministic = True + th.backends.cudnn.benchmark = False return seed @@ -64,7 +63,7 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.episode_scheduler: EpisodeScheduler = build_scheduler(env_config) """Object that returns a config corresponding to the current episode.""" - self.seed = self.episode_scheduler(0).get("game").get("seed") + self.seed = self.episode_scheduler(0).get("game", {}).get("seed") """Get RNG seed from config file. NB: Must be before game instantiation.""" self.seed = set_random_seed(self.seed) self.io = PrimaiteIO.from_config(self.episode_scheduler(0).get("io_settings", {})) From d2011ff32767730e087261f5c4b6c7b7bcf766d6 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 5 Aug 2024 22:23:54 +0100 Subject: [PATCH 110/206] #2811 - Updated syslog messaging around DatabaseClient and DatabaseService connection request and password authentication --- .../system/applications/database_client.py | 50 +++++++++++++------ .../services/database/database_service.py | 18 +++++-- src/primaite/simulator/system/software.py | 6 +-- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index 06d22126..e6cfa343 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -2,7 +2,7 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable @@ -54,6 +54,12 @@ class DatabaseClientConnection(BaseModel): if self.client and self.is_active: self.client._disconnect(self.connection_id) # noqa + def __str__(self) -> str: + return f"{self.__class__.__name__}(connection_id='{self.connection_id}', is_active={self.is_active})" + + def __repr__(self) -> str: + return str(self) + class DatabaseClient(Application, identifier="DatabaseClient"): """ @@ -76,7 +82,7 @@ class DatabaseClient(Application, identifier="DatabaseClient"): """Connection ID to the Database Server.""" client_connections: Dict[str, DatabaseClientConnection] = {} """Keep track of active connections to Database Server.""" - _client_connection_requests: Dict[str, Optional[str]] = {} + _client_connection_requests: Dict[str, Optional[Union[str, DatabaseClientConnection]]] = {} """Dictionary of connection requests to Database Server.""" connected: bool = False """Boolean Value for whether connected to DB Server.""" @@ -187,7 +193,7 @@ class DatabaseClient(Application, identifier="DatabaseClient"): return False return self._query("SELECT * FROM pg_stat_activity", connection_id=connection_id) - def _check_client_connection(self, connection_id: str) -> bool: + def _validate_client_connection_request(self, connection_id: str) -> bool: """Check that client_connection_id is valid.""" return True if connection_id in self._client_connection_requests else False @@ -211,23 +217,30 @@ class DatabaseClient(Application, identifier="DatabaseClient"): :type: is_reattempt: Optional[bool] """ if is_reattempt: - valid_connection = self._check_client_connection(connection_id=connection_request_id) - if valid_connection: + valid_connection_request = self._validate_client_connection_request(connection_id=connection_request_id) + if valid_connection_request: database_client_connection = self._client_connection_requests.pop(connection_request_id) - self.sys_log.info( - f"{self.name}: DatabaseClient connection to {server_ip_address} authorised." - f"Connection Request ID was {connection_request_id}." - ) - self.connected = True - self._last_connection_successful = True - return database_client_connection + if isinstance(database_client_connection, DatabaseClientConnection): + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} authorised. " + f"Using connection id {database_client_connection}" + ) + self.connected = True + self._last_connection_successful = True + return database_client_connection + else: + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} declined" + ) + self._last_connection_successful = False + return None else: - self.sys_log.warning( - f"{self.name}: DatabaseClient connection to {server_ip_address} declined." - f"Connection Request ID was {connection_request_id}." + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} declined " + f"due to unknown client-side connection request id" ) - self._last_connection_successful = False return None + payload = {"type": "connect_request", "password": password, "connection_request_id": connection_request_id} software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( @@ -300,9 +313,14 @@ class DatabaseClient(Application, identifier="DatabaseClient"): """ if not self._can_perform_action(): return None + connection_request_id = str(uuid4()) self._client_connection_requests[connection_request_id] = None + self.sys_log.info( + f"{self.name}: Sending new connection request ({connection_request_id}) to {self.server_ip_address}" + ) + return self._connect( server_ip_address=self.server_ip_address, password=self.server_password, diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 22ae0ff3..74ef51ee 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -191,12 +191,16 @@ class DatabaseService(Service): :return: Response to connection request containing success info. :rtype: Dict[str, Union[int, Dict[str, bool]]] """ + self.sys_log.info(f"{self.name}: Processing new connection request ({connection_request_id}) from {src_ip}") status_code = 500 # Default internal server error connection_id = None if self.operating_state == ServiceOperatingState.RUNNING: status_code = 503 # service unavailable if self.health_state_actual == SoftwareHealthState.OVERWHELMED: - self.sys_log.error(f"{self.name}: Connect request for {src_ip=} declined. Service is at capacity.") + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) from {src_ip} declined, service is at " + f"capacity." + ) if self.health_state_actual in [ SoftwareHealthState.GOOD, SoftwareHealthState.FIXING, @@ -208,12 +212,16 @@ class DatabaseService(Service): # try to create connection if not self.add_connection(connection_id=connection_id, session_id=session_id): status_code = 500 - self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined") - else: - self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) from {src_ip} declined, " + f"returning status code 500" + ) else: status_code = 401 # Unauthorised - self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined") + self.sys_log.info( + f"{self.name}: Connection request ({connection_request_id}) from {src_ip} unauthorised " + f"(incorrect password), returning status code 401" + ) else: status_code = 404 # service not found return { diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7c27534a..efa8c9b1 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -313,7 +313,7 @@ class IOSoftware(Software): # if over or at capacity, set to overwhelmed if len(self._connections) >= self.max_sessions: self.set_health_state(SoftwareHealthState.OVERWHELMED) - self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") + self.sys_log.warning(f"{self.name}: Connection request ({connection_id}) declined. Service is at capacity.") return False else: # if service was previously overwhelmed, set to good because there is enough space for connections @@ -330,11 +330,11 @@ class IOSoftware(Software): "ip_address": session_details.with_ip_address if session_details else None, "time": datetime.now(), } - self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + self.sys_log.info(f"{self.name}: Connection request ({connection_id}) authorised") return True # connection with given id already exists self.sys_log.warning( - f"{self.name}: Connect request for {connection_id=} declined. Connection already exists." + f"{self.name}: Connection request ({connection_id}) declined. Connection already exists." ) return False From 1e64e87798983341dac5aba4b8025a4bc2a075c5 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 09:30:27 +0100 Subject: [PATCH 111/206] #2706 - Actioning Review comments --- .../system/services/terminal.rst | 112 ++++++++++++++++++ .../simulator/network/protocols/ssh.py | 12 +- .../_system/_services/test_terminal.py | 16 +++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 37872b5b..0e362326 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -39,6 +39,12 @@ Implementation - Extends Service class. - A detailed guide on the implementation and functionality of the Terminal class can be found in the "Terminal-Processing" jupyter notebook. + +Usage +===== + +The below code examples demonstrate how to create a terminal, a remote terminal, and how to send a basic application install command to a remote node. + Python """""" @@ -59,3 +65,109 @@ Python ) terminal: Terminal = client.software_manager.software.get("Terminal") + +Obtaining Remote Connection +""""""""""""""""""""""""""" + + +.. code-block:: python + + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection + + + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + terminal_a: Terminal = node_a.software_manager.software.get("Terminal") + + + term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") + + + +Executing a basic application install command +""""""""""""""""""""""""""""""""" + +.. code-block:: python + + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection + from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript + + + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + terminal_a: Terminal = node_a.software_manager.software.get("Terminal") + + + term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") + + term_a_term_b_remote_connection.execute(["software_manager", "application", "install", "RansomwareScript"]) + + + +Creating a file on a remote node +"""""""""""""""""""""""""""""""" + +.. code-block:: python + + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection + from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript + + + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + terminal_a: Terminal = node_a.software_manager.software.get("Terminal") + + + term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") + + term_a_term_b_remote_connection.execute(["file_system", "create", "folder", "downloads"]) + + +Disconnect from Remote Node + +.. code-block:: python + + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection + from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript + + + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + terminal_a: Terminal = node_a.software_manager.software.get("Terminal") + + + term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") + + term_a_term_b_remote_connection.disconnect() diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 7ba629f8..ca9663d8 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -76,10 +76,14 @@ class SSHPacket(DataPacket): user_account: Optional[SSHUserCredentials] = None """User Account Credentials if passed""" - connection_request_uuid: Optional[str] = None # Connection Request uuid. + connection_request_uuid: Optional[str] = None + """Connection Request UUID used when establishing a remote connection""" - connection_uuid: Optional[str] = None # The connection uuid used to validate the session + connection_uuid: Optional[str] = None + """Connection UUID used when validating a remote connection""" - ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse + ssh_output: Optional[RequestResponse] = None + """RequestResponse from Request Manager""" - ssh_command: Optional[str] = None # This is the request string + ssh_command: Optional[str] = None + """Request String""" diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index c86d6466..7e98e501 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -336,3 +336,19 @@ def test_SSH_across_network(wireless_wan_network): terminal_b_on_terminal_a = terminal_b.login(username="username", password="password", ip_address="192.168.0.2") assert len(terminal_a._connections) == 1 + + +def test_multiple_remote_terminals_same_node(basic_network): + """Test to check that multiple remote terminals can be spawned by one node.""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + + assert len(terminal_a._connections) == 0 + + # Spam login requests to terminal. + for attempt in range(10): + remote_connection = terminal_a.login(username="username", password="password", ip_address="192.168.0.11") + + assert len(terminal_a._connections) == 10 From 457395baee922d5ef6d446872f0545d2a8d260f0 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 09:33:41 +0100 Subject: [PATCH 112/206] #2706 - Correcting wording on documentation titles --- .../source/simulation_components/system/services/terminal.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 0e362326..5097f213 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -66,7 +66,7 @@ Python terminal: Terminal = client.software_manager.software.get("Terminal") -Obtaining Remote Connection +Creating Remote Terminal Connection """"""""""""""""""""""""""" @@ -120,7 +120,7 @@ Executing a basic application install command -Creating a file on a remote node +Creating a folder on a remote node """""""""""""""""""""""""""""""" .. code-block:: python From 89107f2c4bad297d4ab6d00bd0ab4d1e0b41f200 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 10:37:11 +0100 Subject: [PATCH 113/206] #2706 - Type-hint changes following review --- .../simulator/system/services/terminal/terminal.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 274353ed..0c94a565 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -8,7 +8,7 @@ from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel -from primaite.interface.request import RequestResponse +from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.protocols.ssh import ( SSHConnectionMessage, @@ -116,27 +116,27 @@ class Terminal(Service): request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())), ) - def _login(request: List[Any], context: Any) -> RequestResponse: + def _login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._process_local_login(username=request[0], password=request[1]) if login: return RequestResponse(status="success", data={}) else: return RequestResponse(status="failure", data={}) - def _remote_login(request: List[Any], context: Any) -> RequestResponse: + def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2]) if login: return RequestResponse(status="success", data={}) else: return RequestResponse(status="failure", data={}) - def _execute_request(request: List[Any], context: Any) -> RequestResponse: + def _execute_request(request: RequestFormat, context: Dict) -> RequestResponse: """Execute an instruction.""" command: str = request[0] self.execute(command) return RequestResponse(status="success", data={}) - def _logoff(request: List[Any]) -> RequestResponse: + def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: """Logoff from connection.""" connection_uuid = request[0] # TODO: Uncomment this when UserSessionManager merged. From 68621f172b18e5fdb183d283f52b5bb49b25b195 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 12:10:14 +0100 Subject: [PATCH 114/206] #2706 - xfail on test_ray_multi_agent_action_masking as this is causing pipeline failures. Bugticket raised as 2812 --- .../action_masking/test_agents_use_action_masks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py b/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py index 745e280b..4260c605 100644 --- a/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py +++ b/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py @@ -100,6 +100,7 @@ def test_ray_single_agent_action_masking(monkeypatch): monkeypatch.undo() +@pytest.mark.xfail(reason="Fails due to being flaky when run in CI.") def test_ray_multi_agent_action_masking(monkeypatch): """Check that Ray agents never take invalid actions when using MARL.""" with open(MARL_PATH, "r") as f: From df49b3b5bbb8b54190d98cfdee152bbd1be24304 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 14:10:10 +0100 Subject: [PATCH 115/206] #2706 - Actioning Review Comments --- .../system/services/terminal/terminal.py | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 0c94a565..0bcec90d 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -1,11 +1,11 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from __future__ import annotations +from datetime import datetime from ipaddress import IPv4Address from typing import Any, Dict, List, Optional, Union from uuid import uuid4 -from prettytable import MARKDOWN, PrettyTable from pydantic import BaseModel from primaite.interface.request import RequestFormat, RequestResponse @@ -41,6 +41,21 @@ class TerminalClientConnection(BaseModel): connection_request_id: str = None """Connection request ID""" + time: datetime = None + """Timestammp connection was created.""" + + ip_address: IPv4Address + """Source IP of Connection""" + + def __str__(self) -> str: + return f"{self.__class__.__name__}(connection_id='{self.connection_uuid}')" + + def __repr__(self) -> str: + return self.__str__() + + def __getitem__(self, key: Any) -> Any: + return getattr(self, key) + @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -59,9 +74,6 @@ class RemoteTerminalConnection(TerminalClientConnection): """ - source_ip: IPv4Address - """Source IP of Connection""" - def execute(self, command: Any) -> bool: """Execute a given command on the remote Terminal.""" if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: @@ -73,7 +85,7 @@ class RemoteTerminalConnection(TerminalClientConnection): class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" - _client_connection_requests: Dict[str, Optional[str]] = {} + _client_connection_requests: Dict[str, Optional[Union[str, TerminalClientConnection]]] = {} def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -99,14 +111,7 @@ class Terminal(Service): :param markdown: Whether to display the table in Markdown format or not. Default is `False`. """ - table = PrettyTable(["Connection ID", "Connection request ID", "Source IP"]) - if markdown: - table.set_style(MARKDOWN) - table.align = "l" - table.title = f"{self.sys_log.hostname} {self.name} Connections" - for connection_id, connection in self._connections.items(): - table.add_row([connection_id, connection.connection_request_id, connection.source_ip]) - print(table.get_string(sortby="Connection ID")) + self.show_connections(markdown=markdown) def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" @@ -179,6 +184,7 @@ class Terminal(Service): parent_terminal=self, connection_uuid=connection_uuid, session_id=session_id, + time=datetime.now(), ) self._connections[connection_uuid] = new_connection self._client_connection_requests[connection_uuid] = new_connection @@ -224,19 +230,20 @@ class Terminal(Service): connection_uuid = str(uuid4()) if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") - # Add new local session to list of connections - self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection") - return TerminalClientConnection( - parent_terminal=self, session_id="Local_Connection", connection_uuid=connection_uuid - ) + # Add new local session to list of connections and return + return self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection") else: self.sys_log.warning("Login failed, incorrect Username or Password") return None - def _check_client_connection(self, connection_id: str) -> bool: + def _validate_client_connection_request(self, connection_id: str) -> bool: """Check that client_connection_id is valid.""" return True if connection_id in self._client_connection_requests else False + def _check_client_connection(self, connection_id: str) -> bool: + """Check that client_connection_id is valid.""" + return True if connection_id in self._connections else False + def _send_remote_login( self, username: str, @@ -267,11 +274,15 @@ class Terminal(Service): """ self.sys_log.info(f"Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}") if is_reattempt: - valid_connection = self._check_client_connection(connection_id=connection_request_id) - if valid_connection: + valid_connection_request = self._validate_client_connection_request(connection_id=connection_request_id) + if valid_connection_request: remote_terminal_connection = self._client_connection_requests.pop(connection_request_id) - self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.") - return remote_terminal_connection + if isinstance(remote_terminal_connection, RemoteTerminalConnection): + self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.") + return remote_terminal_connection + else: + self.sys_log.warning(f"Connection request{connection_request_id} declined") + return None else: self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") return None @@ -322,8 +333,9 @@ class Terminal(Service): parent_terminal=self, session_id=session_id, connection_uuid=connection_id, - source_ip=source_ip, + ip_address=source_ip, connection_request_id=connection_request_id, + time=datetime.now(), ) self._connections[connection_id] = client_connection self._client_connection_requests[connection_request_id] = client_connection @@ -391,8 +403,12 @@ class Terminal(Service): if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "disconnect": connection_id = payload["connection_id"] - self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.") - self._disconnect(payload["connection_id"]) + valid_id = self._check_client_connection(connection_id) + if valid_id: + self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.") + self._disconnect(payload["connection_id"]) + else: + self.sys_log.info("No Active connection held for received connection ID.") if isinstance(payload, list): # A request? For me? @@ -410,8 +426,9 @@ class Terminal(Service): self.sys_log.warning("No remote connection present") return False - session_id = self._connections[connection_uuid].session_id - self._connections.pop(connection_uuid) + # session_id = self._connections[connection_uuid].session_id + connection: RemoteTerminalConnection = self._connections.pop(connection_uuid) + session_id = connection.session_id software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( From dd7e4661044387408bed49fd0a63a57e4d5c3dd9 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 15:01:53 +0100 Subject: [PATCH 116/206] #2706 - Fixing pipeline failure --- .../action_masking/test_agents_use_action_masks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py b/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py index 4260c605..addf6dca 100644 --- a/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py +++ b/tests/e2e_integration_tests/action_masking/test_agents_use_action_masks.py @@ -1,6 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from typing import Dict +import pytest import yaml from ray.rllib.algorithms.ppo import PPOConfig from ray.rllib.core.rl_module.marl_module import MultiAgentRLModuleSpec From de14dfdc485860e5e3fa236114e44e22302fae6e Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 16:22:08 +0100 Subject: [PATCH 117/206] #2706 - Updated Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adf24fdc..7ce4bbf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Implemented Terminal service class, providing a generic terminal simulation. + ### Changed - Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. From 9c68cd4bd0ef86fe3dc7a126613198f227a17b1e Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Tue, 6 Aug 2024 17:05:00 +0100 Subject: [PATCH 118/206] #2689 Agent Actions Implemented, E2E Demo notebook started and a couple of general fixes and improvements. --- src/primaite/game/agent/actions.py | 101 +++++ src/primaite/game/game.py | 2 + .../Command-&-Control-E2E-Demonstration.ipynb | 367 ++++++++++++++++++ .../red_applications/c2/c2_beacon.py | 37 +- .../red_applications/c2/c2_server.py | 25 +- 5 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 9a5fedc9..d2752459 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1071,6 +1071,103 @@ class NodeNetworkServiceReconAction(AbstractAction): ] +class ConfigureC2BeaconAction(AbstractAction): + """Action which configures a C2 Beacon based on the parameters given.""" + + class _Opts(BaseModel): + """Schema for options that can be passed to this action.""" + + c2_server_ip_address: str + keep_alive_frequency: int = Field(default=5, ge=1) + masquerade_protocol: str = Field(default="TCP") + masquerade_port: str = Field(default="HTTP") + + @field_validator( + "c2_server_ip_address", + "keep_alive_frequency", + "masquerade_protocol", + "masquerade_port", + mode="before", + ) + @classmethod + def not_none(cls, v: str, info: ValidationInfo) -> int: + """If None is passed, use the default value instead.""" + if v is None: + return cls.model_fields[info.field_name].default + return v + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, config: Dict) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + config = ConfigureC2BeaconAction._Opts( + c2_server_ip_address=config["c2_server_ip_address"], + keep_alive_frequency=config["keep_alive_frequency"], + masquerade_port=config["masquerade_protocol"], + masquerade_protocol=config["masquerade_port"], + ) + + ConfigureC2BeaconAction._Opts.model_validate(config) # check that options adhere to schema + + return ["network", "node", node_name, "application", "C2Beacon", "configure", config.__dict__] + + +class RansomwareConfigureC2ServerAction(AbstractAction): + """Action which sends a command from the C2 Server to the C2 Beacon which configures a local RansomwareScript.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, config: Dict) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + # Using the ransomware scripts model to validate. + ConfigureRansomwareScriptAction._Opts.model_validate(config) # check that options adhere to schema + return ["network", "node", node_name, "application", "C2Server", "ransomware_configure", config] + + +class RansomwareLaunchC2ServerAction(AbstractAction): + """Action which causes the C2 Server to send a command to the C2 Beacon to launch the RansomwareScript.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + # Not options needed for this action. + return ["network", "node", node_name, "application", "C2Server", "ransomware_launch"] + + +class TerminalC2ServerAction(AbstractAction): + """Action which causes the C2 Server to send a command to the C2 Beacon to execute the terminal command passed.""" + + class _Opts(BaseModel): + """Schema for options that can be passed to this action.""" + + model_config = ConfigDict(extra="forbid") + commands: RequestFormat + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, config: Dict) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + TerminalC2ServerAction._Opts.model_validate(config) # check that options adhere to schema + return ["network", "node", node_name, "application", "C2Server", "terminal_command", config] + + class ActionManager: """Class which manages the action space for an agent.""" @@ -1122,6 +1219,10 @@ class ActionManager: "CONFIGURE_DATABASE_CLIENT": ConfigureDatabaseClientAction, "CONFIGURE_RANSOMWARE_SCRIPT": ConfigureRansomwareScriptAction, "CONFIGURE_DOSBOT": ConfigureDoSBotAction, + "CONFIGURE_C2_BEACON": ConfigureC2BeaconAction, + "C2_SERVER_RANSOMWARE_LAUNCH": RansomwareLaunchC2ServerAction, + "C2_SERVER_RANSOMWARE_CONFIGURE": RansomwareConfigureC2ServerAction, + "C2_SERVER_TERMINAL_COMMAND": TerminalC2ServerAction, } """Dictionary which maps action type strings to the corresponding action class.""" diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 5ef8c14c..831dab2b 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -31,6 +31,8 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.application import Application from primaite.simulator.system.applications.database_client import DatabaseClient # noqa: F401 +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon # noqa: F401 +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server # noqa: F401 from primaite.simulator.system.applications.red_applications.data_manipulation_bot import ( # noqa: F401 DataManipulationBot, ) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb new file mode 100644 index 00000000..60ea756d --- /dev/null +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -0,0 +1,367 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Command and Control Application Suite E2E Demonstration\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook demonstrates the current implementation of the command and control (C2) server and beacon applications in primAITE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "from primaite.config.load import data_manipulation_config_path\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.game.agent.interface import AgentHistoryItem\n", + "import yaml\n", + "from pprint import pprint\n", + "from primaite.simulator.network.container import Network\n", + "from primaite.game.game import PrimaiteGame\n", + "from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon\n", + "from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server\n", + "from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import C2Command, C2Payload\n", + "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", + "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", + "from primaite.simulator.network.hardware.nodes.host.server import Server" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Notebook Setup** | **Network Configuration:**\n", + "\n", + "This notebook uses the same network setup as UC2. Please refer to the main [UC2-E2E-Demo notebook for further reference](./Data-Manipulation-E2E-Demonstration.ipynb).\n", + "\n", + "However, this notebook will replaces with the red agent used in UC2 with a custom proxy red agent built for this notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "custom_c2_agent = \"\"\"\n", + " - ref: CustomC2Agent\n", + " team: RED\n", + " type: ProxyAgent\n", + " observation_space: null\n", + " action_space:\n", + " action_list:\n", + " - type: DONOTHING\n", + " - type: NODE_APPLICATION_INSTALL\n", + " - type: NODE_APPLICATION_EXECUTE\n", + " - type: CONFIGURE_C2_BEACON\n", + " - type: C2_SERVER_RANSOMWARE_LAUNCH\n", + " - type: C2_SERVER_RANSOMWARE_CONFIGURE\n", + " - type: C2_SERVER_TERMINAL_COMMAND\n", + " options:\n", + " nodes:\n", + " - node_name: client_1\n", + " applications: \n", + " - application_name: C2Beacon\n", + " - node_name: domain_controller\n", + " applications: \n", + " - application_name: C2Server\n", + " max_folders_per_node: 1\n", + " max_files_per_folder: 1\n", + " max_services_per_node: 2\n", + " max_nics_per_node: 8\n", + " max_acl_rules: 10\n", + " ip_list:\n", + " - 192.168.1.10\n", + " - 192.168.1.14\n", + " action_map:\n", + " 0:\n", + " action: DONOTHING\n", + " options: {}\n", + " 1:\n", + " action: NODE_APPLICATION_INSTALL\n", + " options:\n", + " node_id: 0\n", + " application_name: C2Beacon\n", + " 2:\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0\n", + " config:\n", + " c2_server_ip_address: 192.168.1.10\n", + " keep_alive_frequency:\n", + " masquerade_protocol:\n", + " masquerade_port:\n", + " 3:\n", + " action: NODE_APPLICATION_EXECUTE\n", + " options:\n", + " node_id: 0\n", + " application_id: 0 \n", + " 4:\n", + " action: NODE_APPLICATION_INSTALL\n", + " options:\n", + " node_id: 0\n", + " application_name: RansomwareScript \n", + " 5:\n", + " action: C2_SERVER_RANSOMWARE_CONFIGURE\n", + " options:\n", + " node_id: 1\n", + " config:\n", + " server_ip_address: 192.168.1.14\n", + " payload: ENCRYPT\n", + " 6:\n", + " action: C2_SERVER_RANSOMWARE_LAUNCH\n", + " options:\n", + " node_id: 1\n", + " 7:\n", + " action: C2_SERVER_TERMINAL_COMMAND\n", + " options:\n", + " node_id: 1\n", + " application_id: 0 \n", + "\n", + " reward_function:\n", + " reward_components:\n", + " - type: DUMMY\n", + "\"\"\"\n", + "c2_agent_yaml = yaml.safe_load(custom_c2_agent)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path()) as f:\n", + " cfg = yaml.safe_load(f)\n", + " # removing all agents & adding the custom agent.\n", + " cfg['agents'] = {}\n", + " cfg['agents'] = c2_agent_yaml\n", + " \n", + "\n", + "env = PrimaiteGymEnv(env_config=cfg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Notebook Setup** | Network Prerequisites\n", + "\n", + "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run before the episode begins.\n", + "\n", + "This is because higher fidelity environments (and the real-world) a C2 server would not be accessible by private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) before the narrative of the use case.\n", + "\n", + "The cells below installs and runs the C2 Server on the domain controller server directly via the simulation API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "domain_controller: Server = env.game.simulation.network.get_node_by_hostname(\"domain_controller\")\n", + "domain_controller.software_manager.install(C2Server)\n", + "c2_server: C2Server = domain_controller.software_manager.software[\"C2Server\"]\n", + "c2_server.run()\n", + "domain_controller.software_manager.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Command and Control** | C2 Beacon Actions\n", + "\n", + "Before the Red Agent is able to perform any C2 Server commands, it must first establish connection with a C2 beacon.\n", + "\n", + "This can be done by installing, configuring and then executing a C2 Beacon. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_red_agent = env.game.agents[\"CustomC2Agent\"]\n", + "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Beacon Actions | Installation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(1)\n", + "client_1.software_manager.show()\n", + "c2_beacon: C2Beacon = client_1.software_manager.software[\"C2Beacon\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Beacon Actions | Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(2)\n", + "c2_beacon.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Beacon Actions | Establishing Connection" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_beacon.show()\n", + "c2_server.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Command and Control** | C2 Server Actions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Server Actions | Configuring Ransomware" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(4)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ransomware_script: RansomwareScript = client_1.software_manager.software[\"RansomwareScript\"]\n", + "client_1.software_manager.show()\n", + "ransomware_script.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Server Actions | Launching Ransomware" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(6)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "database_server: Server = env.game.simulation.network.get_node_by_hostname(\"database_server\")\n", + "database_server.software_manager.file_system.show(full=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Server Actions | Executing Terminal Commands" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: Post Terminal.\n", + "#env.step(7)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 16420164..c73799da 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -5,6 +5,7 @@ from typing import Dict, Optional # from primaite.simulator.system.services.terminal.terminal import Terminal from prettytable import MARKDOWN, PrettyTable +from pydantic import validate_call from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType @@ -17,7 +18,7 @@ from primaite.simulator.system.applications.red_applications.ransomware_script i from primaite.simulator.system.software import SoftwareHealthState -class C2Beacon(AbstractC2, identifier="C2 Beacon"): +class C2Beacon(AbstractC2, identifier="C2Beacon"): """ C2 Beacon Application. @@ -94,16 +95,16 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): ) c2_remote_ip = IPv4Address(c2_remote_ip) - # TODO: validation. frequency = request[-1].get("keep_alive_frequency") protocol = request[-1].get("masquerade_protocol") port = request[-1].get("masquerade_port") + return RequestResponse.from_bool( self.configure( c2_server_ip_address=c2_remote_ip, keep_alive_frequency=frequency, - masquerade_protocol=protocol, - masquerade_port=port, + masquerade_protocol=IPProtocol[protocol], + masquerade_port=Port[port], ) ) @@ -114,12 +115,13 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): kwargs["name"] = "C2Beacon" super().__init__(**kwargs) + @validate_call def configure( self, c2_server_ip_address: IPv4Address = None, - keep_alive_frequency: Optional[int] = 5, - masquerade_protocol: Optional[Enum] = IPProtocol.TCP, - masquerade_port: Optional[Enum] = Port.HTTP, + keep_alive_frequency: int = 5, + masquerade_protocol: Enum = IPProtocol.TCP, + masquerade_port: Enum = Port.HTTP, ) -> bool: """ Configures the C2 beacon to communicate with the C2 server with following additional parameters. @@ -278,8 +280,7 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - # TODO: replace and use terminal - return RequestResponse(status="success", data={"Reason": "Placeholder."}) + return RequestResponse.from_bool(self._host_ransomware_script.attack()) def _command_terminal(self, payload: MasqueradePacket) -> RequestResponse: """ @@ -295,7 +296,6 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): """ # TODO: uncomment and replace (uses terminal) return RequestResponse(status="success", data={"Reason": "Placeholder."}) - # return self._host_terminal.execute(command) def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ @@ -421,10 +421,23 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): ``Keep Alive Frequency``: How often should the C2 Beacon attempt a keep alive? + ``Current Masquerade Protocol``: + The current protocol that the C2 Traffic is using. (e.g TCP/UDP) + + ``Current Masquerade Port``: + The current port that the C2 Traffic is using. (e.g HTTP (Port 80)) + :param markdown: If True, outputs the table in markdown format. Default is False. """ table = PrettyTable( - ["C2 Connection Active", "C2 Remote Connection", "Keep Alive Inactivity", "Keep Alive Frequency"] + [ + "C2 Connection Active", + "C2 Remote Connection", + "Keep Alive Inactivity", + "Keep Alive Frequency", + "Current Masquerade Protocol", + "Current Masquerade Port", + ] ) if markdown: table.set_style(MARKDOWN) @@ -436,6 +449,8 @@ class C2Beacon(AbstractC2, identifier="C2 Beacon"): self.c2_remote_connection, self.keep_alive_inactivity, self.keep_alive_frequency, + self.current_masquerade_protocol, + self.current_masquerade_port, ] ) print(table) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index d01cd412..c381403e 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -10,7 +10,7 @@ from primaite.simulator.network.protocols.masquerade import MasqueradePacket from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload -class C2Server(AbstractC2, identifier="C2 Server"): +class C2Server(AbstractC2, identifier="C2Server"): """ C2 Server Application. @@ -74,8 +74,8 @@ class C2Server(AbstractC2, identifier="C2 Server"): :rtype: RequestResponse """ # TODO: Parse the parameters from the request to get the parameters - placeholder: dict = {} - return self._send_command(given_command=C2Command.TERMINAL, command_options=placeholder) + terminal_commands = {"commands": request[-1].get("commands")} + return self._send_command(given_command=C2Command.TERMINAL, command_options=terminal_commands) rm.add_request( name="ransomware_configure", @@ -250,14 +250,29 @@ class C2Server(AbstractC2, identifier="C2 Server"): ``C2 Remote Connection``: The IP of the C2 Beacon. (Configured by upon receiving a keep alive.) + ``Current Masquerade Protocol``: + The current protocol that the C2 Traffic is using. (e.g TCP/UDP) + + ``Current Masquerade Port``: + The current port that the C2 Traffic is using. (e.g HTTP (Port 80)) + :param markdown: If True, outputs the table in markdown format. Default is False. """ - table = PrettyTable(["C2 Connection Active", "C2 Remote Connection"]) + table = PrettyTable( + ["C2 Connection Active", "C2 Remote Connection", "Current Masquerade Protocol", "Current Masquerade Port"] + ) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.name} Running Status" - table.add_row([self.c2_connection_active, self.c2_remote_connection]) + table.add_row( + [ + self.c2_connection_active, + self.c2_remote_connection, + self.current_masquerade_protocol, + self.current_masquerade_port, + ] + ) print(table) # Abstract method inherited from abstract C2 - Not currently utilised. From d05fd00594e27c70dc7f8be9b3df1beb7e702547 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 19:09:23 +0100 Subject: [PATCH 119/206] #2706 - Resolving an issue that saw disconnected terminal connections still able to send execute commands that were also then processed by the target node. Created a new class: LocalterminalConnection, for local connection objects to terminal. Calling terminal.show() when there is a local connection will have 'Local Connection' as the IP address. Receive and execute will check that the provided connection uuid is valid before actioning any commands. TerminalClientConnection objects now have an is_active flag similar to DatabaseClientConnection. Added a new test to check that terminals will reject commands from disconnected clientconnection objects. --- .../simulator/network/protocols/ssh.py | 2 +- .../system/services/terminal/terminal.py | 104 ++++++++++++++---- .../_system/_services/test_terminal.py | 24 ++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index ca9663d8..be7f842f 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -85,5 +85,5 @@ class SSHPacket(DataPacket): ssh_output: Optional[RequestResponse] = None """RequestResponse from Request Manager""" - ssh_command: Optional[str] = None + ssh_command: Optional[list] = None """Request String""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 0bcec90d..0ebae491 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -1,6 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from __future__ import annotations +from abc import abstractmethod from datetime import datetime from ipaddress import IPv4Address from typing import Any, Dict, List, Optional, Union @@ -42,11 +43,14 @@ class TerminalClientConnection(BaseModel): """Connection request ID""" time: datetime = None - """Timestammp connection was created.""" + """Timestamp connection was created.""" ip_address: IPv4Address """Source IP of Connection""" + is_active: bool = True + """Flag to state whether the connection is active or not""" + def __str__(self) -> str: return f"{self.__class__.__name__}(connection_id='{self.connection_uuid}')" @@ -65,6 +69,28 @@ class TerminalClientConnection(BaseModel): """Disconnect the session.""" return self.parent_terminal._disconnect(connection_uuid=self.connection_uuid) + @abstractmethod + def execute(self, command: Any) -> bool: + """Execute a given command.""" + pass + + +class LocalTerminalConnection(TerminalClientConnection): + """ + LocalTerminalConnectionClass. + + This class represents a local terminal when connected. + """ + + ip_address: str = "Local Connection" + + def execute(self, command: Any) -> RequestResponse: + """Execute a given command on local Terminal.""" + if not self.is_active: + self.parent_terminal.sys_log.warning("Connection inactive, cannot execute") + return None + return self.parent_terminal.execute(command, connection_id=self.connection_uuid) + class RemoteTerminalConnection(TerminalClientConnection): """ @@ -78,8 +104,24 @@ class RemoteTerminalConnection(TerminalClientConnection): """Execute a given command on the remote Terminal.""" if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: self.parent_terminal.sys_log.warning("Cannot process command as system not running") + return False + if not self.is_active: + self.parent_terminal.sys_log.warning("Connection inactive, cannot execute") + return False # Send command to remote terminal to process. - return self.parent_terminal.send(payload=command, session_id=self.session_id) + + transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_SERVICE_REQUEST + connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + + payload: SSHPacket = SSHPacket( + transport_message=transport_message, + connection_message=connection_message, + connection_request_uuid=self.connection_request_id, + connection_uuid=self.connection_uuid, + ssh_command=command, + ) + + return self.parent_terminal.send(payload=payload, session_id=self.session_id) class Terminal(Service): @@ -138,7 +180,8 @@ class Terminal(Service): def _execute_request(request: RequestFormat, context: Dict) -> RequestResponse: """Execute an instruction.""" command: str = request[0] - self.execute(command) + connection_id: str = request[1] + self.execute(command, connection_id=connection_id) return RequestResponse(status="success", data={}) def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: @@ -169,9 +212,14 @@ class Terminal(Service): return rm - def execute(self, command: List[Any]) -> RequestResponse: + def execute(self, command: List[Any], connection_id: str) -> Optional[RequestResponse]: """Execute a passed ssh command via the request manager.""" - return self.parent.apply_request(command) + valid_connection = self._check_client_connection(connection_id=connection_id) + if valid_connection: + return self.parent.apply_request(command) + else: + self.sys_log.error("Invalid connection ID provided") + return None def _create_local_connection(self, connection_uuid: str, session_id: str) -> TerminalClientConnection: """Create a new connection object and amend to list of active connections. @@ -180,7 +228,7 @@ class Terminal(Service): :param session_id: Session ID of the new local connection :return: TerminalClientConnection object """ - new_connection = TerminalClientConnection( + new_connection = LocalTerminalConnection( parent_terminal=self, connection_uuid=connection_uuid, session_id=session_id, @@ -340,7 +388,7 @@ class Terminal(Service): self._connections[connection_id] = client_connection self._client_connection_requests[connection_request_id] = client_connection - def receive(self, session_id: str, payload: Union[SSHPacket, Dict, List], **kwargs) -> bool: + def receive(self, session_id: str, payload: Union[SSHPacket, Dict], **kwargs) -> bool: """ Receive a payload from the Software Manager. @@ -400,6 +448,17 @@ class Terminal(Service): source_ip=source_ip, ) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: + # Requesting a command to be executed + self.sys_log.info("Received command to execute") + command = payload.ssh_command + valid_connection = self._check_client_connection(payload.connection_uuid) + self.sys_log.info(f"Connection uuid is {valid_connection}") + if valid_connection: + return self.execute(command, payload.connection_uuid) + else: + self.sys_log.error(f"Connection UUID:{payload.connection_uuid} is not valid. Rejecting Command.") + if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "disconnect": connection_id = payload["connection_id"] @@ -410,10 +469,6 @@ class Terminal(Service): else: self.sys_log.info("No Active connection held for received connection ID.") - if isinstance(payload, list): - # A request? For me? - self.execute(payload) - return True def _disconnect(self, connection_uuid: str) -> bool: @@ -426,16 +481,25 @@ class Terminal(Service): self.sys_log.warning("No remote connection present") return False - # session_id = self._connections[connection_uuid].session_id - connection: RemoteTerminalConnection = self._connections.pop(connection_uuid) - session_id = connection.session_id + connection = self._connections.pop(connection_uuid) + connection.is_active = False - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload={"type": "disconnect", "connection_id": connection_uuid}, dest_port=self.port, session_id=session_id - ) - self.sys_log.info(f"{self.name}: Disconnected {connection_uuid}") - return True + if isinstance(connection, RemoteTerminalConnection): + # Send disconnect command via software manager + session_id = connection.session_id + + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_uuid}, + dest_port=self.port, + session_id=session_id, + ) + self.sys_log.info(f"{self.name}: Disconnected {connection_uuid}") + return True + + elif isinstance(connection, LocalTerminalConnection): + # No further action needed + return True def send( self, payload: SSHPacket, dest_ip_address: Optional[IPv4Address] = None, session_id: Optional[str] = None diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 7e98e501..cdd0ebb3 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -352,3 +352,27 @@ def test_multiple_remote_terminals_same_node(basic_network): remote_connection = terminal_a.login(username="username", password="password", ip_address="192.168.0.11") assert len(terminal_a._connections) == 10 + + +def test_terminal_rejects_commands_if_disconnect(basic_network): + """Test to check terminal will ignore commands from disconnected connections""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + + terminal_b: Terminal = computer_b.software_manager.software.get("Terminal") + + remote_connection = terminal_a.login(username="username", password="password", ip_address="192.168.0.11") + + assert len(terminal_a._connections) == 1 + assert len(terminal_b._connections) == 1 + + remote_connection.disconnect() + + assert len(terminal_a._connections) == 0 + assert len(terminal_b._connections) == 0 + + assert remote_connection.execute(["software_manager", "application", "install", "RansomwareScript"]) is False + + assert not computer_b.software_manager.software.get("RansomwareScript") From 6d6f21a20a1a02bcb89caaef4aa4b20c18e6ee94 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 6 Aug 2024 19:14:53 +0100 Subject: [PATCH 120/206] #2706 - Additional assert on new test and a guard clause on LocalTerminalConnection.execute() to check that the Terminal service is running before sending a command --- src/primaite/simulator/system/services/terminal/terminal.py | 5 ++++- .../_primaite/_simulator/_system/_services/test_terminal.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 0ebae491..4be2c501 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -84,8 +84,11 @@ class LocalTerminalConnection(TerminalClientConnection): ip_address: str = "Local Connection" - def execute(self, command: Any) -> RequestResponse: + def execute(self, command: Any) -> Optional[RequestResponse]: """Execute a given command on local Terminal.""" + if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: + self.parent_terminal.sys_log.warning("Cannot process command as system not running") + return None if not self.is_active: self.parent_terminal.sys_log.warning("Connection inactive, cannot execute") return None diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index cdd0ebb3..9286fa49 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -376,3 +376,5 @@ def test_terminal_rejects_commands_if_disconnect(basic_network): assert remote_connection.execute(["software_manager", "application", "install", "RansomwareScript"]) is False assert not computer_b.software_manager.software.get("RansomwareScript") + + assert remote_connection.is_active is False From 368e846c8b59488746e56610727fd3d99bc54090 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 7 Aug 2024 10:07:19 +0100 Subject: [PATCH 121/206] 2772 - Generate pdf benchmark from --- benchmark/primaite_benchmark.py | 15 ++++++++++- benchmark/report.py | 47 +++++++++++++++++++++++++-------- benchmark/static/styles.css | 34 ++++++++++++++++++++++++ pyproject.toml | 3 ++- 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 benchmark/static/styles.css diff --git a/benchmark/primaite_benchmark.py b/benchmark/primaite_benchmark.py index 0e6c2acc..2b09870d 100644 --- a/benchmark/primaite_benchmark.py +++ b/benchmark/primaite_benchmark.py @@ -5,7 +5,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, Final, Tuple -from report import build_benchmark_md_report +from report import build_benchmark_md_report, md2pdf from stable_baselines3 import PPO import primaite @@ -159,6 +159,13 @@ def run( learning_rate: float = 3e-4, ) -> None: """Run the PrimAITE benchmark.""" + # generate report folder + v_str = f"v{primaite.__version__}" + + version_result_dir = _RESULTS_ROOT / v_str + version_result_dir.mkdir(exist_ok=True, parents=True) + output_path = version_result_dir / f"PrimAITE {v_str} Benchmark Report.md" + benchmark_start_time = datetime.now() session_metadata_dict = {} @@ -193,6 +200,12 @@ def run( session_metadata=session_metadata_dict, config_path=data_manipulation_config_path(), results_root_path=_RESULTS_ROOT, + output_path=output_path, + ) + md2pdf( + md_path=output_path, + pdf_path=str(output_path).replace(".md", ".pdf"), + css_path="benchmark/static/styles.css", ) diff --git a/benchmark/report.py b/benchmark/report.py index e1ff46b9..408e91cf 100644 --- a/benchmark/report.py +++ b/benchmark/report.py @@ -2,6 +2,7 @@ import json import sys from datetime import datetime +from os import PathLike from pathlib import Path from typing import Dict, Optional @@ -14,7 +15,7 @@ from utils import _get_system_info import primaite PLOT_CONFIG = { - "size": {"auto_size": False, "width": 1500, "height": 900}, + "size": {"auto_size": False, "width": 800, "height": 800}, "template": "plotly_white", "range_slider": False, } @@ -144,6 +145,20 @@ def _plot_benchmark_metadata( yaxis={"title": "Total Reward"}, title=title, ) + fig.update_layout( + legend=dict( + yanchor="top", + y=0.99, + xanchor="left", + x=0.01, + bgcolor="rgba(255,255,255,0.3)", + ) + ) + for trace in fig["data"]: + if trace["name"].startswith("Session"): + trace["showlegend"] = False + fig["data"][0]["name"] = "Individual Sessions" + fig["data"][0]["showlegend"] = True return fig @@ -194,6 +209,7 @@ def _plot_all_benchmarks_combined_session_av(results_directory: Path) -> Figure: title=title, ) fig["data"][0]["showlegend"] = True + fig.update_layout(legend=dict(yanchor="top", y=-0.2, xanchor="left", x=0.01, orientation="h")) return fig @@ -248,14 +264,7 @@ def _plot_av_s_per_100_steps_10_nodes( versions = sorted(list(version_times_dict.keys())) times = [version_times_dict[version] for version in versions] - fig.add_trace( - go.Bar( - x=versions, - y=times, - text=times, - textposition="auto", - ) - ) + fig.add_trace(go.Bar(x=versions, y=times, text=times, textposition="auto", texttemplate="%{y:.3f}")) fig.update_layout( xaxis_title="PrimAITE Version", @@ -267,7 +276,11 @@ def _plot_av_s_per_100_steps_10_nodes( def build_benchmark_md_report( - benchmark_start_time: datetime, session_metadata: Dict, config_path: Path, results_root_path: Path + benchmark_start_time: datetime, + session_metadata: Dict, + config_path: Path, + results_root_path: Path, + output_path: PathLike, ) -> None: """ Generates a Markdown report for a benchmarking session, documenting performance metrics and graphs. @@ -319,7 +332,7 @@ def build_benchmark_md_report( data = benchmark_metadata_dict primaite_version = data["primaite_version"] - with open(version_result_dir / f"PrimAITE v{primaite_version} Benchmark Report.md", "w") as file: + with open(output_path, "w") as file: # Title file.write(f"# PrimAITE v{primaite_version} Learning Benchmark\n") file.write("## PrimAITE Dev Team\n") @@ -393,3 +406,15 @@ def build_benchmark_md_report( f"![Performance of Minor and Bugfix Releases for Major Version {major_v}]" f"({performance_benchmark_plot_path.name})\n" ) + + +def md2pdf(md_path: PathLike, pdf_path: PathLike, css_path: PathLike) -> None: + """Generate PDF version of Markdown report.""" + from md2pdf.core import md2pdf + + md2pdf( + pdf_file_path=pdf_path, + md_file_path=md_path, + base_url=Path(md_path).parent, + css_file_path=css_path, + ) diff --git a/benchmark/static/styles.css b/benchmark/static/styles.css new file mode 100644 index 00000000..4fbb9bd5 --- /dev/null +++ b/benchmark/static/styles.css @@ -0,0 +1,34 @@ +body { + font-family: 'Arial', sans-serif; + line-height: 1.6; + /* margin: 1cm; */ +} +h1, h2, h3, h4, h5, h6 { + font-weight: bold; + /* margin: 1em 0; */ +} +p { + /* margin: 0.5em 0; */ +} +ul, ol { + margin: 1em 0; + padding-left: 1.5em; +} +pre { + background: #f4f4f4; + padding: 0.5em; + overflow-x: auto; +} +img { + max-width: 100%; + height: auto; +} +table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; +} +th, td { + padding: 0.5em; + border: 1px solid #ddd; +} diff --git a/pyproject.toml b/pyproject.toml index c9b7c062..354df8b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,8 @@ dev = [ "wheel==0.38.4", "nbsphinx==0.9.4", "nbmake==1.5.4", - "pytest-xdist==3.3.1" + "pytest-xdist==3.3.1", + "md2pdf", ] [project.scripts] From afa4d2b946ea479efc80be10362a52881f5f939d Mon Sep 17 00:00:00 2001 From: "Archer.Bowen" Date: Wed, 7 Aug 2024 10:34:30 +0100 Subject: [PATCH 122/206] #2689 Address a couple of TODOs and other misc changes. --- .../Command-&-Control-E2E-Demonstration.ipynb | 33 ++++- .../red_applications/c2/c2_beacon.py | 26 ++-- .../red_applications/c2/c2_server.py | 3 +- .../_red_applications/test_c2_suite.py | 136 +++++------------- 4 files changed, 84 insertions(+), 114 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 60ea756d..1df85bb6 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -180,7 +180,7 @@ "source": [ "## **Command and Control** | C2 Beacon Actions\n", "\n", - "Before the Red Agent is able to perform any C2 Server commands, it must first establish connection with a C2 beacon.\n", + "Before any C2 Server commands is able to accept any commands, it must first establish connection with a C2 beacon.\n", "\n", "This can be done by installing, configuring and then executing a C2 Beacon. " ] @@ -341,6 +341,37 @@ "# TODO: Post Terminal.\n", "#env.step(7)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Command and Control** | Blue Agent Relevance" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | Blue Agent Relevance | Observation Space" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | Blue Agent Relevance | Action Space" + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index c73799da..6dd1a873 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -184,8 +184,6 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: The Request Response provided by the terminal execute method. :rtype Request Response: """ - # TODO: Probably could refactor this to be a more clean. - # The elif's are a bit ugly when they are all calling the same method. command = payload.command if not isinstance(command, C2Command): self.sys_log.warning(f"{self.name}: Received unexpected C2 command. Unable to resolve command") @@ -253,19 +251,26 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): """ C2 Command: Ransomware Configuration. - Creates a request that configures the ransomware based off the configuration options given. - This request is then sent to the terminal service in order to be executed. + Calls the locally installed RansomwareScript application's configure method + and passes the given parameters. + + The class attribute self._host_ransomware_script will return None if the host + does not have an instance of the RansomwareScript. :payload MasqueradePacket: The incoming INPUT command. :type Masquerade Packet: MasqueradePacket. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - # TODO: replace and use terminal - # return RequestResponse(status="success", data={"Reason": "Placeholder."}) given_config = payload.payload - host_ransomware = self._host_ransomware_script - return RequestResponse.from_bool(host_ransomware.configure(server_ip_address=given_config["server_ip_address"])) + if self._host_ransomware_script is None: + return RequestResponse( + status="failure", + data={"Reason": "Cannot find any instances of a RansomwareScript. Have you installed one?"}, + ) + return RequestResponse.from_bool( + self._host_ransomware_script.configure(server_ip_address=given_config["server_ip_address"]) + ) def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: """ @@ -280,6 +285,11 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ + if self._host_ransomware_script is None: + return RequestResponse( + status="failure", + data={"Reason": "Cannot find any instances of a RansomwareScript. Have you installed one?"}, + ) return RequestResponse.from_bool(self._host_ransomware_script.attack()) def _command_terminal(self, payload: MasqueradePacket) -> RequestResponse: diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index c381403e..85009cec 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -219,7 +219,7 @@ class C2Server(AbstractC2, identifier="C2Server"): """ Creates and returns a Masquerade Packet using the arguments given. - Creates Masquerade Packet with a payload_type INPUT C2Payload + Creates Masquerade Packet with a payload_type INPUT C2Payload. :param given_command: The C2 command to be sent to the C2 Beacon. :type given_command: C2Command. @@ -228,7 +228,6 @@ class C2Server(AbstractC2, identifier="C2Server"): :return: Returns the construct MasqueradePacket :rtype: MasqueradePacket """ - # TODO: Validation on command_options. constructed_packet = MasqueradePacket( masquerade_protocol=self.current_masquerade_protocol, masquerade_port=self.current_masquerade_port, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 064ef57d..7e4df4f1 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -21,40 +21,44 @@ from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.web_server.web_server import WebServer -@pytest.fixture(scope="function") -def c2_server_on_computer() -> Tuple[C2Beacon, Computer]: - computer: Computer = Computer( - hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0 - ) - computer.power_on() - c2_beacon = computer.software_manager.software.get("C2Beacon") - - return [c2_beacon, computer] - - -@pytest.fixture(scope="function") -def c2_server_on_computer() -> Tuple[C2Server, Computer]: - computer: Computer = Computer( - hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0 - ) - computer.power_on() - c2_server = computer.software_manager.software.get("C2Server") - - return [c2_server, computer] - - @pytest.fixture(scope="function") def basic_network() -> Network: network = Network() - node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + + # Creating two generic nodes for the C2 Server and the C2 Beacon. + node_a = Computer(hostname="node_a", ip_address="192.168.0.2", subnet_mask="255.255.255.252", start_up_duration=0) node_a.power_on() node_a.software_manager.get_open_ports() node_a.software_manager.install(software_class=C2Server) - node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b = Computer(hostname="node_b", ip_address="192.168.255.2", subnet_mask="255.255.255.252", start_up_duration=0) node_b.software_manager.install(software_class=C2Beacon) node_b.power_on() - network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + # Creating a router to sit between node 1 and node 2. + router = Router(hostname="router", num_ports=3, start_up_duration=0) + router.power_on() + router.configure_port(port=1, ip_address="192.168.0.1", subnet_mask="255.255.255.252") + router.configure_port(port=2, ip_address="192.168.255.1", subnet_mask="255.255.255.252") + + # Creating switches for each client. + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + + # Connecting the switches to the router. + network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) + router.enable_port(1) + + switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) + switch_2.power_on() + + network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router.enable_port(2) + + # Connecting the node to each switch + network.connect(node_a.network_interface[1], switch_1.network_interface[1]) + + network.connect(node_b.network_interface[1], switch_2.network_interface[1]) return network @@ -68,9 +72,10 @@ def test_c2_suite_setup_receive(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + computer_a.ping("192.168.255.1") # Assert that the c2 beacon configure correctly. - c2_beacon.configure(c2_server_ip_address="192.168.0.10") - assert c2_beacon.c2_remote_connection == IPv4Address("192.168.0.10") + c2_beacon.configure(c2_server_ip_address="192.168.0.2") + assert c2_beacon.c2_remote_connection == IPv4Address("192.168.0.2") c2_server.run() c2_beacon.establish() @@ -80,7 +85,7 @@ def test_c2_suite_setup_receive(basic_network): # Asserting that the c2 server has established a c2 connection. assert c2_server.c2_connection_active is True - assert c2_server.c2_remote_connection == IPv4Address("192.168.0.11") + assert c2_server.c2_remote_connection == IPv4Address("192.168.255.2") def test_c2_suite_keep_alive_inactivity(basic_network): @@ -177,81 +182,6 @@ def test_c2_suite_terminal(basic_network): """Tests that a red agent is able to execute terminal commands via C2 Server Actions.""" -@pytest.fixture(scope="function") -def acl_network() -> Network: - # 0: Pull out the network - network = Network() - - # 1: Set up network hardware - # 1.1: Configure the router - router = Router(hostname="router", num_ports=3, start_up_duration=0) - router.power_on() - router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") - router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") - - # 1.2: Create and connect switches - switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) - switch_1.power_on() - network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) - router.enable_port(1) - switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) - switch_2.power_on() - network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) - router.enable_port(2) - - # 1.3: Create and connect computer - client_1 = Computer( - hostname="client_1", - ip_address="10.0.1.2", - subnet_mask="255.255.255.0", - default_gateway="10.0.1.1", - start_up_duration=0, - ) - client_1.power_on() - client_1.software_manager.install(software_class=C2Server) - network.connect( - endpoint_a=client_1.network_interface[1], - endpoint_b=switch_1.network_interface[1], - ) - - client_2 = Computer( - hostname="client_2", - ip_address="10.0.1.3", - subnet_mask="255.255.255.0", - default_gateway="10.0.1.1", - start_up_duration=0, - ) - client_2.power_on() - client_2.software_manager.install(software_class=C2Beacon) - network.connect(endpoint_a=client_2.network_interface[1], endpoint_b=switch_2.network_interface[1]) - - # 1.4: Create and connect servers - server_1 = Server( - hostname="server_1", - ip_address="10.0.2.2", - subnet_mask="255.255.255.0", - default_gateway="10.0.2.1", - start_up_duration=0, - ) - server_1.power_on() - network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_2.network_interface[1]) - - server_2 = Server( - hostname="server_2", - ip_address="10.0.2.3", - subnet_mask="255.255.255.0", - default_gateway="10.0.2.1", - start_up_duration=0, - ) - server_2.power_on() - network.connect(endpoint_a=server_2.network_interface[1], endpoint_b=switch_2.network_interface[2]) - - return network - - -# TODO: Fix this test: Not sure why this isn't working - - def test_c2_suite_acl_block(acl_network): """Tests that C2 Beacon disconnects from the C2 Server after blocking ACL rules.""" network: Network = acl_network From 1802648436255edba7593ee39826a109701d83d7 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 7 Aug 2024 11:31:51 +0100 Subject: [PATCH 123/206] #2781 - Initial commit with changes to Terminal to integrate with user_session_manager. Login and logout are now talking to the monitored user session --- .../system/services/terminal/terminal.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 4be2c501..11101d55 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -277,8 +277,7 @@ class Terminal(Service): :return: boolean, True if successful, else False """ # TODO: Un-comment this when UserSessionManager is merged. - # connection_uuid = self.parent.UserSessionManager.login(username=username, password=password) - connection_uuid = str(uuid4()) + connection_uuid = self.parent.user_session_manager.local_login(username=username, password=password) if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections and return @@ -332,7 +331,7 @@ class Terminal(Service): self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.") return remote_terminal_connection else: - self.sys_log.warning(f"Connection request{connection_request_id} declined") + self.sys_log.warning(f"Connection request {connection_request_id} declined") return None else: self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") @@ -405,13 +404,14 @@ class Terminal(Service): if payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: # validate & add connection # TODO: uncomment this as part of 2781 - # connection_id = self.parent.UserSessionManager.login(username=username, password=password) - connection_id = str(uuid4()) + username = payload.user_account.username + password = payload.user_account.password + connection_id = self.parent.user_session_manager.remote_login( + username=username, password=password, remote_ip_address=source_ip + ) + # connection_id = str(uuid4()) if connection_id: connection_request_id = payload.connection_request_uuid - username = payload.user_account.username - password = payload.user_account.password - print(f"Connection ID is: {connection_request_id}") self.sys_log.info(f"Connection authorised, session_id: {session_id}") self._create_remote_connection( connection_id=connection_id, @@ -469,6 +469,7 @@ class Terminal(Service): if valid_id: self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.") self._disconnect(payload["connection_id"]) + self.parent.user_session_manager.remote_logout(remote_session_id=connection_id) else: self.sys_log.info("No Active connection held for received connection ID.") @@ -501,7 +502,7 @@ class Terminal(Service): return True elif isinstance(connection, LocalTerminalConnection): - # No further action needed + self.parent.user_session_manager.local_logout() return True def send( From 9fea34bb434c1a277baf85d8aeac19a7859a8160 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 7 Aug 2024 11:58:17 +0100 Subject: [PATCH 124/206] #2781 - Correcting terminal tests and fixing a typo in base.py --- .../simulator/network/hardware/base.py | 2 +- .../system/services/terminal/terminal.py | 1 - .../_system/_services/test_terminal.py | 32 +++++++++---------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 9230dd47..142561f5 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1174,7 +1174,7 @@ class UserSessionManager(Service): """ rm = super()._init_request_manager() - # todo add doc about requeest schemas + # todo add doc about request schemas rm.add_request( "remote_login", RequestType( diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 11101d55..5e684d89 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -456,7 +456,6 @@ class Terminal(Service): self.sys_log.info("Received command to execute") command = payload.ssh_command valid_connection = self._check_client_connection(payload.connection_uuid) - self.sys_log.info(f"Connection uuid is {valid_connection}") if valid_connection: return self.execute(command, payload.connection_uuid) else: diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 9286fa49..ffe48ab5 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -185,7 +185,7 @@ def test_terminal_receive(basic_network): ) term_a_on_node_b: RemoteTerminalConnection = terminal_a.login( - username="username", password="password", ip_address="192.168.0.11" + username="admin", password="admin", ip_address="192.168.0.11" ) term_a_on_node_b.execute(["file_system", "create", "folder", folder_name]) @@ -208,7 +208,7 @@ def test_terminal_install(basic_network): ) term_a_on_node_b: RemoteTerminalConnection = terminal_a.login( - username="username", password="password", ip_address="192.168.0.11" + username="admin", password="admin", ip_address="192.168.0.11" ) term_a_on_node_b.execute(["software_manager", "application", "install", "RansomwareScript"]) @@ -225,9 +225,7 @@ def test_terminal_fail_when_closed(basic_network): terminal.operating_state = ServiceOperatingState.STOPPED - assert not terminal.login( - username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address - ) + assert not terminal.login(username="admin", password="admin", ip_address=computer_b.network_interface[1].ip_address) def test_terminal_disconnect(basic_network): @@ -241,7 +239,7 @@ def test_terminal_disconnect(basic_network): assert len(terminal_b._connections) == 0 term_a_on_term_b = terminal_a.login( - username="admin", password="Admin123!", ip_address=computer_b.network_interface[1].ip_address + username="admin", password="admin", ip_address=computer_b.network_interface[1].ip_address ) assert len(terminal_b._connections) == 1 @@ -260,7 +258,7 @@ def test_terminal_ignores_when_off(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") term_a_on_term_b: RemoteTerminalConnection = terminal_a.login( - username="admin", password="Admin123!", ip_address="192.168.0.11" + username="admin", password="admin", ip_address="192.168.0.11" ) # login to computer_b terminal_a.operating_state = ServiceOperatingState.STOPPED @@ -276,7 +274,7 @@ def test_computer_remote_login_to_router(wireless_wan_network): assert len(pc_a_terminal._connections) == 0 - pc_a_on_router_1 = pc_a_terminal.login(username="username", password="password", ip_address="192.168.1.1") + pc_a_on_router_1 = pc_a_terminal.login(username="admin", password="admin", ip_address="192.168.1.1") assert len(pc_a_terminal._connections) == 1 @@ -295,7 +293,7 @@ def test_router_remote_login_to_computer(wireless_wan_network): assert len(router_1_terminal._connections) == 0 - router_1_on_pc_a = router_1_terminal.login(username="username", password="password", ip_address="192.168.0.2") + router_1_on_pc_a = router_1_terminal.login(username="admin", password="admin", ip_address="192.168.0.2") assert len(router_1_terminal._connections) == 1 @@ -317,7 +315,7 @@ def test_router_blocks_SSH_traffic(wireless_wan_network): assert len(pc_a_terminal._connections) == 0 - pc_a_terminal.login(username="username", password="password", ip_address="192.168.0.2") + pc_a_terminal.login(username="admin", password="admin", ip_address="192.168.0.2") assert len(pc_a_terminal._connections) == 0 @@ -333,7 +331,7 @@ def test_SSH_across_network(wireless_wan_network): assert len(terminal_a._connections) == 0 - terminal_b_on_terminal_a = terminal_b.login(username="username", password="password", ip_address="192.168.0.2") + terminal_b_on_terminal_a = terminal_b.login(username="admin", password="admin", ip_address="192.168.0.2") assert len(terminal_a._connections) == 1 @@ -347,11 +345,13 @@ def test_multiple_remote_terminals_same_node(basic_network): assert len(terminal_a._connections) == 0 - # Spam login requests to terminal. - for attempt in range(10): - remote_connection = terminal_a.login(username="username", password="password", ip_address="192.168.0.11") + # Spam login requests to node. + for attempt in range(3): + remote_connection = terminal_a.login(username="admin", password="admin", ip_address="192.168.0.11") - assert len(terminal_a._connections) == 10 + terminal_a.show() + + assert len(terminal_a._connections) == 3 def test_terminal_rejects_commands_if_disconnect(basic_network): @@ -363,7 +363,7 @@ def test_terminal_rejects_commands_if_disconnect(basic_network): terminal_b: Terminal = computer_b.software_manager.software.get("Terminal") - remote_connection = terminal_a.login(username="username", password="password", ip_address="192.168.0.11") + remote_connection = terminal_a.login(username="admin", password="admin", ip_address="192.168.0.11") assert len(terminal_a._connections) == 1 assert len(terminal_b._connections) == 1 From fe599f77452bc8e48d8437c77b29e2e649f0dde7 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 7 Aug 2024 12:09:44 +0100 Subject: [PATCH 125/206] #2799 - Fix folder scan not being required and make it configurable --- CHANGELOG.md | 4 + .../observations/file_system_observations.py | 56 +++++++- .../agent/observations/host_observations.py | 18 ++- .../agent/observations/node_observations.py | 4 + .../_game/_agent/test_observations.py | 132 ++++++++++++++++++ 5 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/_primaite/_game/_agent/test_observations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d999607..73a3f496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. +- File and folder observations can now be configured to always show the true health status, or require scanning like before. + +### Fixed +- Folder observations showing the true health state without scanning (the old behaviour can be reenabled via config) ## [3.2.0] - 2024-07-18 diff --git a/src/primaite/game/agent/observations/file_system_observations.py b/src/primaite/game/agent/observations/file_system_observations.py index cb48fe7d..bd130673 100644 --- a/src/primaite/game/agent/observations/file_system_observations.py +++ b/src/primaite/game/agent/observations/file_system_observations.py @@ -23,8 +23,10 @@ class FileObservation(AbstractObservation, identifier="FILE"): """Name of the file, used for querying simulation state dictionary.""" include_num_access: Optional[bool] = None """Whether to include the number of accesses to the file in the observation.""" + file_system_requires_scan: Optional[bool] = None + """If True, the file must be scanned to update the health state. Tf False, the true state is always shown.""" - def __init__(self, where: WhereType, include_num_access: bool) -> None: + def __init__(self, where: WhereType, include_num_access: bool, file_system_requires_scan: bool) -> None: """ Initialise a file observation instance. @@ -34,9 +36,13 @@ class FileObservation(AbstractObservation, identifier="FILE"): :type where: WhereType :param include_num_access: Whether to include the number of accesses to the file in the observation. :type include_num_access: bool + :param file_system_requires_scan: If True, the file must be scanned to update the health state. Tf False, + the true state is always shown. + :type file_system_requires_scan: bool """ self.where: WhereType = where self.include_num_access: bool = include_num_access + self.file_system_requires_scan: bool = file_system_requires_scan self.default_observation: ObsType = {"health_status": 0} if self.include_num_access: @@ -74,7 +80,11 @@ class FileObservation(AbstractObservation, identifier="FILE"): file_state = access_from_nested_dict(state, self.where) if file_state is NOT_PRESENT_IN_STATE: return self.default_observation - obs = {"health_status": file_state["visible_status"]} + if self.file_system_requires_scan: + health_status = file_state["visible_status"] + else: + health_status = file_state["health_status"] + obs = {"health_status": health_status} if self.include_num_access: obs["num_access"] = self._categorise_num_access(file_state["num_access"]) return obs @@ -104,8 +114,15 @@ class FileObservation(AbstractObservation, identifier="FILE"): :type parent_where: WhereType, optional :return: Constructed file observation instance. :rtype: FileObservation + :param file_system_requires_scan: If True, the folder must be scanned to update the health state. Tf False, + the true state is always shown. + :type file_system_requires_scan: bool """ - return cls(where=parent_where + ["files", config.file_name], include_num_access=config.include_num_access) + return cls( + where=parent_where + ["files", config.file_name], + include_num_access=config.include_num_access, + file_system_requires_scan=config.file_system_requires_scan, + ) class FolderObservation(AbstractObservation, identifier="FOLDER"): @@ -122,9 +139,16 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"): """Number of spaces for file observations in this folder.""" include_num_access: Optional[bool] = None """Whether files in this folder should include the number of accesses in their observation.""" + file_system_requires_scan: Optional[bool] = None + """If True, the folder must be scanned to update the health state. Tf False, the true state is always shown.""" def __init__( - self, where: WhereType, files: Iterable[FileObservation], num_files: int, include_num_access: bool + self, + where: WhereType, + files: Iterable[FileObservation], + num_files: int, + include_num_access: bool, + file_system_requires_scan: bool, ) -> None: """ Initialise a folder observation instance. @@ -141,9 +165,17 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"): """ self.where: WhereType = where + self.file_system_requires_scan: bool = file_system_requires_scan + self.files: List[FileObservation] = files while len(self.files) < num_files: - self.files.append(FileObservation(where=None, include_num_access=include_num_access)) + self.files.append( + FileObservation( + where=None, + include_num_access=include_num_access, + file_system_requires_scan=self.file_system_requires_scan, + ) + ) while len(self.files) > num_files: truncated_file = self.files.pop() msg = f"Too many files in folder observation. Truncating file {truncated_file}" @@ -168,7 +200,10 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"): if folder_state is NOT_PRESENT_IN_STATE: return self.default_observation - health_status = folder_state["health_status"] + if self.file_system_requires_scan: + health_status = folder_state["visible_status"] + else: + health_status = folder_state["health_status"] obs = {} @@ -209,6 +244,13 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"): # pass down shared/common config items for file_config in config.files: file_config.include_num_access = config.include_num_access + file_config.file_system_requires_scan = config.file_system_requires_scan files = [FileObservation.from_config(config=f, parent_where=where) for f in config.files] - return cls(where=where, files=files, num_files=config.num_files, include_num_access=config.include_num_access) + return cls( + where=where, + files=files, + num_files=config.num_files, + include_num_access=config.include_num_access, + file_system_requires_scan=config.file_system_requires_scan, + ) diff --git a/src/primaite/game/agent/observations/host_observations.py b/src/primaite/game/agent/observations/host_observations.py index f9fd9b1a..7053d019 100644 --- a/src/primaite/game/agent/observations/host_observations.py +++ b/src/primaite/game/agent/observations/host_observations.py @@ -48,6 +48,10 @@ class HostObservation(AbstractObservation, identifier="HOST"): """A dict containing which traffic types are to be included in the observation.""" include_num_access: Optional[bool] = None """Whether to include the number of accesses to files observations on this host.""" + file_system_requires_scan: Optional[bool] = None + """ + If True, files and folders must be scanned to update the health state. If False, true state is always shown. + """ def __init__( self, @@ -64,6 +68,7 @@ class HostObservation(AbstractObservation, identifier="HOST"): include_nmne: bool, monitored_traffic: Optional[Dict], include_num_access: bool, + file_system_requires_scan: bool, ) -> None: """ Initialise a host observation instance. @@ -95,6 +100,9 @@ class HostObservation(AbstractObservation, identifier="HOST"): :type monitored_traffic: Dict :param include_num_access: Flag to include the number of accesses to files. :type include_num_access: bool + :param file_system_requires_scan: If True, the files and folders must be scanned to update the health state. + If False, the true state is always shown. + :type file_system_requires_scan: bool """ self.where: WhereType = where @@ -120,7 +128,13 @@ class HostObservation(AbstractObservation, identifier="HOST"): self.folders: List[FolderObservation] = folders while len(self.folders) < num_folders: self.folders.append( - FolderObservation(where=None, files=[], num_files=num_files, include_num_access=include_num_access) + FolderObservation( + where=None, + files=[], + num_files=num_files, + include_num_access=include_num_access, + file_system_requires_scan=file_system_requires_scan, + ) ) while len(self.folders) > num_folders: truncated_folder = self.folders.pop() @@ -226,6 +240,7 @@ class HostObservation(AbstractObservation, identifier="HOST"): for folder_config in config.folders: folder_config.include_num_access = config.include_num_access folder_config.num_files = config.num_files + folder_config.file_system_requires_scan = config.file_system_requires_scan for nic_config in config.network_interfaces: nic_config.include_nmne = config.include_nmne @@ -257,4 +272,5 @@ class HostObservation(AbstractObservation, identifier="HOST"): include_nmne=config.include_nmne, monitored_traffic=config.monitored_traffic, include_num_access=config.include_num_access, + file_system_requires_scan=config.file_system_requires_scan, ) diff --git a/src/primaite/game/agent/observations/node_observations.py b/src/primaite/game/agent/observations/node_observations.py index f7bfcc99..c68531f8 100644 --- a/src/primaite/game/agent/observations/node_observations.py +++ b/src/primaite/game/agent/observations/node_observations.py @@ -44,6 +44,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): """A dict containing which traffic types are to be included in the observation.""" include_num_access: Optional[bool] = None """Flag to include the number of accesses.""" + file_system_requires_scan: bool = True + """If True, the folder must be scanned to update the health state. Tf False, the true state is always shown.""" num_ports: Optional[int] = None """Number of ports.""" ip_list: Optional[List[str]] = None @@ -187,6 +189,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): host_config.monitored_traffic = config.monitored_traffic if host_config.include_num_access is None: host_config.include_num_access = config.include_num_access + if host_config.file_system_requires_scan is None: + host_config.file_system_requires_scan = config.file_system_requires_scan for router_config in config.routers: if router_config.num_ports is None: diff --git a/tests/unit_tests/_primaite/_game/_agent/test_observations.py b/tests/unit_tests/_primaite/_game/_agent/test_observations.py new file mode 100644 index 00000000..7f590685 --- /dev/null +++ b/tests/unit_tests/_primaite/_game/_agent/test_observations.py @@ -0,0 +1,132 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import List + +import pytest +import yaml + +from primaite.game.agent.observations import ObservationManager +from primaite.game.agent.observations.file_system_observations import FileObservation, FolderObservation +from primaite.game.agent.observations.host_observations import HostObservation + + +class TestFileSystemRequiresScan: + @pytest.mark.parametrize( + ("yaml_option_string", "expected_val"), + ( + ("file_system_requires_scan: true", True), + ("file_system_requires_scan: false", False), + (" ", True), + ), + ) + def test_obs_config(self, yaml_option_string, expected_val): + """Check that the default behaviour is to set FileSystemRequiresScan to True.""" + obs_cfg_yaml = f""" + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + {yaml_option_string} + include_nmne: true + monitored_traffic: + icmp: + - NONE + tcp: + - DNS + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {{}} + + """ + + cfg = yaml.safe_load(obs_cfg_yaml) + manager = ObservationManager.from_config(cfg) + + hosts: List[HostObservation] = manager.obs.components["NODES"].hosts + for i, host in enumerate(hosts): + folders: List[FolderObservation] = host.folders + for j, folder in enumerate(folders): + assert folder.file_system_requires_scan == expected_val # Make sure folders require scan by default + files: List[FileObservation] = folder.files + for k, file in enumerate(files): + assert file.file_system_requires_scan == expected_val + + def test_file_require_scan(self): + file_state = {"health_status": 3, "visible_status": 1} + + obs_requiring_scan = FileObservation([], include_num_access=False, file_system_requires_scan=True) + assert obs_requiring_scan.observe(file_state)["health_status"] == 1 + + obs_not_requiring_scan = FileObservation([], include_num_access=False, file_system_requires_scan=False) + assert obs_not_requiring_scan.observe(file_state)["health_status"] == 3 + + def test_folder_require_scan(self): + folder_state = {"health_status": 3, "visible_status": 1} + + obs_requiring_scan = FolderObservation( + [], files=[], num_files=0, include_num_access=False, file_system_requires_scan=True + ) + assert obs_requiring_scan.observe(folder_state)["health_status"] == 1 + + obs_not_requiring_scan = FolderObservation( + [], files=[], num_files=0, include_num_access=False, file_system_requires_scan=False + ) + assert obs_not_requiring_scan.observe(folder_state)["health_status"] == 3 From b193b46b7b725137e150c7707e5dc1ff68a5bfd9 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 7 Aug 2024 13:43:11 +0100 Subject: [PATCH 126/206] #2799 - Update observation tests --- .../game_layer/observations/test_file_system_observations.py | 2 ++ .../game_layer/observations/test_node_observations.py | 1 + tests/integration_tests/game_layer/test_observations.py | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/integration_tests/game_layer/observations/test_file_system_observations.py b/tests/integration_tests/game_layer/observations/test_file_system_observations.py index 1031dcb0..e2ab2990 100644 --- a/tests/integration_tests/game_layer/observations/test_file_system_observations.py +++ b/tests/integration_tests/game_layer/observations/test_file_system_observations.py @@ -26,6 +26,7 @@ def test_file_observation(simulation): dog_file_obs = FileObservation( where=["network", "nodes", pc.hostname, "file_system", "folders", "root", "files", "dog.png"], include_num_access=False, + file_system_requires_scan=True, ) assert dog_file_obs.space["health_status"] == spaces.Discrete(6) @@ -53,6 +54,7 @@ def test_folder_observation(simulation): root_folder_obs = FolderObservation( where=["network", "nodes", pc.hostname, "file_system", "folders", "test_folder"], include_num_access=False, + file_system_requires_scan=True, num_files=1, files=[], ) diff --git a/tests/integration_tests/game_layer/observations/test_node_observations.py b/tests/integration_tests/game_layer/observations/test_node_observations.py index 8a36ea5c..1edb0442 100644 --- a/tests/integration_tests/game_layer/observations/test_node_observations.py +++ b/tests/integration_tests/game_layer/observations/test_node_observations.py @@ -38,6 +38,7 @@ def test_host_observation(simulation): applications=[], folders=[], network_interfaces=[], + file_system_requires_scan=True, ) assert host_obs.space["operating_status"] == spaces.Discrete(5) diff --git a/tests/integration_tests/game_layer/test_observations.py b/tests/integration_tests/game_layer/test_observations.py index ff83c532..d5679007 100644 --- a/tests/integration_tests/game_layer/test_observations.py +++ b/tests/integration_tests/game_layer/test_observations.py @@ -17,6 +17,7 @@ def test_file_observation(): dog_file_obs = FileObservation( where=["network", "nodes", pc.hostname, "file_system", "folders", "root", "files", "dog.png"], include_num_access=False, + file_system_requires_scan=True, ) assert dog_file_obs.observe(state) == {"health_status": 1} assert dog_file_obs.space == spaces.Dict({"health_status": spaces.Discrete(6)}) From b1baf023d64f44e399706e0f5402e9ad798c4de0 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Wed, 7 Aug 2024 14:16:50 +0100 Subject: [PATCH 127/206] #2689 Fixed up Pytests and confirmed functionality before merging from dev. --- .../Command-&-Control-E2E-Demonstration.ipynb | 2 +- .../red_applications/c2/c2_beacon.py | 1 + .../integration_tests/network/test_routing.py | 18 +-- .../system/red_applications}/test_c2_suite.py | 114 ++++++++++-------- 4 files changed, 77 insertions(+), 58 deletions(-) rename tests/{unit_tests/_primaite/_simulator/_system/_applications/_red_applications => integration_tests/system/red_applications}/test_c2_suite.py (72%) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 1df85bb6..0810871b 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -390,7 +390,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.8" + "version": "3.10.12" } }, "nbformat": 4, diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 6dd1a873..1dde28a2 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -380,6 +380,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.keep_alive_inactivity += 1 if not self._check_c2_connection(timestep): self.sys_log.error(f"{self.name}: Connection Severed - Application Closing.") + self.c2_connection_active = False self.clear_connections() # TODO: Shouldn't this close() method also set the health state to 'UNUSED'? self.close() diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 62b58cbd..5f9e03ef 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -33,18 +33,18 @@ def pc_a_pc_b_router_1() -> Tuple[Computer, Computer, Router]: ) pc_b.power_on() - router_1 = Router(hostname="router_1", start_up_duration=0) - router_1.power_on() + router = Router(hostname="router", start_up_duration=0) + router.power_on() - router_1.configure_port(1, "192.168.0.1", "255.255.255.0") - router_1.configure_port(2, "192.168.1.1", "255.255.255.0") + router.configure_port(1, "192.168.0.1", "255.255.255.0") + router.configure_port(2, "192.168.1.1", "255.255.255.0") - network.connect(endpoint_a=pc_a.network_interface[1], endpoint_b=router_1.network_interface[1]) - network.connect(endpoint_a=pc_b.network_interface[1], endpoint_b=router_1.network_interface[2]) - router_1.enable_port(1) - router_1.enable_port(2) + network.connect(endpoint_a=pc_a.network_interface[1], endpoint_b=router.network_interface[1]) + network.connect(endpoint_a=pc_b.network_interface[1], endpoint_b=router.network_interface[2]) + router.enable_port(1) + router.enable_port(2) - return pc_a, pc_b, router_1 + return pc_a, pc_b, router @pytest.fixture(scope="function") diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/integration_tests/system/red_applications/test_c2_suite.py similarity index 72% rename from tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py rename to tests/integration_tests/system/red_applications/test_c2_suite.py index 7e4df4f1..9d66f3c1 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite.py @@ -6,6 +6,7 @@ import pytest from primaite.game.agent.interface import ProxyAgent from primaite.game.game import PrimaiteGame +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server @@ -14,9 +15,11 @@ from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript +from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.web_server.web_server import WebServer @@ -26,33 +29,46 @@ def basic_network() -> Network: network = Network() # Creating two generic nodes for the C2 Server and the C2 Beacon. - node_a = Computer(hostname="node_a", ip_address="192.168.0.2", subnet_mask="255.255.255.252", start_up_duration=0) + node_a = Computer( + hostname="node_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.252", + default_gateway="192.168.0.1", + start_up_duration=0, + ) node_a.power_on() node_a.software_manager.get_open_ports() node_a.software_manager.install(software_class=C2Server) - node_b = Computer(hostname="node_b", ip_address="192.168.255.2", subnet_mask="255.255.255.252", start_up_duration=0) - node_b.software_manager.install(software_class=C2Beacon) + node_b = Computer( + hostname="node_b", + ip_address="192.168.255.2", + subnet_mask="255.255.255.252", + default_gateway="192.168.255.1", + start_up_duration=0, + ) node_b.power_on() - + node_b.software_manager.install(software_class=C2Beacon) # Creating a router to sit between node 1 and node 2. router = Router(hostname="router", num_ports=3, start_up_duration=0) + # Default allow all. + router.acl.add_rule(action=ACLAction.PERMIT) router.power_on() - router.configure_port(port=1, ip_address="192.168.0.1", subnet_mask="255.255.255.252") - router.configure_port(port=2, ip_address="192.168.255.1", subnet_mask="255.255.255.252") - # Creating switches for each client. switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) switch_1.power_on() # Connecting the switches to the router. + router.configure_port(port=1, ip_address="192.168.0.1", subnet_mask="255.255.255.252") network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) - router.enable_port(1) switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) switch_2.power_on() network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router.configure_port(port=2, ip_address="192.168.255.1", subnet_mask="255.255.255.252") + + router.enable_port(1) router.enable_port(2) # Connecting the node to each switch @@ -72,7 +88,6 @@ def test_c2_suite_setup_receive(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") - computer_a.ping("192.168.255.1") # Assert that the c2 beacon configure correctly. c2_beacon.configure(c2_server_ip_address="192.168.0.2") assert c2_beacon.c2_remote_connection == IPv4Address("192.168.0.2") @@ -97,8 +112,7 @@ def test_c2_suite_keep_alive_inactivity(basic_network): computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") - # Initial config (#TODO: Make this a function) - c2_beacon.configure(c2_server_ip_address="192.168.0.10", keep_alive_frequency=2) + c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) c2_server.run() c2_beacon.establish() @@ -116,12 +130,11 @@ def test_c2_suite_keep_alive_inactivity(basic_network): c2_beacon.apply_timestep(3) assert c2_beacon.keep_alive_inactivity == 2 assert c2_beacon.c2_connection_active == False - assert c2_beacon.health_state_actual == ApplicationOperatingState.CLOSED + assert c2_beacon.operating_state == ApplicationOperatingState.CLOSED -# TODO: Flesh out these tests. -def test_c2_suite_configure_via_actions(basic_network): - """Tests that a red agent is able to configure the c2 beacon and c2 server via Actions.""" +def test_c2_suite_configure_request(basic_network): + """Tests that the request system can be used to successfully setup a c2 suite.""" # Setting up the network: network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") @@ -131,88 +144,93 @@ def test_c2_suite_configure_via_actions(basic_network): c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") # Testing Via Requests: - network.apply_request(["node", "node_a", "application", "C2Server", "run"]) + c2_server.run() + network.apply_timestep(0) c2_beacon_config = { - "c2_server_ip_address": "192.168.0.10", + "c2_server_ip_address": "192.168.0.2", "keep_alive_frequency": 5, - "masquerade_protocol": IPProtocol.TCP, - "masquerade_port": Port.HTTP, + "masquerade_protocol": "TCP", + "masquerade_port": "HTTP", } network.apply_request(["node", "node_b", "application", "C2Beacon", "configure", c2_beacon_config]) + network.apply_timestep(0) network.apply_request(["node", "node_b", "application", "C2Beacon", "execute"]) assert c2_beacon.c2_connection_active is True assert c2_server.c2_connection_active is True - assert c2_server.c2_remote_connection == IPv4Address("192.168.0.11") - - # Testing Via Agents: - # TODO: + assert c2_server.c2_remote_connection == IPv4Address("192.168.255.2") -def test_c2_suite_configure_ransomware(basic_network): - """Tests that a red agent is able to configure ransomware via C2 Server Actions.""" +def test_c2_suite_ransomware_commands(basic_network): + """Tests the Ransomware commands can be used to configure & launch ransomware via Requests.""" # Setting up the network: network: Network = basic_network computer_a: Computer = network.get_node_by_hostname("node_a") c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + computer_a.software_manager.install(DatabaseService) + computer_a.software_manager.software["DatabaseService"].start() computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + computer_b.software_manager.install(DatabaseClient) + computer_b.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.2")) + computer_b.software_manager.software["DatabaseClient"].run() - c2_beacon.configure(c2_server_ip_address="192.168.0.10", keep_alive_frequency=2) + c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) c2_server.run() c2_beacon.establish() # Testing Via Requests: computer_b.software_manager.install(software_class=RansomwareScript) - ransomware_config = {"server_ip_address": "1.1.1.1"} + ransomware_config = {"server_ip_address": "192.168.0.2"} network.apply_request(["node", "node_a", "application", "C2Server", "ransomware_configure", ransomware_config]) ransomware_script: RansomwareScript = computer_b.software_manager.software["RansomwareScript"] - assert ransomware_script.server_ip_address == "1.1.1.1" + assert ransomware_script.server_ip_address == "192.168.0.2" - # Testing Via Agents: - # TODO: + network.apply_request(["node", "node_a", "application", "C2Server", "ransomware_launch"]) + + database_file = computer_a.software_manager.file_system.get_file("database", "database.db") + + assert database_file.health_status == FileSystemItemHealthStatus.CORRUPT -def test_c2_suite_terminal(basic_network): - """Tests that a red agent is able to execute terminal commands via C2 Server Actions.""" - - -def test_c2_suite_acl_block(acl_network): +def test_c2_suite_acl_block(basic_network): """Tests that C2 Beacon disconnects from the C2 Server after blocking ACL rules.""" - network: Network = acl_network - computer_a: Computer = network.get_node_by_hostname("client_1") + + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - computer_b: Computer = network.get_node_by_hostname("client_2") + computer_b: Computer = network.get_node_by_hostname("node_b") c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") router: Router = network.get_node_by_hostname("router") - network.apply_timestep(0) - # Initial config (#TODO: Make this a function) - c2_beacon.configure(c2_server_ip_address="10.0.1.2", keep_alive_frequency=2) - + c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) c2_server.run() c2_beacon.establish() + c2_beacon.apply_timestep(0) + assert c2_beacon.keep_alive_inactivity == 1 + + # Keep Alive successfully sent and received upon the 2nd timestep. + c2_beacon.apply_timestep(1) assert c2_beacon.keep_alive_inactivity == 0 assert c2_beacon.c2_connection_active == True - assert c2_server.c2_connection_active == True # Now we add a HTTP blocking acl (Thus preventing a keep alive) - router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=1) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) - c2_beacon.apply_timestep(1) c2_beacon.apply_timestep(2) + c2_beacon.apply_timestep(3) assert c2_beacon.keep_alive_inactivity == 2 assert c2_beacon.c2_connection_active == False - assert c2_beacon.health_state_actual == ApplicationOperatingState.CLOSED + assert c2_beacon.operating_state == ApplicationOperatingState.CLOSED -def test_c2_suite_launch_ransomware(basic_network): - """Tests that a red agent is able to launch ransomware via C2 Server Actions.""" +def test_c2_suite_terminal(basic_network): + """Tests the Ransomware commands can be used to configure & launch ransomware via Requests.""" From d2693d974f48b9dad4cead272560783b7b420b94 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 7 Aug 2024 13:18:20 +0000 Subject: [PATCH 128/206] Fix relative path to primaite benchmark to align with build pipeline step --- benchmark/primaite_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/primaite_benchmark.py b/benchmark/primaite_benchmark.py index 2b09870d..86ed22a9 100644 --- a/benchmark/primaite_benchmark.py +++ b/benchmark/primaite_benchmark.py @@ -205,7 +205,7 @@ def run( md2pdf( md_path=output_path, pdf_path=str(output_path).replace(".md", ".pdf"), - css_path="benchmark/static/styles.css", + css_path="static/styles.css", ) From 93ef3076f552baa5d3b8be303843ac1659472b37 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 8 Aug 2024 11:33:42 +0100 Subject: [PATCH 129/206] #2781 - user_session_manager._timeout_session() now sends a user_timeout command when closing remote sessions. Corrected source_ip in Terminal.receive() --- .../notebooks/Terminal-Processing.ipynb | 26 ++++++++-- .../simulator/network/hardware/base.py | 7 +++ .../system/services/terminal/terminal.py | 52 ++++++++++++------- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 30b1a5e7..f3848c84 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -80,14 +80,14 @@ "outputs": [], "source": [ "# Login to the remote (node_b) from local (node_a)\n", - "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=\"192.168.0.11\")" + "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"admin\", ip_address=\"192.168.0.11\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "You can view all active connections to a terminal through use of the `show()` method" + "You can view all active connections to a terminal through use of the `show()` method, " ] }, { @@ -96,7 +96,11 @@ "metadata": {}, "outputs": [], "source": [ - "terminal_b.show()" + "terminal_b.show()\n", + "print(term_a_term_b_remote_connection.ssh_session_id)\n", + "computer_b.user_session_manager.show(include_session_id=True)\n", + "computer_b.user_session_manager.show()\n", + "\n" ] }, { @@ -183,6 +187,22 @@ "\n", "terminal_b.show()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Disconnected Terminal sessions will no longer show in the node's `user_session_manager` as active, but will be under the historic sessions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.user_session_manager.show(include_historic=True, include_session_id=True)" + ] } ], "metadata": { diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 142561f5..7842aa66 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1294,6 +1294,13 @@ class UserSessionManager(Service): self.remote_sessions.pop(session.uuid) session_type = "Remote" session_identity = f"{session_identity} {session.remote_ip_address}" + self.parent.terminal._connections.pop(session.uuid) + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "user_timeout", "connection_id": session.uuid}, + dest_port=Port.SSH, + dest_ip_address=session.remote_ip_address, + ) self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 5e684d89..46386d3b 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -33,8 +33,8 @@ class TerminalClientConnection(BaseModel): parent_terminal: Terminal """The parent Node that this connection was created on.""" - session_id: str = None - """Session ID that connection is linked to""" + ssh_session_id: str = None + """Session ID that connection is linked to, used for sending commands via session manager.""" connection_uuid: str = None """Connection UUID""" @@ -52,7 +52,9 @@ class TerminalClientConnection(BaseModel): """Flag to state whether the connection is active or not""" def __str__(self) -> str: - return f"{self.__class__.__name__}(connection_id='{self.connection_uuid}')" + return ( + f"{self.__class__.__name__}(connection_id: '{self.connection_uuid}, ssh_session_id: {self.ssh_session_id}')" + ) def __repr__(self) -> str: return self.__str__() @@ -124,13 +126,14 @@ class RemoteTerminalConnection(TerminalClientConnection): ssh_command=command, ) - return self.parent_terminal.send(payload=payload, session_id=self.session_id) + return self.parent_terminal.send(payload=payload, session_id=self.ssh_session_id) class Terminal(Service): """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" _client_connection_requests: Dict[str, Optional[Union[str, TerminalClientConnection]]] = {} + """Dictionary of connect requests made to remote nodes.""" def __init__(self, **kwargs): kwargs["name"] = "Terminal" @@ -169,7 +172,14 @@ class Terminal(Service): def _login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._process_local_login(username=request[0], password=request[1]) if login: - return RequestResponse(status="success", data={}) + return RequestResponse( + status="success", + data={ + "connection ID": login.connection_uuid, + "ssh_session_id": login.ssh_session_id, + "ip_address": login.ip_address, + }, + ) else: return RequestResponse(status="failure", data={}) @@ -184,16 +194,13 @@ class Terminal(Service): """Execute an instruction.""" command: str = request[0] connection_id: str = request[1] - self.execute(command, connection_id=connection_id) - return RequestResponse(status="success", data={}) + return self.execute(command, connection_id=connection_id) def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: """Logoff from connection.""" connection_uuid = request[0] - # TODO: Uncomment this when UserSessionManager merged. - # self.parent.UserSessionManager.logoff(connection_uuid) + self.parent.user_session_manager.local_logout(connection_uuid) self._disconnect(connection_uuid) - return RequestResponse(status="success", data={}) rm.add_request( @@ -234,7 +241,7 @@ class Terminal(Service): new_connection = LocalTerminalConnection( parent_terminal=self, connection_uuid=connection_uuid, - session_id=session_id, + ssh_session_id=session_id, time=datetime.now(), ) self._connections[connection_uuid] = new_connection @@ -288,11 +295,11 @@ class Terminal(Service): def _validate_client_connection_request(self, connection_id: str) -> bool: """Check that client_connection_id is valid.""" - return True if connection_id in self._client_connection_requests else False + return connection_id in self._client_connection_requests def _check_client_connection(self, connection_id: str) -> bool: """Check that client_connection_id is valid.""" - return True if connection_id in self._connections else False + return connection_id in self._connections def _send_remote_login( self, @@ -381,7 +388,7 @@ class Terminal(Service): """ client_connection = RemoteTerminalConnection( parent_terminal=self, - session_id=session_id, + ssh_session_id=session_id, connection_uuid=connection_id, ip_address=source_ip, connection_request_id=connection_request_id, @@ -398,7 +405,7 @@ class Terminal(Service): :param session_id: The session id the payload relates to. :return: True. """ - source_ip = kwargs["from_network_interface"].ip_address + source_ip = [kwargs["frame"].ip.src_ip_address][0] self.sys_log.info(f"Received payload: {payload}. Source: {source_ip}") if isinstance(payload, SSHPacket): if payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: @@ -470,12 +477,21 @@ class Terminal(Service): self._disconnect(payload["connection_id"]) self.parent.user_session_manager.remote_logout(remote_session_id=connection_id) else: - self.sys_log.info("No Active connection held for received connection ID.") + self.sys_log.error("No Active connection held for received connection ID.") + + if payload["type"] == "user_timeout": + connection_id = payload["connection_id"] + valid_id = self._check_client_connection(connection_id) + if valid_id: + self._connections.pop(connection_id) + self.sys_log.info(f"{self.name}: Connection {connection_id} disconnected due to inactivity.") + else: + self.sys_log.error(f"{self.name}: Connection {connection_id} is invalid.") return True def _disconnect(self, connection_uuid: str) -> bool: - """Disconnect from the remote. + """Disconnect connection. :param connection_uuid: Connection ID that we want to disconnect. :return True if successful, False otherwise. @@ -489,7 +505,7 @@ class Terminal(Service): if isinstance(connection, RemoteTerminalConnection): # Send disconnect command via software manager - session_id = connection.session_id + session_id = connection.ssh_session_id software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( From ff054830bca7ce3ede3e6bcac8d89d7559193061 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 8 Aug 2024 11:57:30 +0100 Subject: [PATCH 130/206] #2781 - Correcting some typos in Terminal notebook and elaborating the data in _remote_login request --- src/primaite/notebooks/Terminal-Processing.ipynb | 11 +++-------- .../simulator/system/services/terminal/terminal.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index f3848c84..fdf405a7 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -87,7 +87,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can view all active connections to a terminal through use of the `show()` method, " + "You can view all active connections to a terminal through use of the `show()` method." ] }, { @@ -96,11 +96,7 @@ "metadata": {}, "outputs": [], "source": [ - "terminal_b.show()\n", - "print(term_a_term_b_remote_connection.ssh_session_id)\n", - "computer_b.user_session_manager.show(include_session_id=True)\n", - "computer_b.user_session_manager.show()\n", - "\n" + "terminal_b.show()" ] }, { @@ -184,7 +180,6 @@ "term_a_term_b_remote_connection.disconnect()\n", "\n", "terminal_a.show()\n", - "\n", "terminal_b.show()" ] }, @@ -192,7 +187,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Disconnected Terminal sessions will no longer show in the node's `user_session_manager` as active, but will be under the historic sessions" + "Disconnected Terminal sessions will no longer show in the node's Terminal connection list, but will be under the historic sessions in the `user_session_manager`." ] }, { diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 46386d3b..aa3b5d62 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -181,12 +181,19 @@ class Terminal(Service): }, ) else: - return RequestResponse(status="failure", data={}) + return RequestResponse(status="failure", data={"reason": "Invalid login credentials"}) def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2]) if login: - return RequestResponse(status="success", data={}) + return RequestResponse( + status="success", + data={ + "connection ID": login.connection_uuid, + "ssh_session_id": login.ssh_session_id, + "ip_address": login.ip_address, + }, + ) else: return RequestResponse(status="failure", data={}) From 5f5ea5e5246ddb9295267251e2f445ea47fdee6a Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 8 Aug 2024 14:20:23 +0100 Subject: [PATCH 131/206] #2718 - Updates to Terminal following discussion about implementation with actions. --- .../system/services/terminal/terminal.py | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index aa3b5d62..88b6d3a3 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -52,9 +52,7 @@ class TerminalClientConnection(BaseModel): """Flag to state whether the connection is active or not""" def __str__(self) -> str: - return ( - f"{self.__class__.__name__}(connection_id: '{self.connection_uuid}, ssh_session_id: {self.ssh_session_id}')" - ) + return f"{self.__class__.__name__}(connection_id: '{self.connection_uuid}, ip_address: {self.ip_address}')" def __repr__(self) -> str: return self.__str__() @@ -176,7 +174,6 @@ class Terminal(Service): status="success", data={ "connection ID": login.connection_uuid, - "ssh_session_id": login.ssh_session_id, "ip_address": login.ip_address, }, ) @@ -189,19 +186,28 @@ class Terminal(Service): return RequestResponse( status="success", data={ - "connection ID": login.connection_uuid, - "ssh_session_id": login.ssh_session_id, "ip_address": login.ip_address, }, ) else: return RequestResponse(status="failure", data={}) - def _execute_request(request: RequestFormat, context: Dict) -> RequestResponse: + def remote_execute_request(request: RequestFormat, context: Dict) -> RequestResponse: """Execute an instruction.""" command: str = request[0] - connection_id: str = request[1] - return self.execute(command, connection_id=connection_id) + ip_address: IPv4Address = IPv4Address(request[1]) + remote_connection = self._get_connection_from_ip(ip_address=ip_address) + outcome = remote_connection.execute(command) + if outcome: + return RequestResponse( + status="success", + data={}, + ) + else: + return RequestResponse( + status="failure", + data={}, + ) def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: """Logoff from connection.""" @@ -222,20 +228,23 @@ class Terminal(Service): rm.add_request( "Execute", - request_type=RequestType(func=_execute_request), + request_type=RequestType(func=remote_execute_request), ) rm.add_request("Logoff", request_type=RequestType(func=_logoff)) return rm - def execute(self, command: List[Any], connection_id: str) -> Optional[RequestResponse]: + def execute(self, command: List[Any]) -> Optional[RequestResponse]: """Execute a passed ssh command via the request manager.""" - valid_connection = self._check_client_connection(connection_id=connection_id) - if valid_connection: - return self.parent.apply_request(command) + return self.parent.apply_request(command) + + def _get_connection_from_ip(self, ip_address: IPv4Address) -> Optional[RemoteTerminalConnection]: + """Find Remote Terminal Connection from a given IP.""" + for connection in self._connections: + if self._connections[connection].ip_address == ip_address: + return self._connections[connection] else: - self.sys_log.error("Invalid connection ID provided") return None def _create_local_connection(self, connection_uuid: str, session_id: str) -> TerminalClientConnection: @@ -471,7 +480,7 @@ class Terminal(Service): command = payload.ssh_command valid_connection = self._check_client_connection(payload.connection_uuid) if valid_connection: - return self.execute(command, payload.connection_uuid) + return self.execute(command) else: self.sys_log.error(f"Connection UUID:{payload.connection_uuid} is not valid. Rejecting Command.") From 116ac725b0faf5ab5b4c4e51b7a4f080a17aa1a3 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 8 Aug 2024 14:23:10 +0100 Subject: [PATCH 132/206] #2718 - making terminal rm _login() and _remote_login() consistent in their RequestResponse --- src/primaite/simulator/system/services/terminal/terminal.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 88b6d3a3..85e0c87f 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -173,7 +173,6 @@ class Terminal(Service): return RequestResponse( status="success", data={ - "connection ID": login.connection_uuid, "ip_address": login.ip_address, }, ) From 665c53d880b8f19f8ceebf0f67ea4c7a151811d5 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 8 Aug 2024 15:48:44 +0100 Subject: [PATCH 133/206] #2781 - Actioning review comments --- .../simulator/network/hardware/base.py | 4 ++ .../system/services/terminal/terminal.py | 62 ++++++++++--------- .../_system/_services/test_terminal.py | 26 ++++++++ 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 7842aa66..1441c93b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1278,6 +1278,10 @@ class UserSessionManager(Service): if self.local_session: if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: self._timeout_session(self.local_session) + for session in self.remote_sessions: + remote_session = self.remote_sessions[session] + if remote_session.last_active_step + self.remote_session_timeout_steps <= timestep: + self._timeout_session(remote_session) def _timeout_session(self, session: UserSession) -> None: """ diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 85e0c87f..876b1694 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -196,12 +196,13 @@ class Terminal(Service): command: str = request[0] ip_address: IPv4Address = IPv4Address(request[1]) remote_connection = self._get_connection_from_ip(ip_address=ip_address) - outcome = remote_connection.execute(command) - if outcome: - return RequestResponse( - status="success", - data={}, - ) + if remote_connection: + outcome = remote_connection.execute(command) + if outcome: + return RequestResponse( + status="success", + data={}, + ) else: return RequestResponse( status="failure", @@ -240,11 +241,9 @@ class Terminal(Service): def _get_connection_from_ip(self, ip_address: IPv4Address) -> Optional[RemoteTerminalConnection]: """Find Remote Terminal Connection from a given IP.""" - for connection in self._connections: - if self._connections[connection].ip_address == ip_address: - return self._connections[connection] - else: - return None + for connection in self._connections.values(): + if connection.ip_address == ip_address: + return connection def _create_local_connection(self, connection_uuid: str, session_id: str) -> TerminalClientConnection: """Create a new connection object and amend to list of active connections. @@ -279,7 +278,7 @@ class Terminal(Service): :type: ip_address: Optional[IPv4Address] """ if self.operating_state != ServiceOperatingState.RUNNING: - self.sys_log.warning("Cannot login as service is not running.") + self.sys_log.warning(f"{self.name}: Cannot login as service is not running.") return None connection_request_id = str(uuid4()) self._client_connection_requests[connection_request_id] = None @@ -301,11 +300,11 @@ class Terminal(Service): # TODO: Un-comment this when UserSessionManager is merged. connection_uuid = self.parent.user_session_manager.local_login(username=username, password=password) if connection_uuid: - self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") + self.sys_log.info(f"{self.name}: Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections and return return self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection") else: - self.sys_log.warning("Login failed, incorrect Username or Password") + self.sys_log.warning(f"{self.name}: Login failed, incorrect Username or Password") return None def _validate_client_connection_request(self, connection_id: str) -> bool: @@ -344,7 +343,9 @@ class Terminal(Service): :return: RemoteTerminalConnection: Connection Object for sending further commands if successful, else False. """ - self.sys_log.info(f"Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}") + self.sys_log.info( + f"{self.name}: Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}" + ) if is_reattempt: valid_connection_request = self._validate_client_connection_request(connection_id=connection_request_id) if valid_connection_request: @@ -353,7 +354,7 @@ class Terminal(Service): self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.") return remote_terminal_connection else: - self.sys_log.warning(f"Connection request {connection_request_id} declined") + self.sys_log.warning(f"{self.name}: Connection request {connection_request_id} declined") return None else: self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") @@ -420,8 +421,8 @@ class Terminal(Service): :param session_id: The session id the payload relates to. :return: True. """ - source_ip = [kwargs["frame"].ip.src_ip_address][0] - self.sys_log.info(f"Received payload: {payload}. Source: {source_ip}") + source_ip = kwargs["frame"].ip.src_ip_address + self.sys_log.info(f"{self.name}: Received payload: {payload}. Source: {source_ip}") if isinstance(payload, SSHPacket): if payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: # validate & add connection @@ -431,10 +432,9 @@ class Terminal(Service): connection_id = self.parent.user_session_manager.remote_login( username=username, password=password, remote_ip_address=source_ip ) - # connection_id = str(uuid4()) if connection_id: connection_request_id = payload.connection_request_uuid - self.sys_log.info(f"Connection authorised, session_id: {session_id}") + self.sys_log.info(f"{self.name}: Connection authorised, session_id: {session_id}") self._create_remote_connection( connection_id=connection_id, connection_request_id=connection_request_id, @@ -465,7 +465,7 @@ class Terminal(Service): payload=payload, dest_port=self.port, session_id=session_id ) elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: - self.sys_log.info("Login Successful") + self.sys_log.info(f"{self.name}: Login Successful") self._create_remote_connection( connection_id=payload.connection_uuid, connection_request_id=payload.connection_request_uuid, @@ -475,13 +475,16 @@ class Terminal(Service): elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST: # Requesting a command to be executed - self.sys_log.info("Received command to execute") + self.sys_log.info(f"{self.name}: Received command to execute") command = payload.ssh_command valid_connection = self._check_client_connection(payload.connection_uuid) if valid_connection: - return self.execute(command) + self.execute(command) + return True else: - self.sys_log.error(f"Connection UUID:{payload.connection_uuid} is not valid. Rejecting Command.") + self.sys_log.error( + f"{self.name}: Connection UUID:{payload.connection_uuid} is not valid. Rejecting Command." + ) if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "disconnect": @@ -492,13 +495,14 @@ class Terminal(Service): self._disconnect(payload["connection_id"]) self.parent.user_session_manager.remote_logout(remote_session_id=connection_id) else: - self.sys_log.error("No Active connection held for received connection ID.") + self.sys_log.error(f"{self.name}: No Active connection held for received connection ID.") if payload["type"] == "user_timeout": connection_id = payload["connection_id"] valid_id = self._check_client_connection(connection_id) if valid_id: - self._connections.pop(connection_id) + connection = self._connections.pop(connection_id) + connection.is_active = False self.sys_log.info(f"{self.name}: Connection {connection_id} disconnected due to inactivity.") else: self.sys_log.error(f"{self.name}: Connection {connection_id} is invalid.") @@ -512,7 +516,7 @@ class Terminal(Service): :return True if successful, False otherwise. """ if not self._connections: - self.sys_log.warning("No remote connection present") + self.sys_log.warning(f"{self.name}: No remote connection present") return False connection = self._connections.pop(connection_uuid) @@ -545,10 +549,10 @@ class Terminal(Service): :param dest_up_address: The IP address of the payload destination. """ if self.operating_state != ServiceOperatingState.RUNNING: - self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!") + self.sys_log.warning(f"{self.name}: Cannot send commands when Operating state is {self.operating_state}!") return False - self.sys_log.debug(f"Sending payload: {payload}") + self.sys_log.debug(f"{self.name}: Sending payload: {payload}") return super().send( payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id ) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index ffe48ab5..41858b90 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -248,6 +248,8 @@ def test_terminal_disconnect(basic_network): assert len(terminal_b._connections) == 0 + assert term_a_on_term_b.is_active is False + def test_terminal_ignores_when_off(basic_network): """Terminal should ignore commands when not running""" @@ -378,3 +380,27 @@ def test_terminal_rejects_commands_if_disconnect(basic_network): assert not computer_b.software_manager.software.get("RansomwareScript") assert remote_connection.is_active is False + + +def test_terminal_connection_timeout(basic_network): + """Test that terminal_connections are affected by UserSession timeout.""" + network: Network = basic_network + computer_a: Computer = network.get_node_by_hostname("node_a") + terminal_a: Terminal = computer_a.software_manager.software.get("Terminal") + computer_b: Computer = network.get_node_by_hostname("node_b") + terminal_b: Terminal = computer_b.software_manager.software.get("Terminal") + + remote_connection = terminal_a.login(username="admin", password="admin", ip_address="192.168.0.11") + + assert len(terminal_a._connections) == 1 + assert len(terminal_b._connections) == 1 + assert len(computer_b.user_session_manager.remote_sessions) == 1 + + remote_session = computer_b.user_session_manager.remote_sessions[remote_connection.connection_uuid] + computer_b.user_session_manager._timeout_session(remote_session) + + assert len(terminal_a._connections) == 0 + assert len(terminal_b._connections) == 0 + assert len(computer_b.user_session_manager.remote_sessions) == 0 + + assert not remote_connection.is_active From a3a9ca9963c4fc67e46a5eeeb5d067d9c764d2d5 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 8 Aug 2024 21:20:20 +0100 Subject: [PATCH 134/206] #2768 - Fixed issue causing main port to not be included in list of open ports. documented the configuration of listen_on_ports. added test that tests listen_on_ports configuration from yaml. --- CHANGELOG.md | 11 +++++- .../system/common/common_configuration.rst | 32 +++++++++++++++ src/primaite/game/game.py | 22 ++++++++++- .../simulator/system/core/software_manager.py | 29 ++++++++------ ...ic_node_with_software_listening_ports.yaml | 39 +++++++++++++++++++ .../system/test_service_listening_on_ports.py | 20 ++++++++++ 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 tests/assets/configs/basic_node_with_software_listening_ports.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d999607..c354aa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Random Number Generator Seeding by specifying a random number seed in the config file. - Implemented Terminal service class, providing a generic terminal simulation. +- Added `User`, `UserManager` and `UserSessionManager` to enable the creation of user accounts and login on Nodes. +- Added a `listen_on_ports` set in the `IOSoftware` class to enable software listening on ports in addition to the + main port they're assigned. ### Changed -- Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. +- Updated `SoftwareManager` `install` and `uninstall` to handle all functionality that was being done at the `install` + and `uninstall` methods in the `Node` class. +- Updated the `receive_payload_from_session_manager` method in `SoftwareManager` so that it now sends a copy of the + payload to any software listening on the destination port of the `Frame`. + +### Removed +- Removed the `install` and `uninstall` methods in the `Node` class. ## [3.2.0] - 2024-07-18 diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst index e35ee378..420166dd 100644 --- a/docs/source/simulation_components/system/common/common_configuration.rst +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -25,3 +25,35 @@ The configuration options are the attributes that fall under the options for an Optional. Default value is ``2``. The number of timesteps the |SOFTWARE_NAME| will remain in a ``FIXING`` state before going into a ``GOOD`` state. + + +``listen_on_ports`` +""""""""""""""""""" + +The set of ports to listen on. This is in addition to the main port the software is designated. This set can either be +the string name of ports or the port integers + +Example: + +.. code-block:: yaml + + simulation: + network: + nodes: + - hostname: client + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + services: + - type: DatabaseService + options: + backup_server_ip: 10.10.1.12 + listen_on_ports: + - 631 + applications: + - type: WebBrowser + options: + target_url: http://sometech.ai + listen_on_ports: + - SMB diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 6a97ad25..3d3caed9 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -1,7 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK """PrimAITE game - Encapsulates the simulation and agents.""" from ipaddress import IPv4Address -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union import numpy as np from pydantic import BaseModel, ConfigDict @@ -44,8 +44,10 @@ from primaite.simulator.system.services.ftp.ftp_client import FTPClient from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.ntp.ntp_client import NTPClient from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.service import Service from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.services.web_server.web_server import WebServer +from primaite.simulator.system.software import Software _LOGGER = getLogger(__name__) @@ -328,6 +330,21 @@ class PrimaiteGame: user_manager: UserManager = new_node.software_manager.software["UserManager"] # noqa for user_cfg in node_cfg["users"]: user_manager.add_user(**user_cfg, bypass_can_perform_action=True) + + def _set_software_listen_on_ports(software: Union[Software, Service], software_cfg: Dict): + """Set listener ports on software.""" + listen_on_ports = [] + for port_id in set(software_cfg.get("options", {}).get("listen_on_ports", [])): + print("yes", port_id) + port = None + if isinstance(port_id, int): + port = Port(port_id) + elif isinstance(port_id, str): + port = Port[port_id] + if port: + listen_on_ports.append(port) + software.listen_on_ports = set(listen_on_ports) + if "services" in node_cfg: for service_cfg in node_cfg["services"]: new_service = None @@ -341,6 +358,7 @@ class PrimaiteGame: if "fix_duration" in service_cfg.get("options", {}): new_service.fixing_duration = service_cfg["options"]["fix_duration"] + _set_software_listen_on_ports(new_service, service_cfg) # start the service new_service.start() else: @@ -390,6 +408,8 @@ class PrimaiteGame: _LOGGER.error(msg) raise ValueError(msg) + _set_software_listen_on_ports(new_application, application_cfg) + # run the application new_application.run() diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 7b36097b..d45611ed 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -237,19 +237,24 @@ class SoftwareManager: self.software.get("NMAP").receive(payload=payload, session_id=session_id) return main_receiver = self.port_protocol_mapping.get((port, protocol), None) - listening_receivers = [software for software in self.software.values() if port in software.listen_on_ports] - receivers = [main_receiver] + listening_receivers if main_receiver else listening_receivers - if receivers: - for receiver in receivers: - receiver.receive( - payload=deepcopy(payload), - session_id=session_id, - from_network_interface=from_network_interface, - frame=frame, - ) - else: + if main_receiver: + main_receiver.receive( + payload=payload, session_id=session_id, from_network_interface=from_network_interface, frame=frame + ) + listening_receivers = [ + software + for software in self.software.values() + if port in software.listen_on_ports and software != main_receiver + ] + for receiver in listening_receivers: + receiver.receive( + payload=deepcopy(payload), + session_id=session_id, + from_network_interface=from_network_interface, + frame=frame, + ) + if not main_receiver and not listening_receivers: self.sys_log.warning(f"No service or application found for port {port} and protocol {protocol}") - pass def show(self, markdown: bool = False): """ diff --git a/tests/assets/configs/basic_node_with_software_listening_ports.yaml b/tests/assets/configs/basic_node_with_software_listening_ports.yaml new file mode 100644 index 00000000..53eee87f --- /dev/null +++ b/tests/assets/configs/basic_node_with_software_listening_ports.yaml @@ -0,0 +1,39 @@ +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + sys_log_level: WARNING + agent_log_level: INFO + save_agent_logs: true + write_agent_log_to_terminal: True + + +game: + max_episode_length: 256 + ports: + - ARP + protocols: + - ICMP + - UDP + + +simulation: + network: + nodes: + - hostname: client + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + services: + - type: DatabaseService + options: + backup_server_ip: 10.10.1.12 + listen_on_ports: + - 631 + applications: + - type: WebBrowser + options: + target_url: http://sometech.ai + listen_on_ports: + - SMB diff --git a/tests/integration_tests/system/test_service_listening_on_ports.py b/tests/integration_tests/system/test_service_listening_on_ports.py index 0cb1ad54..fd502a70 100644 --- a/tests/integration_tests/system/test_service_listening_on_ports.py +++ b/tests/integration_tests/system/test_service_listening_on_ports.py @@ -1,13 +1,17 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from typing import Any, Dict, List, Set +import yaml from pydantic import Field +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.service import Service +from tests import TEST_ASSETS_ROOT class _DatabaseListener(Service): @@ -62,3 +66,19 @@ def test_http_listener(client_server): assert db_connection.query("SELECT") assert len(server_db_listener.payloads_received) == 3 + + +def test_set_listen_on_ports_from_config(): + config_path = TEST_ASSETS_ROOT / "configs" / "basic_node_with_software_listening_ports.yaml" + + with open(config_path, "r") as f: + config_dict = yaml.safe_load(f) + network = PrimaiteGame.from_config(cfg=config_dict).simulation.network + + client: Computer = network.get_node_by_hostname("client") + assert Port.SMB in client.software_manager.get_open_ports() + assert Port.IPP in client.software_manager.get_open_ports() + + web_browser = client.software_manager.software["WebBrowser"] + + assert not web_browser.listen_on_ports.difference({Port.SMB, Port.IPP}) From df9ab13209c49458d267f4ae01478e9eb9947585 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 9 Aug 2024 09:11:54 +0100 Subject: [PATCH 135/206] #2799 - Fix docstring --- .../game/agent/observations/file_system_observations.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/primaite/game/agent/observations/file_system_observations.py b/src/primaite/game/agent/observations/file_system_observations.py index bd130673..1c73d026 100644 --- a/src/primaite/game/agent/observations/file_system_observations.py +++ b/src/primaite/game/agent/observations/file_system_observations.py @@ -162,6 +162,9 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"): :type num_files: int :param include_num_access: Whether to include the number of accesses to files in the observation. :type include_num_access: bool + :param file_system_requires_scan: If True, the folder must be scanned to update the health state. Tf False, + the true state is always shown. + :type file_system_requires_scan: bool """ self.where: WhereType = where From 72e6e78ed7c9b39ec04643888016c9b3830a9745 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 9 Aug 2024 09:32:13 +0100 Subject: [PATCH 136/206] #2768 - Removed debugging print statement --- src/primaite/game/game.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 3d3caed9..9117d30a 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -335,7 +335,6 @@ class PrimaiteGame: """Set listener ports on software.""" listen_on_ports = [] for port_id in set(software_cfg.get("options", {}).get("listen_on_ports", [])): - print("yes", port_id) port = None if isinstance(port_id, int): port = Port(port_id) From 6ec575d18ec1c3db269d5a01711c655ebd311973 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 9 Aug 2024 09:58:44 +0100 Subject: [PATCH 137/206] #2689 Updated actions E2E notebook and other additions --- src/primaite/game/agent/actions.py | 20 +- .../Command-&-Control-E2E-Demonstration.ipynb | 727 +++++++++++++++++- .../red_applications/c2/c2_beacon.py | 93 ++- .../red_applications/c2/c2_server.py | 25 +- 4 files changed, 797 insertions(+), 68 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 7b07c660..5f045ccb 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1153,19 +1153,29 @@ class TerminalC2ServerAction(AbstractAction): class _Opts(BaseModel): """Schema for options that can be passed to this action.""" - model_config = ConfigDict(extra="forbid") - commands: RequestFormat + commands: List[RequestFormat] + ip_address: Optional[str] + username: Optional[str] + password: Optional[str] def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: int, config: Dict) -> RequestFormat: + def form_request(self, node_id: int, commands: List, ip_address: Optional[str], account: dict) -> RequestFormat: """Return the action formatted as a request that can be ingested by the simulation.""" node_name = self.manager.get_node_name_by_idx(node_id) if node_name is None: return ["do_nothing"] - TerminalC2ServerAction._Opts.model_validate(config) # check that options adhere to schema - return ["network", "node", node_name, "application", "C2Server", "terminal_command", config] + + command_model = { + "commands": commands, + "ip_address": ip_address, + "username": account["username"], + "password": account["password"], + } + + TerminalC2ServerAction._Opts.model_validate(command_model) + return ["network", "node", node_name, "application", "C2Server", "terminal_command", command_model] class ActionManager: diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 0810871b..3cdb3324 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -20,15 +20,18 @@ "# Imports\n", "from primaite.config.load import data_manipulation_config_path\n", "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.simulator.network.hardware.nodes.network.router import Router\n", "from primaite.game.agent.interface import AgentHistoryItem\n", "import yaml\n", "from pprint import pprint\n", "from primaite.simulator.network.container import Network\n", "from primaite.game.game import PrimaiteGame\n", + "from primaite.simulator.system.applications.application import ApplicationOperatingState\n", "from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon\n", "from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server\n", "from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import C2Command, C2Payload\n", "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", + "from primaite.simulator.system.software import SoftwareHealthState\n", "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", "from primaite.simulator.network.hardware.nodes.host.server import Server" ] @@ -66,10 +69,10 @@ " - type: C2_SERVER_TERMINAL_COMMAND\n", " options:\n", " nodes:\n", - " - node_name: client_1\n", + " - node_name: web_server\n", " applications: \n", " - application_name: C2Beacon\n", - " - node_name: domain_controller\n", + " - node_name: client_1\n", " applications: \n", " - application_name: C2Server\n", " max_folders_per_node: 1\n", @@ -78,7 +81,7 @@ " max_nics_per_node: 8\n", " max_acl_rules: 10\n", " ip_list:\n", - " - 192.168.1.10\n", + " - 192.168.1.21\n", " - 192.168.1.14\n", " action_map:\n", " 0:\n", @@ -94,7 +97,7 @@ " options:\n", " node_id: 0\n", " config:\n", - " c2_server_ip_address: 192.168.1.10\n", + " c2_server_ip_address: 192.168.10.21\n", " keep_alive_frequency:\n", " masquerade_protocol:\n", " masquerade_port:\n", @@ -104,10 +107,19 @@ " node_id: 0\n", " application_id: 0 \n", " 4:\n", - " action: NODE_APPLICATION_INSTALL\n", + " action: C2_SERVER_TERMINAL_COMMAND\n", " options:\n", - " node_id: 0\n", - " application_name: RansomwareScript \n", + " node_id: 1\n", + " ip_address:\n", + " account:\n", + " username: test123\n", + " password: pass123\n", + " commands:\n", + " - \n", + " - software_manager\n", + " - application\n", + " - install\n", + " - RansomwareScript\n", " 5:\n", " action: C2_SERVER_RANSOMWARE_CONFIGURE\n", " options:\n", @@ -119,11 +131,8 @@ " action: C2_SERVER_RANSOMWARE_LAUNCH\n", " options:\n", " node_id: 1\n", - " 7:\n", - " action: C2_SERVER_TERMINAL_COMMAND\n", - " options:\n", - " node_id: 1\n", - " application_id: 0 \n", + "\n", + "\n", "\n", " reward_function:\n", " reward_components:\n", @@ -158,7 +167,7 @@ "\n", "This is because higher fidelity environments (and the real-world) a C2 server would not be accessible by private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) before the narrative of the use case.\n", "\n", - "The cells below installs and runs the C2 Server on the domain controller server directly via the simulation API." + "The cells below installs and runs the C2 Server on the client_1 directly via the simulation API." ] }, { @@ -167,11 +176,11 @@ "metadata": {}, "outputs": [], "source": [ - "domain_controller: Server = env.game.simulation.network.get_node_by_hostname(\"domain_controller\")\n", - "domain_controller.software_manager.install(C2Server)\n", - "c2_server: C2Server = domain_controller.software_manager.software[\"C2Server\"]\n", + "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "client_1.software_manager.install(C2Server)\n", + "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", "c2_server.run()\n", - "domain_controller.software_manager.show()" + "client_1.software_manager.show()" ] }, { @@ -185,16 +194,6 @@ "This can be done by installing, configuring and then executing a C2 Beacon. " ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "c2_red_agent = env.game.agents[\"CustomC2Agent\"]\n", - "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -209,8 +208,8 @@ "outputs": [], "source": [ "env.step(1)\n", - "client_1.software_manager.show()\n", - "c2_beacon: C2Beacon = client_1.software_manager.software[\"C2Beacon\"]" + "web_server: Computer = env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "web_server.software_manager.show()" ] }, { @@ -227,6 +226,8 @@ "outputs": [], "source": [ "env.step(2)\n", + "c2_beacon: C2Beacon = web_server.software_manager.software[\"C2Beacon\"]\n", + "web_server.software_manager.show()\n", "c2_beacon.show()" ] }, @@ -243,7 +244,7 @@ "metadata": {}, "outputs": [], "source": [ - "env.step(3)" + "env.step(3) " ] }, { @@ -267,7 +268,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Server Actions | Configuring Ransomware" + "### **Command and Control** | C2 Server Actions | Executing Terminal Commands" ] }, { @@ -279,6 +280,22 @@ "env.step(4)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client_1.software_manager.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Server Actions | Configuring Ransomware" + ] + }, { "cell_type": "code", "execution_count": null, @@ -294,8 +311,17 @@ "metadata": {}, "outputs": [], "source": [ - "ransomware_script: RansomwareScript = client_1.software_manager.software[\"RansomwareScript\"]\n", - "client_1.software_manager.show()\n", + "env.step(6)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ransomware_script: RansomwareScript = web_server.software_manager.software[\"RansomwareScript\"]\n", + "web_server.software_manager.show()\n", "ransomware_script.show()" ] }, @@ -329,7 +355,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Server Actions | Executing Terminal Commands" + "## **Command and Control** | Blue Agent Relevance\n", + "\n", + "The next section of the notebook will demonstrate the impact that the command and control suite has to the Blue Agent's observation space as well as some potential actions that can be used to prevent the attack from being successfully.\n", + "\n", + "The code cell below re-creates the UC2 network and swaps out the previous custom red agent with a custom blue agent. \n" ] }, { @@ -338,39 +368,652 @@ "metadata": {}, "outputs": [], "source": [ - "# TODO: Post Terminal.\n", - "#env.step(7)" + "custom_blue_agent_yaml = \"\"\" \n", + " - ref: defender\n", + " team: BLUE\n", + " type: ProxyAgent\n", + "\n", + " observation_space:\n", + " type: CUSTOM\n", + " options:\n", + " components:\n", + " - type: NODES\n", + " label: NODES\n", + " options:\n", + " hosts:\n", + " - hostname: web_server\n", + " applications:\n", + " - application_name: C2Beacon\n", + " - application_name: RansomwareScript\n", + " - hostname: database_server\n", + " folders:\n", + " - folder_name: database\n", + " files:\n", + " - file_name: database.db\n", + " - hostname: client_1\n", + " - hostname: client_2\n", + " num_services: 0\n", + " num_applications: 2\n", + " num_folders: 1\n", + " num_files: 1\n", + " num_nics: 0\n", + " include_num_access: false\n", + " include_nmne: false\n", + " monitored_traffic:\n", + " icmp:\n", + " - NONE\n", + " tcp:\n", + " - HTTP\n", + " routers:\n", + " - hostname: router_1\n", + " num_ports: 1\n", + " ip_list:\n", + " - 192.168.10.21\n", + " - 192.168.1.12\n", + " wildcard_list:\n", + " - 0.0.0.1\n", + " port_list:\n", + " - 80\n", + " protocol_list:\n", + " - ICMP\n", + " - TCP\n", + " - UDP\n", + " num_rules: 10\n", + "\n", + " - type: LINKS\n", + " label: LINKS\n", + " options:\n", + " link_references:\n", + " - router_1:eth-1<->switch_1:eth-8\n", + " - router_1:eth-2<->switch_2:eth-8\n", + " - switch_1:eth-1<->web_server:eth-1\n", + " - switch_1:eth-2<->web_server:eth-1\n", + " - switch_1:eth-3<->database_server:eth-1\n", + " - switch_1:eth-4<->backup_server:eth-1\n", + " - switch_1:eth-7<->security_suite:eth-1\n", + " - switch_2:eth-1<->client_1:eth-1\n", + " - switch_2:eth-2<->client_2:eth-1\n", + " - switch_2:eth-7<->security_suite:eth-2\n", + " - type: \"NONE\"\n", + " label: ICS\n", + " options: {}\n", + " \n", + " action_space:\n", + " action_list:\n", + " - type: NODE_APPLICATION_REMOVE\n", + " - type: NODE_SHUTDOWN\n", + " - type: ROUTER_ACL_ADDRULE\n", + " - type: DONOTHING\n", + " action_map:\n", + " 0:\n", + " action: DONOTHING\n", + " options: {}\n", + " 1:\n", + " action: NODE_APPLICATION_REMOVE\n", + " options:\n", + " node_id: 0\n", + " application_name: C2Beacon\n", + " 2:\n", + " action: NODE_SHUTDOWN\n", + " options:\n", + " node_id: 0\n", + " 3:\n", + " action: ROUTER_ACL_ADDRULE\n", + " options:\n", + " target_router: router_1\n", + " position: 1\n", + " permission: 2\n", + " source_ip_id: 2\n", + " dest_ip_id: 3\n", + " source_port_id: 2\n", + " dest_port_id: 2\n", + " protocol_id: 1\n", + " source_wildcard_id: 0\n", + " dest_wildcard_id: 0\n", + "\n", + " options:\n", + " nodes:\n", + " - node_name: web_server\n", + " applications:\n", + " - application_name: C2Beacon\n", + "\n", + " - node_name: database_server\n", + " folders:\n", + " - folder_name: database\n", + " files:\n", + " - file_name: database.db\n", + " services:\n", + " - service_name: DatabaseService\n", + " - node_name: router_1\n", + "\n", + " max_folders_per_node: 2\n", + " max_files_per_folder: 2\n", + " max_services_per_node: 2\n", + " max_nics_per_node: 8\n", + " max_acl_rules: 10\n", + " ip_list:\n", + " - 192.168.10.21\n", + " - 192.168.1.12\n", + " wildcard_list:\n", + " - 0.0.0.1\n", + "\n", + " reward_function:\n", + " reward_components:\n", + " - type: DUMMY\n", + "\n", + " agent_settings:\n", + " flatten_obs: False\n", + "\"\"\"\n", + "custom_blue = yaml.safe_load(custom_blue_agent_yaml)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path()) as f:\n", + " cfg = yaml.safe_load(f)\n", + " # removing all agents & adding the custom agent.\n", + " cfg['agents'] = {}\n", + " cfg['agents'] = custom_blue\n", + " \n", + "\n", + "blue_env = PrimaiteGymEnv(env_config=cfg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Utility function for showing OBS changes between each time step.\n", + "\n", + "from deepdiff.diff import DeepDiff\n", + "\n", + "def display_obs_diffs(old, new, step_counter):\n", + " \"\"\"\n", + " Use DeepDiff to extract and display differences in old and new instances of\n", + " the observation space.\n", + "\n", + " :param old: observation space instance.\n", + " :param new: observation space instance.\n", + " :param step_counter: current step counter.\n", + " \"\"\"\n", + " print(\"\\nObservation space differences\")\n", + " print(\"-----------------------------\")\n", + " diff = DeepDiff(old, new)\n", + " print(f\"Step {step_counter}\")\n", + " for d,v in diff.get('values_changed', {}).items():\n", + " print(f\"{d}: {v['old_value']} -> {v['new_value']}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## **Command and Control** | Blue Agent Relevance" + "### **Command and Control** | Blue Agent Relevance | Observation Space\n", + "\n", + "This section demonstrates the OBS impact if the C2 suite is successfully installed and then used to install, configure and launch the ransomwarescript." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Resetting the environment and capturing the default observation space.\n", + "blue_env.reset()\n", + "default_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setting up the C2 Suite via the simulation API.\n", + "\n", + "client_1: Computer = blue_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "web_server: Server = blue_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "\n", + "# Installing the C2 Server.\n", + "client_1.software_manager.install(C2Server)\n", + "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + "c2_server.run()\n", + "\n", + "# Installing the C2 Beacon.\n", + "web_server.software_manager.install(C2Beacon)\n", + "c2_beacon: C2Beacon = web_server.software_manager.software[\"C2Beacon\"]\n", + "c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\")\n", + "c2_beacon.establish()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capturing the observation impacts of the previous code cell: C2 Suite setup.\n", + "c2_configuration_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(default_obs, c2_configuration_obs, blue_env.game.step_counter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Installing RansomwareScript via C2 Terminal Commands\n", + "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", + " \"username\": \"pass123\",\n", + " \"password\": \"password123\"}\n", + "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Configuring the RansomwareScript\n", + "ransomware_config = {\"server_ip_address\": \"192.168.1.14\", \"payload\": \"ENCRYPT\"}\n", + "c2_server._send_command(C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capturing the observation impacts of the previous code cell: Ransomware installation & configuration.\n", + "c2_ransomware_obs, _, _, _, _ = blue_env.step(0)" ] }, { "cell_type": "markdown", "metadata": {}, - "source": [] + "source": [ + "The code cell below demonstrates the differences between the default observation space and the configuration of the C2 Server and the Ransomware installation." + ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "### **Command and Control** | Blue Agent Relevance | Observation Space" + "display_obs_diffs(default_obs, c2_ransomware_obs, env.game.step_counter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Waiting for the ransomware to finish installing and then launching the RansomwareScript.\n", + "blue_env.step(0)\n", + "c2_server._send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capturing the observation impacts of the previous code cell: Launching the RansomwareScript.\n", + "c2_final_obs, _, _, _, _ = blue_env.step(0)" ] }, { "cell_type": "markdown", "metadata": {}, - "source": [] + "source": [ + "The code cell below demonstrates the differences between the default observation space and the configuration of the C2 Server, the ransomware script installation as well as the impact of RansomwareScript upon the database." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(c2_ransomware_obs, c2_final_obs, blue_env.game.step_counter)" + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | Blue Agent Relevance | Action Space" + "### **Command and Control** | Blue Agent Relevance | Action Space\n", + "\n", + "The next section of this notebook will go over some potential blue agent actions that could be use to thwart the previously demonstrated attack." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# This method is used to shorthand setting up the C2Server and the C2 Beacon.\n", + "def c2_setup(given_env: PrimaiteGymEnv):\n", + " client_1: Computer = given_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + " web_server: Server = given_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "\n", + " client_1.software_manager.install(C2Server)\n", + " c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + " c2_server.run()\n", + "\n", + " web_server.software_manager.install(C2Beacon)\n", + " c2_beacon: C2Beacon = web_server.software_manager.software[\"C2Beacon\"]\n", + " c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\")\n", + " c2_beacon.establish()\n", + "\n", + " return given_env, c2_server, c2_beacon" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Removing the C2 Beacon.\n", + "\n", + "The simplest way a blue agent could prevent the C2 suite is by simply removing the C2 beacon from it's installation point. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "blue_env.reset()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", + "\n", + "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code cell below uses the custom blue agent defined at the start of this section perform NODE_APPLICATION_REMOVE on the C2 beacon" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Using CAOS ACTION: NODE_APPLICATION_REMOVE & capturing the OBS\n", + "post_blue_action_obs, _, _, _, _ = blue_env.step(1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which we can see after the effects of after stepping another timestep and looking at the web_servers software manager and the OBS differences." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "blue_env.step(0)\n", + "web_server.software_manager.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(pre_blue_action_obs, post_blue_action_obs, blue_env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we are unable to do so as the C2 Server is unable has lost it's connection to the C2 Beacon:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Attempting to install the C2 RansomwareScript\n", + "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", + " \"username\": \"pass123\",\n", + " \"password\": \"password123\"}\n", + "\n", + "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Shutting down the node infected with a C2 Beacon.\n", + "\n", + "Another way a blue agent can prevent the C2 suite is via shutting down the C2 beacon's host node. Whilst not as effective as the previous option, dependant on situation (such as multiple malicious applications) or other scenarios it may be more timestep efficient for a blue agent to shut down a node directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "blue_env.reset()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", + "\n", + "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code cell below uses the custom blue agent defined at the start of this section perform NODE_SHUT_DOWN on the web server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Using CAOS ACTION: NODE_SHUT_DOWN & capturing the OBS\n", + "post_blue_action_obs, _, _, _, _ = blue_env.step(2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which we can see after the effects of after stepping another timestep and looking at the web_servers operating state & the OBS differences." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "web_server = blue_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "print(web_server.operating_state)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(pre_blue_action_obs, post_blue_action_obs, blue_env.game.step_counter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Attempting to install the C2 RansomwareScript\n", + "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", + " \"username\": \"pass123\",\n", + " \"password\": \"password123\"}\n", + "\n", + "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Blocking C2 Traffic via ACL.\n", + "\n", + "Another potential option a blue agent could take is by placing an ACL rule which blocks traffic between the C2 Server can C2 Beacon.\n", + "\n", + "It's worth noting the potential effectiveness of approach is also linked by the current green agent traffic on the network. The same applies for the previous example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "blue_env.reset()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", + "\n", + "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code cell below uses the custom blue agent defined at the start of this section to perform a ROUTER_ACL_ADDRULE on router 1." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Using CAOS ACTION: ROUTER_ACL_ADDRULE & capturing the OBS\n", + "post_blue_action_obs, _, _, _, _ = blue_env.step(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which we can see after the effects of after stepping another timestep and looking at router 1's ACLs and the OBS differences." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "router_1: Router = blue_env.game.simulation.network.get_node_by_hostname(\"router_1\")\n", + "router_1.acl.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(default_obs, c2_ransomware_obs, env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we can see that the C2 applications are unable to maintain connection - thus being unable to execute correctly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Waiting for the ransomware to finish installing and then launching the RansomwareScript.\n", + "blue_env.step(0)\n", + "c2_server._send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "router_1.acl.show()" ] } ], diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 1dde28a2..1bb4d70f 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -3,7 +3,6 @@ from enum import Enum from ipaddress import IPv4Address from typing import Dict, Optional -# from primaite.simulator.system.services.terminal.terminal import Terminal from prettytable import MARKDOWN, PrettyTable from pydantic import validate_call @@ -15,6 +14,11 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript +from primaite.simulator.system.services.terminal.terminal import ( + LocalTerminalConnection, + RemoteTerminalConnection, + Terminal, +) from primaite.simulator.system.software import SoftwareHealthState @@ -44,17 +48,19 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): keep_alive_frequency: int = 5 "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." - # TODO: - # Implement the placeholder command methods - # Uncomment the terminal Import and the terminal property after terminal PR + local_terminal_session: LocalTerminalConnection = None + """#TODO""" - # @property - # def _host_terminal(self) -> Terminal: - # """Return the Terminal that is installed on the same machine as the C2 Beacon.""" - # host_terminal: Terminal = self.software_manager.software.get("Terminal") - # if host_terminal: is None: - # self.sys_log.warning(f"{self.__class__.__name__} cannot find a terminal on its host.") - # return host_terminal + remote_terminal_session: RemoteTerminalConnection = None + """#TODO""" + + @property + def _host_terminal(self) -> Optional[Terminal]: + """Return the Terminal that is installed on the same machine as the C2 Beacon.""" + host_terminal: Terminal = self.software_manager.software.get("Terminal") + if host_terminal is None: + self.sys_log.warning(f"{self.__class__.__name__} cannot find a terminal on its host.") + return host_terminal @property def _host_ransomware_script(self) -> RansomwareScript: @@ -64,6 +70,26 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.sys_log.warning(f"{self.__class__.__name__} cannot find installed ransomware on its host.") return ransomware_script + def get_terminal_session(self, username: str, password: str) -> Optional[LocalTerminalConnection]: + """Return an instance of a Local Terminal Connection upon successful login. Otherwise returns None.""" + if self.local_terminal_session is None: + host_terminal: Terminal = self._host_terminal + self.local_terminal_session = host_terminal.login(username=username, password=password) + + return self.local_terminal_session + + def get_remote_terminal_session( + self, username: str, password: str, ip_address: IPv4Address + ) -> Optional[RemoteTerminalConnection]: + """Return an instance of a Local Terminal Connection upon successful login. Otherwise returns None.""" + if self.remote_terminal_session is None: + host_terminal: Terminal = self._host_terminal + self.remote_terminal_session = host_terminal.login( + username=username, password=password, ip_address=ip_address + ) + + return self.remote_terminal_session + def _init_request_manager(self) -> RequestManager: """ Initialise the request manager. @@ -153,7 +179,6 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) return True - # I THINK that once the application is running it can respond to incoming traffic but I'll need to test this later. def establish(self) -> bool: """Establishes connection to the C2 server via a send alive. The C2 Beacon must already be configured.""" if self.c2_remote_connection is None: @@ -269,7 +294,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Cannot find any instances of a RansomwareScript. Have you installed one?"}, ) return RequestResponse.from_bool( - self._host_ransomware_script.configure(server_ip_address=given_config["server_ip_address"]) + self._host_ransomware_script.configure( + server_ip_address=given_config["server_ip_address"], payload=given_config["payload"] + ) ) def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: @@ -304,8 +331,44 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - # TODO: uncomment and replace (uses terminal) - return RequestResponse(status="success", data={"Reason": "Placeholder."}) + terminal_output: Dict[int, RequestResponse] = {} + given_commands: list[RequestFormat] + + if self._host_terminal is None: + return RequestResponse( + status="failure", + data={"Reason": "Host does not seem to have terminal installed. Unable to resolve command."}, + ) + + # TODO: Placeholder until further details on handling user sessions. + given_commands = payload.payload.get("commands") + given_username = payload.payload.get("username") + given_password = payload.payload.get("password") + remote_ip = payload.payload.get("ip_address") + + # Creating a remote terminal session if given an IP Address, otherwise using a local terminal session. + if payload.payload.get("ip_address") is None: + terminal_session = self.get_terminal_session(username=given_username, password=given_password) + else: + terminal_session = self.get_remote_terminal_session( + username=given_username, password=given_password, ip_address=remote_ip + ) + + if terminal_session is None: + RequestResponse( + status="failure", + data={"Reason": "Host cannot is unable to connect to terminal. Unable to resolve command."}, + ) + + for index, given_command in enumerate(given_commands): + # A try catch exception ladder was used but was considered not the best approach + # as it can end up obscuring visibility of actual bugs (Not the expected ones) and was a temporary solution. + # TODO: Refactor + add further validation to ensure that a request is correct. (maybe a pydantic method?) + terminal_output[index] = terminal_session.execute(given_command) + + # Reset our remote terminal session. + self.remote_terminal_session is None + return RequestResponse(status="success", data=terminal_output) def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: """ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 85009cec..211da210 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -47,8 +47,10 @@ class C2Server(AbstractC2, identifier="C2Server"): :return: RequestResponse object with a success code reflecting whether the configuration could be applied. :rtype: RequestResponse """ - # TODO: Parse the parameters from the request to get the parameters - ransomware_config = {"server_ip_address": request[-1].get("server_ip_address")} + ransomware_config = { + "server_ip_address": request[-1].get("server_ip_address"), + "payload": request[-1].get("payload"), + } return self._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: @@ -73,9 +75,8 @@ class C2Server(AbstractC2, identifier="C2Server"): :return: RequestResponse object with a success code reflecting whether the ransomware was launched. :rtype: RequestResponse """ - # TODO: Parse the parameters from the request to get the parameters - terminal_commands = {"commands": request[-1].get("commands")} - return self._send_command(given_command=C2Command.TERMINAL, command_options=terminal_commands) + command_payload = request[-1] + return self._send_command(given_command=C2Command.TERMINAL, command_options=command_payload) rm.add_request( name="ransomware_configure", @@ -174,7 +175,7 @@ class C2Server(AbstractC2, identifier="C2Server"): ---------------------|------------------------ RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. RANSOMWARE_LAUNCH | Launches the installed ransomware script. - Terminal | Executes a command via the terminal installed on the C2 Beacons Host. + TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. For more information on the impact of these commands please refer to the terminal and the ransomware applications. @@ -198,6 +199,18 @@ class C2Server(AbstractC2, identifier="C2Server"): status="failure", data={"Reason": "Unable to access networking resources. Unable to send command."} ) + if self.c2_remote_connection is False: + self.sys_log.warning(f"{self.name}: C2 Beacon has yet to establish connection. Rejecting command.") + return RequestResponse( + status="failure", data={"Reason": "C2 Beacon has yet to establish connection. Unable to send command."} + ) + + if self.current_c2_session is None: + self.sys_log.warning(f"{self.name}: C2 Beacon cannot be reached. Rejecting command.") + return RequestResponse( + status="failure", data={"Reason": "C2 Beacon cannot be reached. Unable to send command."} + ) + self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") command_packet = self._craft_packet(given_command=given_command, command_options=command_options) From bf44ceaeac912195b683492d5d0843b9d74de16d Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 9 Aug 2024 09:26:37 +0000 Subject: [PATCH 138/206] Apply suggestions from code review --- benchmark/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/report.py b/benchmark/report.py index 408e91cf..4035ceca 100644 --- a/benchmark/report.py +++ b/benchmark/report.py @@ -15,7 +15,7 @@ from utils import _get_system_info import primaite PLOT_CONFIG = { - "size": {"auto_size": False, "width": 800, "height": 800}, + "size": {"auto_size": False, "width": 800, "height": 640}, "template": "plotly_white", "range_slider": False, } From ddc9acd03a2765725e7e33a4fbedf26316278041 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 9 Aug 2024 11:04:12 +0100 Subject: [PATCH 139/206] #2689 Fix notebook blue agent actions not functioning correctly. --- .../Command-&-Control-E2E-Demonstration.ipynb | 62 ++++++++++++++----- .../red_applications/c2/abstract_c2.py | 6 +- .../system/red_applications/test_c2_suite.py | 6 ++ 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 3cdb3324..e41b6e08 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -83,6 +83,8 @@ " ip_list:\n", " - 192.168.1.21\n", " - 192.168.1.14\n", + " wildcard_list:\n", + " - 0.0.0.1\n", " action_map:\n", " 0:\n", " action: DONOTHING\n", @@ -469,7 +471,8 @@ " dest_port_id: 2\n", " protocol_id: 1\n", " source_wildcard_id: 0\n", - " dest_wildcard_id: 0\n", + " dest_wildcard_id: 0 \n", + "\n", "\n", " options:\n", " nodes:\n", @@ -496,7 +499,6 @@ " - 192.168.1.12\n", " wildcard_list:\n", " - 0.0.0.1\n", - "\n", " reward_function:\n", " reward_components:\n", " - type: DUMMY\n", @@ -728,7 +730,7 @@ " c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\")\n", " c2_beacon.establish()\n", "\n", - " return given_env, c2_server, c2_beacon" + " return given_env, c2_server, c2_beacon, client_1, web_server" ] }, { @@ -757,7 +759,7 @@ "source": [ "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", "\n", - "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "blue_env, c2_server, c2_beacon, client_1, web_server = c2_setup(given_env=blue_env)\n", "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" ] }, @@ -852,7 +854,7 @@ "source": [ "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", "\n", - "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "blue_env, c2_server, c2_beacon, client_1, web_server = c2_setup(given_env=blue_env)\n", "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" ] }, @@ -942,7 +944,7 @@ "source": [ "# Setting up the C2 Suite using the c2_setup method & capturing the OBS impacts\n", "\n", - "blue_env, c2_server, c2_beacon = c2_setup(blue_env=blue_env)\n", + "blue_env, c2_server, c2_beacon, client_1, web_server = c2_setup(given_env=blue_env)\n", "pre_blue_action_obs, _, _, _, _ = blue_env.step(0)" ] }, @@ -980,15 +982,6 @@ "router_1.acl.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "display_obs_diffs(default_obs, c2_ransomware_obs, env.game.step_counter)" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -1002,8 +995,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Waiting for the ransomware to finish installing and then launching the RansomwareScript.\n", "blue_env.step(0)\n", + "\n", + "# Attempting to install and execute the ransomware script\n", + "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n", "c2_server._send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" ] }, @@ -1015,6 +1010,41 @@ "source": [ "router_1.acl.show()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Because of the ACL rule the C2 beacon never received the ransomware installation and execute commands from the C2 server:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "web_server.software_manager.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "database_server: Server = blue_env.game.simulation.network.get_node_by_hostname(\"database_server\")\n", + "database_server.software_manager.file_system.show(full=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(pre_blue_action_obs, post_blue_action_obs, blue_env.game.step_counter)" + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 9158d80f..47944633 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -90,7 +90,8 @@ class AbstractC2(Application, identifier="AbstractC2"): # TODO: Update this post application/services requiring to listen to multiple ports def __init__(self, **kwargs): """Initialise the C2 applications to by default listen for HTTP traffic.""" - kwargs["port"] = Port.HTTP # TODO: Update this post application/services requiring to listen to multiple ports + kwargs["listen_on_ports"] = {Port.HTTP, Port.FTP, Port.DNS} + kwargs["port"] = Port.HTTP kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) @@ -241,9 +242,6 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return False - # TODO: Validation on Ports (E.g only allow HTTP, FTP etc) - # Potentially compare to IPProtocol & Port children? Depends on how listening on multiple ports is implemented. - # Setting the Ports self.current_masquerade_port = payload.masquerade_port self.current_masquerade_protocol = payload.masquerade_protocol diff --git a/tests/integration_tests/system/red_applications/test_c2_suite.py b/tests/integration_tests/system/red_applications/test_c2_suite.py index 9d66f3c1..9b799ff5 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite.py @@ -102,6 +102,12 @@ def test_c2_suite_setup_receive(basic_network): assert c2_server.c2_connection_active is True assert c2_server.c2_remote_connection == IPv4Address("192.168.255.2") + for i in range(50): + network.apply_timestep(i) + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + def test_c2_suite_keep_alive_inactivity(basic_network): """Tests that C2 Beacon disconnects from the C2 Server after inactivity.""" From 4241118d260fadf05d43e7e4e69fb6e6fd716944 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 9 Aug 2024 12:14:57 +0100 Subject: [PATCH 140/206] #2689 Adding slight changes to c2_Beacon & terminal that appeared when merging from dev. --- .../Command-&-Control-E2E-Demonstration.ipynb | 4 ++-- .../applications/red_applications/c2/c2_beacon.py | 14 ++++++-------- .../simulator/system/services/terminal/terminal.py | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index e41b6e08..10077cd4 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -114,8 +114,8 @@ " node_id: 1\n", " ip_address:\n", " account:\n", - " username: test123\n", - " password: pass123\n", + " username: admin\n", + " password: admin\n", " commands:\n", " - \n", " - software_manager\n", diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 1bb4d70f..d8911622 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -49,10 +49,10 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." local_terminal_session: LocalTerminalConnection = None - """#TODO""" + "The currently in use local terminal session." remote_terminal_session: RemoteTerminalConnection = None - """#TODO""" + "The currently in use remote terminal session" @property def _host_terminal(self) -> Optional[Terminal]: @@ -199,7 +199,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ---------------------|------------------------ RANSOMWARE_CONFIGURE | self._command_ransomware_config() RANSOMWARE_LAUNCH | self._command_ransomware_launch() - Terminal | self._command_terminal() + TERMINAL | self._command_terminal() Please see each method individually for further information regarding the implementation of these commands. @@ -340,14 +340,13 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Host does not seem to have terminal installed. Unable to resolve command."}, ) - # TODO: Placeholder until further details on handling user sessions. given_commands = payload.payload.get("commands") given_username = payload.payload.get("username") given_password = payload.payload.get("password") remote_ip = payload.payload.get("ip_address") # Creating a remote terminal session if given an IP Address, otherwise using a local terminal session. - if payload.payload.get("ip_address") is None: + if remote_ip is None: terminal_session = self.get_terminal_session(username=given_username, password=given_password) else: terminal_session = self.get_remote_terminal_session( @@ -355,9 +354,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) if terminal_session is None: - RequestResponse( - status="failure", - data={"Reason": "Host cannot is unable to connect to terminal. Unable to resolve command."}, + return RequestResponse( + status="failure", data={"reason": "Terminal Login failed. Cannot create a terminal session."} ) for index, given_command in enumerate(given_commands): diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 876b1694..df2098df 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -92,7 +92,7 @@ class LocalTerminalConnection(TerminalClientConnection): if not self.is_active: self.parent_terminal.sys_log.warning("Connection inactive, cannot execute") return None - return self.parent_terminal.execute(command, connection_id=self.connection_uuid) + return self.parent_terminal.execute(command) class RemoteTerminalConnection(TerminalClientConnection): From ab91f993a52e916e2d1f9a88aa74b4e3bb661ccc Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 9 Aug 2024 12:45:15 +0100 Subject: [PATCH 141/206] #2689 Initial Implementation of multi-port listeners. --- src/primaite/game/agent/actions.py | 4 +- .../Command-&-Control-E2E-Demonstration.ipynb | 130 ++++++++++++++++++ .../red_applications/c2/abstract_c2.py | 5 +- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 5f045ccb..92b175a9 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1107,8 +1107,8 @@ class ConfigureC2BeaconAction(AbstractAction): config = ConfigureC2BeaconAction._Opts( c2_server_ip_address=config["c2_server_ip_address"], keep_alive_frequency=config["keep_alive_frequency"], - masquerade_port=config["masquerade_protocol"], - masquerade_protocol=config["masquerade_port"], + masquerade_port=config["masquerade_port"], + masquerade_protocol=config["masquerade_protocol"], ) ConfigureC2BeaconAction._Opts.model_validate(config) # check that options adhere to schema diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 10077cd4..bbe29cd6 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -133,6 +133,15 @@ " action: C2_SERVER_RANSOMWARE_LAUNCH\n", " options:\n", " node_id: 1\n", + " 7:\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0\n", + " config:\n", + " c2_server_ip_address: 192.168.10.21\n", + " keep_alive_frequency: 10\n", + " masquerade_protocol: TCP\n", + " masquerade_port: DNS\n", "\n", "\n", "\n", @@ -1045,6 +1054,127 @@ "source": [ "display_obs_diffs(pre_blue_action_obs, post_blue_action_obs, blue_env.game.step_counter)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Command and Control** | C2 Beacon Actions\n", + "\n", + "Before any C2 Server commands is able to accept any commands, it must first establish connection with a C2 beacon.\n", + "\n", + "This can be done by installing, configuring and then executing a C2 Beacon. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## **Command and Control** | Configurability \n", + "\n", + "TODO: Fleshout" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path()) as f:\n", + " cfg = yaml.safe_load(f)\n", + " # removing all agents & adding the custom agent.\n", + " cfg['agents'] = {}\n", + " cfg['agents'] = c2_agent_yaml\n", + " \n", + "\n", + "c2_config_env = PrimaiteGymEnv(env_config=cfg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Installing the C2 Server" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client_1: Computer = c2_config_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "client_1.software_manager.install(C2Server)\n", + "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + "c2_server.run()\n", + "client_1.software_manager.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Installing the C2 Beacon via NODE_APPLICATION_INSTALL" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_config_env.step(1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Configuring the C2 Beacon using different parameters:\n", + "\n", + "``` yaml\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0\n", + " config:\n", + " c2_server_ip_address: 192.168.10.21\n", + " keep_alive_frequency: 10\n", + " masquerade_protocol: TCP\n", + " masquerade_port: DNS\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_config_env.step(7)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Establishing connection to the C2 Server.\n", + "c2_config_env.step(3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "web_server: Server = c2_config_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "c2_beacon: C2Beacon = web_server.software_manager.software[\"C2Beacon\"]\n", + "c2_beacon.show()\n", + "c2_server.show()" + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 47944633..c7b0d32c 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -87,11 +87,10 @@ class AbstractC2(Application, identifier="AbstractC2"): """ return super().describe_state() - # TODO: Update this post application/services requiring to listen to multiple ports def __init__(self, **kwargs): """Initialise the C2 applications to by default listen for HTTP traffic.""" kwargs["listen_on_ports"] = {Port.HTTP, Port.FTP, Port.DNS} - kwargs["port"] = Port.HTTP + kwargs["port"] = Port.NONE kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) @@ -242,7 +241,7 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return False - # Setting the Ports + # Setting the masquerade_port/protocol attribute: self.current_masquerade_port = payload.masquerade_port self.current_masquerade_protocol = payload.masquerade_protocol From 53433ce7b6bbefb46fdd0d288defd1468a81477b Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 9 Aug 2024 17:53:47 +0100 Subject: [PATCH 142/206] #2689 General improvements. 1. Abstract TAP now handles .apply_timestep 2. Expanded tests 3. Added pydantic model for c2 configuration. --- .../system/applications/c2_suite.rst | 4 +- .../Command-&-Control-E2E-Demonstration.ipynb | 12 +- .../simulator/network/protocols/masquerade.py | 6 + .../red_applications/c2/abstract_c2.py | 120 ++++++++++---- .../red_applications/c2/c2_beacon.py | 110 +++++-------- .../red_applications/c2/c2_server.py | 72 ++++++--- ..._suite.py => test_c2_suite_integration.py} | 153 +++++++++++------- .../_red_applications/test_c2_suite.py | 138 ++++++++++++++++ 8 files changed, 421 insertions(+), 194 deletions(-) rename tests/integration_tests/system/red_applications/{test_c2_suite.py => test_c2_suite_integration.py} (71%) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index c360d0be..e299bb0e 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -14,7 +14,7 @@ Overview: ========= These two new classes intend to Red Agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application. -Whilst introducing both more oppourtinies for the blue agent to notice and subvert Red Agents during an episode. +Whilst introducing both more opportunities for the blue agent to notice and subvert Red Agents during an episode. For a more in-depth look at the command and control applications then please refer to the ``C2-E2E-Notebook``. @@ -42,7 +42,7 @@ It's important to note that in order to keep the PrimAITE realistic from a cyber The C2 Server application should never be visible or actionable upon directly by the blue agent. This is because in the real world, C2 servers are hosted on ephemeral public domains that would not be accessible by private network blue agent. -Therefore granting a blue agent's the ability to perform counter measures directly against the application would be unrealistic. +Therefore granting blue agent(s) the ability to perform counter measures directly against the application would be unrealistic. It is more accurate to see the host that the C2 Server is installed on as being able to route to the C2 Server (Internet Access). diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index bbe29cd6..b41b9f2e 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -631,8 +631,8 @@ "source": [ "# Installing RansomwareScript via C2 Terminal Commands\n", "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", - " \"username\": \"pass123\",\n", - " \"password\": \"password123\"}\n", + " \"username\": \"admin\",\n", + " \"password\": \"admin\"}\n", "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n" ] }, @@ -830,8 +830,8 @@ "source": [ "# Attempting to install the C2 RansomwareScript\n", "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", - " \"username\": \"pass123\",\n", - " \"password\": \"password123\"}\n", + " \"username\": \"admin\",\n", + " \"password\": \"admin\"}\n", "\n", "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" @@ -918,8 +918,8 @@ "source": [ "# Attempting to install the C2 RansomwareScript\n", "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", - " \"username\": \"pass123\",\n", - " \"password\": \"password123\"}\n", + " \"username\": \"admin\",\n", + " \"password\": \"admin\"}\n", "\n", "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" diff --git a/src/primaite/simulator/network/protocols/masquerade.py b/src/primaite/simulator/network/protocols/masquerade.py index 7ef17fc0..e2a7b6a0 100644 --- a/src/primaite/simulator/network/protocols/masquerade.py +++ b/src/primaite/simulator/network/protocols/masquerade.py @@ -12,6 +12,12 @@ class MasqueradePacket(DataPacket): masquerade_port: Enum # The 'Masquerade' port that is currently in use + +class C2Packet(MasqueradePacket): + """Represents C2 suite communications packets.""" + payload_type: Enum # The type of C2 traffic (e.g keep alive, command or command out) command: Optional[Enum] = None # Used to pass the actual C2 Command in C2 INPUT + + keep_alive_frequency: int diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index c7b0d32c..a00b8570 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -4,13 +4,14 @@ from enum import Enum from ipaddress import IPv4Address from typing import Dict, Optional -from pydantic import validate_call +from pydantic import BaseModel, Field, validate_call -from primaite.simulator.network.protocols.masquerade import MasqueradePacket +from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.applications.application import Application, ApplicationOperatingState from primaite.simulator.system.core.session_manager import Session +from primaite.simulator.system.software import SoftwareHealthState # TODO: # Create test that leverage all the functionality needed for the different TAPs @@ -65,19 +66,30 @@ class AbstractC2(Application, identifier="AbstractC2"): c2_remote_connection: IPv4Address = None """The IPv4 Address of the remote c2 connection. (Either the IP of the beacon or the server).""" - # These two attributes are set differently in the c2 server and c2 beacon. - # The c2 server parses the keep alive and sets these accordingly. - # The c2 beacon will set this attributes upon installation and configuration - - current_masquerade_protocol: IPProtocol = IPProtocol.TCP - """The currently chosen protocol that the C2 traffic is masquerading as. Defaults as TCP.""" - - current_masquerade_port: Port = Port.HTTP - """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" - - current_c2_session: Session = None + c2_session: Session = None """The currently active session that the C2 Traffic is using. Set after establishing connection.""" + keep_alive_inactivity: int = 0 + """Indicates how many timesteps since the last time the c2 application received a keep alive.""" + + class _C2_Opts(BaseModel): + """A Pydantic Schema for the different C2 configuration options.""" + + keep_alive_frequency: int = Field(default=5, ge=1) + """The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon.""" + + masquerade_protocol: IPProtocol = Field(default=IPProtocol.TCP) + """The currently chosen protocol that the C2 traffic is masquerading as. Defaults as TCP.""" + + masquerade_port: Port = Field(default=Port.HTTP) + """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" + + # The c2 beacon sets the c2_config through it's own internal method - configure (which is also used by agents) + # and then passes the config attributes to the c2 server via keep alives + # The c2 server parses the C2 configurations from keep alive traffic and sets the c2_config accordingly. + c2_config: _C2_Opts = _C2_Opts() + """Holds the current configuration settings of the C2 Suite.""" + def describe_state(self) -> Dict: """ Describe the state of the C2 application. @@ -96,7 +108,7 @@ class AbstractC2(Application, identifier="AbstractC2"): # Validate call ensures we are only handling Masquerade Packets. @validate_call - def _handle_c2_payload(self, payload: MasqueradePacket, session_id: Optional[str] = None) -> bool: + def _handle_c2_payload(self, payload: C2Packet, session_id: Optional[str] = None) -> bool: """Handles masquerade payloads for both c2 beacons and c2 servers. Currently, the C2 application suite can handle the following payloads: @@ -151,18 +163,18 @@ class AbstractC2(Application, identifier="AbstractC2"): """Abstract Method: Used in C2 beacon to parse and handle commands received from the c2 server.""" pass - def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + def _handle_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: """Abstract Method: The C2 Server and the C2 Beacon handle the KEEP ALIVEs differently.""" # from_network_interface=from_network_interface - def receive(self, payload: MasqueradePacket, session_id: Optional[str] = None, **kwargs) -> bool: + def receive(self, payload: C2Packet, session_id: Optional[str] = None, **kwargs) -> bool: """Receives masquerade packets. Used by both c2 server and c2 beacon. Defining the `Receive` method so that the application can receive packets via the session manager. These packets are then immediately handed to ._handle_c2_payload. :param payload: The Masquerade Packet to be received. - :type payload: MasqueradePacket + :type payload: C2Packet :param session_id: The transport session_id that the payload is originating from. :type session_id: str """ @@ -193,9 +205,10 @@ class AbstractC2(Application, identifier="AbstractC2"): # We also Pass masquerade proto`col/port so that the c2 server can reply on the correct protocol/port. # (This also lays the foundations for switching masquerade port/protocols mid episode.) - keep_alive_packet = MasqueradePacket( - masquerade_protocol=self.current_masquerade_protocol, - masquerade_port=self.current_masquerade_port, + keep_alive_packet = C2Packet( + masquerade_protocol=self.c2_config.masquerade_protocol, + masquerade_port=self.c2_config.masquerade_port, + keep_alive_frequency=self.c2_config.keep_alive_frequency, payload_type=C2Payload.KEEP_ALIVE, command=None, ) @@ -203,13 +216,15 @@ class AbstractC2(Application, identifier="AbstractC2"): if self.send( payload=keep_alive_packet, dest_ip_address=self.c2_remote_connection, - dest_port=self.current_masquerade_port, - ip_protocol=self.current_masquerade_protocol, + dest_port=self.c2_config.masquerade_port, + ip_protocol=self.c2_config.masquerade_protocol, session_id=session_id, ): self.keep_alive_sent = True self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") - self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") + self.sys_log.debug( + f"{self.name}: on {self.c2_config.masquerade_port} via {self.c2_config.masquerade_protocol}" + ) return True else: self.sys_log.warning( @@ -217,7 +232,7 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return False - def _resolve_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + def _resolve_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: """ Parses the Masquerade Port/Protocol within the received Keep Alive packet. @@ -227,7 +242,7 @@ class AbstractC2(Application, identifier="AbstractC2"): Returns False otherwise. :param payload: The Keep Alive payload received. - :type payload: MasqueradePacket + :type payload: C2Packet :param session_id: The transport session_id that the payload is originating from. :type session_id: str :return: True on successful configuration, false otherwise. @@ -241,16 +256,61 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return False - # Setting the masquerade_port/protocol attribute: - self.current_masquerade_port = payload.masquerade_port - self.current_masquerade_protocol = payload.masquerade_protocol + # Updating the C2 Configuration attribute. + + self.c2_config.masquerade_port = payload.masquerade_port + self.c2_config.masquerade_protocol = payload.masquerade_protocol + self.c2_config.keep_alive_frequency = payload.keep_alive_frequency # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) if self.c2_remote_connection is None: self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") - self.c2_remote_connection = IPv4Address(self.current_c2_session.with_ip_address) + self.c2_remote_connection = IPv4Address(self.c2_session.with_ip_address) self.c2_connection_active = True # Sets the connection to active self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero return True + + def _reset_c2_connection(self) -> None: + """Resets all currently established C2 communications to their default setting.""" + self.c2_connection_active = False + self.c2_session = None + self.keep_alive_inactivity = 0 + self.keep_alive_frequency = 5 + self.c2_remote_connection = None + self.c2_config.masquerade_port = Port.HTTP + self.c2_config.masquerade_protocol = IPProtocol.TCP + + @abstractmethod + def _confirm_connection(self, timestep: int) -> bool: + """Abstract method - Checks the suitability of the current C2 Server/Beacon connection.""" + + def apply_timestep(self, timestep: int) -> None: + """Apply a timestep to the c2_server & c2 beacon. + + Used to keep track of when the c2 server should consider a beacon dead + and set it's c2_remote_connection attribute to false. + + 1. Each timestep the keep_alive_inactivity is increased. + + 2. If the keep alive inactivity eclipses that of the keep alive frequency then another keep alive is sent. + + 3. If a keep alive response packet is received then the ``keep_alive_inactivity`` attribute is reset. + + Therefore, if ``keep_alive_inactivity`` attribute is not 0 after a keep alive is sent + then the connection is considered severed and c2 beacon will shut down. + + :param timestep: The current timestep of the simulation. + :type timestep: Int + :return bool: Returns false if connection was lost. Returns True if connection is active or re-established. + :rtype bool: + """ + super().apply_timestep(timestep=timestep) + if ( + self.operating_state is ApplicationOperatingState.RUNNING + and self.health_state_actual is SoftwareHealthState.GOOD + ): + self.keep_alive_inactivity += 1 + self._confirm_connection(timestep) + return diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index d8911622..55dd1474 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -8,10 +8,9 @@ from pydantic import validate_call from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.network.protocols.masquerade import MasqueradePacket +from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.terminal.terminal import ( @@ -19,7 +18,6 @@ from primaite.simulator.system.services.terminal.terminal import ( RemoteTerminalConnection, Terminal, ) -from primaite.simulator.system.software import SoftwareHealthState class C2Beacon(AbstractC2, identifier="C2Beacon"): @@ -41,13 +39,6 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): keep_alive_attempted: bool = False """Indicates if a keep alive has been attempted to be sent this timestep. Used to prevent packet storms.""" - # We should set the application to NOT_RUNNING if the inactivity count reaches a certain thresh hold. - keep_alive_inactivity: int = 0 - """Indicates how many timesteps since the last time the c2 application received a keep alive.""" - - keep_alive_frequency: int = 5 - "The frequency at which ``Keep Alive`` packets are sent to the C2 Server from the C2 Beacon." - local_terminal_session: LocalTerminalConnection = None "The currently in use local terminal session." @@ -164,9 +155,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :type masquerade_port: Enum (Port) """ self.c2_remote_connection = IPv4Address(c2_server_ip_address) - self.keep_alive_frequency = keep_alive_frequency - self.current_masquerade_port = masquerade_port - self.current_masquerade_protocol = masquerade_protocol + self.c2_config.keep_alive_frequency = keep_alive_frequency + self.c2_config.masquerade_port = masquerade_port + self.c2_config.masquerade_protocol = masquerade_protocol self.sys_log.info( f"{self.name}: Configured {self.name} with remote C2 server connection: {c2_server_ip_address=}." ) @@ -188,7 +179,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.num_executions += 1 return self._send_keep_alive(session_id=None) - def _handle_command_input(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + def _handle_command_input(self, payload: C2Packet, session_id: Optional[str]) -> bool: """ Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets). @@ -205,7 +196,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): the implementation of these commands. :param payload: The INPUT C2 Payload - :type payload: MasqueradePacket + :type payload: C2Packet :return: The Request Response provided by the terminal execute method. :rtype Request Response: """ @@ -250,21 +241,24 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :param session_id: The current session established with the C2 Server. :type session_id: Str """ - output_packet = MasqueradePacket( - masquerade_protocol=self.current_masquerade_protocol, - masquerade_port=self.current_masquerade_port, + output_packet = C2Packet( + masquerade_protocol=self.c2_config.masquerade_protocol, + masquerade_port=self.c2_config.masquerade_port, + keep_alive_frequency=self.c2_config.keep_alive_frequency, payload_type=C2Payload.OUTPUT, payload=command_output, ) if self.send( payload=output_packet, dest_ip_address=self.c2_remote_connection, - dest_port=self.current_masquerade_port, - ip_protocol=self.current_masquerade_protocol, + dest_port=self.c2_config.masquerade_port, + ip_protocol=self.c2_config.masquerade_protocol, session_id=session_id, ): self.sys_log.info(f"{self.name}: Command output sent to {self.c2_remote_connection}") - self.sys_log.debug(f"{self.name}: on {self.current_masquerade_port} via {self.current_masquerade_protocol}") + self.sys_log.debug( + f"{self.name}: on {self.c2_config.masquerade_port} via {self.c2_config.masquerade_protocol}" + ) return True else: self.sys_log.warning( @@ -272,7 +266,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) return False - def _command_ransomware_config(self, payload: MasqueradePacket) -> RequestResponse: + def _command_ransomware_config(self, payload: C2Packet) -> RequestResponse: """ C2 Command: Ransomware Configuration. @@ -282,8 +276,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): The class attribute self._host_ransomware_script will return None if the host does not have an instance of the RansomwareScript. - :payload MasqueradePacket: The incoming INPUT command. - :type Masquerade Packet: MasqueradePacket. + :payload C2Packet: The incoming INPUT command. + :type Masquerade Packet: C2Packet. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ @@ -299,7 +293,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) ) - def _command_ransomware_launch(self, payload: MasqueradePacket) -> RequestResponse: + def _command_ransomware_launch(self, payload: C2Packet) -> RequestResponse: """ C2 Command: Ransomware Launch. @@ -307,8 +301,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): This request is then sent to the terminal service in order to be executed. - :payload MasqueradePacket: The incoming INPUT command. - :type Masquerade Packet: MasqueradePacket. + :payload C2Packet: The incoming INPUT command. + :type Masquerade Packet: C2Packet. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ @@ -319,15 +313,15 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) return RequestResponse.from_bool(self._host_ransomware_script.attack()) - def _command_terminal(self, payload: MasqueradePacket) -> RequestResponse: + def _command_terminal(self, payload: C2Packet) -> RequestResponse: """ C2 Command: Terminal. Creates a request that executes a terminal command. This request is then sent to the terminal service in order to be executed. - :payload MasqueradePacket: The incoming INPUT command. - :type Masquerade Packet: MasqueradePacket. + :payload C2Packet: The incoming INPUT command. + :type Masquerade Packet: C2Packet. :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ @@ -368,7 +362,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.remote_terminal_session is None return RequestResponse(status="success", data=terminal_output) - def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + def _handle_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: """ Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. @@ -395,7 +389,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): if self.keep_alive_attempted is True: self.c2_connection_active = True # Sets the connection to active self.keep_alive_inactivity = 0 # Sets the keep alive inactivity to zero - self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] + self.c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] # We set keep alive_attempted here to show that we've achieved connection. self.keep_alive_attempted = False @@ -412,42 +406,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # If this method returns true then we have sent successfully sent a keep alive. return self._send_keep_alive(session_id) - def apply_timestep(self, timestep: int) -> None: - """Apply a timestep to the c2_beacon. - - Used to keep track of when the c2 beacon should send another keep alive. - The following logic is applied: - - 1. Each timestep the keep_alive_inactivity is increased. - - 2. If the keep alive inactivity eclipses that of the keep alive frequency then another keep alive is sent. - - 3. If a keep alive response packet is received then the ``keep_alive_inactivity`` attribute is reset. - - Therefore, if ``keep_alive_inactivity`` attribute is not 0 after a keep alive is sent - then the connection is considered severed and c2 beacon will shut down. - - :param timestep: The current timestep of the simulation. - :type timestep: Int - :return bool: Returns false if connection was lost. Returns True if connection is active or re-established. - :rtype bool: - """ - super().apply_timestep(timestep=timestep) - self.keep_alive_attempted = False # Resetting keep alive sent. - if ( - self.operating_state is ApplicationOperatingState.RUNNING - and self.health_state_actual is SoftwareHealthState.GOOD - ): - self.keep_alive_inactivity += 1 - if not self._check_c2_connection(timestep): - self.sys_log.error(f"{self.name}: Connection Severed - Application Closing.") - self.c2_connection_active = False - self.clear_connections() - # TODO: Shouldn't this close() method also set the health state to 'UNUSED'? - self.close() - return - - def _check_c2_connection(self, timestep: int) -> bool: + def _confirm_connection(self, timestep: int) -> bool: """Checks the suitability of the current C2 Server connection. If a connection cannot be confirmed then this method will return false otherwise true. @@ -457,20 +416,23 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns False if connection was lost. Returns True if connection is active or re-established. :rtype bool: """ - if self.keep_alive_inactivity == self.keep_alive_frequency: + self.keep_alive_attempted = False # Resetting keep alive sent. + if self.keep_alive_inactivity == self.c2_config.keep_alive_frequency: self.sys_log.info( f"{self.name}: Attempting to Send Keep Alive to {self.c2_remote_connection} at timestep {timestep}." ) - self._send_keep_alive(session_id=self.current_c2_session.uuid) + self._send_keep_alive(session_id=self.c2_session.uuid) if self.keep_alive_inactivity != 0: self.sys_log.warning( f"{self.name}: Did not receive keep alive from c2 Server. Connection considered severed." ) + self._reset_c2_connection() + self.close() return False return True # Defining this abstract method from Abstract C2 - def _handle_command_output(self, payload: MasqueradePacket): + def _handle_command_output(self, payload: C2Packet): """C2 Beacons currently does not need to handle output commands coming from the C2 Servers.""" self.sys_log.warning(f"{self.name}: C2 Beacon received an unexpected OUTPUT payload: {payload}.") pass @@ -520,9 +482,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.c2_connection_active, self.c2_remote_connection, self.keep_alive_inactivity, - self.keep_alive_frequency, - self.current_masquerade_protocol, - self.current_masquerade_port, + self.c2_config.keep_alive_frequency, + self.c2_config.masquerade_protocol, + self.c2_config.masquerade_port, ] ) print(table) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 211da210..e4bf3302 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -6,7 +6,7 @@ from pydantic import validate_call from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.network.protocols.masquerade import MasqueradePacket +from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload @@ -96,18 +96,18 @@ class C2Server(AbstractC2, identifier="C2Server"): kwargs["name"] = "C2Server" super().__init__(**kwargs) - def _handle_command_output(self, payload: MasqueradePacket) -> bool: + def _handle_command_output(self, payload: C2Packet) -> bool: """ Handles the parsing of C2 Command Output from C2 Traffic (Masquerade Packets). - Parses the Request Response within MasqueradePacket's payload attribute (Inherited from Data packet). + Parses the Request Response within C2Packet's payload attribute (Inherited from Data packet). The class attribute self.current_command_output is then set to this Request Response. If the payload attribute does not contain a RequestResponse, then an error will be raised in syslog and the self.current_command_output is updated to reflect the error. :param payload: The OUTPUT C2 Payload - :type payload: MasqueradePacket + :type payload: C2Packet :return: Returns True if the self.current_command_output is currently updated, false otherwise. :rtype Bool: """ @@ -123,7 +123,7 @@ class C2Server(AbstractC2, identifier="C2Server"): self.current_command_output = command_output return True - def _handle_keep_alive(self, payload: MasqueradePacket, session_id: Optional[str]) -> bool: + def _handle_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: """ Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. @@ -137,7 +137,7 @@ class C2Server(AbstractC2, identifier="C2Server"): Returns True if a keep alive was successfully sent or already has been sent this timestep. :param payload: The Keep Alive payload received. - :type payload: MasqueradePacket + :type payload: C2Packet :param session_id: The transport session_id that the payload is originating from. :type session_id: str :return: True if successfully handled, false otherwise. @@ -146,7 +146,7 @@ class C2Server(AbstractC2, identifier="C2Server"): self.sys_log.info(f"{self.name}: Keep Alive Received. Attempting to resolve the remote connection details.") self.c2_connection_active = True # Sets the connection to active - self.current_c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] + self.c2_session = self.software_manager.session_manager.sessions_by_uuid[session_id] if self._resolve_keep_alive(payload, session_id) == False: self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") @@ -205,7 +205,7 @@ class C2Server(AbstractC2, identifier="C2Server"): status="failure", data={"Reason": "C2 Beacon has yet to establish connection. Unable to send command."} ) - if self.current_c2_session is None: + if self.c2_session is None: self.sys_log.warning(f"{self.name}: C2 Beacon cannot be reached. Rejecting command.") return RequestResponse( status="failure", data={"Reason": "C2 Beacon cannot be reached. Unable to send command."} @@ -217,18 +217,22 @@ class C2Server(AbstractC2, identifier="C2Server"): if self.send( payload=command_packet, dest_ip_address=self.c2_remote_connection, - session_id=self.current_c2_session.uuid, - dest_port=self.current_masquerade_port, - ip_protocol=self.current_masquerade_protocol, + session_id=self.c2_session.uuid, + dest_port=self.c2_config.masquerade_port, + ip_protocol=self.c2_config.masquerade_protocol, ): self.sys_log.info(f"{self.name}: Successfully sent {given_command}.") self.sys_log.info(f"{self.name}: Awaiting command response {given_command}.") # If the command output was handled currently, the self.current_command_output will contain the RequestResponse. + if self.current_command_output is None: + return RequestResponse( + status="failure", data={"Reason": "Command sent to the C2 Beacon but no response was ever received."} + ) return self.current_command_output - # TODO: Probably could move this as a class method in MasqueradePacket. - def _craft_packet(self, given_command: C2Command, command_options: Dict) -> MasqueradePacket: + # TODO: Probably could move this as a class method in C2Packet. + def _craft_packet(self, given_command: C2Command, command_options: Dict) -> C2Packet: """ Creates and returns a Masquerade Packet using the arguments given. @@ -238,12 +242,13 @@ class C2Server(AbstractC2, identifier="C2Server"): :type given_command: C2Command. :param command_options: The relevant C2 Beacon parameters.F :type command_options: Dict - :return: Returns the construct MasqueradePacket - :rtype: MasqueradePacket + :return: Returns the construct C2Packet + :rtype: C2Packet """ - constructed_packet = MasqueradePacket( - masquerade_protocol=self.current_masquerade_protocol, - masquerade_port=self.current_masquerade_port, + constructed_packet = C2Packet( + masquerade_protocol=self.c2_config.masquerade_protocol, + masquerade_port=self.c2_config.masquerade_port, + keep_alive_frequency=self.c2_config.keep_alive_frequency, payload_type=C2Payload.INPUT, command=given_command, payload=command_options, @@ -281,20 +286,41 @@ class C2Server(AbstractC2, identifier="C2Server"): [ self.c2_connection_active, self.c2_remote_connection, - self.current_masquerade_protocol, - self.current_masquerade_port, + self.c2_config.masquerade_protocol, + self.c2_config.masquerade_port, ] ) print(table) # Abstract method inherited from abstract C2 - Not currently utilised. - def _handle_command_input(self, payload: MasqueradePacket) -> None: + def _handle_command_input(self, payload: C2Packet) -> None: """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. C2 Servers currently do not receive input commands coming from the C2 Beacons. - :param payload: The incoming MasqueradePacket - :type payload: MasqueradePacket. + :param payload: The incoming C2Packet + :type payload: C2Packet. """ self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") pass + + def _confirm_connection(self, timestep: int) -> bool: + """Checks the suitability of the current C2 Beacon connection. + + If a C2 Server has not received a keep alive within the current set + keep alive frequency (self._keep_alive_frequency) then the C2 beacons + connection is considered dead and any commands will be rejected. + + :param timestep: The current timestep of the simulation. + :type timestep: Int + :return: Returns False if the C2 beacon is considered dead. Otherwise True. + :rtype bool: + """ + if self.keep_alive_inactivity > self.c2_config.keep_alive_frequency: + self.sys_log.debug( + f"{self.name}: Failed to receive expected keep alive from {self.c2_remote_connection} at {timestep}." + ) + self.sys_log.info(f"{self.name}: C2 Beacon connection considered dead due to inactivity.") + self._reset_c2_connection() + return False + return True diff --git a/tests/integration_tests/system/red_applications/test_c2_suite.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py similarity index 71% rename from tests/integration_tests/system/red_applications/test_c2_suite.py rename to tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 9b799ff5..ab609cb0 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -17,7 +17,7 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon -from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer @@ -29,6 +29,7 @@ def basic_network() -> Network: network = Network() # Creating two generic nodes for the C2 Server and the C2 Beacon. + node_a = Computer( hostname="node_a", ip_address="192.168.0.2", @@ -43,12 +44,24 @@ def basic_network() -> Network: node_b = Computer( hostname="node_b", ip_address="192.168.255.2", - subnet_mask="255.255.255.252", + subnet_mask="255.255.255.248", default_gateway="192.168.255.1", start_up_duration=0, ) + node_b.power_on() node_b.software_manager.install(software_class=C2Beacon) + + # Creating a generic computer for testing remote terminal connections. + node_c = Computer( + hostname="node_c", + ip_address="192.168.255.3", + subnet_mask="255.255.255.248", + default_gateway="192.168.255.1", + start_up_duration=0, + ) + node_c.power_on() + # Creating a router to sit between node 1 and node 2. router = Router(hostname="router", num_ports=3, start_up_duration=0) # Default allow all. @@ -66,35 +79,43 @@ def basic_network() -> Network: switch_2.power_on() network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) - router.configure_port(port=2, ip_address="192.168.255.1", subnet_mask="255.255.255.252") + router.configure_port(port=2, ip_address="192.168.255.1", subnet_mask="255.255.255.248") router.enable_port(1) router.enable_port(2) # Connecting the node to each switch network.connect(node_a.network_interface[1], switch_1.network_interface[1]) - network.connect(node_b.network_interface[1], switch_2.network_interface[1]) + network.connect(node_c.network_interface[1], switch_2.network_interface[2]) return network +def setup_c2(given_network: Network): + """Installs the C2 Beacon & Server, configures and then returns.""" + computer_a: Computer = given_network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + computer_a.software_manager.install(DatabaseService) + computer_a.software_manager.software["DatabaseService"].start() + + computer_b: Computer = given_network.get_node_by_hostname("node_b") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + computer_b.software_manager.install(DatabaseClient) + computer_b.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.2")) + computer_b.software_manager.software["DatabaseClient"].run() + + c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) + c2_server.run() + c2_beacon.establish() + + return given_network, computer_a, c2_server, computer_b, c2_beacon + + def test_c2_suite_setup_receive(basic_network): """Test that C2 Beacon can successfully establish connection with the C2 Server.""" network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - - computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") - - # Assert that the c2 beacon configure correctly. - c2_beacon.configure(c2_server_ip_address="192.168.0.2") - assert c2_beacon.c2_remote_connection == IPv4Address("192.168.0.2") - - c2_server.run() - c2_beacon.establish() - + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) # Asserting that the c2 beacon has established a c2 connection assert c2_beacon.c2_connection_active is True @@ -112,15 +133,7 @@ def test_c2_suite_setup_receive(basic_network): def test_c2_suite_keep_alive_inactivity(basic_network): """Tests that C2 Beacon disconnects from the C2 Server after inactivity.""" network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - - computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") - - c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) - c2_server.run() - c2_beacon.establish() + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) c2_beacon.apply_timestep(0) assert c2_beacon.keep_alive_inactivity == 1 @@ -133,21 +146,21 @@ def test_c2_suite_keep_alive_inactivity(basic_network): # Now we turn off the c2 server (Thus preventing a keep alive) c2_server.close() c2_beacon.apply_timestep(2) + + assert c2_beacon.keep_alive_inactivity == 1 + c2_beacon.apply_timestep(3) - assert c2_beacon.keep_alive_inactivity == 2 + + # C2 Beacon resets it's connections back to default. + assert c2_beacon.keep_alive_inactivity == 0 assert c2_beacon.c2_connection_active == False assert c2_beacon.operating_state == ApplicationOperatingState.CLOSED def test_c2_suite_configure_request(basic_network): """Tests that the request system can be used to successfully setup a c2 suite.""" - # Setting up the network: network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - - computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) # Testing Via Requests: c2_server.run() @@ -173,20 +186,7 @@ def test_c2_suite_ransomware_commands(basic_network): """Tests the Ransomware commands can be used to configure & launch ransomware via Requests.""" # Setting up the network: network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - computer_a.software_manager.install(DatabaseService) - computer_a.software_manager.software["DatabaseService"].start() - - computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") - computer_b.software_manager.install(DatabaseClient) - computer_b.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.2")) - computer_b.software_manager.software["DatabaseClient"].run() - - c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) - c2_server.run() - c2_beacon.establish() + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) # Testing Via Requests: computer_b.software_manager.install(software_class=RansomwareScript) @@ -208,18 +208,10 @@ def test_c2_suite_acl_block(basic_network): """Tests that C2 Beacon disconnects from the C2 Server after blocking ACL rules.""" network: Network = basic_network - computer_a: Computer = network.get_node_by_hostname("node_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - - computer_b: Computer = network.get_node_by_hostname("node_b") - c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) router: Router = network.get_node_by_hostname("router") - c2_beacon.configure(c2_server_ip_address="192.168.0.2", keep_alive_frequency=2) - c2_server.run() - c2_beacon.establish() - c2_beacon.apply_timestep(0) assert c2_beacon.keep_alive_inactivity == 1 @@ -233,10 +225,53 @@ def test_c2_suite_acl_block(basic_network): c2_beacon.apply_timestep(2) c2_beacon.apply_timestep(3) - assert c2_beacon.keep_alive_inactivity == 2 + + # C2 Beacon resets after unable to maintain contact. + + assert c2_beacon.keep_alive_inactivity == 0 assert c2_beacon.c2_connection_active == False assert c2_beacon.operating_state == ApplicationOperatingState.CLOSED -def test_c2_suite_terminal(basic_network): - """Tests the Ransomware commands can be used to configure & launch ransomware via Requests.""" +def test_c2_suite_terminal_command_file_creation(basic_network): + """Tests the C2 Terminal command can be used on local and remote.""" + network: Network = basic_network + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + computer_c: Computer = network.get_node_by_hostname("node_c") + + # Asserting to demonstrate that the test files don't exist: + assert ( + computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == False + ) + + assert ( + computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == False + ) + + # Testing that we can create the test file and folders via the terminal command (Local C2 Terminal). + + # Local file/folder creation commands. + file_create_command = { + "commands": [ + ["file_system", "create", "folder", "test_folder"], + ["file_system", "create", "file", "test_folder", "test_file", "True"], + ], + "username": "admin", + "password": "admin", + "ip_address": None, + } + + c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + + assert computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True + assert c2_beacon.local_terminal_session is not None + + # Testing that we can create the same test file/folders via on node 3 via a remote terminal. + + # node_c's IP is 192.168.255.3 + file_create_command.update({"ip_address": "192.168.255.3"}) + + c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + + assert computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True + assert c2_beacon.remote_terminal_session is not None diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py new file mode 100644 index 00000000..a790081f --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -0,0 +1,138 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server + + +@pytest.fixture(scope="function") +def basic_c2_network() -> Network: + network = Network() + + # Creating two generic nodes for the C2 Server and the C2 Beacon. + + computer_a = Computer( + hostname="computer_a", + ip_address="192.168.0.1", + subnet_mask="255.255.255.252", + start_up_duration=0, + ) + computer_a.power_on() + computer_a.software_manager.install(software_class=C2Server) + + computer_b = Computer( + hostname="computer_b", ip_address="192.168.0.2", subnet_mask="255.255.255.252", start_up_duration=0 + ) + + computer_b.power_on() + computer_b.software_manager.install(software_class=C2Beacon) + + network.connect(endpoint_a=computer_a.network_interface[1], endpoint_b=computer_b.network_interface[1]) + return network + + +def setup_c2(given_network: Network): + """Installs the C2 Beacon & Server, configures and then returns.""" + network: Network = given_network + + computer_a: Computer = network.get_node_by_hostname("computer_a") + computer_b: Computer = network.get_node_by_hostname("computer_b") + + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + c2_beacon.configure(c2_server_ip_address="192.168.0.1", keep_alive_frequency=2) + c2_server.run() + c2_beacon.establish() + + return network, computer_a, c2_server, computer_b, c2_beacon + + +def test_c2_handle_server_disconnect(basic_c2_network): + """Tests that the C2 suite will be able handle the c2 server application closing.""" + network: Network = basic_c2_network + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + assert c2_beacon.c2_connection_active is True + + ##### C2 Server disconnecting. + + # Closing the C2 Server + c2_server.close() + + # Applying 10 timesteps to trigger C2 beacon keep alive + + for i in range(10): + network.apply_timestep(i) + + assert c2_beacon.c2_connection_active is False + assert c2_beacon.operating_state is ApplicationOperatingState.CLOSED + + # C2 Beacon disconnected. + + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + +def test_c2_handle_beacon_disconnect(basic_c2_network): + """Tests that the C2 suite will be able handle the c2 beacon application closing.""" + network: Network = basic_c2_network + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + assert c2_server.c2_connection_active is True + + # Closing the C2 beacon + + c2_beacon.close() + + assert c2_beacon.operating_state is ApplicationOperatingState.CLOSED + + # Attempting a simple C2 Server command: + file_create_command = { + "commands": [["file_system", "create", "folder", "test_folder"]], + "username": "admin", + "password": "admin", + "ip_address": None, + } + + command_request_response = c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + + assert command_request_response.status == "failure" + + # Despite the command failing - The C2 Server will still consider the beacon alive + # Until it does not respond within the keep alive frequency set in the last keep_alive. + assert c2_server.c2_connection_active is True + + # Stepping 6 timesteps in order for the C2 server to consider the beacon dead. + for i in range(6): + network.apply_timestep(i) + + assert c2_server.c2_connection_active is False + + +# TODO: Finalise and complete these tests. + + +def test_c2_handle_switching_port(basic_c2_network): + """Tests that the C2 suite will be able handle switching destination/src port.""" + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + # Asserting that the c2 beacon has established a c2 connection + assert c2_beacon.c2_connection_active is True + + +def test_c2_handle_switching_frequency(basic_c2_network): + """Tests that the C2 suite will be able handle switching keep alive frequency.""" + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + # Asserting that the c2 beacon has established a c2 connection + assert c2_beacon.c2_connection_active is True From 3df55a708d31f192a8a414673dce3e23e9126486 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 11 Aug 2024 23:24:29 +0100 Subject: [PATCH 143/206] #2769 - add actions and tests for terminal --- src/primaite/game/agent/actions.py | 28 ++- .../system/services/terminal/terminal.py | 120 +++++++------ tests/conftest.py | 7 +- .../actions/test_terminal_actions.py | 165 ++++++++++++++++++ .../test_remote_user_account_actions.py | 49 ------ .../test_user_account_change_password.py | 23 --- 6 files changed, 253 insertions(+), 139 deletions(-) create mode 100644 tests/integration_tests/game_layer/actions/test_terminal_actions.py delete mode 100644 tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py delete mode 100644 tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 2ddeff3d..f421cb0b 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1101,15 +1101,14 @@ class NodeSessionsRemoteLoginAction(AbstractAction): def form_request(self, node_id: str, username: str, password: str, remote_ip: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - # TODO: change this so it creates a remote connection using terminal rather than a local remote login node_name = self.manager.get_node_name_by_idx(node_id) return [ "network", "node", node_name, "service", - "UserSessionManager", - "remote_login", + "Terminal", + "ssh_to_remote", username, password, remote_ip, @@ -1122,11 +1121,21 @@ class NodeSessionsRemoteLogoutAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: str, remote_session_id: str) -> RequestFormat: + def form_request(self, node_id: str, remote_ip: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - # TODO: change this so it destroys a remote connection using terminal rather than a local remote login node_name = self.manager.get_node_name_by_idx(node_id) - return ["network", "node", node_name, "service", "UserSessionManager", "remote_logout", remote_session_id] + return ["network", "node", node_name, "service", "Terminal", "remote_logoff", remote_ip] + + +class NodeSendRemoteCommandAction(AbstractAction): + """Action which sends a terminal command to a remote node via SSH.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: + node_name = self.manager.get_node_name_by_idx(node_id) + return ["network", "node", node_name, "service", "Terminal", "send_remote_command", remote_ip, command] class ActionManager: @@ -1180,9 +1189,10 @@ class ActionManager: "CONFIGURE_DATABASE_CLIENT": ConfigureDatabaseClientAction, "CONFIGURE_RANSOMWARE_SCRIPT": ConfigureRansomwareScriptAction, "CONFIGURE_DOSBOT": ConfigureDoSBotAction, - "NODE_ACCOUNTS_CHANGEPASSWORD": NodeAccountsChangePasswordAction, - "NODE_SESSIONS_REMOTE_LOGIN": NodeSessionsRemoteLoginAction, - "NODE_SESSIONS_REMOTE_LOGOUT": NodeSessionsRemoteLogoutAction, + "NODE_ACCOUNTS_CHANGE_PASSWORD": NodeAccountsChangePasswordAction, + "SSH_TO_REMOTE": NodeSessionsRemoteLoginAction, + "SSH_LOGOUT_LOGOUT": NodeSessionsRemoteLogoutAction, + "NODE_SEND_REMOTE_COMMAND": NodeSendRemoteCommandAction, } """Dictionary which maps action type strings to the corresponding action class.""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 876b1694..ead5c66a 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -92,7 +92,7 @@ class LocalTerminalConnection(TerminalClientConnection): if not self.is_active: self.parent_terminal.sys_log.warning("Connection inactive, cannot execute") return None - return self.parent_terminal.execute(command, connection_id=self.connection_uuid) + return self.parent_terminal.execute(command) class RemoteTerminalConnection(TerminalClientConnection): @@ -162,22 +162,36 @@ class Terminal(Service): def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" rm = super()._init_request_manager() - rm.add_request( - "send", - request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())), - ) + # rm.add_request( + # "send", + # request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())), + # ) - def _login(request: RequestFormat, context: Dict) -> RequestResponse: - login = self._process_local_login(username=request[0], password=request[1]) - if login: - return RequestResponse( - status="success", - data={ - "ip_address": login.ip_address, - }, - ) - else: - return RequestResponse(status="failure", data={"reason": "Invalid login credentials"}) + # def _login(request: RequestFormat, context: Dict) -> RequestResponse: + # login = self._process_local_login(username=request[0], password=request[1]) + # if login: + # return RequestResponse( + # status="success", + # data={ + # "ip_address": login.ip_address, + # }, + # ) + # else: + # return RequestResponse(status="failure", data={"reason": "Invalid login credentials"}) + # + # rm.add_request( + # "Login", + # request_type=RequestType(func=_login), + # ) + + # def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: + # """Logoff from connection.""" + # connection_uuid = request[0] + # self.parent.user_session_manager.local_logout(connection_uuid) + # self._disconnect(connection_uuid) + # return RequestResponse(status="success", data={}) + # + # rm.add_request("Logoff", request_type=RequestType(func=_logoff)) def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2]) @@ -191,10 +205,34 @@ class Terminal(Service): else: return RequestResponse(status="failure", data={}) + rm.add_request( + "ssh_to_remote", + request_type=RequestType(func=_remote_login), + ) + + def _remote_logoff(request: RequestFormat, context: Dict) -> RequestResponse: + """Logoff from remote connection.""" + ip_address = IPv4Address(request[0]) + remote_connection = self._get_connection_from_ip(ip_address=ip_address) + if remote_connection: + outcome = self._disconnect(remote_connection.connection_uuid) + if outcome: + return RequestResponse( + status="success", + data={}, + ) + else: + return RequestResponse( + status="failure", + data={"reason": "No remote connection held."}, + ) + + rm.add_request("remote_logoff", request_type=RequestType(func=_remote_logoff)) + def remote_execute_request(request: RequestFormat, context: Dict) -> RequestResponse: """Execute an instruction.""" - command: str = request[0] - ip_address: IPv4Address = IPv4Address(request[1]) + ip_address: IPv4Address = IPv4Address(request[0]) + command: str = request[1] remote_connection = self._get_connection_from_ip(ip_address=ip_address) if remote_connection: outcome = remote_connection.execute(command) @@ -209,30 +247,11 @@ class Terminal(Service): data={}, ) - def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: - """Logoff from connection.""" - connection_uuid = request[0] - self.parent.user_session_manager.local_logout(connection_uuid) - self._disconnect(connection_uuid) - return RequestResponse(status="success", data={}) - rm.add_request( - "Login", - request_type=RequestType(func=_login), - ) - - rm.add_request( - "Remote Login", - request_type=RequestType(func=_remote_login), - ) - - rm.add_request( - "Execute", + "send_remote_command", request_type=RequestType(func=remote_execute_request), ) - rm.add_request("Logoff", request_type=RequestType(func=_logoff)) - return rm def execute(self, command: List[Any]) -> Optional[RequestResponse]: @@ -280,13 +299,9 @@ class Terminal(Service): if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning(f"{self.name}: Cannot login as service is not running.") return None - connection_request_id = str(uuid4()) - self._client_connection_requests[connection_request_id] = None if ip_address: # Assuming that if IP is passed we are connecting to remote - return self._send_remote_login( - username=username, password=password, ip_address=ip_address, connection_request_id=connection_request_id - ) + return self._send_remote_login(username=username, password=password, ip_address=ip_address) else: return self._process_local_login(username=username, password=password) @@ -320,32 +335,24 @@ class Terminal(Service): username: str, password: str, ip_address: IPv4Address, - connection_request_id: str, + connection_request_id: Optional[str] = None, is_reattempt: bool = False, ) -> Optional[RemoteTerminalConnection]: """Send a remote login attempt and connect to Node. :param: username: Username used to connect to the remote node. :type: username: str - :param: password: Password used to connect to the remote node :type: password: str - :param: ip_address: Target Node IP address for login attempt. :type: ip_address: IPv4Address - - :param: connection_request_id: Connection Request ID - :type: connection_request_id: str - + :param: connection_request_id: Connection Request ID, if not provided, a new one is generated + :type: connection_request_id: Optional[str] :param: is_reattempt: True if the request has been reattempted. Default False. :type: is_reattempt: Optional[bool] - :return: RemoteTerminalConnection: Connection Object for sending further commands if successful, else False. - """ - self.sys_log.info( - f"{self.name}: Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}" - ) + connection_request_id = connection_request_id or str(uuid4()) if is_reattempt: valid_connection_request = self._validate_client_connection_request(connection_id=connection_request_id) if valid_connection_request: @@ -360,6 +367,9 @@ class Terminal(Service): self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") return None + self.sys_log.info( + f"{self.name}: Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}" + ) transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA user_details: SSHUserCredentials = SSHUserCredentials(username=username, password=password) diff --git a/tests/conftest.py b/tests/conftest.py index d2f9bb2f..2ae6299d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -458,9 +458,10 @@ def game_and_agent(): {"type": "HOST_NIC_DISABLE"}, {"type": "NETWORK_PORT_ENABLE"}, {"type": "NETWORK_PORT_DISABLE"}, - {"type": "NODE_ACCOUNTS_CHANGEPASSWORD"}, - {"type": "NODE_SESSIONS_REMOTE_LOGIN"}, - {"type": "NODE_SESSIONS_REMOTE_LOGOUT"}, + {"type": "NODE_ACCOUNTS_CHANGE_PASSWORD"}, + {"type": "SSH_TO_REMOTE"}, + {"type": "SSH_LOGOUT_LOGOUT"}, + {"type": "NODE_SEND_REMOTE_COMMAND"}, ] action_space = ActionManager( diff --git a/tests/integration_tests/game_layer/actions/test_terminal_actions.py b/tests/integration_tests/game_layer/actions/test_terminal_actions.py new file mode 100644 index 00000000..ce0810eb --- /dev/null +++ b/tests/integration_tests/game_layer/actions/test_terminal_actions.py @@ -0,0 +1,165 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Tuple + +import pytest + +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.hardware.base import UserManager +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection + + +@pytest.fixture +def game_and_agent_fixture(game_and_agent): + """Create a game with a simple agent that can be controlled by the tests.""" + game, agent = game_and_agent + + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + client_1.start_up_duration = 3 + + return (game, agent) + + +def test_remote_login(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + game, agent = game_and_agent_fixture + + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + # create a new user account on server_1 that will be logged into remotely + server_1_usm: UserManager = server_1.software_manager.software["UserManager"] + server_1_usm.add_user("user123", "password", is_admin=True) + + action = ( + "SSH_TO_REMOTE", + { + "node_id": 0, + "username": "user123", + "password": "password", + "remote_ip": str(server_1.network_interface[1].ip_address), + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + connection_established = False + for conn_str, conn_obj in client_1.terminal.connections.items(): + conn_obj: RemoteTerminalConnection + if conn_obj.ip_address == server_1.network_interface[1].ip_address: + connection_established = True + if not connection_established: + pytest.fail("Remote SSH connection could not be established") + + +def test_remote_login_wrong_password(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + game, agent = game_and_agent_fixture + + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + # create a new user account on server_1 that will be logged into remotely + server_1_usm: UserManager = server_1.software_manager.software["UserManager"] + server_1_usm.add_user("user123", "password", is_admin=True) + + action = ( + "SSH_TO_REMOTE", + { + "node_id": 0, + "username": "user123", + "password": "wrong_password", + "remote_ip": str(server_1.network_interface[1].ip_address), + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "failure" + + connection_established = False + for conn_str, conn_obj in client_1.terminal.connections.items(): + conn_obj: RemoteTerminalConnection + if conn_obj.ip_address == server_1.network_interface[1].ip_address: + connection_established = True + if connection_established: + pytest.fail("Remote SSH connection was established despite wrong password") + + +def test_remote_login_change_password(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + game, agent = game_and_agent_fixture + + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + # create a new user account on server_1 that will be logged into remotely + server_1_um: UserManager = server_1.software_manager.software["UserManager"] + server_1_um.add_user("user123", "password", is_admin=True) + + action = ( + "NODE_ACCOUNTS_CHANGE_PASSWORD", + { + "node_id": 1, # server_1 + "username": "user123", + "current_password": "password", + "new_password": "different_password", + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + assert server_1_um.users["user123"].password == "different_password" + + +def test_change_password_logs_out_user(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + game, agent = game_and_agent_fixture + + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + # create a new user account on server_1 that will be logged into remotely + server_1_usm: UserManager = server_1.software_manager.software["UserManager"] + server_1_usm.add_user("user123", "password", is_admin=True) + + # Log in remotely + action = ( + "SSH_TO_REMOTE", + { + "node_id": 0, + "username": "user123", + "password": "password", + "remote_ip": str(server_1.network_interface[1].ip_address), + }, + ) + agent.store_action(action) + game.step() + + # Change password + action = ( + "NODE_ACCOUNTS_CHANGE_PASSWORD", + { + "node_id": 1, # server_1 + "username": "user123", + "current_password": "password", + "new_password": "different_password", + }, + ) + agent.store_action(action) + game.step() + + # Assert that the user cannot execute an action + # TODO: should the db conn object get destroyed on both nodes? or is that not realistic? + action = ( + "NODE_SEND_REMOTE_COMMAND", + { + "node_id": 0, + "remote_ip": server_1.network_interface[1].ip_address, + "command": ["file_system", "create", "file", "folder123", "doggo.pdf", False], + }, + ) + agent.store_action(action) + game.step() + + assert server_1.file_system.get_folder("folder123") is None + assert server_1.file_system.get_file("folder123", "doggo.pdf") is None diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py deleted file mode 100644 index 25079226..00000000 --- a/tests/integration_tests/game_layer/actions/user_account_actions/test_remote_user_account_actions.py +++ /dev/null @@ -1,49 +0,0 @@ -# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from primaite.simulator.network.hardware.nodes.host.computer import Computer - - -def test_remote_logon(game_and_agent): - """Test that the remote session login action works.""" - game, agent = game_and_agent - - client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") - - client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) - - action = ( - "NODE_SESSIONS_REMOTE_LOGIN", - {"node_id": 0, "username": "test_user", "password": "password", "remote_ip": "10.0.2.2"}, - ) - agent.store_action(action) - game.step() - - assert len(client_1.user_session_manager.remote_sessions) == 1 - - -def test_remote_logoff(game_and_agent): - """Test that the remote session logout action works.""" - game, agent = game_and_agent - - client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") - - client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) - - action = ( - "NODE_SESSIONS_REMOTE_LOGIN", - {"node_id": 0, "username": "test_user", "password": "password"}, - ) - agent.store_action(action) - game.step() - - assert len(client_1.user_session_manager.remote_sessions) == 1 - - remote_session_id = client_1.user_session_manager.remote_sessions[0].uuid - - action = ( - "NODE_SESSIONS_REMOTE_LOGOUT", - {"node_id": 0, "remote_session_id": remote_session_id}, - ) - agent.store_action(action) - game.step() - - assert len(client_1.user_session_manager.remote_sessions) == 0 diff --git a/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py b/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py deleted file mode 100644 index 3e6f55f6..00000000 --- a/tests/integration_tests/game_layer/actions/user_account_actions/test_user_account_change_password.py +++ /dev/null @@ -1,23 +0,0 @@ -# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -from primaite.simulator.network.hardware.nodes.host.computer import Computer - - -def test_remote_logon(game_and_agent): - """Test that the remote session login action works.""" - game, agent = game_and_agent - - client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") - - client_1.user_manager.add_user(username="test_user", password="password", bypass_can_perform_action=True) - user = next((user for user in client_1.user_manager.users.values() if user.username == "test_user"), None) - - assert user.password == "password" - - action = ( - "NODE_ACCOUNTS_CHANGEPASSWORD", - {"node_id": 0, "username": user.username, "current_password": user.password, "new_password": "test_pass"}, - ) - agent.store_action(action) - game.step() - - assert user.password == "test_pass" From ce3805cd15c0bdece670ef42fbd34b2f91de5f5d Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 12 Aug 2024 10:47:56 +0100 Subject: [PATCH 144/206] #2689 Updated c2 tests significantly and improved quality of debug logging. --- .../red_applications/c2/abstract_c2.py | 22 +- .../red_applications/c2/c2_beacon.py | 4 + .../red_applications/c2/c2_server.py | 8 +- .../test_c2_suite_integration.py | 190 +++++++++++++++++- .../_red_applications/test_c2_suite.py | 71 ++++++- 5 files changed, 281 insertions(+), 14 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index a00b8570..3c9080b3 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -13,11 +13,6 @@ from primaite.simulator.system.applications.application import Application, Appl from primaite.simulator.system.core.session_manager import Session from primaite.simulator.system.software import SoftwareHealthState -# TODO: -# Create test that leverage all the functionality needed for the different TAPs -# Create a .RST doc -# Potentially? A notebook which demonstrates a custom red agent using the c2 server for various means. - class C2Command(Enum): """Enumerations representing the different commands the C2 suite currently supports.""" @@ -196,11 +191,11 @@ class AbstractC2(Application, identifier="AbstractC2"): # (Using NOT to improve code readability) if self.c2_remote_connection is None: self.sys_log.error( - f"{self.name}: Unable to Establish connection as the C2 Server's IP Address has not been given." + f"{self.name}: Unable to establish connection as the C2 Server's IP Address has not been configured." ) if not self._can_perform_network_action(): - self.sys_log.warning(f"{self.name}: Unable to perform network actions.") + self.sys_log.warning(f"{self.name}: Unable to perform network actions. Unable to send Keep Alive.") return False # We also Pass masquerade proto`col/port so that the c2 server can reply on the correct protocol/port. @@ -223,12 +218,14 @@ class AbstractC2(Application, identifier="AbstractC2"): self.keep_alive_sent = True self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug( - f"{self.name}: on {self.c2_config.masquerade_port} via {self.c2_config.masquerade_protocol}" + f"{self.name}: Keep Alive sent to {self.c2_remote_connection}" + f"Using Masquerade Port: {self.c2_config.masquerade_port}" + f"Using Masquerade Protocol: {self.c2_config.masquerade_protocol}" ) return True else: self.sys_log.warning( - f"{self.name}: failed to send a Keep Alive. The node may be unable to access networking resources." + f"{self.name}: Failed to send a Keep Alive. The node may be unable to access networking resources." ) return False @@ -262,6 +259,13 @@ class AbstractC2(Application, identifier="AbstractC2"): self.c2_config.masquerade_protocol = payload.masquerade_protocol self.c2_config.keep_alive_frequency = payload.keep_alive_frequency + self.sys_log.debug( + f"{self.name}: C2 Config Resolved Config from Keep Alive:" + f"Masquerade Port: {self.c2_config.masquerade_port}" + f"Masquerade Protocol: {self.c2_config.masquerade_protocol}" + f"Keep Alive Frequency: {self.c2_config.keep_alive_frequency}" + ) + # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) if self.c2_remote_connection is None: self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 55dd1474..8052d0f2 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -168,6 +168,10 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): f"Masquerade Protocol: {masquerade_protocol}" f"Masquerade Port: {masquerade_port}" ) + # Send a keep alive to the C2 Server if we already have a keep alive. + if self.c2_connection_active is True: + self.sys_log.info(f"{self.name}: Updating C2 Server with updated C2 configuration.") + self._send_keep_alive(self.c2_session.uuid if not None else None) return True def establish(self) -> bool: diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index e4bf3302..6b51f8c7 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -317,10 +317,12 @@ class C2Server(AbstractC2, identifier="C2Server"): :rtype bool: """ if self.keep_alive_inactivity > self.c2_config.keep_alive_frequency: - self.sys_log.debug( - f"{self.name}: Failed to receive expected keep alive from {self.c2_remote_connection} at {timestep}." - ) self.sys_log.info(f"{self.name}: C2 Beacon connection considered dead due to inactivity.") + self.sys_log.debug( + f"{self.name}: Did not receive expected keep alive connection from {self.c2_remote_connection}" + f"{self.name}: Expected at timestep: {timestep} due to frequency: {self.c2_config.keep_alive_frequency}" + f"{self.name}: Last Keep Alive received at {(timestep - self.keep_alive_inactivity)}" + ) self._reset_c2_connection() return False return True diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index ab609cb0..56b354d7 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -10,7 +10,7 @@ from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHe from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server -from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.router import AccessControlList, ACLAction, Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -209,6 +209,8 @@ def test_c2_suite_acl_block(basic_network): network: Network = basic_network network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + computer_b.software_manager.install(software_class=RansomwareScript) + ransomware_config = {"server_ip_address": "192.168.0.2"} router: Router = network.get_node_by_hostname("router") @@ -275,3 +277,189 @@ def test_c2_suite_terminal_command_file_creation(basic_network): assert computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True assert c2_beacon.remote_terminal_session is not None + + +def test_c2_suite_acl_bypass(basic_network): + """Tests that C2 Beacon can be reconfigured to connect C2 Server to bypass blocking ACL rules. + + 1. This Test first configures a router to block HTTP traffic and asserts the following: + 1. C2 Beacon and C2 Server are unable to maintain connection + 2. Traffic is confirmed to be blocked by the ACL rule. + + 2. Next the C2 Beacon is re-configured to use FTP which is permitted by the ACL and asserts the following; + 1. The C2 Beacon and C2 Server re-establish connection + 2. The ACL rule has not prevent any further traffic. + 3. A test file create command is sent & it's output confirmed + + 3. The ACL is then re-configured to block FTP traffic and asserts the following: + 1. C2 Beacon and C2 Server are unable to maintain connection + 2. Traffic is confirmed to be blocked by the ACL rule. + + 4. Next the C2 Beacon is re-configured to use HTTP which is permitted by the ACL and asserts the following; + 1. The C2 Beacon and C2 Server re-establish connection + 2. The ACL rule has not prevent any further traffic. + 3. A test file create command is sent & it's output confirmed + """ + + network: Network = basic_network + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + router: Router = network.get_node_by_hostname("router") + + ################ Confirm Default Setup ######################### + + # Permitting all HTTP & FTP traffic + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.FTP, dst_port=Port.FTP, position=1) + + c2_beacon.apply_timestep(0) + assert c2_beacon.keep_alive_inactivity == 1 + + # Keep Alive successfully sent and received upon the 2nd timestep. + c2_beacon.apply_timestep(1) + + assert c2_beacon.keep_alive_inactivity == 0 + assert c2_beacon.c2_connection_active == True + + ################ Denying HTTP Traffic ######################### + + # Now we add a HTTP blocking acl (Thus preventing a keep alive) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) + blocking_acl: AccessControlList = router.acl.acl[0] + + # Asserts to show the C2 Suite is unable to maintain connection: + + network.apply_timestep(2) + network.apply_timestep(3) + + c2_packets_blocked = blocking_acl.match_count + assert c2_packets_blocked != 0 + assert c2_beacon.c2_connection_active is False + + # Stepping one more time to confirm that the C2 server drops its connection + network.apply_timestep(4) + assert c2_server.c2_connection_active is False + + ################ Configuring C2 to use FTP ##################### + + # Reconfiguring the c2 beacon to now use FTP + c2_beacon.configure( + c2_server_ip_address="192.168.0.2", + keep_alive_frequency=2, + masquerade_port=Port.FTP, + masquerade_protocol=IPProtocol.TCP, + ) + + c2_beacon.establish() + + ################ Confirming connection via FTP ##################### + + # Confirming we've re-established connection + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Confirming that we can send commands: + + ftp_file_create_command = { + "commands": [ + ["file_system", "create", "folder", "test_folder"], + ["file_system", "create", "file", "test_folder", "ftp_test_file", "True"], + ], + "username": "admin", + "password": "admin", + "ip_address": None, + } + c2_server._send_command(C2Command.TERMINAL, command_options=ftp_file_create_command) + assert ( + computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="ftp_test_file") + == True + ) + + # Confirming we can maintain connection + + # Stepping twenty timesteps in the network + i = 4 # We're already at the 4th timestep (starting at timestep 4) + + for i in range(20): + network.apply_timestep(i) + + # Confirming HTTP ACL ineffectiveness (C2 Bypass) + + # Asserting that the ACL hasn't caught more traffic and the c2 connection is still active + assert c2_packets_blocked == blocking_acl.match_count + assert c2_server.c2_connection_active is True + assert c2_beacon.c2_connection_active is True + + ################ Denying FTP Traffic & Enable HTTP ######################### + + # Blocking FTP and re-permitting HTTP: + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.FTP, dst_port=Port.FTP, position=1) + blocking_acl: AccessControlList = router.acl.acl[1] + + # Asserts to show the C2 Suite is unable to maintain connection: + + network.apply_timestep(25) + network.apply_timestep(26) + + c2_packets_blocked = blocking_acl.match_count + assert c2_packets_blocked != 0 + assert c2_beacon.c2_connection_active is False + + # Stepping one more time to confirm that the C2 server drops its connection + network.apply_timestep(27) + assert c2_server.c2_connection_active is False + + ################ Configuring C2 to use HTTP ##################### + + # Reconfiguring the c2 beacon to now use HTTP Again + c2_beacon.configure( + c2_server_ip_address="192.168.0.2", + keep_alive_frequency=2, + masquerade_port=Port.HTTP, + masquerade_protocol=IPProtocol.TCP, + ) + + c2_beacon.establish() + + ################ Confirming connection via HTTP ##################### + + # Confirming we've re-established connection + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Confirming that we can send commands + + http_file_create_command = { + "commands": [ + ["file_system", "create", "folder", "test_folder"], + ["file_system", "create", "file", "test_folder", "http_test_file", "True"], + ], + "username": "admin", + "password": "admin", + "ip_address": None, + } + c2_server._send_command(C2Command.TERMINAL, command_options=http_file_create_command) + assert ( + computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="http_test_file") + == True + ) + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Confirming we can maintain connection + + # Stepping twenty timesteps in the network + i = 28 # We're already at the 28th timestep + + for i in range(20): + network.apply_timestep(i) + + # Confirming FTP ACL ineffectiveness (C2 Bypass) + + # Asserting that the ACL hasn't caught more traffic and the c2 connection is still active + assert c2_packets_blocked == blocking_acl.match_count + assert c2_server.c2_connection_active is True + assert c2_beacon.c2_connection_active is True diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index a790081f..ed408d14 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -4,6 +4,8 @@ import pytest from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server @@ -124,8 +126,38 @@ def test_c2_handle_switching_port(basic_c2_network): network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) - # Asserting that the c2 beacon has established a c2 connection + # Asserting that the c2 applications have established a c2 connection assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Assert to confirm that both the C2 server and the C2 beacon are configured correctly. + assert c2_beacon.c2_config.keep_alive_frequency is 2 + assert c2_beacon.c2_config.masquerade_port is Port.HTTP + assert c2_beacon.c2_config.masquerade_protocol is IPProtocol.TCP + + assert c2_server.c2_config.keep_alive_frequency is 2 + assert c2_server.c2_config.masquerade_port is Port.HTTP + assert c2_server.c2_config.masquerade_protocol is IPProtocol.TCP + + # Configuring the C2 Beacon. + c2_beacon.configure( + c2_server_ip_address="192.168.0.1", + keep_alive_frequency=2, + masquerade_port=Port.FTP, + masquerade_protocol=IPProtocol.TCP, + ) + + # Asserting that the c2 applications have established a c2 connection + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Assert to confirm that both the C2 server and the C2 beacon + # Have reconfigured their C2 settings. + assert c2_beacon.c2_config.masquerade_port is Port.FTP + assert c2_beacon.c2_config.masquerade_protocol is IPProtocol.TCP + + assert c2_server.c2_config.masquerade_port is Port.FTP + assert c2_server.c2_config.masquerade_protocol is IPProtocol.TCP def test_c2_handle_switching_frequency(basic_c2_network): @@ -136,3 +168,40 @@ def test_c2_handle_switching_frequency(basic_c2_network): # Asserting that the c2 beacon has established a c2 connection assert c2_beacon.c2_connection_active is True + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + # Asserting that the c2 applications have established a c2 connection + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Assert to confirm that both the C2 server and the C2 beacon are configured correctly. + assert c2_beacon.c2_config.keep_alive_frequency is 2 + assert c2_server.c2_config.keep_alive_frequency is 2 + + # Configuring the C2 Beacon. + c2_beacon.configure(c2_server_ip_address="192.168.0.1", keep_alive_frequency=10) + + # Asserting that the c2 applications have established a c2 connection + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + # Assert to confirm that both the C2 server and the C2 beacon + # Have reconfigured their C2 settings. + assert c2_beacon.c2_config.keep_alive_frequency is 10 + assert c2_server.c2_config.keep_alive_frequency is 10 + + # Now skipping 9 time steps to confirm keep alive inactivity + for i in range(9): + network.apply_timestep(i) + + # If the keep alive reconfiguration failed then the keep alive inactivity could never reach 9 + # As another keep alive would have already been sent. + assert c2_beacon.keep_alive_inactivity is 9 + assert c2_server.keep_alive_inactivity is 9 + + network.apply_timestep(10) + + assert c2_beacon.keep_alive_inactivity is 0 + assert c2_server.keep_alive_inactivity is 0 From 929bd46d6dea2e53d292a26ec765bdb06908d792 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 12 Aug 2024 14:16:04 +0100 Subject: [PATCH 145/206] #2769 - Make changing password disconnect remote sessions --- src/primaite/game/agent/actions.py | 12 +++++++++++- .../simulator/network/hardware/base.py | 18 ++++++++++++++++++ .../system/services/terminal/terminal.py | 7 ++++++- .../actions/test_terminal_actions.py | 8 +++++--- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index f421cb0b..d588c018 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1134,8 +1134,18 @@ class NodeSendRemoteCommandAction(AbstractAction): super().__init__(manager=manager) def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" node_name = self.manager.get_node_name_by_idx(node_id) - return ["network", "node", node_name, "service", "Terminal", "send_remote_command", remote_ip, command] + return [ + "network", + "node", + node_name, + "service", + "Terminal", + "send_remote_command", + remote_ip, + {"command": command}, + ] class ActionManager: diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 1441c93b..68b45c2e 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -990,6 +990,7 @@ class UserManager(Service): if user and user.password == current_password: user.password = new_password self.sys_log.info(f"{self.name}: Password changed for {username}") + self._user_session_manager._logout_user(user=user) return True self.sys_log.info(f"{self.name}: Password change failed for {username}") return False @@ -1027,6 +1028,10 @@ class UserManager(Service): self.sys_log.info(f"{self.name}: Failed to enable user: {username}") return False + @property + def _user_session_manager(self) -> "UserSessionManager": + return self.software_manager.software["UserSessionManager"] # noqa + class UserSession(SimComponent): """ @@ -1435,6 +1440,19 @@ class UserSessionManager(Service): """ return self._logout(local=False, remote_session_id=remote_session_id) + def _logout_user(self, user: Union[str, User]) -> bool: + """End a user session by username or user object.""" + if isinstance(user, str): + user = self._user_manager.users[user] # grab user object from username + for sess_id, session in self.remote_sessions.items(): + if session.user is user: + self._logout(local=False, remote_session_id=sess_id) + return True + if self.local_user_logged_in and self.local_session.user is user: + self.local_logout() + return True + return False + @property def local_user_logged_in(self) -> bool: """ diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index ead5c66a..79dc698f 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -23,6 +23,8 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service, ServiceOperatingState +# TODO 2824: Since remote terminal connections and remote user sessions are the same thing, we could refactor +# the terminal to leverage the user session manager's list. This way we avoid potential bugs and code ducplication class TerminalClientConnection(BaseModel): """ TerminalClientConnection Class. @@ -232,7 +234,7 @@ class Terminal(Service): def remote_execute_request(request: RequestFormat, context: Dict) -> RequestResponse: """Execute an instruction.""" ip_address: IPv4Address = IPv4Address(request[0]) - command: str = request[1] + command: str = request[1]["command"] remote_connection = self._get_connection_from_ip(ip_address=ip_address) if remote_connection: outcome = remote_connection.execute(command) @@ -328,6 +330,9 @@ class Terminal(Service): def _check_client_connection(self, connection_id: str) -> bool: """Check that client_connection_id is valid.""" + if not self.parent.user_session_manager.validate_remote_session_uuid(connection_id): + self._disconnect(connection_id) + return False return connection_id in self._connections def _send_remote_login( diff --git a/tests/integration_tests/game_layer/actions/test_terminal_actions.py b/tests/integration_tests/game_layer/actions/test_terminal_actions.py index ce0810eb..84d21bb0 100644 --- a/tests/integration_tests/game_layer/actions/test_terminal_actions.py +++ b/tests/integration_tests/game_layer/actions/test_terminal_actions.py @@ -8,6 +8,8 @@ from primaite.game.game import PrimaiteGame from primaite.simulator.network.hardware.base import UserManager from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection @@ -17,8 +19,8 @@ def game_and_agent_fixture(game_and_agent): """Create a game with a simple agent that can be controlled by the tests.""" game, agent = game_and_agent - client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") - client_1.start_up_duration = 3 + router = game.simulation.network.get_node_by_hostname("router") + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=4) return (game, agent) @@ -154,7 +156,7 @@ def test_change_password_logs_out_user(game_and_agent_fixture: Tuple[PrimaiteGam "NODE_SEND_REMOTE_COMMAND", { "node_id": 0, - "remote_ip": server_1.network_interface[1].ip_address, + "remote_ip": str(server_1.network_interface[1].ip_address), "command": ["file_system", "create", "file", "folder123", "doggo.pdf", False], }, ) From cbf02ebf3224f30fbf85dd8ea2e12bf4f33ba41d Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 12 Aug 2024 14:16:21 +0100 Subject: [PATCH 146/206] #2689 Updated documentation and moved _craft_packet into abstract C2 --- .../system/applications/c2_suite.rst | 144 ++++++++++++++++-- .../Command-&-Control-E2E-Demonstration.ipynb | 14 +- .../red_applications/c2/abstract_c2.py | 123 +++++++++++---- .../red_applications/c2/c2_beacon.py | 26 ++-- .../red_applications/c2/c2_server.py | 68 +++------ .../test_c2_suite_integration.py | 8 +- .../_red_applications/test_c2_suite.py | 32 +++- 7 files changed, 306 insertions(+), 109 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index e299bb0e..4d5f685a 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -34,7 +34,7 @@ Currently, the C2 Server offers three commands: +---------------------+---------------------------------------------------------------------------+ |RANSOMWARE_LAUNCH | Launches the installed ransomware script. | +---------------------+---------------------------------------------------------------------------+ -|TERMINAL_COMMAND | Executes a command via the terminal installed on the C2 Beacons Host. | +|TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. | +---------------------+---------------------------------------------------------------------------+ @@ -69,9 +69,17 @@ As mentioned, the C2 Suite is intended to grant Red Agents further flexibility w Adding to this, the following behaviour of the C2 beacon can be configured by users for increased domain randomisation: -- Frequency of C2 ``Keep Alive `` Communication`` -- C2 Communication Port -- C2 Communication Protocol ++---------------------+---------------------------------------------------------------------------+ +|Configuration Option | Option Meaning | ++=====================+===========================================================================+ +|c2_server_ip_address | The IP Address of the C2 Server. (The C2 Server must be running) | ++---------------------+---------------------------------------------------------------------------+ +|keep_alive_frequency | How often should the C2 Beacon confirm it's connection in timesteps. | ++---------------------+---------------------------------------------------------------------------+ +|masquerade_protocol | What protocol should the C2 traffic masquerade as? (HTTP, FTP or DNS) | ++---------------------+---------------------------------------------------------------------------+ +|masquerade_port | What port should the C2 traffic use? (TCP or UDP) | ++---------------------+---------------------------------------------------------------------------+ Implementation @@ -91,6 +99,7 @@ However, each host implements it's receive methods individually. - Receives the RequestResponse of the C2 Commands executed by C2 Beacon via ``C2Payload.OUTPUT``. +For further details and more in-depth examples please refer to the ``Command-&-Control notebook`` Examples ======== @@ -120,8 +129,8 @@ Python # C2 Application objects - c2_server_host = simulation_testing_network.get_node_by_hostname("node_a") - c2_beacon_host = simulation_testing_network.get_node_by_hostname("node_b") + c2_server_host: computer = simulation_testing_network.get_node_by_hostname("node_a") + c2_beacon_host: computer = simulation_testing_network.get_node_by_hostname("node_b") c2_server: C2Server = c2_server_host.software_manager.software["C2Server"] @@ -136,10 +145,125 @@ Python # Establishing connection c2_beacon.establish() - # Example command: Configuring Ransomware + # Example command: Creating a file - ransomware_config = {"server_ip_address": "1.1.1.1"} - c2_server._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) + file_create_command = { + "commands": [ + ["file_system", "create", "folder", "test_folder"], + ["file_system", "create", "file", "test_folder", "example_file", "True"], + ], + "username": "admin", + "password": "admin", + "ip_address": None, + } + + c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) + + # Example commands: Installing and configuring Ransomware: + + ransomware_installation_command = { "commands": [ + ["software_manager","application","install","RansomwareScript"], + ], + "username": "admin", + "password": "admin", + "ip_address": None, + } + c2_server.send_command(given_command=C2Command.TERMINAL, command_options=ransomware_config) + + ransomware_config = {"server_ip_address": "192.168.0.10"} + + c2_server.send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) + + c2_beacon_host.software_manager.show() -For a more in-depth look at the command and control applications then please refer to the ``C2-Suite-E2E-Notebook``. +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_computer_1 + hostname: computer_a + type: computer + ... + applications: + type: C2Server + ... + hostname: computer_b + type: computer + ... + # A C2 Beacon will not automatically connection to a C2 Server. + # Either an agent must use application_execute. + # Or a user must use .establish(). + applications: + type: C2Beacon + options: + c2_server_ip_address: ... + keep_alive_frequency: 5 + masquerade_protocol: tcp + masquerade_port: http + + + +C2 Beacon Configuration +======================= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: C2Beacon +.. |SOFTWARE_NAME_BACKTICK| replace:: ``C2Beacon`` + +``c2_server_ip_address`` +""""""""""""""""""""""" + +IP address of the ``C2Server`` that the C2 Beacon will use to establish connection. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + + +``Keep Alive Frequency`` +""""""""""""""""""""""" + +How often should the C2 Beacon confirm it's connection in timesteps. + +For example, if the keep alive Frequency is set to one then every single timestep +the C2 connection will be confirmed. + +It's worth noting that this may be useful option when investigating +network blue agent observation space. + +This must be a valid integer i.e ``10``. Defaults to ``5``. + + +``Masquerade Protocol`` +""""""""""""""""""""""" + +The protocol that the C2 Beacon will use to communicate to the C2 Server with. + +Currently only ``tcp`` and ``udp`` are valid masquerade protocol options. + +It's worth noting that this may be useful option to bypass ACL rules. + +This must be a string i.e ``udp``. Defaults to ``tcp``. + +_Please refer to the ``IPProtocol`` class for further reference._ + +``Masquerade Port`` +""""""""""""""""""" + +What port that the C2 Beacon will use to communicate to the C2 Server with. + +Currently only ``FTP``, ``HTTP`` and ``DNS`` are valid masquerade port options. + +It's worth noting that this may be useful option to bypass ACL rules. + +This must be a string i.e ``DNS``. Defaults to ``HTTP``. + +_Please refer to the ``IPProtocol`` class for further reference._ + + + +_The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour._ diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index b41b9f2e..7ee1c5cf 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -633,7 +633,7 @@ "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", " \"username\": \"admin\",\n", " \"password\": \"admin\"}\n", - "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n" + "c2_server.send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n" ] }, { @@ -644,7 +644,7 @@ "source": [ "# Configuring the RansomwareScript\n", "ransomware_config = {\"server_ip_address\": \"192.168.1.14\", \"payload\": \"ENCRYPT\"}\n", - "c2_server._send_command(C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config)" + "c2_server.send_command(C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config)" ] }, { @@ -681,7 +681,7 @@ "source": [ "# Waiting for the ransomware to finish installing and then launching the RansomwareScript.\n", "blue_env.step(0)\n", - "c2_server._send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" + "c2_server.send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" ] }, { @@ -834,7 +834,7 @@ " \"password\": \"admin\"}\n", "\n", "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", - "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" + "c2_server.send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" ] }, { @@ -922,7 +922,7 @@ " \"password\": \"admin\"}\n", "\n", "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", - "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" + "c2_server.send_command(C2Command.TERMINAL, command_options=ransomware_install_command)" ] }, { @@ -1007,8 +1007,8 @@ "blue_env.step(0)\n", "\n", "# Attempting to install and execute the ransomware script\n", - "c2_server._send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n", - "c2_server._send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" + "c2_server.send_command(C2Command.TERMINAL, command_options=ransomware_install_command)\n", + "c2_server.send_command(C2Command.RANSOMWARE_LAUNCH, command_options={})" ] }, { diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 3c9080b3..f5fb0929 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -6,6 +6,7 @@ from typing import Dict, Optional from pydantic import BaseModel, Field, validate_call +from primaite.interface.request import RequestResponse from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -53,6 +54,8 @@ class AbstractC2(Application, identifier="AbstractC2"): as well as providing the abstract methods for sending, receiving and parsing commands. Defaults to masquerading as HTTP (Port 80) via TCP. + + Please refer to the Command-&-Control notebook for an in-depth example of the C2 Suite. """ c2_connection_active: bool = False @@ -79,11 +82,46 @@ class AbstractC2(Application, identifier="AbstractC2"): masquerade_port: Port = Field(default=Port.HTTP) """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" - # The c2 beacon sets the c2_config through it's own internal method - configure (which is also used by agents) - # and then passes the config attributes to the c2 server via keep alives - # The c2 server parses the C2 configurations from keep alive traffic and sets the c2_config accordingly. + def _craft_packet( + self, c2_payload: C2Payload, c2_command: Optional[C2Command] = None, command_options: Optional[Dict] = {} + ) -> C2Packet: + """ + Creates and returns a Masquerade Packet using the parameters given. + + The packet uses the current c2 configuration and parameters given + to construct a C2 Packet. + + :param c2_payload: The type of C2 Traffic ot be sent + :type c2_payload: C2Payload + :param c2_command: The C2 command to be sent to the C2 Beacon. + :type c2_command: C2Command. + :param command_options: The relevant C2 Beacon parameters.F + :type command_options: Dict + :return: Returns the construct C2Packet + :rtype: C2Packet + """ + constructed_packet = C2Packet( + masquerade_protocol=self.c2_config.masquerade_protocol, + masquerade_port=self.c2_config.masquerade_port, + keep_alive_frequency=self.c2_config.keep_alive_frequency, + payload_type=c2_payload, + command=c2_command, + payload=command_options, + ) + return constructed_packet + c2_config: _C2_Opts = _C2_Opts() - """Holds the current configuration settings of the C2 Suite.""" + """ + Holds the current configuration settings of the C2 Suite. + + The C2 beacon initialise this class through it's internal configure method. + + The C2 Server when receiving a keep alive will initialise it's own configuration + to match that of the configuration settings passed in the keep alive through _resolve keep alive. + + If the C2 Beacon is reconfigured then a new keep alive is set which causes the + C2 beacon to reconfigure it's configuration settings. + """ def describe_state(self) -> Dict: """ @@ -187,27 +225,18 @@ class AbstractC2(Application, identifier="AbstractC2"): :returns: Returns True if a send alive was successfully sent. False otherwise. :rtype bool: """ - # Checking that the c2 application is capable of performing both actions and has an enabled NIC - # (Using NOT to improve code readability) - if self.c2_remote_connection is None: - self.sys_log.error( - f"{self.name}: Unable to establish connection as the C2 Server's IP Address has not been configured." + # Checking that the c2 application is capable of connecting to remote. + # Purely a safety guard clause. + if not (connection_status := self._check_connection()[0]): + self.sys_log.warning( + f"{self.name}: Unable to send keep alive due to c2 connection status: {connection_status}." ) - - if not self._can_perform_network_action(): - self.sys_log.warning(f"{self.name}: Unable to perform network actions. Unable to send Keep Alive.") return False - # We also Pass masquerade proto`col/port so that the c2 server can reply on the correct protocol/port. - # (This also lays the foundations for switching masquerade port/protocols mid episode.) - keep_alive_packet = C2Packet( - masquerade_protocol=self.c2_config.masquerade_protocol, - masquerade_port=self.c2_config.masquerade_port, - keep_alive_frequency=self.c2_config.keep_alive_frequency, - payload_type=C2Payload.KEEP_ALIVE, - command=None, - ) - # C2 Server will need to configure c2_remote_connection after it receives it's first keep alive. + # Passing our current C2 configuration in remain in sync. + keep_alive_packet = self._craft_packet(c2_payload=C2Payload.KEEP_ALIVE) + + # Sending the keep alive via the .send() method (as with all other applications.) if self.send( payload=keep_alive_packet, dest_ip_address=self.c2_remote_connection, @@ -215,6 +244,8 @@ class AbstractC2(Application, identifier="AbstractC2"): ip_protocol=self.c2_config.masquerade_protocol, session_id=session_id, ): + # Setting the keep_alive_sent guard condition to True. This is used to prevent packet storms. + # This prevents the _resolve_keep_alive method from calling this method again (until the next timestep.) self.keep_alive_sent = True self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug( @@ -266,7 +297,7 @@ class AbstractC2(Application, identifier="AbstractC2"): f"Keep Alive Frequency: {self.c2_config.keep_alive_frequency}" ) - # This statement is intended to catch on the C2 Application that is listening for connection. (C2 Beacon) + # This statement is intended to catch on the C2 Application that is listening for connection. if self.c2_remote_connection is None: self.sys_log.debug(f"{self.name}: Attempting to configure remote C2 connection based off received output.") self.c2_remote_connection = IPv4Address(self.c2_session.with_ip_address) @@ -287,8 +318,14 @@ class AbstractC2(Application, identifier="AbstractC2"): self.c2_config.masquerade_protocol = IPProtocol.TCP @abstractmethod - def _confirm_connection(self, timestep: int) -> bool: - """Abstract method - Checks the suitability of the current C2 Server/Beacon connection.""" + def _confirm_remote_connection(self, timestep: int) -> bool: + """ + Abstract method - Confirms the suitability of the current C2 application remote connection. + + Each application will have perform different behaviour to confirm the remote connection. + + :return: Boolean. True if remote connection is confirmed, false otherwise. + """ def apply_timestep(self, timestep: int) -> None: """Apply a timestep to the c2_server & c2 beacon. @@ -316,5 +353,39 @@ class AbstractC2(Application, identifier="AbstractC2"): and self.health_state_actual is SoftwareHealthState.GOOD ): self.keep_alive_inactivity += 1 - self._confirm_connection(timestep) + self._confirm_remote_connection(timestep) return + + def _check_connection(self) -> tuple[bool, RequestResponse]: + """ + Validation method: Checks that the C2 application is capable of sending C2 Command input/output. + + Performs a series of connection validation to ensure that the C2 application is capable of + sending and responding to the remote c2 connection. + + :return: A tuple containing a boolean True/False and a corresponding Request Response + :rtype: tuple[bool, RequestResponse] + """ + if self._can_perform_network_action == False: + self.sys_log.warning(f"{self.name}: Unable to make leverage networking resources. Rejecting Command.") + return [ + False, + RequestResponse( + status="failure", data={"Reason": "Unable to access networking resources. Unable to send command."} + ), + ] + + if self.c2_remote_connection is False: + self.sys_log.warning(f"{self.name}: C2 Application has yet to establish connection. Rejecting command.") + return [ + False, + RequestResponse( + status="failure", + data={"Reason": "C2 Application has yet to establish connection. Unable to send command."}, + ), + ] + else: + return [ + True, + RequestResponse(status="success", data={"Reason": "C2 Application is able to send connections."}), + ] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 8052d0f2..d256be42 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -28,12 +28,15 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): to simulate malicious communications and infrastructure within primAITE. Must be configured with the C2 Server's IP Address upon installation. + Please refer to the _configure method for further information. Extends the Abstract C2 application to include the following: 1. Receiving commands from the C2 Server (Command input) 2. Leveraging the terminal application to execute requests (dependant on the command given) 3. Sending the RequestResponse back to the C2 Server (Command output) + + Please refer to the Command-&-Control notebook for an in-depth example of the C2 Suite. """ keep_alive_attempted: bool = False @@ -141,9 +144,18 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): masquerade_port: Enum = Port.HTTP, ) -> bool: """ - Configures the C2 beacon to communicate with the C2 server with following additional parameters. + Configures the C2 beacon to communicate with the C2 server. + + The C2 Beacon has four different configuration options which can be used to + modify the networking behaviour between the C2 Server and the C2 Beacon. + + Configuration Option | Option Meaning + ---------------------|------------------------ + c2_server_ip_address | The IP Address of the C2 Server. (The C2 Server must be running) + keep_alive_frequency | How often should the C2 Beacon confirm it's connection in timesteps. + masquerade_protocol | What protocol should the C2 traffic masquerade as? (HTTP, FTP or DNS) + masquerade_port | What port should the C2 traffic use? (TCP or UDP) - # TODO: Expand docustring. :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. :type c2_server_ip_address: IPv4Address @@ -245,13 +257,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :param session_id: The current session established with the C2 Server. :type session_id: Str """ - output_packet = C2Packet( - masquerade_protocol=self.c2_config.masquerade_protocol, - masquerade_port=self.c2_config.masquerade_port, - keep_alive_frequency=self.c2_config.keep_alive_frequency, - payload_type=C2Payload.OUTPUT, - payload=command_output, - ) + output_packet = self._craft_packet(c2_payload=C2Payload.OUTPUT, command_options=command_output) if self.send( payload=output_packet, dest_ip_address=self.c2_remote_connection, @@ -410,7 +416,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # If this method returns true then we have sent successfully sent a keep alive. return self._send_keep_alive(session_id) - def _confirm_connection(self, timestep: int) -> bool: + def _confirm_remote_connection(self, timestep: int) -> bool: """Checks the suitability of the current C2 Server connection. If a connection cannot be confirmed then this method will return false otherwise true. diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 6b51f8c7..577a13cb 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -24,6 +24,8 @@ class C2Server(AbstractC2, identifier="C2Server"): 1. Sending commands to the C2 Beacon. (Command input) 2. Parsing terminal RequestResponses back to the Agent. + + Please refer to the Command-&-Control notebook for an in-depth example of the C2 Suite. """ current_command_output: RequestResponse = None @@ -51,7 +53,7 @@ class C2Server(AbstractC2, identifier="C2Server"): "server_ip_address": request[-1].get("server_ip_address"), "payload": request[-1].get("payload"), } - return self._send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) + return self.send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a RANSOMWARE_LAUNCH C2Command to the C2 Beacon with the given parameters. @@ -63,7 +65,7 @@ class C2Server(AbstractC2, identifier="C2Server"): :return: RequestResponse object with a success code reflecting whether the ransomware was launched. :rtype: RequestResponse """ - return self._send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options={}) + return self.send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options={}) def _remote_terminal_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a TERMINAL C2Command to the C2 Beacon with the given parameters. @@ -76,7 +78,7 @@ class C2Server(AbstractC2, identifier="C2Server"): :rtype: RequestResponse """ command_payload = request[-1] - return self._send_command(given_command=C2Command.TERMINAL, command_options=command_payload) + return self.send_command(given_command=C2Command.TERMINAL, command_options=command_payload) rm.add_request( name="ransomware_configure", @@ -159,7 +161,7 @@ class C2Server(AbstractC2, identifier="C2Server"): return self._send_keep_alive(session_id) @validate_call - def _send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: + def send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: """ Sends a command to the C2 Beacon. @@ -193,26 +195,17 @@ class C2Server(AbstractC2, identifier="C2Server"): status="failure", data={"Reason": "Received unexpected C2Command. Unable to send command."} ) - if self._can_perform_network_action == False: - self.sys_log.warning(f"{self.name}: Unable to make leverage networking resources. Rejecting Command.") - return RequestResponse( - status="failure", data={"Reason": "Unable to access networking resources. Unable to send command."} - ) - - if self.c2_remote_connection is False: - self.sys_log.warning(f"{self.name}: C2 Beacon has yet to establish connection. Rejecting command.") - return RequestResponse( - status="failure", data={"Reason": "C2 Beacon has yet to establish connection. Unable to send command."} - ) - - if self.c2_session is None: - self.sys_log.warning(f"{self.name}: C2 Beacon cannot be reached. Rejecting command.") - return RequestResponse( - status="failure", data={"Reason": "C2 Beacon cannot be reached. Unable to send command."} - ) + # Lambda method used to return a failure RequestResponse if we're unable to confirm a connection. + # If _check_connection returns false then connection_status will return reason (A 'failure' Request Response) + if connection_status := (lambda return_bool, reason: reason if return_bool is False else None)( + *self._check_connection() + ): + return connection_status self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") - command_packet = self._craft_packet(given_command=given_command, command_options=command_options) + command_packet = self._craft_packet( + c2_payload=C2Payload.INPUT, c2_command=given_command, command_options=command_options + ) if self.send( payload=command_packet, @@ -231,30 +224,6 @@ class C2Server(AbstractC2, identifier="C2Server"): ) return self.current_command_output - # TODO: Probably could move this as a class method in C2Packet. - def _craft_packet(self, given_command: C2Command, command_options: Dict) -> C2Packet: - """ - Creates and returns a Masquerade Packet using the arguments given. - - Creates Masquerade Packet with a payload_type INPUT C2Payload. - - :param given_command: The C2 command to be sent to the C2 Beacon. - :type given_command: C2Command. - :param command_options: The relevant C2 Beacon parameters.F - :type command_options: Dict - :return: Returns the construct C2Packet - :rtype: C2Packet - """ - constructed_packet = C2Packet( - masquerade_protocol=self.c2_config.masquerade_protocol, - masquerade_port=self.c2_config.masquerade_port, - keep_alive_frequency=self.c2_config.keep_alive_frequency, - payload_type=C2Payload.INPUT, - command=given_command, - payload=command_options, - ) - return constructed_packet - def show(self, markdown: bool = False): """ Prints a table of the current C2 attributes on a C2 Server. @@ -292,7 +261,8 @@ class C2Server(AbstractC2, identifier="C2Server"): ) print(table) - # Abstract method inherited from abstract C2 - Not currently utilised. + # Abstract method inherited from abstract C2. + # C2 Servers do not currently receive any input commands from the C2 beacon. def _handle_command_input(self, payload: C2Packet) -> None: """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. @@ -304,13 +274,15 @@ class C2Server(AbstractC2, identifier="C2Server"): self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") pass - def _confirm_connection(self, timestep: int) -> bool: + def _confirm_remote_connection(self, timestep: int) -> bool: """Checks the suitability of the current C2 Beacon connection. If a C2 Server has not received a keep alive within the current set keep alive frequency (self._keep_alive_frequency) then the C2 beacons connection is considered dead and any commands will be rejected. + This method is used to + :param timestep: The current timestep of the simulation. :type timestep: Int :return: Returns False if the C2 beacon is considered dead. Otherwise True. diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 56b354d7..42091ec2 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -263,7 +263,7 @@ def test_c2_suite_terminal_command_file_creation(basic_network): "ip_address": None, } - c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True assert c2_beacon.local_terminal_session is not None @@ -273,7 +273,7 @@ def test_c2_suite_terminal_command_file_creation(basic_network): # node_c's IP is 192.168.255.3 file_create_command.update({"ip_address": "192.168.255.3"}) - c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True assert c2_beacon.remote_terminal_session is not None @@ -369,7 +369,7 @@ def test_c2_suite_acl_bypass(basic_network): "password": "admin", "ip_address": None, } - c2_server._send_command(C2Command.TERMINAL, command_options=ftp_file_create_command) + c2_server.send_command(C2Command.TERMINAL, command_options=ftp_file_create_command) assert ( computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="ftp_test_file") == True @@ -440,7 +440,7 @@ def test_c2_suite_acl_bypass(basic_network): "password": "admin", "ip_address": None, } - c2_server._send_command(C2Command.TERMINAL, command_options=http_file_create_command) + c2_server.send_command(C2Command.TERMINAL, command_options=http_file_create_command) assert ( computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="http_test_file") == True diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index ed408d14..813fb810 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -102,7 +102,7 @@ def test_c2_handle_beacon_disconnect(basic_c2_network): "ip_address": None, } - command_request_response = c2_server._send_command(C2Command.TERMINAL, command_options=file_create_command) + command_request_response = c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert command_request_response.status == "failure" @@ -117,9 +117,6 @@ def test_c2_handle_beacon_disconnect(basic_c2_network): assert c2_server.c2_connection_active is False -# TODO: Finalise and complete these tests. - - def test_c2_handle_switching_port(basic_c2_network): """Tests that the C2 suite will be able handle switching destination/src port.""" network: Network = basic_c2_network @@ -205,3 +202,30 @@ def test_c2_handle_switching_frequency(basic_c2_network): assert c2_beacon.keep_alive_inactivity is 0 assert c2_server.keep_alive_inactivity is 0 + + +def test_c2_handles_1_timestep_keep_alive(basic_c2_network): + """Tests that the C2 suite will be able handle a C2 Beacon will a keep alive of 1 timestep.""" + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + c2_beacon.configure(c2_server_ip_address="192.168.0.1", keep_alive_frequency=1) + c2_server.run() + c2_beacon.establish() + + for i in range(50): + network.apply_timestep(i) + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True + + +def test_c2_server_runs_on_default(basic_c2_network): + """Tests that the C2 Server begins running by default.""" + network: Network = basic_c2_network + + computer_a: Computer = network.get_node_by_hostname("computer_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + assert c2_server.operating_state == ApplicationOperatingState.RUNNING From 27ec06658fde17d04e49f13d0bb482cdf6356c82 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 12 Aug 2024 19:25:30 +0100 Subject: [PATCH 147/206] #2689 Majorly updated the command and control notebook to demonstrate more configuration options and more text to explain the code cells. --- .../Command-&-Control-E2E-Demonstration.ipynb | 629 +++++++++++++++--- .../red_applications/c2/abstract_c2.py | 2 - 2 files changed, 532 insertions(+), 99 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 7ee1c5cf..46fbe886 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -142,8 +142,15 @@ " keep_alive_frequency: 10\n", " masquerade_protocol: TCP\n", " masquerade_port: DNS\n", - "\n", - "\n", + " 8:\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0\n", + " config:\n", + " c2_server_ip_address: 192.168.10.22\n", + " keep_alive_frequency:\n", + " masquerade_protocol:\n", + " masquerade_port:\n", "\n", " reward_function:\n", " reward_components:\n", @@ -202,14 +209,39 @@ "\n", "Before any C2 Server commands is able to accept any commands, it must first establish connection with a C2 beacon.\n", "\n", - "This can be done by installing, configuring and then executing a C2 Beacon. " + "A red agent is able to install, configure and establish a C2 beacon at any point of an episode. The code cells below demonstrate what actions and option parameters are needed to perform this." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Beacon Actions | Installation" + "### **Command and Control** | C2 Beacon Actions | NODE_APPLICATION_INSTALL\n", + "\n", + "The custom proxy red agent defined at the start of this notebook has been configured to install the C2 Beacon as action ``1`` on it's action map. \n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", + "\n", + "```yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: NODE_APPLICATION_INSTALL\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " - node_name: web_server\n", + " applications: \n", + " - application_name: C2Beacon\n", + " ...\n", + " ...\n", + " action_map:\n", + " 1:\n", + " action: NODE_APPLICATION_INSTALL \n", + " options:\n", + " node_id: 0 # Index 0 at the node list.\n", + " application_name: C2Beacon\n", + "```" ] }, { @@ -227,7 +259,35 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Beacon Actions | Configuration" + "### **Command and Control** | C2 Beacon Actions | CONFIGURE_C2_BEACON \n", + "\n", + "The custom proxy red agent defined at the start of this notebook can configure the C2 Beacon via action ``2`` on it's action map. \n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", + "\n", + "```yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: CONFIGURE_C2_BEACON\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " - node_name: web_server\n", + " ...\n", + " ...\n", + " action_map:\n", + " ...\n", + " 2:\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0 # Node Index\n", + " config: # Further information about these config options can be found at the bottom of this notebook.\n", + " c2_server_ip_address: 192.168.10.21\n", + " keep_alive_frequency:\n", + " masquerade_protocol:\n", + " masquerade_port:\n", + "```" ] }, { @@ -246,7 +306,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Beacon Actions | Establishing Connection" + "### **Command and Control** | C2 Beacon Actions | NODE_APPLICATION_EXECUTE\n", + "\n", + "The final action is ``NODE_APPLICATION_EXECUTE`` which is used to establish connection for the C2 application. This action can be called by the Red Agent via action ``3`` on it's action map. \n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", + "\n", + "```yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: NODE_APPLICATION_EXECUTE\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " - node_name: web_server\n", + " applications: \n", + " - application_name: C2Beacon\n", + " ...\n", + " ...\n", + " action_map:\n", + " ...\n", + " 3:\n", + " action: NODE_APPLICATION_EXECUTE\n", + " options:\n", + " node_id: 0\n", + " application_id: 0\n", + "```" ] }, { @@ -272,14 +358,59 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## **Command and Control** | C2 Server Actions" + "## **Command and Control** | C2 Server Actions\n", + "\n", + "Once the C2 suite has been successfully established, the C2 Server based actions become available to the Red Agent. \n", + "\n", + "\n", + "This next section will demonstrate the different actions that become available to a red agent after establishing C2 connection:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Server Actions | Executing Terminal Commands" + "### **Command and Control** | C2 Server Actions | C2_SERVER_TERMINAL_COMMAND\n", + "\n", + "The C2 Server's terminal action is indexed at ``4`` on the custom red agent action map. \n", + "\n", + "This action leverages the terminal service that is installed by default on all nodes to grant red agents a lot more configurability. If you're unfamiliar with terminals then it's recommended that you refer to the ``Terminal Processing`` notebook.\n", + "\n", + "It's worth noting that an additional benefit that a red agent has when using terminal via the C2 Server is that you can execute multiple commands in one action. \n", + "\n", + "In this notebook, the ``C2_SERVER_TERMINAL_COMMAND`` is used to install a RansomwareScript application on the ``web_server`` node.\n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", + "\n", + "``` yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: C2_SERVER_TERMINAL_COMMAND\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " ...\n", + " - node_name: client_1\n", + " applications: \n", + " - application_name: C2Server\n", + " ...\n", + " action_map:\n", + " 4:\n", + " action: C2_SERVER_TERMINAL_COMMAND\n", + " options:\n", + " node_id: 1\n", + " ip_address:\n", + " account:\n", + " username: admin\n", + " password: admin\n", + " commands:\n", + " - \n", + " - software_manager\n", + " - application\n", + " - install\n", + " - RansomwareScript\n", + "```" ] }, { @@ -304,7 +435,36 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Server Actions | Configuring Ransomware" + "### **Command and Control** | C2 Server Actions | C2_SERVER_RANSOMWARE_CONFIGURE\n", + "\n", + "Another action that the C2 Server grants is the ability for a Red Agent to configure ransomware via the C2 Server. \n", + "\n", + "This action is indexed as action ``5``.\n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", + "\n", + "``` yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: C2_SERVER_RANSOMWARE_CONFIGURE\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " ...\n", + " - node_name: client_1\n", + " applications: \n", + " - application_name: C2Server\n", + " ...\n", + " action_map:\n", + " 5:\n", + " action: C2_SERVER_RANSOMWARE_CONFIG\n", + " options:\n", + " node_id: 1\n", + " config:\n", + " server_ip_address: 192.168.1.14\n", + " payload: ENCRYPT\n", + "```\n" ] }, { @@ -316,15 +476,6 @@ "env.step(5)" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "env.step(6)" - ] - }, { "cell_type": "code", "execution_count": null, @@ -340,7 +491,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### **Command and Control** | C2 Server Actions | Launching Ransomware" + "### **Command and Control** | C2 Server Actions | C2_SERVER_RANSOMWARE_LAUNCH\n", + "\n", + "Finally, currently the last action available is the ``C2_SERVER_RANSOMWARE_LAUNCH`` which quite simply launches the ransomware script installed on the same node as the C2 beacon.\n", + "\n", + "This action is indexed as action ``6``.\n", + "\n", + "The below yaml snippet shows all the relevant agent options for this actio\n", + "\n", + "``` yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: C2_SERVER_RANSOMWARE_LAUNCH\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " ...\n", + " - node_name: client_1\n", + " applications: \n", + " - application_name: C2Server\n", + " ...\n", + " action_map:\n", + " 6:\n", + " action: C2_SERVER_RANSOMWARE_LAUNCH\n", + " options:\n", + " node_id: 1\n", + "```\n" ] }, { @@ -407,7 +584,7 @@ " num_applications: 2\n", " num_folders: 1\n", " num_files: 1\n", - " num_nics: 0\n", + " num_nics: 1\n", " include_num_access: false\n", " include_nmne: false\n", " monitored_traffic:\n", @@ -415,16 +592,26 @@ " - NONE\n", " tcp:\n", " - HTTP\n", + " - DNS\n", + " - FTP\n", " routers:\n", " - hostname: router_1\n", - " num_ports: 1\n", + " num_ports: 3\n", " ip_list:\n", - " - 192.168.10.21\n", + " - 192.168.1.10\n", " - 192.168.1.12\n", + " - 192.168.1.14\n", + " - 192.168.1.16\n", + " - 192.168.1.110\n", + " - 192.168.10.21\n", + " - 192.168.10.22\n", + " - 192.168.10.110\n", " wildcard_list:\n", " - 0.0.0.1\n", " port_list:\n", " - 80\n", + " - 53\n", + " - 21\n", " protocol_list:\n", " - ICMP\n", " - TCP\n", @@ -776,7 +963,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The code cell below uses the custom blue agent defined at the start of this section perform NODE_APPLICATION_REMOVE on the C2 beacon" + "The code cell below uses the custom blue agent defined at the start of this section perform a NODE_APPLICATION_REMOVE on the C2 beacon:" ] }, { @@ -1059,20 +1246,57 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## **Command and Control** | C2 Beacon Actions\n", + "## **Command and Control** | Configurability \n", "\n", - "Before any C2 Server commands is able to accept any commands, it must first establish connection with a C2 beacon.\n", - "\n", - "This can be done by installing, configuring and then executing a C2 Beacon. " + "This section of the notebook demonstrates the C2 configuration options and their impact on the simulation layer and the game layer." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## **Command and Control** | Configurability \n", + "The table below is the currently offered C2 Beacon configuration options:\n", "\n", - "TODO: Fleshout" + "|Configuration Option | Option Meaning |Default Option | Type | _Optional_ |\n", + "|---------------------|---------------------------------------------------------------------------|---------------|---------|------------|\n", + "|c2_server_ip_address | The IP Address of the C2 Server. (The C2 Server must be running) |_None_ |str (IP) | _No_ |\n", + "|keep_alive_frequency | How often should the C2 Beacon confirm it's connection in timesteps. |5 |Int | _Yes_ |\n", + "|masquerade_port | What port should the C2 traffic use? (TCP or UDP) |TCP |Str | _Yes_ |\n", + "|masquerade_protocol | What protocol should the C2 traffic masquerade as? (HTTP, FTP or DNS) |HTTP |Str | _Yes_ |\n", + "\n", + "The C2 Server currently does not offer any unique configuration options. The C2 Server aligns itself with the C2 Beacon's configuration options once connection is established." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As demonstrated earlier, red agents can use the ``CONFIGURE_C2_BEACON`` action to configure these settings mid episode through the configuration options:\n", + "\n", + "``` YAML\n", + "...\n", + " action: CONFIGURE_C2_BEACON\n", + " options:\n", + " node_id: 0\n", + " config:\n", + " c2_server_ip_address: 192.168.10.21\n", + " keep_alive_frequency: 10\n", + " masquerade_protocol: TCP\n", + " masquerade_port: DNS\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | Configurability | C2 Server IP Address\n", + "\n", + "As with a majority of client and server based application configuration in primaite, the remote IP of server must be supplied.\n", + "\n", + "In the case of the C2 Beacon, the C2 Server's IP must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", + "\n", + "If the network contains multiple C2 Servers then it's also possible to switch to different C2 servers mid episode which is demonstrated in the below code cells." ] }, { @@ -1095,73 +1319,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Installing the C2 Server" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_1: Computer = c2_config_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", - "client_1.software_manager.install(C2Server)\n", - "c2_server: C2Server = client_1.software_manager.software[\"C2Server\"]\n", - "c2_server.run()\n", - "client_1.software_manager.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Installing the C2 Beacon via NODE_APPLICATION_INSTALL" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "c2_config_env.step(1)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Configuring the C2 Beacon using different parameters:\n", - "\n", - "``` yaml\n", - " action: CONFIGURE_C2_BEACON\n", - " options:\n", - " node_id: 0\n", - " config:\n", - " c2_server_ip_address: 192.168.10.21\n", - " keep_alive_frequency: 10\n", - " masquerade_protocol: TCP\n", - " masquerade_port: DNS\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "c2_config_env.step(7)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Establishing connection to the C2 Server.\n", - "c2_config_env.step(3)" + "Installing the C2 Server on both client 1 and client 2." ] }, { @@ -1171,9 +1329,286 @@ "outputs": [], "source": [ "web_server: Server = c2_config_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "web_server.software_manager.install(C2Beacon)\n", "c2_beacon: C2Beacon = web_server.software_manager.software[\"C2Beacon\"]\n", + "\n", + "client_1: Computer = c2_config_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "client_1.software_manager.install(C2Server)\n", + "c2_server_1: C2Server = client_1.software_manager.software[\"C2Server\"]\n", + "c2_server_1.run()\n", + "\n", + "client_2: Computer = c2_config_env.game.simulation.network.get_node_by_hostname(\"client_2\")\n", + "client_2.software_manager.install(C2Server)\n", + "c2_server_2: C2Server = client_2.software_manager.software[\"C2Server\"]\n", + "c2_server_2.run()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Configuring the C2 Beacon to establish connection to the C2 Server on client_1 (192.168.10.21)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(2) # Agent Action Equivalent to c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\")\n", + "env.step(3) # Agent action Equivalent to c2_beacon.establish()\n", "c2_beacon.show()\n", - "c2_server.show()" + "c2_server_1.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now reconfiguring the C2 Beacon to establish connection to the C2 Server on client_2 (192.168.10.22)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(8) # Equivalent of to c2_beacon.configure(c2_server_ip_address=\"192.168.10.22\")\n", + "env.step(3)\n", + "\n", + "c2_beacon.show()\n", + "c2_server_2.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "After six timesteps the client_1 server will recognise the c2 beacon previous connection as dead and clear it's connections. (This is dependant o the ``Keep Alive Frequency`` setting.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(6):\n", + " env.step(0)\n", + " \n", + "c2_server_1.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | Configurability | Keep Alive Frequency\n", + "\n", + "In order to confirm it's connection the C2 Beacon will send out a ``Keep Alive`` to the C2 Server and receive a keep alive back. \n", + "\n", + "By default, this occurs at a rate of 5 timesteps. However, this setting can be configured to be much more infrequent or as frequent as every timestep. \n", + "\n", + "The next set of code cells below demonstrate the impact that this setting has on blue agent observation space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path()) as f:\n", + " cfg = yaml.safe_load(f)\n", + " # removing all agents & adding the custom agent.\n", + " cfg['agents'] = {}\n", + " cfg['agents'] = custom_blue\n", + " cfg['agents'][0]['observation_space']['options']['components'][0]['options']['num_ports'] = 3\n", + " cfg['agents'][0]['observation_space']['options']['components'][0]['options']['monitored_traffic'].update({\"tcp\": [\"HTTP\",\"FTP\"]})\n", + " cfg['agents'][0]['observation_space']['options']['components'][0]['options']['monitored_traffic'].update({\"udp\": [\"DNS\"]})\n", + "\n", + "blue_config_env = PrimaiteGymEnv(env_config=cfg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Performing the usual c2 setup:\n", + "blue_config_env, c2_server, c2_beacon, client_1, web_server = c2_setup(given_env=blue_config_env)\n", + "\n", + "# Flushing out the OBS impacts from setting up the C2 suite.\n", + "blue_config_env.step(0)\n", + "blue_config_env.step(0)\n", + "\n", + "# Capturing the 'default' obs (Post C2 installation and configuration):\n", + "default_obs, _, _, _, _ = blue_config_env.step(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The next code cells capture the obs impact of the default Keep Alive Frequency which is 5 timesteps:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\")\n", + "c2_beacon.establish()\n", + "c2_beacon.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code cell below goes through 10 timesteps and displays the differences between the default and the current timestep.\n", + "\n", + "You will notice that the only observation space differences after 10 timesteps. This is due to the C2 Suite confirming their connection through sending ``Keep Alive`` traffic across the network." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(10):\n", + " keep_alive_obs, _, _, _, _ = blue_config_env.step(0)\n", + " display_obs_diffs(default_obs, keep_alive_obs, blue_config_env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, the code cells below configuring the C2 Beacon's Keep Alive Frequency to confirm connection on every timestep." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\", keep_alive_frequency=1)\n", + "c2_beacon.establish()\n", + "c2_beacon.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code cells below demonstrate that the observation impacts of the Keep Alive can be seen on every timestep. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Comparing the OBS of the default frequency to a timestep frequency of 1 \n", + "for i in range(2):\n", + " keep_alive_obs, _, _, _, _ = blue_config_env.step(0)\n", + " display_obs_diffs(default_obs, keep_alive_obs, blue_config_env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | Configurability | Masquerade Port & Masquerade Protocol\n", + "\n", + "The final configurable options are ``Masquerade Port`` & ``Masquerade Protocol``. These options can be used to control what networking IP Protocol and Port the C2 traffic is currently using.\n", + "\n", + "In the real world, Adversaries take defensive steps to reduce the chance that an installed C2 Beacon is discovered. One of the most commonly used methods is to masquerade c2 traffic as other commonly used networking protocols.\n", + "\n", + "In primAITE, red agents can begin to simulate stealth behaviour by configuring C2 traffic to use different protocols mid episode or between episodes. \n", + "Currently, red agent actions are limited to using ports: ``DNS``, ``FTP`` and ``HTTP`` and protocols: ``UDP`` and ``TCP``.\n", + "\n", + "The next set of code cells will demonstrate the impact this option from a blue agent perspective." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "blue_config_env.reset()\n", + "\n", + "# Performing the usual c2 setup:\n", + "blue_config_env, c2_server, c2_beacon, client_1, web_server = c2_setup(given_env=blue_config_env)\n", + "\n", + "blue_config_env.step(0)\n", + "\n", + "# Capturing the 'default' obs (Post C2 installation and configuration):\n", + "default_obs, _, _, _, _ = blue_config_env.step(0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, the C2 suite will masquerade a Web Browser, meaning C2 Traffic will opt to use ``TCP`` and ``HTTP`` (Port 80):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capturing default C2 Traffic \n", + "for i in range(3):\n", + " tcp_c2_obs, _, _, _, _ = blue_config_env.step(0)\n", + "\n", + "display_obs_diffs(default_obs, tcp_c2_obs, blue_config_env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "However, C2 Beacon can be configured to use UDP (``Masquerade Protocol``) and we can also configure the C2 Beacon to use a different Port (``Masquerade Port``) for example ``DNS``. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.transmission.network_layer import IPProtocol\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "# As we're configuring via the PrimAITE API we need to pass the actual IPProtocol/Port (Agents leverage the simulation via the game layer and thus can pass strings).\n", + "c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\", masquerade_protocol=IPProtocol.UDP, masquerade_port=Port.DNS)\n", + "c2_beacon.establish()\n", + "c2_beacon.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Capturing UDP C2 Traffic\n", + "for i in range(5):\n", + " udp_c2_obs, _, _, _, _ = blue_config_env.step(0)\n", + "\n", + "display_obs_diffs(tcp_c2_obs, udp_c2_obs, blue_config_env.game.step_counter)" ] } ], diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index f5fb0929..2a3e78bb 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -27,8 +27,6 @@ class C2Command(Enum): TERMINAL = "Terminal" "Instructs the c2 beacon to execute the provided terminal command." - # The terminal command should also be able to pass a session which can be used for remote connections. - class C2Payload(Enum): """Represents the different types of command and control payloads.""" From 6c7376ab4b5c55d23af9715a5bd5ed66cd08759b Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 09:37:11 +0100 Subject: [PATCH 148/206] #2681 Updated to include yaml file tests + include listening on multiports. --- .../system/applications/c2_suite.rst | 15 +++- src/primaite/game/game.py | 16 ++++ .../red_applications/c2/abstract_c2.py | 6 +- .../red_applications/c2/c2_server.py | 1 + tests/assets/configs/basic_c2_setup.yaml | 76 +++++++++++++++++++ .../test_c2_suite_integration.py | 34 +++++++++ 6 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/assets/configs/basic_c2_setup.yaml diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 4d5f685a..55f58ff4 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -197,7 +197,7 @@ Via Configuration ... # A C2 Beacon will not automatically connection to a C2 Server. # Either an agent must use application_execute. - # Or a user must use .establish(). + # Or a if using the simulation layer - .establish(). applications: type: C2Beacon options: @@ -205,6 +205,10 @@ Via Configuration keep_alive_frequency: 5 masquerade_protocol: tcp masquerade_port: http + listen_on_ports: + - 80 + - 53 + - 21 @@ -264,6 +268,13 @@ This must be a string i.e ``DNS``. Defaults to ``HTTP``. _Please refer to the ``IPProtocol`` class for further reference._ - +C2 Server Configuration +======================= _The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour._ + + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: C2Server +.. |SOFTWARE_NAME_BACKTICK| replace:: ``C2Server`` diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 7f7f69eb..f4722514 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -27,6 +27,7 @@ from primaite.simulator.network.hardware.nodes.network.router import Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter from primaite.simulator.network.nmne import NMNEConfig +from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.application import Application @@ -455,6 +456,21 @@ class PrimaiteGame: dos_intensity=float(opt.get("dos_intensity", "1.0")), max_sessions=int(opt.get("max_sessions", "1000")), ) + elif application_type == "C2Beacon": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.configure( + c2_server_ip_address=IPv4Address(opt.get("c2_server_ip_address")), + keep_alive_frequency=(opt.get("keep_alive_frequency")) + if opt.get("keep_alive_frequency") + else 5, + masquerade_protocol=IPProtocol[(opt.get("masquerade_protocol"))] + if opt.get("masquerade_protocol") + else IPProtocol.TCP, + masquerade_port=Port[(opt.get("masquerade_port"))] + if opt.get("masquerade_port") + else Port.HTTP, + ) if "network_interfaces" in node_cfg: for nic_num, nic_cfg in node_cfg["network_interfaces"].items(): new_node.connect_nic(NIC(ip_address=nic_cfg["ip_address"], subnet_mask=nic_cfg["subnet_mask"])) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 2a3e78bb..e6740d9f 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -247,9 +247,9 @@ class AbstractC2(Application, identifier="AbstractC2"): self.keep_alive_sent = True self.sys_log.info(f"{self.name}: Keep Alive sent to {self.c2_remote_connection}") self.sys_log.debug( - f"{self.name}: Keep Alive sent to {self.c2_remote_connection}" - f"Using Masquerade Port: {self.c2_config.masquerade_port}" - f"Using Masquerade Protocol: {self.c2_config.masquerade_protocol}" + f"{self.name}: Keep Alive sent to {self.c2_remote_connection} " + f"Masquerade Port: {self.c2_config.masquerade_port} " + f"Masquerade Protocol: {self.c2_config.masquerade_protocol} " ) return True else: diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 577a13cb..a93fd8b6 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -97,6 +97,7 @@ class C2Server(AbstractC2, identifier="C2Server"): def __init__(self, **kwargs): kwargs["name"] = "C2Server" super().__init__(**kwargs) + self.run() def _handle_command_output(self, payload: C2Packet) -> bool: """ diff --git a/tests/assets/configs/basic_c2_setup.yaml b/tests/assets/configs/basic_c2_setup.yaml new file mode 100644 index 00000000..0cae2ba0 --- /dev/null +++ b/tests/assets/configs/basic_c2_setup.yaml @@ -0,0 +1,76 @@ +# Basic Switched network +# +# -------------- -------------- -------------- +# | node_a |------| switch_1 |------| node_b | +# -------------- -------------- -------------- +# +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + sys_log_level: WARNING + agent_log_level: INFO + save_agent_logs: true + write_agent_log_to_terminal: True + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +simulation: + network: + nodes: + + - type: switch + hostname: switch_1 + num_ports: 8 + + - hostname: node_a + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + applications: + - type: C2Server + options: + listen_on_ports: + - 80 + - 53 + - 21 + - hostname: node_b + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + applications: + - type: C2Beacon + options: + c2_server_ip_address: 192.168.10.21 + keep_alive_frequency: 5 + masquerade_protocol: TCP + masquerade_port: HTTP + listen_on_ports: + - 80 + - 53 + - 21 + + links: + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: node_a + endpoint_b_port: 1 + bandwidth: 200 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: node_b + endpoint_b_port: 1 + bandwidth: 200 diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 42091ec2..904fb449 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -3,6 +3,7 @@ from ipaddress import IPv4Address from typing import Tuple import pytest +import yaml from primaite.game.agent.interface import ProxyAgent from primaite.game.game import PrimaiteGame @@ -22,6 +23,7 @@ from primaite.simulator.system.applications.red_applications.ransomware_script i from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.web_server.web_server import WebServer +from tests import TEST_ASSETS_ROOT @pytest.fixture(scope="function") @@ -463,3 +465,35 @@ def test_c2_suite_acl_bypass(basic_network): assert c2_packets_blocked == blocking_acl.match_count assert c2_server.c2_connection_active is True assert c2_beacon.c2_connection_active is True + + +def test_c2_suite_yaml(): + """Tests that the C2 Suite is can be configured correctly via the Yaml.""" + with open(TEST_ASSETS_ROOT / "configs" / "basic_c2_setup.yaml") as f: + cfg = yaml.safe_load(f) + game = PrimaiteGame.from_config(cfg) + + yaml_network = game.simulation.network + computer_a: Computer = yaml_network.get_node_by_hostname("node_a") + c2_server: C2Server = computer_a.software_manager.software.get("C2Server") + + computer_b: Computer = yaml_network.get_node_by_hostname("node_b") + c2_beacon: C2Beacon = computer_b.software_manager.software.get("C2Beacon") + + assert c2_server.operating_state == ApplicationOperatingState.RUNNING + + assert c2_beacon.c2_remote_connection == IPv4Address("192.168.10.21") + + c2_beacon.establish() + + # Asserting that the c2 beacon has established a c2 connection + assert c2_beacon.c2_connection_active is True + # Asserting that the c2 server has established a c2 connection. + assert c2_server.c2_connection_active is True + assert c2_server.c2_remote_connection == IPv4Address("192.168.10.22") + + for i in range(50): + yaml_network.apply_timestep(i) + + assert c2_beacon.c2_connection_active is True + assert c2_server.c2_connection_active is True From 845a4c6bd65b35ebfc1b9280ef0e7f67ab5c13ab Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 10:18:56 +0100 Subject: [PATCH 149/206] #2689 Final docustring updates before PR. --- .../red_applications/c2/abstract_c2.py | 19 ++- .../red_applications/c2/c2_beacon.py | 12 +- .../red_applications/c2/c2_server.py | 117 ++++++++++-------- .../_red_applications/test_c2_suite.py | 10 -- 4 files changed, 88 insertions(+), 70 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index e6740d9f..fc383837 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -87,7 +87,10 @@ class AbstractC2(Application, identifier="AbstractC2"): Creates and returns a Masquerade Packet using the parameters given. The packet uses the current c2 configuration and parameters given - to construct a C2 Packet. + to construct the base networking information such as the masquerade + protocol/port. Additionally all C2 Traffic packets pass the currently + in use C2 configuration. This ensures that the all C2 applications + can keep their configuration in sync. :param c2_payload: The type of C2 Traffic ot be sent :type c2_payload: C2Payload @@ -208,6 +211,8 @@ class AbstractC2(Application, identifier="AbstractC2"): :type payload: C2Packet :param session_id: The transport session_id that the payload is originating from. :type session_id: str + :return: Returns a bool if the traffic was received correctly (See _handle_c2_payload.) + :rtype: bool """ return self._handle_c2_payload(payload, session_id) @@ -306,7 +311,13 @@ class AbstractC2(Application, identifier="AbstractC2"): return True def _reset_c2_connection(self) -> None: - """Resets all currently established C2 communications to their default setting.""" + """ + Resets all currently established C2 communications to their default setting. + + This method is called once a C2 application considers their remote connection + severed and reverts back to default settings. Worth noting that that this will + revert any non-default configuration that a user/agent may have set. + """ self.c2_connection_active = False self.c2_session = None self.keep_alive_inactivity = 0 @@ -359,7 +370,9 @@ class AbstractC2(Application, identifier="AbstractC2"): Validation method: Checks that the C2 application is capable of sending C2 Command input/output. Performs a series of connection validation to ensure that the C2 application is capable of - sending and responding to the remote c2 connection. + sending and responding to the remote c2 connection. This method is used to confirm connection + before carrying out Agent Commands hence why this method also returns a tuple + containing both a success boolean as well as RequestResponse. :return: A tuple containing a boolean True/False and a corresponding Request Response :rtype: tuple[bool, RequestResponse] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index d256be42..e66bedc5 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -156,6 +156,11 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): masquerade_protocol | What protocol should the C2 traffic masquerade as? (HTTP, FTP or DNS) masquerade_port | What port should the C2 traffic use? (TCP or UDP) + These configuration options are used to reassign the fields in the inherited inner class + ``c2_config``. + + If a connection is already in progress then this method also sends a keep alive to the C2 + Server in order for the C2 Server to sync with the new configuration settings. :param c2_server_ip_address: The IP Address of the C2 Server. Used to establish connection. :type c2_server_ip_address: IPv4Address @@ -165,6 +170,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :type masquerade_protocol: Enum (IPProtocol) :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. :type masquerade_port: Enum (Port) + :return: Returns True if the configuration was successful, False otherwise. """ self.c2_remote_connection = IPv4Address(c2_server_ip_address) self.c2_config.keep_alive_frequency = keep_alive_frequency @@ -183,7 +189,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # Send a keep alive to the C2 Server if we already have a keep alive. if self.c2_connection_active is True: self.sys_log.info(f"{self.name}: Updating C2 Server with updated C2 configuration.") - self._send_keep_alive(self.c2_session.uuid if not None else None) + return self._send_keep_alive(self.c2_session.uuid if not None else None) return True def establish(self) -> bool: @@ -193,14 +199,14 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): return False self.run() self.num_executions += 1 + # Creates a new session if using the establish method. return self._send_keep_alive(session_id=None) def _handle_command_input(self, payload: C2Packet, session_id: Optional[str]) -> bool: """ Handles the parsing of C2 Commands from C2 Traffic (Masquerade Packets). - Dependant the C2 Command contained within the payload. - The following methods are called and returned. + Dependant the C2 Command parsed from the payload, the following methods are called and returned: C2 Command | Internal Method ---------------------|------------------------ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index a93fd8b6..3d71b881 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -103,15 +103,15 @@ class C2Server(AbstractC2, identifier="C2Server"): """ Handles the parsing of C2 Command Output from C2 Traffic (Masquerade Packets). - Parses the Request Response within C2Packet's payload attribute (Inherited from Data packet). - The class attribute self.current_command_output is then set to this Request Response. + Parses the Request Response from the given C2Packet's payload attribute (Inherited from Data packet). + This RequestResponse is then stored in the C2 Server class attribute self.current_command_output. If the payload attribute does not contain a RequestResponse, then an error will be raised in syslog and the self.current_command_output is updated to reflect the error. :param payload: The OUTPUT C2 Payload :type payload: C2Packet - :return: Returns True if the self.current_command_output is currently updated, false otherwise. + :return: Returns True if the self.current_command_output was updated, false otherwise. :rtype Bool: """ self.sys_log.info(f"{self.name}: Received command response from C2 Beacon: {payload}.") @@ -130,20 +130,27 @@ class C2Server(AbstractC2, identifier="C2Server"): """ Handles receiving and sending keep alive payloads. This method is only called if we receive a keep alive. - In the C2 Server implementation of this method the c2 connection active boolean - is set to true and the keep alive inactivity is reset after receiving one keep alive. + Abstract method inherited from abstract C2. + + In the C2 Server implementation of this method the following logic is performed: + + 1. The ``self.c2_connection_active`` is set to True. (Indicates that we're received a connection) + 2. The received keep alive (Payload parameter) is then resolved by _resolve_keep_alive. + 3. After the keep alive is resolved, a keep alive is sent back to confirm connection. This is because the C2 Server is the listener and thus will only ever receive packets from - the C2 Beacon rather than the other way around. (The C2 Beacon is akin to a reverse shell) + the C2 Beacon rather than the other way around. + + The C2 Beacon/Server communication is akin to that of a real-world reverse shells. Returns False if a keep alive was unable to be sent. Returns True if a keep alive was successfully sent or already has been sent this timestep. :param payload: The Keep Alive payload received. :type payload: C2Packet - :param session_id: The transport session_id that the payload is originating from. + :param session_id: The transport session_id that the payload originates from. :type session_id: str - :return: True if successfully handled, false otherwise. + :return: True if the keep alive was successfully handled, false otherwise. :rtype: Bool """ self.sys_log.info(f"{self.name}: Keep Alive Received. Attempting to resolve the remote connection details.") @@ -155,16 +162,22 @@ class C2Server(AbstractC2, identifier="C2Server"): self.sys_log.warning(f"{self.name}: Keep Alive Could not be resolved correctly. Refusing Keep Alive.") return False - # If this method returns true then we have sent successfully sent a keep alive. self.sys_log.info(f"{self.name}: Remote connection successfully established: {self.c2_remote_connection}.") self.sys_log.debug(f"{self.name}: Attempting to send Keep Alive response back to {self.c2_remote_connection}.") + # If this method returns true then we have sent successfully sent a keep alive response back. return self._send_keep_alive(session_id) @validate_call def send_command(self, given_command: C2Command, command_options: Dict) -> RequestResponse: """ - Sends a command to the C2 Beacon. + Sends a C2 command to the C2 Beacon using the given parameters. + + C2 Command | Command Synopsis + ---------------------|------------------------ + RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. + RANSOMWARE_LAUNCH | Launches the installed ransomware script. + TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. Currently, these commands leverage the pre-existing capability of other applications. However, the commands are sent via the network rather than the game layer which @@ -174,12 +187,6 @@ class C2Server(AbstractC2, identifier="C2Server"): more complex red agent behaviour such as file extraction, establishing further fall back channels or introduce red applications that are only installable via C2 Servers. (T1105) - C2 Command | Meaning - ---------------------|------------------------ - RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. - RANSOMWARE_LAUNCH | Launches the installed ransomware script. - TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. - For more information on the impact of these commands please refer to the terminal and the ransomware applications. @@ -225,6 +232,46 @@ class C2Server(AbstractC2, identifier="C2Server"): ) return self.current_command_output + def _confirm_remote_connection(self, timestep: int) -> bool: + """Checks the suitability of the current C2 Beacon connection. + + Inherited Abstract Method. + + If a C2 Server has not received a keep alive within the current set + keep alive frequency (self._keep_alive_frequency) then the C2 beacons + connection is considered dead and any commands will be rejected. + + This method is called on each timestep (Called by .apply_timestep) + + :param timestep: The current timestep of the simulation. + :type timestep: Int + :return: Returns False if the C2 beacon is considered dead. Otherwise True. + :rtype bool: + """ + if self.keep_alive_inactivity > self.c2_config.keep_alive_frequency: + self.sys_log.info(f"{self.name}: C2 Beacon connection considered dead due to inactivity.") + self.sys_log.debug( + f"{self.name}: Did not receive expected keep alive connection from {self.c2_remote_connection}" + f"{self.name}: Expected at timestep: {timestep} due to frequency: {self.c2_config.keep_alive_frequency}" + f"{self.name}: Last Keep Alive received at {(timestep - self.keep_alive_inactivity)}" + ) + self._reset_c2_connection() + return False + return True + + # Abstract method inherited from abstract C2. + # C2 Servers do not currently receive any input commands from the C2 beacon. + def _handle_command_input(self, payload: C2Packet) -> None: + """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. + + C2 Servers currently do not receive input commands coming from the C2 Beacons. + + :param payload: The incoming C2Packet + :type payload: C2Packet. + """ + self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") + pass + def show(self, markdown: bool = False): """ Prints a table of the current C2 attributes on a C2 Server. @@ -261,41 +308,3 @@ class C2Server(AbstractC2, identifier="C2Server"): ] ) print(table) - - # Abstract method inherited from abstract C2. - # C2 Servers do not currently receive any input commands from the C2 beacon. - def _handle_command_input(self, payload: C2Packet) -> None: - """Defining this method (Abstract method inherited from abstract C2) in order to instantiate the class. - - C2 Servers currently do not receive input commands coming from the C2 Beacons. - - :param payload: The incoming C2Packet - :type payload: C2Packet. - """ - self.sys_log.warning(f"{self.name}: C2 Server received an unexpected INPUT payload: {payload}") - pass - - def _confirm_remote_connection(self, timestep: int) -> bool: - """Checks the suitability of the current C2 Beacon connection. - - If a C2 Server has not received a keep alive within the current set - keep alive frequency (self._keep_alive_frequency) then the C2 beacons - connection is considered dead and any commands will be rejected. - - This method is used to - - :param timestep: The current timestep of the simulation. - :type timestep: Int - :return: Returns False if the C2 beacon is considered dead. Otherwise True. - :rtype bool: - """ - if self.keep_alive_inactivity > self.c2_config.keep_alive_frequency: - self.sys_log.info(f"{self.name}: C2 Beacon connection considered dead due to inactivity.") - self.sys_log.debug( - f"{self.name}: Did not receive expected keep alive connection from {self.c2_remote_connection}" - f"{self.name}: Expected at timestep: {timestep} due to frequency: {self.c2_config.keep_alive_frequency}" - f"{self.name}: Last Keep Alive received at {(timestep - self.keep_alive_inactivity)}" - ) - self._reset_c2_connection() - return False - return True diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 813fb810..30defe8b 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -219,13 +219,3 @@ def test_c2_handles_1_timestep_keep_alive(basic_c2_network): assert c2_beacon.c2_connection_active is True assert c2_server.c2_connection_active is True - - -def test_c2_server_runs_on_default(basic_c2_network): - """Tests that the C2 Server begins running by default.""" - network: Network = basic_c2_network - - computer_a: Computer = network.get_node_by_hostname("computer_a") - c2_server: C2Server = computer_a.software_manager.software.get("C2Server") - - assert c2_server.operating_state == ApplicationOperatingState.RUNNING From c36af13a665d5bcf41e5f6464e6836db947c656b Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 10:30:44 +0100 Subject: [PATCH 150/206] #2689 Updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c354aa14..f06301a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `User`, `UserManager` and `UserSessionManager` to enable the creation of user accounts and login on Nodes. - Added a `listen_on_ports` set in the `IOSoftware` class to enable software listening on ports in addition to the main port they're assigned. +- Added two new red applications: ``C2Beacon`` and ``C2Server`` which aim to simulate malicious network infrastructure. + Refer to the ``Command and Control Application Suite E2E Demonstration`` notebook for more information. ### Changed - Updated `SoftwareManager` `install` and `uninstall` to handle all functionality that was being done at the `install` From 1138605e2bf5be8e93ad04918215c3ff1451ec7d Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 10:48:17 +0100 Subject: [PATCH 151/206] #2689 Fixing mistakenly altered test file. --- .../integration_tests/network/test_routing.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 5f9e03ef..1ef38e15 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -33,18 +33,18 @@ def pc_a_pc_b_router_1() -> Tuple[Computer, Computer, Router]: ) pc_b.power_on() - router = Router(hostname="router", start_up_duration=0) - router.power_on() + router_1 = Router(hostname="router", start_up_duration=0) + router_1.power_on() - router.configure_port(1, "192.168.0.1", "255.255.255.0") - router.configure_port(2, "192.168.1.1", "255.255.255.0") + router_1.configure_port(1, "192.168.0.1", "255.255.255.0") + router_1.configure_port(2, "192.168.1.1", "255.255.255.0") - network.connect(endpoint_a=pc_a.network_interface[1], endpoint_b=router.network_interface[1]) - network.connect(endpoint_a=pc_b.network_interface[1], endpoint_b=router.network_interface[2]) - router.enable_port(1) - router.enable_port(2) + network.connect(endpoint_a=pc_a.network_interface[1], endpoint_b=router_1.network_interface[1]) + network.connect(endpoint_a=pc_b.network_interface[1], endpoint_b=router_1.network_interface[2]) + router_1.enable_port(1) + router_1.enable_port(2) - return pc_a, pc_b, router + return pc_a, pc_b, router_1 @pytest.fixture(scope="function") From 57dcd325a034a349c72fbfcdb8fe7924bfa28258 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 10:49:10 +0100 Subject: [PATCH 152/206] #2689 missed the hostname... --- tests/integration_tests/network/test_routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 1ef38e15..62b58cbd 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -33,7 +33,7 @@ def pc_a_pc_b_router_1() -> Tuple[Computer, Computer, Router]: ) pc_b.power_on() - router_1 = Router(hostname="router", start_up_duration=0) + router_1 = Router(hostname="router_1", start_up_duration=0) router_1.power_on() router_1.configure_port(1, "192.168.0.1", "255.255.255.0") From ead302c95de3ae00643e10a1bffe155a8b9d90b9 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 12:33:41 +0100 Subject: [PATCH 153/206] #2689 Added Tests for the C2 actions (Was previously covered via the notebook - now explicitly in a test.) --- tests/conftest.py | 8 +- .../actions/test_c2_suite_actions.py | 152 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/integration_tests/game_layer/actions/test_c2_suite_actions.py diff --git a/tests/conftest.py b/tests/conftest.py index 2d605c94..b6375acd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -458,6 +458,10 @@ def game_and_agent(): {"type": "HOST_NIC_DISABLE"}, {"type": "NETWORK_PORT_ENABLE"}, {"type": "NETWORK_PORT_DISABLE"}, + {"type": "CONFIGURE_C2_BEACON"}, + {"type": "C2_SERVER_RANSOMWARE_LAUNCH"}, + {"type": "C2_SERVER_RANSOMWARE_CONFIGURE"}, + {"type": "C2_SERVER_TERMINAL_COMMAND"}, ] action_space = ActionManager( @@ -468,12 +472,14 @@ def game_and_agent(): "applications": [ {"application_name": "WebBrowser"}, {"application_name": "DoSBot"}, + {"application_name": "C2Server"}, ], "folders": [{"folder_name": "downloads", "files": [{"file_name": "cat.png"}]}], }, { "node_name": "server_1", "services": [{"service_name": "DNSServer"}], + "applications": [{"application_name": "C2Beacon"}], }, {"node_name": "server_2", "services": [{"service_name": "WebServer"}]}, {"node_name": "router"}, @@ -481,7 +487,7 @@ def game_and_agent(): max_folders_per_node=2, max_files_per_folder=2, max_services_per_node=2, - max_applications_per_node=2, + max_applications_per_node=3, max_nics_per_node=2, max_acl_rules=10, protocols=["TCP", "UDP", "ICMP"], diff --git a/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py b/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py new file mode 100644 index 00000000..990c6363 --- /dev/null +++ b/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py @@ -0,0 +1,152 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.hardware.base import UserManager +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon +from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture +def game_and_agent_fixture(game_and_agent): + """Create a game with a simple agent that can be controlled by the tests.""" + game, agent = game_and_agent + + router = game.simulation.network.get_node_by_hostname("router") + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=4) + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.DNS, dst_port=Port.DNS, position=5) + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.FTP, dst_port=Port.FTP, position=6) + + c2_server_host = game.simulation.network.get_node_by_hostname("client_1") + c2_server_host.software_manager.install(software_class=C2Server) + c2_server: C2Server = c2_server_host.software_manager.software["C2Server"] + c2_server.run() + + return (game, agent) + + +def test_c2_beacon_default(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + """Tests that a Red Agent can install, configure and establish a C2 Beacon (default params).""" + game, agent = game_and_agent_fixture + + # Installing C2 Beacon on Server_1 + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + + action = ( + "NODE_APPLICATION_INSTALL", + {"node_id": 1, "application_name": "C2Beacon"}, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + action = ( + "CONFIGURE_C2_BEACON", + { + "node_id": 1, + "config": { + "c2_server_ip_address": "10.0.1.2", + "keep_alive_frequency": 5, + "masquerade_protocol": "TCP", + "masquerade_port": "HTTP", + }, + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + action = ( + "NODE_APPLICATION_EXECUTE", + {"node_id": 1, "application_id": 0}, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + # Asserting that we've confirmed our connection + c2_beacon: C2Beacon = server_1.software_manager.software["C2Beacon"] + assert c2_beacon.c2_connection_active == True + + +def test_c2_server_ransomware(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + """Tests that a Red Agent can install a RansomwareScript, Configure and launch all via C2 Server actions.""" + game, agent = game_and_agent_fixture + + # Installing a C2 Beacon on server_1 + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + server_1.software_manager.install(C2Beacon) + + # Installing a database on Server_2 for the ransomware to attack + server_2: Server = game.simulation.network.get_node_by_hostname("server_2") + server_2.software_manager.install(DatabaseService) + server_2.software_manager.software["DatabaseService"].start() + # Configuring the C2 to connect to client 1 (C2 Server) + c2_beacon: C2Beacon = server_1.software_manager.software["C2Beacon"] + c2_beacon.configure(c2_server_ip_address=IPv4Address("10.0.1.2")) + c2_beacon.establish() + assert c2_beacon.c2_connection_active == True + + # C2 Action 1: Installing the RansomwareScript & Database client via Terminal + + action = ( + "C2_SERVER_TERMINAL_COMMAND", + { + "node_id": 0, + "ip_address": None, + "account": { + "username": "admin", + "password": "admin", + }, + "commands": [ + ["software_manager", "application", "install", "RansomwareScript"], + ["software_manager", "application", "install", "DatabaseClient"], + ], + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + action = ( + "C2_SERVER_RANSOMWARE_CONFIGURE", + { + "node_id": 0, + "config": {"server_ip_address": "10.0.2.3", "payload": "ENCRYPT"}, + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + # Stepping a few timesteps to allow for the RansowmareScript to finish installing. + + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + game.step() + game.step() + + action = ( + "C2_SERVER_RANSOMWARE_LAUNCH", + { + "node_id": 0, + }, + ) + agent.store_action(action) + game.step() + assert agent.history[-1].response.status == "success" + + database_file = server_2.software_manager.file_system.get_file("database", "database.db") + assert database_file.health_status == FileSystemItemHealthStatus.CORRUPT From d6e2994d6b5426273e7cc181bd01b6b0e12bc76d Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 15:43:21 +0000 Subject: [PATCH 154/206] Apply suggestions from code review --- src/primaite/game/game.py | 2 +- .../system/applications/red_applications/c2/abstract_c2.py | 2 +- .../system/applications/red_applications/c2/c2_server.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index f4722514..d3035a5a 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -461,7 +461,7 @@ class PrimaiteGame: opt = application_cfg["options"] new_application.configure( c2_server_ip_address=IPv4Address(opt.get("c2_server_ip_address")), - keep_alive_frequency=(opt.get("keep_alive_frequency")) + keep_alive_frequency=(opt.get("keep_alive_frequency", 5)) if opt.get("keep_alive_frequency") else 5, masquerade_protocol=IPProtocol[(opt.get("masquerade_protocol"))] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index fc383837..7fa8f9ad 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -187,7 +187,7 @@ class AbstractC2(Application, identifier="AbstractC2"): # Used in C2 server to parse and receive the output of commands sent to the c2 beacon. @abstractmethod def _handle_command_output(payload): - """Abstract Method: Used in C2 server to prase and receive the output of commands sent to the c2 beacon.""" + """Abstract Method: Used in C2 server to parse and receive the output of commands sent to the c2 beacon.""" pass # Abstract method diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 3d71b881..f413a4b7 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -14,7 +14,7 @@ class C2Server(AbstractC2, identifier="C2Server"): """ C2 Server Application. - Represents a vendor generic C2 Server is used in conjunction with the C2 beacon + Represents a vendor generic C2 Server used in conjunction with the C2 beacon to simulate malicious communications and infrastructure within primAITE. The C2 Server must be installed and be in a running state before it's able to receive From 559f48006255d34c6b32b2980decae948c687ce0 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 16:47:40 +0100 Subject: [PATCH 155/206] #2689 Fixed .rst formatting issues and removed unnecessary comments. --- .../simulation_components/system/applications/c2_suite.rst | 6 +++--- .../system/applications/red_applications/c2/abstract_c2.py | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 55f58ff4..fff670e7 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -253,7 +253,7 @@ It's worth noting that this may be useful option to bypass ACL rules. This must be a string i.e ``udp``. Defaults to ``tcp``. -_Please refer to the ``IPProtocol`` class for further reference._ +*Please refer to the ``IPProtocol`` class for further reference.* ``Masquerade Port`` """"""""""""""""""" @@ -266,12 +266,12 @@ It's worth noting that this may be useful option to bypass ACL rules. This must be a string i.e ``DNS``. Defaults to ``HTTP``. -_Please refer to the ``IPProtocol`` class for further reference._ +*Please refer to the ``IPProtocol`` class for further reference.* C2 Server Configuration ======================= -_The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour._ +*The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour.* .. include:: ../common/common_configuration.rst diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 7fa8f9ad..8a03491e 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -183,15 +183,11 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return False - # Abstract method - # Used in C2 server to parse and receive the output of commands sent to the c2 beacon. @abstractmethod def _handle_command_output(payload): """Abstract Method: Used in C2 server to parse and receive the output of commands sent to the c2 beacon.""" pass - # Abstract method - # Used in C2 beacon to parse and handle commands received from the c2 server. @abstractmethod def _handle_command_input(payload): """Abstract Method: Used in C2 beacon to parse and handle commands received from the c2 server.""" From 192ca814e0284e1a6246c98c0e231acdfec3bef0 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 13 Aug 2024 15:49:52 +0000 Subject: [PATCH 156/206] Apply suggestions from code review --- .../simulation_components/system/applications/c2_suite.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index fff670e7..c3044d1d 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -38,7 +38,7 @@ Currently, the C2 Server offers three commands: +---------------------+---------------------------------------------------------------------------+ -It's important to note that in order to keep the PrimAITE realistic from a cyber perspective, +It's important to note that in order to keep PrimAITE realistic from a cyber perspective, The C2 Server application should never be visible or actionable upon directly by the blue agent. This is because in the real world, C2 servers are hosted on ephemeral public domains that would not be accessible by private network blue agent. From 6a28f17f1be8ba50226b51019b07f47e96d77fcd Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Wed, 14 Aug 2024 19:49:58 +0100 Subject: [PATCH 157/206] #2689 Initial draft of File exfiltration. --- src/primaite/game/agent/actions.py | 43 ++++++ .../Command-&-Control-E2E-Demonstration.ipynb | 100 ++++++++++++-- .../red_applications/c2/abstract_c2.py | 121 ++++++++++++++--- .../red_applications/c2/c2_beacon.py | 126 +++++++++++++++++- .../red_applications/c2/c2_server.py | 61 +++++++++ .../system/services/ftp/ftp_client.py | 53 +++++++- tests/conftest.py | 1 + .../actions/test_c2_suite_actions.py | 51 +++++++ .../test_c2_suite_integration.py | 41 ++++++ 9 files changed, 568 insertions(+), 29 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 92b175a9..aa74399e 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1147,6 +1147,48 @@ class RansomwareLaunchC2ServerAction(AbstractAction): return ["network", "node", node_name, "application", "C2Server", "ransomware_launch"] +class ExfiltrationC2ServerAction(AbstractAction): + """Action which exfiltrates a target file from a certain node onto the C2 beacon and then the C2 Server.""" + + class _Opts(BaseModel): + """Schema for options that can be passed to this action.""" + + username: Optional[str] + password: Optional[str] + target_ip_address: str + target_file_name: str + target_folder_name: str + exfiltration_folder_name: Optional[str] + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request( + self, + node_id: int, + account: dict, + target_ip_address: str, + target_file_name: str, + target_folder_name: str, + exfiltration_folder_name: Optional[str], + ) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + + command_model = { + "target_file_name": target_file_name, + "target_folder_name": target_folder_name, + "exfiltration_folder_name": exfiltration_folder_name, + "target_ip_address": target_ip_address, + "username": account["username"], + "password": account["password"], + } + ExfiltrationC2ServerAction._Opts.model_validate(command_model) + return ["network", "node", node_name, "application", "C2Server", "exfiltrate", command_model] + + class TerminalC2ServerAction(AbstractAction): """Action which causes the C2 Server to send a command to the C2 Beacon to execute the terminal command passed.""" @@ -1233,6 +1275,7 @@ class ActionManager: "C2_SERVER_RANSOMWARE_LAUNCH": RansomwareLaunchC2ServerAction, "C2_SERVER_RANSOMWARE_CONFIGURE": RansomwareConfigureC2ServerAction, "C2_SERVER_TERMINAL_COMMAND": TerminalC2ServerAction, + "C2_SERVER_DATA_EXFILTRATE": ExfiltrationC2ServerAction, } """Dictionary which maps action type strings to the corresponding action class.""" diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 46fbe886..052136f8 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -67,6 +67,7 @@ " - type: C2_SERVER_RANSOMWARE_LAUNCH\n", " - type: C2_SERVER_RANSOMWARE_CONFIGURE\n", " - type: C2_SERVER_TERMINAL_COMMAND\n", + " - type: C2_SERVER_DATA_EXFILTRATE\n", " options:\n", " nodes:\n", " - node_name: web_server\n", @@ -130,10 +131,22 @@ " server_ip_address: 192.168.1.14\n", " payload: ENCRYPT\n", " 6:\n", + " action: C2_SERVER_DATA_EXFILTRATE\n", + " options:\n", + " node_id: 1\n", + " target_file_name: \"database.db\"\n", + " target_folder_name: \"database\"\n", + " exfiltration_folder_name: \"spoils\"\n", + " target_ip_address: 192.168.1.14\n", + " account:\n", + " username: admin\n", + " password: admin \n", + "\n", + " 7:\n", " action: C2_SERVER_RANSOMWARE_LAUNCH\n", " options:\n", " node_id: 1\n", - " 7:\n", + " 8:\n", " action: CONFIGURE_C2_BEACON\n", " options:\n", " node_id: 0\n", @@ -142,7 +155,7 @@ " keep_alive_frequency: 10\n", " masquerade_protocol: TCP\n", " masquerade_port: DNS\n", - " 8:\n", + " 9:\n", " action: CONFIGURE_C2_BEACON\n", " options:\n", " node_id: 0\n", @@ -487,17 +500,88 @@ "ransomware_script.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### **Command and Control** | C2 Server Actions | C2_SERVER_DATA_EXFILTRATE\n", + "\n", + "Finally, currently the last action available is the ``C2_SERVER_DATA_EXFILTRATE`` which can be used to exfiltrate a target_file on a remote node to the C2 Beacon & Server's host file system via the ``FTP`` services.\n", + "\n", + "This action is indexed as action ``9``. # TODO: Update.\n", + "\n", + "The below yaml snippet shows all the relevant agent options for this action\n", + "\n", + "``` yaml\n", + " action_space:\n", + " action_list:\n", + " ...\n", + " - type: C2_SERVER_DATA_EXFILTRATE\n", + " ...\n", + " options:\n", + " nodes: # Node List\n", + " ...\n", + " - node_name: client_1\n", + " applications: \n", + " - application_name: C2Server\n", + " ...\n", + " action_map:\n", + " 6:\n", + " action: C2_SERVER_DATA_EXFILTRATE\n", + " options:\n", + " node_id: 1\n", + " target_file_name: \"database.db\"\n", + " target_folder_name: \"database\"\n", + " exfiltration_folder_name: \"spoils\"\n", + " target_ip_address: \"192.168.1.14\"\n", + " account:\n", + " username: \"admin\",\n", + " password: \"admin\"\n", + "\n", + "```\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(6)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "client_1.software_manager.file_system.show(full=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "web_server: Computer = env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", + "web_server.software_manager.file_system.show(full=True)" + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_RANSOMWARE_LAUNCH\n", "\n", - "Finally, currently the last action available is the ``C2_SERVER_RANSOMWARE_LAUNCH`` which quite simply launches the ransomware script installed on the same node as the C2 beacon.\n", + "Finally, to the ransomware configuration action, there is also the ``C2_SERVER_RANSOMWARE_LAUNCH`` which quite simply launches the ransomware script installed on the same node as the C2 beacon.\n", "\n", - "This action is indexed as action ``6``.\n", + "This action is indexed as action ``7``.\n", "\n", - "The below yaml snippet shows all the relevant agent options for this actio\n", + "The below yaml snippet shows all the relevant agent options for this action\n", "\n", "``` yaml\n", " action_space:\n", @@ -513,7 +597,7 @@ " - application_name: C2Server\n", " ...\n", " action_map:\n", - " 6:\n", + " 7:\n", " action: C2_SERVER_RANSOMWARE_LAUNCH\n", " options:\n", " node_id: 1\n", @@ -526,7 +610,7 @@ "metadata": {}, "outputs": [], "source": [ - "env.step(6)" + "env.step(7)" ] }, { @@ -1375,7 +1459,7 @@ "metadata": {}, "outputs": [], "source": [ - "env.step(8) # Equivalent of to c2_beacon.configure(c2_server_ip_address=\"192.168.10.22\")\n", + "env.step(9) # Equivalent of to c2_beacon.configure(c2_server_ip_address=\"192.168.10.22\")\n", "env.step(3)\n", "\n", "c2_beacon.show()\n", diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 8a03491e..6fa34fd6 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -2,16 +2,20 @@ from abc import abstractmethod from enum import Enum from ipaddress import IPv4Address -from typing import Dict, Optional +from typing import Dict, Optional, Union from pydantic import BaseModel, Field, validate_call from primaite.interface.request import RequestResponse +from primaite.simulator.file_system.file_system import FileSystem, Folder from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application, ApplicationOperatingState from primaite.simulator.system.core.session_manager import Session +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.software import SoftwareHealthState @@ -24,6 +28,9 @@ class C2Command(Enum): RANSOMWARE_LAUNCH = "Ransomware Launch" "Instructs the c2 beacon to execute the installed ransomware." + DATA_EXFILTRATION = "Data Exfiltration" + "Instructs the c2 beacon to attempt to return a file to the C2 Server." + TERMINAL = "Terminal" "Instructs the c2 beacon to execute the provided terminal command." @@ -80,6 +87,19 @@ class AbstractC2(Application, identifier="AbstractC2"): masquerade_port: Port = Field(default=Port.HTTP) """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" + c2_config: _C2_Opts = _C2_Opts() + """ + Holds the current configuration settings of the C2 Suite. + + The C2 beacon initialise this class through it's internal configure method. + + The C2 Server when receiving a keep alive will initialise it's own configuration + to match that of the configuration settings passed in the keep alive through _resolve keep alive. + + If the C2 Beacon is reconfigured then a new keep alive is set which causes the + C2 beacon to reconfigure it's configuration settings. + """ + def _craft_packet( self, c2_payload: C2Payload, c2_command: Optional[C2Command] = None, command_options: Optional[Dict] = {} ) -> C2Packet: @@ -111,19 +131,6 @@ class AbstractC2(Application, identifier="AbstractC2"): ) return constructed_packet - c2_config: _C2_Opts = _C2_Opts() - """ - Holds the current configuration settings of the C2 Suite. - - The C2 beacon initialise this class through it's internal configure method. - - The C2 Server when receiving a keep alive will initialise it's own configuration - to match that of the configuration settings passed in the keep alive through _resolve keep alive. - - If the C2 Beacon is reconfigured then a new keep alive is set which causes the - C2 beacon to reconfigure it's configuration settings. - """ - def describe_state(self) -> Dict: """ Describe the state of the C2 application. @@ -140,6 +147,82 @@ class AbstractC2(Application, identifier="AbstractC2"): kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) + # TODO: We may need to disable the ftp_server/client when using the opposite service. (To test) + @property + def _host_ftp_client(self) -> Optional[FTPClient]: + """Return the FTPClient that is installed C2 Application's host. + + This method confirms that the FTP Client is functional via the ._can_perform_action + method. If the FTP Client service is not in a suitable state (e.g disabled/pause) + then this method will return None. + + (The FTP Client service is installed by default) + + :return: An FTPClient object is successful, else None + :rtype: union[FTPClient, None] + """ + ftp_client: Union[FTPClient, None] = self.software_manager.software.get("FTPClient") + if ftp_client is None: + self.sys_log.warning(f"{self.__class__.__name__}: No FTPClient. Attempting to install.") + self.software_manager.install(FTPClient) + ftp_client = self.software_manager.software.get("FTPClient") + + # Force start if the service is stopped. + if ftp_client.operating_state == ServiceOperatingState.STOPPED: + if not ftp_client.start(): + self.sys_log.warning(f"{self.__class__.__name__}: cannot start the FTP Client.") + + if not ftp_client._can_perform_action(): + self.sys_log.error(f"{self.__class__.__name__}: is unable to use the FTP service on its host.") + return + + return ftp_client + + @property + def _host_ftp_server(self) -> Optional[FTPServer]: + """ + Returns the FTP Server that is installed C2 Application's host. + + If a FTPServer is not installed then this method will attempt to install one. + + :return: An FTPServer object is successful, else None + :rtype: union[FTPServer, None] + """ + ftp_server: Union[FTPServer, None] = self.software_manager.software.get("FTPServer") + if ftp_server is None: + self.sys_log.warning(f"{self.__class__.__name__}:No FTPServer installed. Attempting to install FTPServer.") + self.software_manager.install(FTPServer) + ftp_server = self.software_manager.software.get("FTPServer") + + # Force start if the service is stopped. + if ftp_server.operating_state == ServiceOperatingState.STOPPED: + if not ftp_server.start(): + self.sys_log.warning(f"{self.__class__.__name__}: cannot start the FTP Server.") + + if not ftp_server._can_perform_action(): + self.sys_log.error(f"{self.__class__.__name__}: is unable use FTP Server service on its host.") + return + + return ftp_server + + # Getter property for the get_exfiltration_folder method () + @property + def _host_file_system(self) -> FileSystem: + """Return the C2 Host's filesystem (Used for exfiltration related commands) .""" + host_file_system: FileSystem = self.software_manager.file_system + if host_file_system is None: + self.sys_log.error(f"{self.__class__.__name__}: does not seem to have a file system!") + return host_file_system + + def get_exfiltration_folder(self, folder_name: str) -> Optional[Folder]: + """Return a folder used for storing exfiltrated data. Otherwise returns None.""" + exfiltration_folder: Union[Folder, None] = self._host_file_system.get_folder(folder_name) + if exfiltration_folder is None: + self.sys_log.info(f"{self.__class__.__name__}: Creating a exfiltration folder.") + return self._host_file_system.create_folder(folder_name=folder_name) + + return exfiltration_folder + # Validate call ensures we are only handling Masquerade Packets. @validate_call def _handle_c2_payload(self, payload: C2Packet, session_id: Optional[str] = None) -> bool: @@ -197,7 +280,7 @@ class AbstractC2(Application, identifier="AbstractC2"): """Abstract Method: The C2 Server and the C2 Beacon handle the KEEP ALIVEs differently.""" # from_network_interface=from_network_interface - def receive(self, payload: C2Packet, session_id: Optional[str] = None, **kwargs) -> bool: + def receive(self, payload: any, session_id: Optional[str] = None, **kwargs) -> bool: """Receives masquerade packets. Used by both c2 server and c2 beacon. Defining the `Receive` method so that the application can receive packets via the session manager. @@ -210,6 +293,11 @@ class AbstractC2(Application, identifier="AbstractC2"): :return: Returns a bool if the traffic was received correctly (See _handle_c2_payload.) :rtype: bool """ + if not isinstance(payload, C2Packet): + self.sys_log.warning(f"{self.name}: Payload is not an C2Packet") + self.sys_log.debug(f"{self.name}: {payload}") + return False + return self._handle_c2_payload(payload, session_id) def _send_keep_alive(self, session_id: Optional[str]) -> bool: @@ -352,14 +440,13 @@ class AbstractC2(Application, identifier="AbstractC2"): :return bool: Returns false if connection was lost. Returns True if connection is active or re-established. :rtype bool: """ - super().apply_timestep(timestep=timestep) if ( self.operating_state is ApplicationOperatingState.RUNNING and self.health_state_actual is SoftwareHealthState.GOOD ): self.keep_alive_inactivity += 1 self._confirm_remote_connection(timestep) - return + return super().apply_timestep(timestep=timestep) def _check_connection(self) -> tuple[bool, RequestResponse]: """ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index e66bedc5..b3bf1902 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -135,6 +135,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): kwargs["name"] = "C2Beacon" super().__init__(**kwargs) + # Configure is practically setter method for the ``c2.config`` attribute that also ties into the request manager. @validate_call def configure( self, @@ -212,6 +213,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ---------------------|------------------------ RANSOMWARE_CONFIGURE | self._command_ransomware_config() RANSOMWARE_LAUNCH | self._command_ransomware_launch() + DATA_EXFILTRATION | self._command_data_exfiltration() TERMINAL | self._command_terminal() Please see each method individually for further information regarding @@ -249,6 +251,12 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.sys_log.info(f"{self.name}: Received a terminal C2 command.") return self._return_command_output(command_output=self._command_terminal(payload), session_id=session_id) + elif command == C2Command.DATA_EXFILTRATION: + self.sys_log.info(f"{self.name}: Received a Data Exfiltration C2 command.") + return self._return_command_output( + command_output=self._command_data_exfiltration(payload), session_id=session_id + ) + else: self.sys_log.error(f"{self.name}: Received an C2 command: {command} but was unable to resolve command.") return self._return_command_output( @@ -313,9 +321,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): """ C2 Command: Ransomware Launch. - Creates a request that executes the ransomware script. - This request is then sent to the terminal service in order to be executed. - + Uses the RansomwareScript's public method .attack() to carry out the + ransomware attack and uses the .from_bool method to return a RequestResponse :payload C2Packet: The incoming INPUT command. :type Masquerade Packet: C2Packet. @@ -329,6 +336,119 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) return RequestResponse.from_bool(self._host_ransomware_script.attack()) + def _command_data_exfiltration(self, payload: C2Packet) -> RequestResponse: + """ + C2 Command: Data Exfiltration. + + Uses the FTP Client & Server services to perform the data exfiltration. + + Similar to the terminal, the logic of this command is dependant on if a remote_ip + is present within the payload. + + If a payload does contain an IP address then the C2 Beacon will ssh into the target ip + and execute a command which causes the FTPClient service to send a + + target file will be moved from the target IP address onto the C2 Beacon's host + file system. + + However, if no IP is given, then the command will move the target file from this + machine onto the C2 server. (This logic is performed on the C2) + + :payload C2Packet: The incoming INPUT command. + :type Masquerade Packet: C2Packet. + :return: Returns the Request Response returned by the Terminal execute method. + :rtype: Request Response + """ + if self._host_ftp_server is None: + self.sys_log.warning(f"{self.name}: C2 Beacon unable to the FTP Server. Unable to resolve command.") + return RequestResponse( + status="failure", + data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, + ) + + remote_ip = payload.payload.get("target_ip_address") + target_folder = payload.payload.get("target_folder_name") + dest_folder = payload.payload.get("exfiltration_folder_name") + + # Using the same name for both the target/destination file for clarity. + file_name = payload.payload.get("target_file_name") + + # TODO: Split Remote file extraction and local file extraction into different methods. + # if remote_ip is None: + # self._host_ftp_client.start() + # self.sys_log.info(f"{self.name}: No Remote IP given. Returning target file from local file system.") + # return RequestResponse.from_bool(self._host_ftp_client.send_file( + # dest_ip_address=self.c2_remote_connection, + # src_folder_name=target_folder, + # src_file_name=file_name, + # dest_folder_name=dest_folder, + # dest_file_name=file_name, + # session_id=self.c2_session.uuid + # )) + + # Parsing remote login credentials + given_username = payload.payload.get("username") + given_password = payload.payload.get("password") + + # Setting up the terminal session and the ftp server + terminal_session = self.get_remote_terminal_session( + username=given_username, password=given_password, ip_address=remote_ip + ) + + # Initialising the exfiltration folder. + exfiltration_folder = self.get_exfiltration_folder(dest_folder) + + # Using the terminal to start the FTP Client on the remote machine. + # This can fail if the FTP Client is already enabled. + terminal_session.execute(command=["service", "start", "FTPClient"]) + host_network_interfaces = self.software_manager.node.network_interfaces + local_ip = host_network_interfaces.get(next(iter(host_network_interfaces))).ip_address + # Creating the FTP creation options. + remote_ftp_options = { + "dest_ip_address": str(local_ip), + "src_folder_name": target_folder, + "src_file_name": file_name, + "dest_folder_name": dest_folder, + "dest_file_name": file_name, + } + + # Using the terminal to send the target data back to the C2 Beacon. + remote_ftp_response: RequestResponse = RequestResponse.from_bool( + terminal_session.execute(command=["service", "FTPClient", "send", remote_ftp_options]) + ) + + # Validating that we successfully received the target data. + + if remote_ftp_response.status == "failure": + self.sys_log.warning( + f"{self.name}: Remote connection: {remote_ip} failed to transfer the target data via FTP." + ) + return remote_ftp_response + + if exfiltration_folder.get_file(file_name) is None: + self.sys_log.warning(f"{self.name}: Unable to locate exfiltrated file on local filesystem.") + return RequestResponse( + status="failure", data={"reason": "Unable to locate exfiltrated data on file system."} + ) + + if self._host_ftp_client is None: + self.sys_log.warning(f"{self.name}: C2 Beacon unable to the FTP Server. Unable to resolve command.") + return RequestResponse( + status="failure", + data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, + ) + + # Sending the transferred target data back to the C2 Server to successfully exfiltrate the data out the network. + return RequestResponse.from_bool( + self._host_ftp_client.send_file( + dest_ip_address=self.c2_remote_connection, + src_folder_name=dest_folder, # TODO: Clarify this - Dest folder has the same name on c2server/c2beacon. + src_file_name=file_name, + dest_folder_name=dest_folder, + dest_file_name=file_name, + ) + ) + def _command_terminal(self, payload: C2Packet) -> RequestResponse: """ C2 Command: Terminal. diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index f413a4b7..3441a86b 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -67,6 +67,19 @@ class C2Server(AbstractC2, identifier="C2Server"): """ return self.send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options={}) + def _data_exfiltration_action(request: RequestFormat, context: Dict) -> RequestResponse: + """Agent Action - Sends a Data Exfiltration C2Command to the C2 Beacon with the given parameters. + + :param request: Request with one element containing a dict of parameters for the configure method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the ransomware was launched. + :rtype: RequestResponse + """ + command_payload = request[-1] + return self.send_command(given_command=C2Command.DATA_EXFILTRATION, command_options=command_payload) + def _remote_terminal_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a TERMINAL C2Command to the C2 Beacon with the given parameters. @@ -92,6 +105,10 @@ class C2Server(AbstractC2, identifier="C2Server"): name="terminal_command", request_type=RequestType(func=_remote_terminal_action), ) + rm.add_request( + name="exfiltrate", + request_type=RequestType(func=_data_exfiltration_action), + ) return rm def __init__(self, **kwargs): @@ -177,6 +194,7 @@ class C2Server(AbstractC2, identifier="C2Server"): ---------------------|------------------------ RANSOMWARE_CONFIGURE | Configures an installed ransomware script based on the passed parameters. RANSOMWARE_LAUNCH | Launches the installed ransomware script. + DATA_EXFILTRATION | Utilises the FTP Service to exfiltrate data back to the C2 Server. TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. Currently, these commands leverage the pre-existing capability of other applications. @@ -210,6 +228,14 @@ class C2Server(AbstractC2, identifier="C2Server"): ): return connection_status + if not self._command_setup(given_command, command_options): + self.sys_log.warning( + f"{self.name}: Failed to perform necessary C2 Server setup for given command: {given_command}." + ) + return RequestResponse( + status="failure", data={"Reason": "Failed to perform necessary C2 Server setup for given command."} + ) + self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") command_packet = self._craft_packet( c2_payload=C2Payload.INPUT, c2_command=given_command, command_options=command_options @@ -232,6 +258,41 @@ class C2Server(AbstractC2, identifier="C2Server"): ) return self.current_command_output + def _command_setup(self, given_command: C2Command, command_options: dict) -> bool: + """ + Performs any necessary C2 Server setup needed to perform certain commands. + + The following table details any C2 Server prequisites for following commands. + + C2 Command | Command Service/Application Requirements + ---------------------|----------------------------------------- + RANSOMWARE_CONFIGURE | N/A + RANSOMWARE_LAUNCH | N/A + DATA_EXFILTRATION | FTP Server & File system folder + TERMINAL | N/A + + Currently, only the data exfiltration command require the C2 Server + to perform any necessary setup. Specifically, the Data Exfiltration command requires + the C2 Server to have an running FTP Server service as well as a folder for + storing any exfiltrated data. + + :param given_command: Any C2 Command. + :type given_command: C2Command. + :param command_options: The relevant command parameters. + :type command_options: Dict + :returns: True the setup was successful, false otherwise. + :rtype: bool + """ + if given_command == C2Command.DATA_EXFILTRATION: # Data exfiltration setup + if self._host_ftp_server is None: + self.sys_log.warning(f"{self.name}: Unable to setup the FTP Server for data exfiltration") + return False + if not self.get_exfiltration_folder(command_options.get("exfiltration_folder_name", "exfil")): + self.sys_log.warning(f"{self.name}: Unable to create a folder for storing exfiltration data.") + return False + + return True + def _confirm_remote_connection(self, timestep: int) -> bool: """Checks the suitability of the current C2 Beacon connection. diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 28a591dd..79216deb 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -1,8 +1,10 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from ipaddress import IPv4Address -from typing import Optional +from typing import Dict, Optional from primaite import getLogger +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode from primaite.simulator.network.transmission.network_layer import IPProtocol @@ -28,6 +30,55 @@ class FTPClient(FTPServiceABC): super().__init__(**kwargs) self.start() + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + def _send_data_request(request: RequestFormat, context: Dict) -> RequestResponse: + """ + Request for sending data via the ftp_client using the request options parameters. + + :param request: Request with one element containing a dict of parameters for the send method. + :type request: RequestFormat + :param context: additional context for resolving this action, currently unused + :type context: dict + :return: RequestResponse object with a success code reflecting whether the configuration could be applied. + :rtype: RequestResponse + """ + dest_ip = request[-1].get("dest_ip_address") + dest_ip = None if dest_ip is None else IPv4Address(dest_ip) + + # TODO: Confirm that the default values lead to a safe failure. + src_folder = request[-1].get("src_folder_name", None) + src_file_name = request[-1].get("src_file_name", None) + dest_folder = request[-1].get("dest_folder_name", None) + dest_file_name = request[-1].get("dest_file_name", None) + + if not self.file_system.access_file(folder_name=src_folder, file_name=src_file_name): + self.sys_log.debug( + f"{self.name}: Received a FTP Request to transfer file: {src_file_name} to Remote IP: {dest_ip}." + ) + return RequestResponse( + status="failure", data={"reason": "Unable to locate requested file on local file system."} + ) + + return RequestResponse.from_bool( + self.send_file( + dest_ip_address=dest_ip, + src_folder_name=src_folder, + src_file_name=src_file_name, + dest_folder_name=dest_folder, + dest_file_name=dest_file_name, + ) + ) + + rm.add_request("send", request_type=RequestType(func=_send_data_request)), + return rm + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. diff --git a/tests/conftest.py b/tests/conftest.py index b6375acd..1328bc9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -462,6 +462,7 @@ def game_and_agent(): {"type": "C2_SERVER_RANSOMWARE_LAUNCH"}, {"type": "C2_SERVER_RANSOMWARE_CONFIGURE"}, {"type": "C2_SERVER_TERMINAL_COMMAND"}, + {"type": "C2_SERVER_DATA_EXFILTRATE"}, ] action_space = ActionManager( diff --git a/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py b/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py index 990c6363..806ce063 100644 --- a/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py +++ b/tests/integration_tests/game_layer/actions/test_c2_suite_actions.py @@ -15,6 +15,8 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.service import ServiceOperatingState @@ -150,3 +152,52 @@ def test_c2_server_ransomware(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyA database_file = server_2.software_manager.file_system.get_file("database", "database.db") assert database_file.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_c2_server_data_exfiltration(game_and_agent_fixture: Tuple[PrimaiteGame, ProxyAgent]): + """Tests that a Red Agent can extract a database.db file via C2 Server actions.""" + game, agent = game_and_agent_fixture + + # Installing a C2 Beacon on server_1 + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + server_1.software_manager.install(C2Beacon) + + # Installing a database on Server_2 (creates a database.db file.) + server_2: Server = game.simulation.network.get_node_by_hostname("server_2") + server_2.software_manager.install(DatabaseService) + server_2.software_manager.software["DatabaseService"].start() + + # Configuring the C2 to connect to client 1 (C2 Server) + c2_beacon: C2Beacon = server_1.software_manager.software["C2Beacon"] + c2_beacon.configure(c2_server_ip_address=IPv4Address("10.0.1.2")) + c2_beacon.establish() + assert c2_beacon.c2_connection_active == True + + # Selecting a target file to steal: database.db + # Server 2 ip : 10.0.2.3 + database_file = server_2.software_manager.file_system.get_file(folder_name="database", file_name="database.db") + assert database_file is not None + + # C2 Action: Data exfiltrate. + + action = ( + "C2_SERVER_DATA_EXFILTRATE", + { + "node_id": 0, + "target_file_name": "database.db", + "target_folder_name": "database", + "exfiltration_folder_name": "spoils", + "target_ip_address": "10.0.2.3", + "account": { + "username": "admin", + "password": "admin", + }, + }, + ) + agent.store_action(action) + game.step() + + assert server_1.file_system.access_file(folder_name="spoils", file_name="database.db") + + client_1 = game.simulation.network.get_node_by_hostname("client_1") + assert client_1.file_system.access_file(folder_name="spoils", file_name="database.db") diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 904fb449..4d6432f3 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -22,6 +22,8 @@ from primaite.simulator.system.applications.red_applications.c2.c2_server import from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.web_server.web_server import WebServer from tests import TEST_ASSETS_ROOT @@ -497,3 +499,42 @@ def test_c2_suite_yaml(): assert c2_beacon.c2_connection_active is True assert c2_server.c2_connection_active is True + + +def test_c2_suite_file_extraction(basic_network): + """Test that C2 Beacon can successfully exfiltrate a target file.""" + network: Network = basic_network + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + # Asserting that the c2 beacon has established a c2 connection + assert c2_beacon.c2_connection_active is True + + # Asserting that the c2 server has established a c2 connection. + assert c2_server.c2_connection_active is True + assert c2_server.c2_remote_connection == IPv4Address("192.168.255.2") + + # Creating the target file on computer_c + computer_c: Computer = network.get_node_by_hostname("node_c") + computer_c.file_system.create_folder("important_files") + computer_c.file_system.create_file(file_name="secret.txt", folder_name="important_files") + assert computer_c.file_system.access_file(folder_name="important_files", file_name="secret.txt") + + # Installing an FTP Server on the same node as C2 Beacon via the terminal: + + # Attempting to exfiltrate secret.txt from computer c to the C2 Server + c2_server.send_command( + given_command=C2Command.DATA_EXFILTRATION, + command_options={ + "username": "admin", + "password": "admin", + "target_ip_address": "192.168.255.3", + "target_folder_name": "important_files", + "exfiltration_folder_name": "yoinked_files", + "target_file_name": "secret.txt", + }, + ) + + # Asserting that C2 Beacon has managed to get the file + assert c2_beacon._host_file_system.access_file(folder_name="yoinked_files", file_name="secret.txt") + + # Asserting that the C2 Beacon can relay it back to the C2 Server + assert c2_server._host_file_system.access_file(folder_name="yoinked_files", file_name="secret.txt") From e53ac846660a149fb398abde7c2c8b708edc7bcb Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Thu, 15 Aug 2024 11:36:55 +0100 Subject: [PATCH 158/206] #2689 Fixed small bugs, added pydantic class validation and divided the data_Exfil command on c2 beacon into two separate methods. --- src/primaite/game/agent/actions.py | 2 +- .../simulator/network/hardware/base.py | 9 +- .../red_applications/c2/__init__.py | 55 +++++++ .../red_applications/c2/abstract_c2.py | 5 +- .../red_applications/c2/c2_beacon.py | 139 +++++++++--------- .../red_applications/c2/c2_server.py | 2 +- 6 files changed, 138 insertions(+), 74 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index aa74399e..07bb039e 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1195,7 +1195,7 @@ class TerminalC2ServerAction(AbstractAction): class _Opts(BaseModel): """Schema for options that can be passed to this action.""" - commands: List[RequestFormat] + commands: Union[List[RequestFormat], RequestFormat] ip_address: Optional[str] username: Optional[str] password: Optional[str] diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 1441c93b..08164f22 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1275,13 +1275,18 @@ class UserSessionManager(Service): def pre_timestep(self, timestep: int) -> None: """Apply any pre-timestep logic that helps make sure we have the correct observations.""" self.current_timestep = timestep + inactive_sessions: list = [] if self.local_session: if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: - self._timeout_session(self.local_session) + inactive_sessions.append(self.local_session) + for session in self.remote_sessions: remote_session = self.remote_sessions[session] if remote_session.last_active_step + self.remote_session_timeout_steps <= timestep: - self._timeout_session(remote_session) + inactive_sessions.append(remote_session) + + for sessions in inactive_sessions: + self._timeout_session(sessions) def _timeout_session(self, session: UserSession) -> None: """ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py index be6c00e7..97923284 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py @@ -1 +1,56 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Optional, Union + +from pydantic import BaseModel, Field + +from primaite.interface.request import RequestFormat + + +class Command_Opts(BaseModel): + """A C2 Pydantic Schema acting as a base class for all C2 Commands.""" + + +class Ransomware_Opts(Command_Opts): + """A Pydantic Schema for the Ransomware Configuration command options.""" + + server_ip_address: str + """""" + + payload: Optional[str] = Field(default="ENCRYPT") + """""" + + +class Remote_Opts(Command_Opts): + """A base C2 Pydantic Schema for all C2 Commands that require a remote terminal connection.""" + + ip_address: Optional[str] = Field(default=None) + """""" + + username: str + """""" + + password: str + """""" + + +class Exfil_Opts(Remote_Opts): + """A Pydantic Schema for the C2 Data Exfiltration command options.""" + + target_ip_address: str + """""" + + target_folder_name: str + """""" + + target_file_name: str + """""" + + exfiltration_folder_name: Optional[str] = Field(default="exfiltration_folder") + """""" + + +class Terminal_Opts(Remote_Opts): + """A Pydantic Schema for the C2 Terminal command options.""" + + commands: Union[list[RequestFormat], RequestFormat] + """""" diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 6fa34fd6..d273ea23 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -147,7 +147,6 @@ class AbstractC2(Application, identifier="AbstractC2"): kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - # TODO: We may need to disable the ftp_server/client when using the opposite service. (To test) @property def _host_ftp_client(self) -> Optional[FTPClient]: """Return the FTPClient that is installed C2 Application's host. @@ -214,8 +213,10 @@ class AbstractC2(Application, identifier="AbstractC2"): self.sys_log.error(f"{self.__class__.__name__}: does not seem to have a file system!") return host_file_system - def get_exfiltration_folder(self, folder_name: str) -> Optional[Folder]: + def get_exfiltration_folder(self, folder_name: Optional[str] = "exfiltration_folder") -> Optional[Folder]: """Return a folder used for storing exfiltrated data. Otherwise returns None.""" + if self._host_file_system is None: + return exfiltration_folder: Union[Folder, None] = self._host_file_system.get_folder(folder_name) if exfiltration_folder is None: self.sys_log.info(f"{self.__class__.__name__}: Creating a exfiltration folder.") diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index b3bf1902..47e4f902 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -11,6 +11,7 @@ from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.red_applications.c2 import Exfil_Opts, Ransomware_Opts, Terminal_Opts from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.terminal.terminal import ( @@ -305,7 +306,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - given_config = payload.payload + command_opts = Ransomware_Opts.model_validate(payload.payload) if self._host_ransomware_script is None: return RequestResponse( status="failure", @@ -313,7 +314,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) return RequestResponse.from_bool( self._host_ransomware_script.configure( - server_ip_address=given_config["server_ip_address"], payload=given_config["payload"] + server_ip_address=command_opts.server_ip_address, payload=command_opts.payload ) ) @@ -342,10 +343,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): Uses the FTP Client & Server services to perform the data exfiltration. - Similar to the terminal, the logic of this command is dependant on if a remote_ip - is present within the payload. - - If a payload does contain an IP address then the C2 Beacon will ssh into the target ip + This command instructs the C2 Beacon to ssh into the target ip and execute a command which causes the FTPClient service to send a target file will be moved from the target IP address onto the C2 Beacon's host @@ -366,67 +364,74 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, ) - remote_ip = payload.payload.get("target_ip_address") - target_folder = payload.payload.get("target_folder_name") - dest_folder = payload.payload.get("exfiltration_folder_name") - - # Using the same name for both the target/destination file for clarity. - file_name = payload.payload.get("target_file_name") - - # TODO: Split Remote file extraction and local file extraction into different methods. - # if remote_ip is None: - # self._host_ftp_client.start() - # self.sys_log.info(f"{self.name}: No Remote IP given. Returning target file from local file system.") - # return RequestResponse.from_bool(self._host_ftp_client.send_file( - # dest_ip_address=self.c2_remote_connection, - # src_folder_name=target_folder, - # src_file_name=file_name, - # dest_folder_name=dest_folder, - # dest_file_name=file_name, - # session_id=self.c2_session.uuid - # )) - - # Parsing remote login credentials - given_username = payload.payload.get("username") - given_password = payload.payload.get("password") + command_opts = Exfil_Opts.model_validate(payload.payload) # Setting up the terminal session and the ftp server terminal_session = self.get_remote_terminal_session( - username=given_username, password=given_password, ip_address=remote_ip + username=command_opts.username, password=command_opts.password, ip_address=command_opts.target_ip_address ) - # Initialising the exfiltration folder. - exfiltration_folder = self.get_exfiltration_folder(dest_folder) - # Using the terminal to start the FTP Client on the remote machine. - # This can fail if the FTP Client is already enabled. terminal_session.execute(command=["service", "start", "FTPClient"]) + + # Need to supply to the FTP Client the C2 Beacon's host IP. host_network_interfaces = self.software_manager.node.network_interfaces local_ip = host_network_interfaces.get(next(iter(host_network_interfaces))).ip_address + # Creating the FTP creation options. - remote_ftp_options = { + exfil_opts = { "dest_ip_address": str(local_ip), - "src_folder_name": target_folder, - "src_file_name": file_name, - "dest_folder_name": dest_folder, - "dest_file_name": file_name, + "src_folder_name": command_opts.target_folder_name, + "src_file_name": command_opts.target_file_name, + "dest_folder_name": command_opts.exfiltration_folder_name, + "dest_file_name": command_opts.target_file_name, } + # Lambda method used to return a failure RequestResponse if we're unable to perform the exfiltration. + # If _check_connection returns false then connection_status will return reason (A 'failure' Request Response) + if attempt_exfiltration := (lambda return_bool, reason: reason if return_bool is False else None)( + *self._perform_target_exfiltration(exfil_opts, terminal_session) + ): + return attempt_exfiltration + + # Sending the transferred target data back to the C2 Server to successfully exfiltrate the data out the network. + + return RequestResponse.from_bool( + self._host_ftp_client.send_file( + dest_ip_address=self.c2_remote_connection, + src_folder_name=command_opts.exfiltration_folder_name, # The Exfil folder is inherited attribute. + src_file_name=command_opts.target_file_name, + dest_folder_name=command_opts.exfiltration_folder_name, + dest_file_name=command_opts.target_file_name, + ) + ) + + def _perform_target_exfiltration( + self, exfil_opts: dict, terminal_session: RemoteTerminalConnection + ) -> tuple[bool, RequestResponse]: + """Confirms that the target data is currently present within the C2 Beacon's hosts file system.""" # Using the terminal to send the target data back to the C2 Beacon. - remote_ftp_response: RequestResponse = RequestResponse.from_bool( - terminal_session.execute(command=["service", "FTPClient", "send", remote_ftp_options]) + exfil_response: RequestResponse = RequestResponse.from_bool( + terminal_session.execute(command=["service", "FTPClient", "send", exfil_opts]) ) # Validating that we successfully received the target data. - if remote_ftp_response.status == "failure": - self.sys_log.warning( - f"{self.name}: Remote connection: {remote_ip} failed to transfer the target data via FTP." - ) - return remote_ftp_response + if exfil_response.status == "failure": + self.sys_log.warning(f"{self.name}: Remote connection failure. failed to transfer the target data via FTP.") + return [False, exfil_response] - if exfiltration_folder.get_file(file_name) is None: - self.sys_log.warning(f"{self.name}: Unable to locate exfiltrated file on local filesystem.") + # Target file: + target_file: str = exfil_opts.get("src_file_name") + + # Creating the exfiltration folder . + exfiltration_folder = self.get_exfiltration_folder(exfil_opts.get("src_folder_name")) + + if exfiltration_folder.get_file(target_file) is None: + self.sys_log.warning( + f"{self.name}: Unable to locate exfiltrated file on local filesystem." + f"Perhaps the file transfer failed?" + ) return RequestResponse( status="failure", data={"reason": "Unable to locate exfiltrated data on file system."} ) @@ -438,16 +443,13 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, ) - # Sending the transferred target data back to the C2 Server to successfully exfiltrate the data out the network. - return RequestResponse.from_bool( - self._host_ftp_client.send_file( - dest_ip_address=self.c2_remote_connection, - src_folder_name=dest_folder, # TODO: Clarify this - Dest folder has the same name on c2server/c2beacon. - src_file_name=file_name, - dest_folder_name=dest_folder, - dest_file_name=file_name, - ) - ) + return [ + True, + RequestResponse( + status="success", + data={"Reason": "Located the target file on local file system. Data exfiltration successful."}, + ), + ] def _command_terminal(self, payload: C2Packet) -> RequestResponse: """ @@ -461,8 +463,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - terminal_output: Dict[int, RequestResponse] = {} - given_commands: list[RequestFormat] + command_opts = Terminal_Opts.model_validate(payload.payload) if self._host_terminal is None: return RequestResponse( @@ -470,17 +471,14 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Host does not seem to have terminal installed. Unable to resolve command."}, ) - given_commands = payload.payload.get("commands") - given_username = payload.payload.get("username") - given_password = payload.payload.get("password") - remote_ip = payload.payload.get("ip_address") + terminal_output: Dict[int, RequestResponse] = {} # Creating a remote terminal session if given an IP Address, otherwise using a local terminal session. - if remote_ip is None: - terminal_session = self.get_terminal_session(username=given_username, password=given_password) + if command_opts.ip_address is None: + terminal_session = self.get_terminal_session(username=command_opts.username, password=command_opts.password) else: terminal_session = self.get_remote_terminal_session( - username=given_username, password=given_password, ip_address=remote_ip + username=command_opts.username, password=command_opts.password, ip_address=command_opts.ip_address ) if terminal_session is None: @@ -488,7 +486,12 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): status="failure", data={"reason": "Terminal Login failed. Cannot create a terminal session."} ) - for index, given_command in enumerate(given_commands): + # Converts a singular terminal command: [RequestFormat] into a list with one element [[RequestFormat]] + command_opts.commands = ( + [command_opts.commands] if not isinstance(command_opts.commands, list) else command_opts.commands + ) + + for index, given_command in enumerate(command_opts.commands): # A try catch exception ladder was used but was considered not the best approach # as it can end up obscuring visibility of actual bugs (Not the expected ones) and was a temporary solution. # TODO: Refactor + add further validation to ensure that a request is correct. (maybe a pydantic method?) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 3441a86b..f4cc7aa6 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -287,7 +287,7 @@ class C2Server(AbstractC2, identifier="C2Server"): if self._host_ftp_server is None: self.sys_log.warning(f"{self.name}: Unable to setup the FTP Server for data exfiltration") return False - if not self.get_exfiltration_folder(command_options.get("exfiltration_folder_name", "exfil")): + if self.get_exfiltration_folder(command_options.get("exfiltration_folder_name")) is None: self.sys_log.warning(f"{self.name}: Unable to create a folder for storing exfiltration data.") return False From c50b005c375faee7b425d5aae7a7429904e8a066 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Thu, 15 Aug 2024 13:10:47 +0100 Subject: [PATCH 159/206] #2689 Improved terminal session handling. --- .../Command-&-Control-E2E-Demonstration.ipynb | 42 ++++++- .../red_applications/c2/__init__.py | 24 ++-- .../red_applications/c2/c2_beacon.py | 118 ++++++++++-------- 3 files changed, 112 insertions(+), 72 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 052136f8..6fa91c04 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -1557,7 +1557,7 @@ "source": [ "The code cell below goes through 10 timesteps and displays the differences between the default and the current timestep.\n", "\n", - "You will notice that the only observation space differences after 10 timesteps. This is due to the C2 Suite confirming their connection through sending ``Keep Alive`` traffic across the network." + "You will notice that the only two timesteps displayed observation space differences. This is due to the C2 Suite confirming their connection through sending ``Keep Alive`` traffic across the network every 5 timesteps." ] }, { @@ -1575,7 +1575,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Next, the code cells below configuring the C2 Beacon's Keep Alive Frequency to confirm connection on every timestep." + "Next, the code cells below configure the C2 Beacon to confirm connection on every timestep via changing the ``keep_alive_frequency`` to ``1``." ] }, { @@ -1593,7 +1593,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The code cells below demonstrate that the observation impacts of the Keep Alive can be seen on every timestep. " + "Demonstrating that the observation impacts of the Keep Alive can be seen on every timestep:" ] }, { @@ -1608,6 +1608,29 @@ " display_obs_diffs(default_obs, keep_alive_obs, blue_config_env.game.step_counter)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Additionally, the keep_alive_frequency can also be used to configure the C2 Beacon to confirm connection less frequently. \n", + "\n", + "The code cells below demonstrate the impacts of changing the frequency rate to ``7`` timesteps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_beacon.configure(c2_server_ip_address=\"192.168.10.21\", keep_alive_frequency=7)\n", + "\n", + "# Comparing the OBS of the default frequency to a timestep frequency of 7\n", + "for i in range(7):\n", + " keep_alive_obs, _, _, _, _ = blue_config_env.step(0)\n", + " display_obs_diffs(default_obs, keep_alive_obs, blue_config_env.game.step_counter)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -1618,8 +1641,17 @@ "\n", "In the real world, Adversaries take defensive steps to reduce the chance that an installed C2 Beacon is discovered. One of the most commonly used methods is to masquerade c2 traffic as other commonly used networking protocols.\n", "\n", - "In primAITE, red agents can begin to simulate stealth behaviour by configuring C2 traffic to use different protocols mid episode or between episodes. \n", - "Currently, red agent actions are limited to using ports: ``DNS``, ``FTP`` and ``HTTP`` and protocols: ``UDP`` and ``TCP``.\n", + "In primAITE, red agents can begin to simulate stealth behaviour by configuring C2 traffic to use different protocols mid episode or between episodes.\n", + "\n", + "Currently, red agent actions support the following port and protocol options:\n", + "\n", + "| Supported Ports | Supported Protocols |\n", + "|------------------|---------------------|\n", + "|``DNS`` | ``UDP`` |\n", + "|``FTP`` | ``TCP`` |\n", + "|``HTTP`` | |\n", + "\n", + "\n", "\n", "The next set of code cells will demonstrate the impact this option from a blue agent perspective." ] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py index 97923284..919b1bf5 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py @@ -14,36 +14,36 @@ class Ransomware_Opts(Command_Opts): """A Pydantic Schema for the Ransomware Configuration command options.""" server_ip_address: str - """""" + """The IP Address of the target database that the RansomwareScript will attack.""" payload: Optional[str] = Field(default="ENCRYPT") - """""" + """The malicious payload to be used to attack the target database.""" class Remote_Opts(Command_Opts): - """A base C2 Pydantic Schema for all C2 Commands that require a remote terminal connection.""" + """A base C2 Pydantic Schema for all C2 Commands that require a terminal connection.""" ip_address: Optional[str] = Field(default=None) - """""" + """The IP address of a remote host. If this field defaults to None then a local session is used.""" username: str - """""" + """A Username of a valid user account. Used to login into both remote and local hosts.""" password: str - """""" + """A Password of a valid user account. Used to login into both remote and local hosts.""" class Exfil_Opts(Remote_Opts): """A Pydantic Schema for the C2 Data Exfiltration command options.""" target_ip_address: str - """""" - - target_folder_name: str - """""" + """The IP address of the target host that will be the target of the exfiltration.""" target_file_name: str - """""" + """The name of the file that is attempting to be exfiltrated.""" + + target_folder_name: str + """The name of the remote folder which contains the target file.""" exfiltration_folder_name: Optional[str] = Field(default="exfiltration_folder") """""" @@ -53,4 +53,4 @@ class Terminal_Opts(Remote_Opts): """A Pydantic Schema for the C2 Terminal command options.""" commands: Union[list[RequestFormat], RequestFormat] - """""" + """A list or individual Terminal Command. Please refer to the RequestResponse system for further info.""" diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 47e4f902..71703500 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -14,11 +14,7 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.red_applications.c2 import Exfil_Opts, Ransomware_Opts, Terminal_Opts from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript -from primaite.simulator.system.services.terminal.terminal import ( - LocalTerminalConnection, - RemoteTerminalConnection, - Terminal, -) +from primaite.simulator.system.services.terminal.terminal import Terminal, TerminalClientConnection class C2Beacon(AbstractC2, identifier="C2Beacon"): @@ -43,11 +39,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): keep_alive_attempted: bool = False """Indicates if a keep alive has been attempted to be sent this timestep. Used to prevent packet storms.""" - local_terminal_session: LocalTerminalConnection = None - "The currently in use local terminal session." - - remote_terminal_session: RemoteTerminalConnection = None - "The currently in use remote terminal session" + terminal_session: TerminalClientConnection = None + "The currently in use terminal session." @property def _host_terminal(self) -> Optional[Terminal]: @@ -65,25 +58,20 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): self.sys_log.warning(f"{self.__class__.__name__} cannot find installed ransomware on its host.") return ransomware_script - def get_terminal_session(self, username: str, password: str) -> Optional[LocalTerminalConnection]: - """Return an instance of a Local Terminal Connection upon successful login. Otherwise returns None.""" - if self.local_terminal_session is None: - host_terminal: Terminal = self._host_terminal - self.local_terminal_session = host_terminal.login(username=username, password=password) + def _set_terminal_session(self, username: str, password: str, ip_address: Optional[IPv4Address] = None) -> bool: + """ + Attempts to create and a terminal session using the parameters given. - return self.local_terminal_session + If an IP Address is passed then this method will attempt to create a remote terminal + session. Otherwise a local terminal session will be created. - def get_remote_terminal_session( - self, username: str, password: str, ip_address: IPv4Address - ) -> Optional[RemoteTerminalConnection]: - """Return an instance of a Local Terminal Connection upon successful login. Otherwise returns None.""" - if self.remote_terminal_session is None: - host_terminal: Terminal = self._host_terminal - self.remote_terminal_session = host_terminal.login( - username=username, password=password, ip_address=ip_address - ) - - return self.remote_terminal_session + :return: Returns true if a terminal session was successfully set. False otherwise. + :rtype: Bool + """ + self.terminal_session is None + host_terminal: Terminal = self._host_terminal + self.terminal_session = host_terminal.login(username=username, password=password, ip_address=ip_address) + return self.terminal_session is not None def _init_request_manager(self) -> RequestManager: """ @@ -354,7 +342,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :payload C2Packet: The incoming INPUT command. :type Masquerade Packet: C2Packet. - :return: Returns the Request Response returned by the Terminal execute method. + :return: Returns a tuple containing Request Response returned by the Terminal execute method. :rtype: Request Response """ if self._host_ftp_server is None: @@ -367,12 +355,16 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): command_opts = Exfil_Opts.model_validate(payload.payload) # Setting up the terminal session and the ftp server - terminal_session = self.get_remote_terminal_session( + if not self._set_terminal_session( username=command_opts.username, password=command_opts.password, ip_address=command_opts.target_ip_address - ) + ): + return RequestResponse( + status="failure", + data={"Reason": "Cannot create a terminal session. Are the credentials correct?"}, + ) # Using the terminal to start the FTP Client on the remote machine. - terminal_session.execute(command=["service", "start", "FTPClient"]) + self.terminal_session.execute(command=["service", "start", "FTPClient"]) # Need to supply to the FTP Client the C2 Beacon's host IP. host_network_interfaces = self.software_manager.node.network_interfaces @@ -390,7 +382,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # Lambda method used to return a failure RequestResponse if we're unable to perform the exfiltration. # If _check_connection returns false then connection_status will return reason (A 'failure' Request Response) if attempt_exfiltration := (lambda return_bool, reason: reason if return_bool is False else None)( - *self._perform_target_exfiltration(exfil_opts, terminal_session) + *self._perform_exfiltration(exfil_opts) ): return attempt_exfiltration @@ -406,13 +398,29 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) ) - def _perform_target_exfiltration( - self, exfil_opts: dict, terminal_session: RemoteTerminalConnection - ) -> tuple[bool, RequestResponse]: - """Confirms that the target data is currently present within the C2 Beacon's hosts file system.""" + def _perform_exfiltration(self, exfil_opts: Exfil_Opts) -> tuple[bool, RequestResponse]: + """ + Attempts to exfiltrate a target file from a target using the parameters given. + + Uses the current terminal_session to send a command to the + remote host's FTP Client passing the exfil_opts as command options. + + This will instruct the FTP client to send the target file to the + dest_ip_address's destination folder. + + This method assumes that the following: + 1. The self.terminal_session is the remote target. + 2. The target has a functioning FTP Client Service. + + + :exfil_opts: A Pydantic model containing the require configuration options + :type exfil_opts: Exfil_Opts + :return: Returns a tuple containing a success boolean and a Request Response.. + :rtype: tuple[bool, RequestResponse + """ # Using the terminal to send the target data back to the C2 Beacon. exfil_response: RequestResponse = RequestResponse.from_bool( - terminal_session.execute(command=["service", "FTPClient", "send", exfil_opts]) + self.terminal_session.execute(command=["service", "FTPClient", "send", exfil_opts]) ) # Validating that we successfully received the target data. @@ -432,16 +440,20 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): f"{self.name}: Unable to locate exfiltrated file on local filesystem." f"Perhaps the file transfer failed?" ) - return RequestResponse( - status="failure", data={"reason": "Unable to locate exfiltrated data on file system."} - ) + return [ + False, + RequestResponse(status="failure", data={"reason": "Unable to locate exfiltrated data on file system."}), + ] if self._host_ftp_client is None: self.sys_log.warning(f"{self.name}: C2 Beacon unable to the FTP Server. Unable to resolve command.") - return RequestResponse( - status="failure", - data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, - ) + return [ + False, + RequestResponse( + status="failure", + data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, + ), + ] return [ True, @@ -474,16 +486,12 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): terminal_output: Dict[int, RequestResponse] = {} # Creating a remote terminal session if given an IP Address, otherwise using a local terminal session. - if command_opts.ip_address is None: - terminal_session = self.get_terminal_session(username=command_opts.username, password=command_opts.password) - else: - terminal_session = self.get_remote_terminal_session( - username=command_opts.username, password=command_opts.password, ip_address=command_opts.ip_address - ) - - if terminal_session is None: + if not self._set_terminal_session( + username=command_opts.username, password=command_opts.password, ip_address=command_opts.ip_address + ): return RequestResponse( - status="failure", data={"reason": "Terminal Login failed. Cannot create a terminal session."} + status="failure", + data={"Reason": "Cannot create a terminal session. Are the credentials correct?"}, ) # Converts a singular terminal command: [RequestFormat] into a list with one element [[RequestFormat]] @@ -495,10 +503,10 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # A try catch exception ladder was used but was considered not the best approach # as it can end up obscuring visibility of actual bugs (Not the expected ones) and was a temporary solution. # TODO: Refactor + add further validation to ensure that a request is correct. (maybe a pydantic method?) - terminal_output[index] = terminal_session.execute(given_command) + terminal_output[index] = self.terminal_session.execute(given_command) # Reset our remote terminal session. - self.remote_terminal_session is None + self.terminal_session is None return RequestResponse(status="success", data=terminal_output) def _handle_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: From f32b3a931f62bdaa39364d3afc48566af31f24cd Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Thu, 15 Aug 2024 14:41:35 +0100 Subject: [PATCH 160/206] #2689 Addressed failing tests + updated c2_suite.rst to include the Data exfil command. --- .../system/applications/c2_suite.rst | 42 +++++++++++++++---- .../Command-&-Control-E2E-Demonstration.ipynb | 10 +---- .../red_applications/c2/c2_beacon.py | 19 ++++----- .../red_applications/c2/c2_server.py | 10 ++--- .../test_c2_suite_integration.py | 4 +- 5 files changed, 52 insertions(+), 33 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index c3044d1d..974bb6ce 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -34,6 +34,8 @@ Currently, the C2 Server offers three commands: +---------------------+---------------------------------------------------------------------------+ |RANSOMWARE_LAUNCH | Launches the installed ransomware script. | +---------------------+---------------------------------------------------------------------------+ +|DATA_EXFILTRATION | Copies a target file from a remote node to the C2 Beacon & Server via FTP | ++---------------------+---------------------------------------------------------------------------+ |TERMINAL | Executes a command via the terminal installed on the C2 Beacons Host. | +---------------------+---------------------------------------------------------------------------+ @@ -111,21 +113,28 @@ Python from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command from primaite.simulator.network.hardware.nodes.host.computer import Computer - + from primaite.simulator.system.services.database.database_service import DatabaseService + from primaite.simulator.system.applications.database_client import DatabaseClient # Network Setup + switch = Switch(hostname="switch", start_up_duration=0, num_ports=4) + switch.power_on() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) node_a.power_on() node_a.software_manager.install(software_class=C2Server) - node_a.software_manager.get_open_ports() - + network.connect(node_a.network_interface[1], switch.network_interface[1]) node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) node_b.power_on() node_b.software_manager.install(software_class=C2Beacon) - node_b.software_manager.install(software_class=RansomwareScript) - network.connect(node_a.network_interface[1], node_b.network_interface[1]) + node_b.software_manager.install(software_class=DatabaseClient) + network.connect(node_b.network_interface[1], switch.network_interface[2]) + node_c = Computer(hostname="node_c", ip_address="192.168.0.12", subnet_mask="255.255.255.0", start_up_duration=0) + node_c.power_on() + node_c.software_manager.install(software_class=DatabaseServer) + network.connect(node_c.network_interface[1], switch.network_interface[3]) # C2 Application objects @@ -159,7 +168,7 @@ Python c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) - # Example commands: Installing and configuring Ransomware: + # Example command: Installing and configuring Ransomware: ransomware_installation_command = { "commands": [ ["software_manager","application","install","RansomwareScript"], @@ -170,12 +179,31 @@ Python } c2_server.send_command(given_command=C2Command.TERMINAL, command_options=ransomware_config) - ransomware_config = {"server_ip_address": "192.168.0.10"} + ransomware_config = {"server_ip_address": "192.168.0.12"} c2_server.send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) c2_beacon_host.software_manager.show() + # Example command: File Exfiltration + + data_exfil_options = { + "username": "admin", + "password": "admin", + "ip_address": None, + "target_ip_address": "192.168.0.12", + "target_file_name": "database.db" + "target_folder_name": "database" + "exfiltration_folder_name": + } + + c2_server.send_command(given_command=C2Command.DATA_EXFILTRATION, command_options=data_exfil_options) + + # Example command: Launching Ransomware + + c2_server.send_command(given_command=C2Command.RANSOMWARE_LAUNCH, command_options={}) + + Via Configuration """"""""""""""""" diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 6fa91c04..0e8c8931 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -18,20 +18,14 @@ "outputs": [], "source": [ "# Imports\n", + "import yaml\n", "from primaite.config.load import data_manipulation_config_path\n", "from primaite.session.environment import PrimaiteGymEnv\n", "from primaite.simulator.network.hardware.nodes.network.router import Router\n", - "from primaite.game.agent.interface import AgentHistoryItem\n", - "import yaml\n", - "from pprint import pprint\n", - "from primaite.simulator.network.container import Network\n", - "from primaite.game.game import PrimaiteGame\n", - "from primaite.simulator.system.applications.application import ApplicationOperatingState\n", "from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon\n", "from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server\n", - "from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import C2Command, C2Payload\n", + "from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import C2Command\n", "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", - "from primaite.simulator.system.software import SoftwareHealthState\n", "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", "from primaite.simulator.network.hardware.nodes.host.server import Server" ] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 71703500..e2393ff1 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -379,12 +379,11 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): "dest_file_name": command_opts.target_file_name, } - # Lambda method used to return a failure RequestResponse if we're unable to perform the exfiltration. - # If _check_connection returns false then connection_status will return reason (A 'failure' Request Response) - if attempt_exfiltration := (lambda return_bool, reason: reason if return_bool is False else None)( - *self._perform_exfiltration(exfil_opts) - ): - return attempt_exfiltration + attempt_exfiltration: tuple[bool, RequestResponse] = self._perform_exfiltration(exfil_opts) + + if attempt_exfiltration[0] is False: + self.sys_log.error(f"{self.name}: File Exfiltration Attempt Failed: {attempt_exfiltration[1].data}") + return attempt_exfiltration[1] # Sending the transferred target data back to the C2 Server to successfully exfiltrate the data out the network. @@ -418,6 +417,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns a tuple containing a success boolean and a Request Response.. :rtype: tuple[bool, RequestResponse """ + # Creating the exfiltration folder . + exfiltration_folder = self.get_exfiltration_folder(exfil_opts.get("dest_folder_name")) + # Using the terminal to send the target data back to the C2 Beacon. exfil_response: RequestResponse = RequestResponse.from_bool( self.terminal_session.execute(command=["service", "FTPClient", "send", exfil_opts]) @@ -432,12 +434,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): # Target file: target_file: str = exfil_opts.get("src_file_name") - # Creating the exfiltration folder . - exfiltration_folder = self.get_exfiltration_folder(exfil_opts.get("src_folder_name")) - if exfiltration_folder.get_file(target_file) is None: self.sys_log.warning( - f"{self.name}: Unable to locate exfiltrated file on local filesystem." + f"{self.name}: Unable to locate exfiltrated file on local filesystem. " f"Perhaps the file transfer failed?" ) return [ diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index f4cc7aa6..9816bb15 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -221,12 +221,10 @@ class C2Server(AbstractC2, identifier="C2Server"): status="failure", data={"Reason": "Received unexpected C2Command. Unable to send command."} ) - # Lambda method used to return a failure RequestResponse if we're unable to confirm a connection. - # If _check_connection returns false then connection_status will return reason (A 'failure' Request Response) - if connection_status := (lambda return_bool, reason: reason if return_bool is False else None)( - *self._check_connection() - ): - return connection_status + connection_status: tuple[bool, RequestResponse] = self._check_connection() + + if connection_status[0] is False: + return connection_status[1] if not self._command_setup(given_command, command_options): self.sys_log.warning( diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 4d6432f3..910f4760 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -270,7 +270,7 @@ def test_c2_suite_terminal_command_file_creation(basic_network): c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True - assert c2_beacon.local_terminal_session is not None + assert c2_beacon.terminal_session is not None # Testing that we can create the same test file/folders via on node 3 via a remote terminal. @@ -280,7 +280,7 @@ def test_c2_suite_terminal_command_file_creation(basic_network): c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True - assert c2_beacon.remote_terminal_session is not None + assert c2_beacon.terminal_session is not None def test_c2_suite_acl_bypass(basic_network): From 7d086ec35e2a5cd3f0a3a9f9b4e25005a598942a Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Thu, 15 Aug 2024 17:08:10 +0100 Subject: [PATCH 161/206] #2689 Implemented pydantic model validation on C2 Server setup method + updated E2E notebook with data exfiltration. --- .../Command-&-Control-E2E-Demonstration.ipynb | 110 +++++++++++++++--- .../red_applications/c2/__init__.py | 14 ++- .../red_applications/c2/abstract_c2.py | 4 +- .../red_applications/c2/c2_beacon.py | 15 ++- .../red_applications/c2/c2_server.py | 51 ++++++-- .../_red_applications/test_c2_suite.py | 27 +++++ 6 files changed, 182 insertions(+), 39 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 0e8c8931..f8c550e0 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -500,9 +500,9 @@ "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_DATA_EXFILTRATE\n", "\n", - "Finally, currently the last action available is the ``C2_SERVER_DATA_EXFILTRATE`` which can be used to exfiltrate a target_file on a remote node to the C2 Beacon & Server's host file system via the ``FTP`` services.\n", + "The second to last action available is the ``C2_SERVER_DATA_EXFILTRATE`` which can be used to exfiltrate a target file on a remote node to the C2 Beacon & Server's host file system via the ``FTP`` services.\n", "\n", - "This action is indexed as action ``9``. # TODO: Update.\n", + "This action is indexed as action ``6``..\n", "\n", "The below yaml snippet shows all the relevant agent options for this action\n", "\n", @@ -651,9 +651,13 @@ " applications:\n", " - application_name: C2Beacon\n", " - application_name: RansomwareScript\n", + " folders:\n", + " - folder_name: exfiltration_folder\n", + " files:\n", + " - file_name: database.db\n", " - hostname: database_server\n", " folders:\n", - " - folder_name: database\n", + " - folder_name: exfiltration_folder\n", " files:\n", " - file_name: database.db\n", " - hostname: client_1\n", @@ -663,7 +667,7 @@ " num_folders: 1\n", " num_files: 1\n", " num_nics: 1\n", - " include_num_access: false\n", + " include_num_access: true\n", " include_nmne: false\n", " monitored_traffic:\n", " icmp:\n", @@ -832,7 +836,14 @@ "source": [ "### **Command and Control** | Blue Agent Relevance | Observation Space\n", "\n", - "This section demonstrates the OBS impact if the C2 suite is successfully installed and then used to install, configure and launch the ransomwarescript." + "This section demonstrates the impacts that each of that the C2 Beacon and the C2 Server's commands cause on the observation space (OBS)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### **Command and Control** | OBS Impact | C2 Beacon | Installation & Configuration" ] }, { @@ -888,6 +899,19 @@ "display_obs_diffs(default_obs, c2_configuration_obs, blue_env.game.step_counter)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### **Command and Control** | OBS Impact | C2 Server | Terminal Command\n", + "\n", + "Using the C2 Server's ``TERMINAL`` command it is possible to install a ``RansomwareScript`` application onto the C2 Beacon's host.\n", + "\n", + "The below code cells perform this as well as capturing the OBS impacts.\n", + "\n", + "It's important to note that the ``TERMINAL`` command is not limited to just installing software." + ] + }, { "cell_type": "code", "execution_count": null, @@ -922,11 +946,22 @@ "c2_ransomware_obs, _, _, _, _ = blue_env.step(0)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(default_obs, c2_ransomware_obs, env.game.step_counter)" + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The code cell below demonstrates the differences between the default observation space and the configuration of the C2 Server and the Ransomware installation." + "#### **Command and Control** | OBS Impact | C2 Server | Data Exfiltration\n", + "\n", + "Before encrypting the database.db file, the ``DATA_EXFILTRATION`` command can be used to copy the database.db file onto both the C2 Server and the C2 Beacon's file systems:" ] }, { @@ -935,7 +970,61 @@ "metadata": {}, "outputs": [], "source": [ - "display_obs_diffs(default_obs, c2_ransomware_obs, env.game.step_counter)" + "exfil_options={\n", + " \"username\": \"admin\",\n", + " \"password\": \"admin\",\n", + " \"target_ip_address\": \"192.168.1.14\",\n", + " \"target_folder_name\": \"database\",\n", + " \"exfiltration_folder_name\": \"exfiltration_folder\",\n", + " \"target_file_name\": \"database.db\",\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_server.send_command(given_command=C2Command.DATA_EXFILTRATION, command_options=exfil_options)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "c2_exfil_obs, _, _, _, _ = blue_env.step(0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "display_obs_diffs(c2_ransomware_obs, c2_exfil_obs, env.game.step_counter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### **Command and Control** | OBS Impact | C2 Server | Ransomware Commands\n", + "\n", + "The code cell below demonstrates the differences between the ransomware script installation obs and the impact of RansomwareScript upon the database." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Configuring the RansomwareScript\n", + "ransomware_config = {\"server_ip_address\": \"192.168.1.14\", \"payload\": \"ENCRYPT\"}\n", + "c2_server.send_command(C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config)" ] }, { @@ -959,13 +1048,6 @@ "c2_final_obs, _, _, _, _ = blue_env.step(0)" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The code cell below demonstrates the differences between the default observation space and the configuration of the C2 Server, the ransomware script installation as well as the impact of RansomwareScript upon the database." - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py index 919b1bf5..23dfeb31 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py @@ -1,7 +1,7 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from typing import Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, ValidationInfo from primaite.interface.request import RequestFormat @@ -9,6 +9,14 @@ from primaite.interface.request import RequestFormat class Command_Opts(BaseModel): """A C2 Pydantic Schema acting as a base class for all C2 Commands.""" + @field_validator("payload", "exfiltration_folder_name", "ip_address", mode="before", check_fields=False) + @classmethod + def not_none(cls, v: str, info: ValidationInfo) -> int: + """If None is passed, use the default value instead.""" + if v is None: + return cls.model_fields[info.field_name].default + return v + class Ransomware_Opts(Command_Opts): """A Pydantic Schema for the Ransomware Configuration command options.""" @@ -16,7 +24,7 @@ class Ransomware_Opts(Command_Opts): server_ip_address: str """The IP Address of the target database that the RansomwareScript will attack.""" - payload: Optional[str] = Field(default="ENCRYPT") + payload: str = Field(default="ENCRYPT") """The malicious payload to be used to attack the target database.""" @@ -45,7 +53,7 @@ class Exfil_Opts(Remote_Opts): target_folder_name: str """The name of the remote folder which contains the target file.""" - exfiltration_folder_name: Optional[str] = Field(default="exfiltration_folder") + exfiltration_folder_name: str = Field(default="exfiltration_folder") """""" diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index d273ea23..b21a996d 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -185,9 +185,9 @@ class AbstractC2(Application, identifier="AbstractC2"): If a FTPServer is not installed then this method will attempt to install one. :return: An FTPServer object is successful, else None - :rtype: union[FTPServer, None] + :rtype: Optional[FTPServer] """ - ftp_server: Union[FTPServer, None] = self.software_manager.software.get("FTPServer") + ftp_server: Optional[FTPServer] = self.software_manager.software.get("FTPServer") if ftp_server is None: self.sys_log.warning(f"{self.__class__.__name__}:No FTPServer installed. Attempting to install FTPServer.") self.software_manager.install(FTPServer) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index e2393ff1..9c63bb53 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -359,8 +359,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): username=command_opts.username, password=command_opts.password, ip_address=command_opts.target_ip_address ): return RequestResponse( - status="failure", - data={"Reason": "Cannot create a terminal session. Are the credentials correct?"}, + status="failure", data={"Reason": "Cannot create a terminal session. Are the credentials correct?"} ) # Using the terminal to start the FTP Client on the remote machine. @@ -371,7 +370,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): local_ip = host_network_interfaces.get(next(iter(host_network_interfaces))).ip_address # Creating the FTP creation options. - exfil_opts = { + ftp_opts = { "dest_ip_address": str(local_ip), "src_folder_name": command_opts.target_folder_name, "src_file_name": command_opts.target_file_name, @@ -379,7 +378,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): "dest_file_name": command_opts.target_file_name, } - attempt_exfiltration: tuple[bool, RequestResponse] = self._perform_exfiltration(exfil_opts) + attempt_exfiltration: tuple[bool, RequestResponse] = self._perform_exfiltration(ftp_opts) if attempt_exfiltration[0] is False: self.sys_log.error(f"{self.name}: File Exfiltration Attempt Failed: {attempt_exfiltration[1].data}") @@ -397,7 +396,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) ) - def _perform_exfiltration(self, exfil_opts: Exfil_Opts) -> tuple[bool, RequestResponse]: + def _perform_exfiltration(self, ftp_opts: dict) -> tuple[bool, RequestResponse]: """ Attempts to exfiltrate a target file from a target using the parameters given. @@ -418,11 +417,11 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :rtype: tuple[bool, RequestResponse """ # Creating the exfiltration folder . - exfiltration_folder = self.get_exfiltration_folder(exfil_opts.get("dest_folder_name")) + exfiltration_folder = self.get_exfiltration_folder(ftp_opts.get("dest_folder_name")) # Using the terminal to send the target data back to the C2 Beacon. exfil_response: RequestResponse = RequestResponse.from_bool( - self.terminal_session.execute(command=["service", "FTPClient", "send", exfil_opts]) + self.terminal_session.execute(command=["service", "FTPClient", "send", ftp_opts]) ) # Validating that we successfully received the target data. @@ -432,7 +431,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): return [False, exfil_response] # Target file: - target_file: str = exfil_opts.get("src_file_name") + target_file: str = ftp_opts.get("src_file_name") if exfiltration_folder.get_file(target_file) is None: self.sys_log.warning( diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 9816bb15..8384d922 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -7,6 +7,12 @@ from pydantic import validate_call from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.protocols.masquerade import C2Packet +from primaite.simulator.system.applications.red_applications.c2 import ( + Command_Opts, + Exfil_Opts, + Ransomware_Opts, + Terminal_Opts, +) from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload @@ -49,11 +55,11 @@ class C2Server(AbstractC2, identifier="C2Server"): :return: RequestResponse object with a success code reflecting whether the configuration could be applied. :rtype: RequestResponse """ - ransomware_config = { + command_payload = { "server_ip_address": request[-1].get("server_ip_address"), "payload": request[-1].get("payload"), } - return self.send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=ransomware_config) + return self.send_command(given_command=C2Command.RANSOMWARE_CONFIGURE, command_options=command_payload) def _launch_ransomware_action(request: RequestFormat, context: Dict) -> RequestResponse: """Agent Action - Sends a RANSOMWARE_LAUNCH C2Command to the C2 Beacon with the given parameters. @@ -226,7 +232,9 @@ class C2Server(AbstractC2, identifier="C2Server"): if connection_status[0] is False: return connection_status[1] - if not self._command_setup(given_command, command_options): + setup_success, command_options = self._command_setup(given_command, command_options) + + if setup_success is False: self.sys_log.warning( f"{self.name}: Failed to perform necessary C2 Server setup for given command: {given_command}." ) @@ -236,7 +244,7 @@ class C2Server(AbstractC2, identifier="C2Server"): self.sys_log.info(f"{self.name}: Attempting to send command {given_command}.") command_packet = self._craft_packet( - c2_payload=C2Payload.INPUT, c2_command=given_command, command_options=command_options + c2_payload=C2Payload.INPUT, c2_command=given_command, command_options=command_options.model_dump() ) if self.send( @@ -256,10 +264,12 @@ class C2Server(AbstractC2, identifier="C2Server"): ) return self.current_command_output - def _command_setup(self, given_command: C2Command, command_options: dict) -> bool: + def _command_setup(self, given_command: C2Command, command_options: dict) -> tuple[bool, Command_Opts]: """ Performs any necessary C2 Server setup needed to perform certain commands. + This includes any option validation and any other required setup. + The following table details any C2 Server prequisites for following commands. C2 Command | Command Service/Application Requirements @@ -278,18 +288,35 @@ class C2Server(AbstractC2, identifier="C2Server"): :type given_command: C2Command. :param command_options: The relevant command parameters. :type command_options: Dict - :returns: True the setup was successful, false otherwise. - :rtype: bool + :returns: Tuple containing a success bool if the setup was successful and the validated c2 opts. + :rtype: tuple[bool, Command_Opts] """ + server_setup_success: bool = True + if given_command == C2Command.DATA_EXFILTRATION: # Data exfiltration setup + # Validating command options + command_options = Exfil_Opts.model_validate(command_options) if self._host_ftp_server is None: self.sys_log.warning(f"{self.name}: Unable to setup the FTP Server for data exfiltration") - return False - if self.get_exfiltration_folder(command_options.get("exfiltration_folder_name")) is None: - self.sys_log.warning(f"{self.name}: Unable to create a folder for storing exfiltration data.") - return False + server_setup_success = False - return True + if self.get_exfiltration_folder(command_options.exfiltration_folder_name) is None: + self.sys_log.warning(f"{self.name}: Unable to create a folder for storing exfiltration data.") + server_setup_success = False + + if given_command == C2Command.TERMINAL: + # Validating command options + command_options = Terminal_Opts.model_validate(command_options) + + if given_command == C2Command.RANSOMWARE_CONFIGURE: + # Validating command options + command_options = Ransomware_Opts.model_validate(command_options) + + if given_command == C2Command.RANSOMWARE_LAUNCH: + # Validating command options + command_options = Command_Opts.model_validate(command_options) + + return [server_setup_success, command_options] def _confirm_remote_connection(self, timestep: int) -> bool: """Checks the suitability of the current C2 Beacon connection. diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py index 30defe8b..885a3cb6 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_c2_suite.py @@ -219,3 +219,30 @@ def test_c2_handles_1_timestep_keep_alive(basic_c2_network): assert c2_beacon.c2_connection_active is True assert c2_server.c2_connection_active is True + + +def test_c2_exfil_folder(basic_c2_network): + """Tests that the C2 suite correctly default and setup their exfiltration_folders.""" + network: Network = basic_c2_network + + network, computer_a, c2_server, computer_b, c2_beacon = setup_c2(network) + + c2_beacon.get_exfiltration_folder() + c2_server.get_exfiltration_folder() + assert c2_beacon.file_system.get_folder("exfiltration_folder") + assert c2_server.file_system.get_folder("exfiltration_folder") + + c2_server.file_system.create_file(folder_name="test_folder", file_name="test_file") + + # asserting to check that by default the c2 exfil will use "exfiltration_folder" + exfil_options = { + "username": "admin", + "password": "admin", + "target_ip_address": "192.168.0.1", + "target_folder_name": "test_folder", + "exfiltration_folder_name": None, + "target_file_name": "test_file", + } + c2_server.send_command(given_command=C2Command.DATA_EXFILTRATION, command_options=exfil_options) + + assert c2_beacon.file_system.get_file(folder_name="exfiltration_folder", file_name="test_file") From e5be392ea8d8bf534d0c11d51eb89ff585989b5d Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Thu, 15 Aug 2024 17:47:33 +0100 Subject: [PATCH 162/206] #2689 Updated documentation and docustrings following PR comments. --- .../system/applications/c2_suite.rst | 13 ++- src/primaite/game/agent/actions.py | 2 +- .../Command-&-Control-E2E-Demonstration.ipynb | 82 +++++++++---------- .../red_applications/c2/__init__.py | 12 +-- .../red_applications/c2/abstract_c2.py | 4 +- .../red_applications/c2/c2_beacon.py | 18 ++-- .../red_applications/c2/c2_server.py | 20 ++--- 7 files changed, 75 insertions(+), 76 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 974bb6ce..ab6a49e2 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -7,14 +7,13 @@ Command and Control Application Suite ##################################### -Comprising of two applications, the command and control (C2) suites intends to introduce +Comprising of two applications, the Command and Control (C2) suites intends to introduce malicious network architecture and begin to further the realism of red agents within primAITE. Overview: ========= -These two new classes intend to Red Agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application. -Whilst introducing both more opportunities for the blue agent to notice and subvert Red Agents during an episode. +These two new classes give red agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application whilst introducing more opportunities for the blue agent to notice and subvert a red agent during an episode. For a more in-depth look at the command and control applications then please refer to the ``C2-E2E-Notebook``. @@ -23,7 +22,7 @@ For a more in-depth look at the command and control applications then please ref The C2 Server application is intended to represent the malicious infrastructure already under the control of an adversary. -The C2 Server is configured to listen and await ``keep alive`` traffic from a c2 beacon. Once received the C2 Server is able to send and receive c2 commands. +The C2 Server is configured to listen and await ``keep alive`` traffic from a C2 beacon. Once received the C2 Server is able to send and receive C2 commands. Currently, the C2 Server offers three commands: @@ -88,7 +87,7 @@ Implementation ============== Both applications inherit from an abstract C2 which handles the keep alive functionality and main logic. -However, each host implements it's receive methods individually. +However, each host implements it's own receive methods. - The ``C2 Beacon`` is responsible for the following logic: - Establishes and confirms connection to the C2 Server via sending ``C2Payload.KEEP_ALIVE``. @@ -275,11 +274,11 @@ This must be a valid integer i.e ``10``. Defaults to ``5``. The protocol that the C2 Beacon will use to communicate to the C2 Server with. -Currently only ``tcp`` and ``udp`` are valid masquerade protocol options. +Currently only ``TCP`` and ``UDP`` are valid masquerade protocol options. It's worth noting that this may be useful option to bypass ACL rules. -This must be a string i.e ``udp``. Defaults to ``tcp``. +This must be a string i.e *UDP*. Defaults to ``TCP``. *Please refer to the ``IPProtocol`` class for further reference.* diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 07bb039e..654ac0ac 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1143,7 +1143,7 @@ class RansomwareLaunchC2ServerAction(AbstractAction): node_name = self.manager.get_node_name_by_idx(node_id) if node_name is None: return ["do_nothing"] - # Not options needed for this action. + # This action currently doesn't require any further configuration options. return ["network", "node", node_name, "application", "C2Server", "ransomware_launch"] diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index f8c550e0..9da39e32 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -38,7 +38,7 @@ "\n", "This notebook uses the same network setup as UC2. Please refer to the main [UC2-E2E-Demo notebook for further reference](./Data-Manipulation-E2E-Demonstration.ipynb).\n", "\n", - "However, this notebook will replaces with the red agent used in UC2 with a custom proxy red agent built for this notebook." + "However, this notebook replaces the red agent used in UC2 with a custom proxy red agent built for this notebook." ] }, { @@ -188,11 +188,10 @@ "source": [ "## **Notebook Setup** | Network Prerequisites\n", "\n", - "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run before the episode begins.\n", + "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run before the Red Agent can perform any C2 specific action.\n", + "This is because in higher fidelity environments (and the real-world) a C2 server would not be accessible by a private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) starts.\n", "\n", - "This is because higher fidelity environments (and the real-world) a C2 server would not be accessible by private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) before the narrative of the use case.\n", - "\n", - "The cells below installs and runs the C2 Server on the client_1 directly via the simulation API." + "The cells below install and runs the C2 Server on client_1 directly via the simulation API." ] }, { @@ -214,9 +213,9 @@ "source": [ "## **Command and Control** | C2 Beacon Actions\n", "\n", - "Before any C2 Server commands is able to accept any commands, it must first establish connection with a C2 beacon.\n", + "Before a C2 Server can accept any commands it must first establish connection with a C2 Beacon.\n", "\n", - "A red agent is able to install, configure and establish a C2 beacon at any point of an episode. The code cells below demonstrate what actions and option parameters are needed to perform this." + "A red agent is able to install, configure and establish a C2 beacon at any point in an episode. The code cells below demonstrate the actions and option parameters that are needed to perform this." ] }, { @@ -225,7 +224,7 @@ "source": [ "### **Command and Control** | C2 Beacon Actions | NODE_APPLICATION_INSTALL\n", "\n", - "The custom proxy red agent defined at the start of this notebook has been configured to install the C2 Beacon as action ``1`` on it's action map. \n", + "The custom proxy red agent defined at the start of this notebook has been configured to install the C2 Beacon as action ``1`` in it's action map. \n", "\n", "The below yaml snippet shows all the relevant agent options for this action:\n", "\n", @@ -268,9 +267,9 @@ "source": [ "### **Command and Control** | C2 Beacon Actions | CONFIGURE_C2_BEACON \n", "\n", - "The custom proxy red agent defined at the start of this notebook can configure the C2 Beacon via action ``2`` on it's action map. \n", + "The custom proxy red agent defined at the start of this notebook can configure the C2 Beacon via action ``2`` in it's action map. \n", "\n", - "The below yaml snippet shows all the relevant agent options for this action:\n", + "The yaml snippet below shows all the relevant agent options for this action:\n", "\n", "```yaml\n", " action_space:\n", @@ -315,9 +314,9 @@ "source": [ "### **Command and Control** | C2 Beacon Actions | NODE_APPLICATION_EXECUTE\n", "\n", - "The final action is ``NODE_APPLICATION_EXECUTE`` which is used to establish connection for the C2 application. This action can be called by the Red Agent via action ``3`` on it's action map. \n", + "The final action is ``NODE_APPLICATION_EXECUTE`` which is used to establish a connection for the C2 application. This action can be called by the Red Agent via action ``3`` in it's action map. \n", "\n", - "The below yaml snippet shows all the relevant agent options for this action:\n", + "The yaml snippet below shows all the relevant agent options for this action:\n", "\n", "```yaml\n", " action_space:\n", @@ -370,7 +369,7 @@ "Once the C2 suite has been successfully established, the C2 Server based actions become available to the Red Agent. \n", "\n", "\n", - "This next section will demonstrate the different actions that become available to a red agent after establishing C2 connection:" + "This next section will demonstrate the different actions that become available to a red agent after establishing a C2 connection:" ] }, { @@ -379,15 +378,15 @@ "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_TERMINAL_COMMAND\n", "\n", - "The C2 Server's terminal action is indexed at ``4`` on the custom red agent action map. \n", + "The C2 Server's terminal action: ``C2_SERVER_TERMINAL_COMMAND`` is indexed at ``4`` in it's action map. \n", "\n", "This action leverages the terminal service that is installed by default on all nodes to grant red agents a lot more configurability. If you're unfamiliar with terminals then it's recommended that you refer to the ``Terminal Processing`` notebook.\n", "\n", - "It's worth noting that an additional benefit that a red agent has when using terminal via the C2 Server is that you can execute multiple commands in one action. \n", + "It's worth noting that an additional benefit a red agent has when using the terminal service via the C2 Server is that you can execute multiple commands in one action. \n", "\n", "In this notebook, the ``C2_SERVER_TERMINAL_COMMAND`` is used to install a RansomwareScript application on the ``web_server`` node.\n", "\n", - "The below yaml snippet shows all the relevant agent options for this action:\n", + "The yaml snippet below shows all the relevant agent options for this action:\n", "\n", "``` yaml\n", " action_space:\n", @@ -444,11 +443,11 @@ "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_RANSOMWARE_CONFIGURE\n", "\n", - "Another action that the C2 Server grants is the ability for a Red Agent to configure ransomware via the C2 Server. \n", + "Another action the C2 Server grants is the ability for a Red Agent to configure the RansomwareScript via the C2 Server rather than the note directly.\n", "\n", "This action is indexed as action ``5``.\n", "\n", - "The below yaml snippet shows all the relevant agent options for this action:\n", + "The yaml snippet below shows all the relevant agent options for this action:\n", "\n", "``` yaml\n", " action_space:\n", @@ -500,11 +499,11 @@ "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_DATA_EXFILTRATE\n", "\n", - "The second to last action available is the ``C2_SERVER_DATA_EXFILTRATE`` which can be used to exfiltrate a target file on a remote node to the C2 Beacon & Server's host file system via the ``FTP`` services.\n", + "The second to last action available is the ``C2_SERVER_DATA_EXFILTRATE`` which is indexed as action ``6`` in the action map.\n", "\n", - "This action is indexed as action ``6``..\n", + "This action can be used to exfiltrate a target file on a remote node to the C2 Beacon and the C2 Server's host file system via the ``FTP`` services.\n", "\n", - "The below yaml snippet shows all the relevant agent options for this action\n", + "The below yaml snippet shows all the relevant agent options for this action:\n", "\n", "``` yaml\n", " action_space:\n", @@ -532,8 +531,7 @@ " username: \"admin\",\n", " password: \"admin\"\n", "\n", - "```\n", - "\n" + "```" ] }, { @@ -571,11 +569,11 @@ "source": [ "### **Command and Control** | C2 Server Actions | C2_SERVER_RANSOMWARE_LAUNCH\n", "\n", - "Finally, to the ransomware configuration action, there is also the ``C2_SERVER_RANSOMWARE_LAUNCH`` which quite simply launches the ransomware script installed on the same node as the C2 beacon.\n", + "Finally, the last available action is for the C2_SERVER_RANSOMWARE_LAUNCH to start the ransomware script installed on the same node as the C2 beacon.\n", "\n", "This action is indexed as action ``7``.\n", "\n", - "The below yaml snippet shows all the relevant agent options for this action\n", + "\"The yaml snippet below shows all the relevant agent options for this action:\n", "\n", "``` yaml\n", " action_space:\n", @@ -623,9 +621,9 @@ "source": [ "## **Command and Control** | Blue Agent Relevance\n", "\n", - "The next section of the notebook will demonstrate the impact that the command and control suite has to the Blue Agent's observation space as well as some potential actions that can be used to prevent the attack from being successfully.\n", + "The next section of the notebook will demonstrate the impact the command and control suite has on the Blue Agent's observation space as well as some potential actions that can be used to prevent the attack from being successful.\n", "\n", - "The code cell below re-creates the UC2 network and swaps out the previous custom red agent with a custom blue agent. \n" + "The code cell below recreates the UC2 network and swaps out the previous custom red agent with a custom blue agent. " ] }, { @@ -1072,7 +1070,7 @@ "metadata": {}, "outputs": [], "source": [ - "# This method is used to shorthand setting up the C2Server and the C2 Beacon.\n", + "# This method is used to simplify setting up the C2Server and the C2 Beacon.\n", "def c2_setup(given_env: PrimaiteGymEnv):\n", " client_1: Computer = given_env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", " web_server: Server = given_env.game.simulation.network.get_node_by_hostname(\"web_server\")\n", @@ -1190,7 +1188,7 @@ "source": [ "#### Shutting down the node infected with a C2 Beacon.\n", "\n", - "Another way a blue agent can prevent the C2 suite is via shutting down the C2 beacon's host node. Whilst not as effective as the previous option, dependant on situation (such as multiple malicious applications) or other scenarios it may be more timestep efficient for a blue agent to shut down a node directly." + "Another way a blue agent can prevent the C2 suite is by shutting down the C2 beacon's host node. Whilst not as effective as the previous option, dependant on the situation (such as multiple malicious applications) or other scenarios it may be more timestep efficient for a blue agent to shut down a node directly." ] }, { @@ -1218,7 +1216,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The code cell below uses the custom blue agent defined at the start of this section perform NODE_SHUT_DOWN on the web server." + "The code cell below uses the custom blue agent defined at the start of this section perform a ``NODE_SHUT_DOWN`` action on the web server." ] }, { @@ -1235,7 +1233,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Which we can see after the effects of after stepping another timestep and looking at the web_servers operating state & the OBS differences." + "Which we can see the effects of after another timestep and looking at the web_server's operating state & the OBS differences." ] }, { @@ -1264,7 +1262,7 @@ "outputs": [], "source": [ "# Attempting to install the C2 RansomwareScript\n", - "ransomware_install_command = {\"commands\":[[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"]],\n", + "ransomware_install_command = {\"commands\":[\"software_manager\", \"application\", \"install\", \"RansomwareScript\"],\n", " \"username\": \"admin\",\n", " \"password\": \"admin\"}\n", "\n", @@ -1280,7 +1278,7 @@ "\n", "Another potential option a blue agent could take is by placing an ACL rule which blocks traffic between the C2 Server can C2 Beacon.\n", "\n", - "It's worth noting the potential effectiveness of approach is also linked by the current green agent traffic on the network. The same applies for the previous example." + "It's worth noting the potential effectiveness of this approach is connected to the current green agent traffic on the network. For example, if there are multiple green agents using the C2 Beacon's host node then blocking all traffic would lead to a negative reward. The same applies for the previous example." ] }, { @@ -1325,7 +1323,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Which we can see after the effects of after stepping another timestep and looking at router 1's ACLs and the OBS differences." + "Which we can see the effects of after another timestep and looking at router 1's ACLs and the OBS differences." ] }, { @@ -1454,9 +1452,9 @@ "\n", "As with a majority of client and server based application configuration in primaite, the remote IP of server must be supplied.\n", "\n", - "In the case of the C2 Beacon, the C2 Server's IP must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", + "In the case of the C2 Beacon, the C2 Server's IP address must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", "\n", - "If the network contains multiple C2 Servers then it's also possible to switch to different C2 servers mid episode which is demonstrated in the below code cells." + "If the network contains multiple C2 Servers then it's also possible to switch to different C2 servers mid-episode which is demonstrated in the below code cells." ] }, { @@ -1546,7 +1544,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After six timesteps the client_1 server will recognise the c2 beacon previous connection as dead and clear it's connections. (This is dependant o the ``Keep Alive Frequency`` setting.)" + "After six timesteps the client_1 server will recognise the C2 beacon's previous connection as dead and clear its connections. (This is dependant on the ``Keep Alive Frequency`` setting.)" ] }, { @@ -1569,7 +1567,7 @@ "\n", "In order to confirm it's connection the C2 Beacon will send out a ``Keep Alive`` to the C2 Server and receive a keep alive back. \n", "\n", - "By default, this occurs at a rate of 5 timesteps. However, this setting can be configured to be much more infrequent or as frequent as every timestep. \n", + "By default, this occurs every 5 timesteps. However, this setting can be configured to be much more infrequent or as frequent as every timestep. \n", "\n", "The next set of code cells below demonstrate the impact that this setting has on blue agent observation space." ] @@ -1631,7 +1629,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The code cell below goes through 10 timesteps and displays the differences between the default and the current timestep.\n", + "The code cell below executes 10 timesteps and displays the differences between the default and the current timestep.\n", "\n", "You will notice that the only two timesteps displayed observation space differences. This is due to the C2 Suite confirming their connection through sending ``Keep Alive`` traffic across the network every 5 timesteps." ] @@ -1688,7 +1686,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Additionally, the keep_alive_frequency can also be used to configure the C2 Beacon to confirm connection less frequently. \n", + "Lastly, the keep_alive_frequency can also be used to configure the C2 Beacon to confirm connection less frequently. \n", "\n", "The code cells below demonstrate the impacts of changing the frequency rate to ``7`` timesteps." ] @@ -1713,9 +1711,9 @@ "source": [ "### **Command and Control** | Configurability | Masquerade Port & Masquerade Protocol\n", "\n", - "The final configurable options are ``Masquerade Port`` & ``Masquerade Protocol``. These options can be used to control what networking IP Protocol and Port the C2 traffic is currently using.\n", + "The final configurable options are ``Masquerade Port`` & ``Masquerade Protocol``. These options can be used to control the networking IP Protocol and Port the C2 traffic is currently using.\n", "\n", - "In the real world, Adversaries take defensive steps to reduce the chance that an installed C2 Beacon is discovered. One of the most commonly used methods is to masquerade c2 traffic as other commonly used networking protocols.\n", + "In the real world, adversaries take defensive steps to reduce the chance that an installed C2 Beacon is discovered. One of the most commonly used methods is to masquerade C2 traffic as other commonly used networking protocols.\n", "\n", "In primAITE, red agents can begin to simulate stealth behaviour by configuring C2 traffic to use different protocols mid episode or between episodes.\n", "\n", diff --git a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py index 23dfeb31..60e39743 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/__init__.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/__init__.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator, ValidationInfo from primaite.interface.request import RequestFormat -class Command_Opts(BaseModel): +class CommandOpts(BaseModel): """A C2 Pydantic Schema acting as a base class for all C2 Commands.""" @field_validator("payload", "exfiltration_folder_name", "ip_address", mode="before", check_fields=False) @@ -18,7 +18,7 @@ class Command_Opts(BaseModel): return v -class Ransomware_Opts(Command_Opts): +class RansomwareOpts(CommandOpts): """A Pydantic Schema for the Ransomware Configuration command options.""" server_ip_address: str @@ -28,7 +28,7 @@ class Ransomware_Opts(Command_Opts): """The malicious payload to be used to attack the target database.""" -class Remote_Opts(Command_Opts): +class RemoteOpts(CommandOpts): """A base C2 Pydantic Schema for all C2 Commands that require a terminal connection.""" ip_address: Optional[str] = Field(default=None) @@ -41,7 +41,7 @@ class Remote_Opts(Command_Opts): """A Password of a valid user account. Used to login into both remote and local hosts.""" -class Exfil_Opts(Remote_Opts): +class ExfilOpts(RemoteOpts): """A Pydantic Schema for the C2 Data Exfiltration command options.""" target_ip_address: str @@ -54,10 +54,10 @@ class Exfil_Opts(Remote_Opts): """The name of the remote folder which contains the target file.""" exfiltration_folder_name: str = Field(default="exfiltration_folder") - """""" + """The name of C2 Suite folder used to store the target file. Defaults to ``exfiltration_folder``""" -class Terminal_Opts(Remote_Opts): +class TerminalOpts(RemoteOpts): """A Pydantic Schema for the C2 Terminal command options.""" commands: Union[list[RequestFormat], RequestFormat] diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index b21a996d..0d7bbf1f 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -277,8 +277,10 @@ class AbstractC2(Application, identifier="AbstractC2"): """Abstract Method: Used in C2 beacon to parse and handle commands received from the c2 server.""" pass + @abstractmethod def _handle_keep_alive(self, payload: C2Packet, session_id: Optional[str]) -> bool: - """Abstract Method: The C2 Server and the C2 Beacon handle the KEEP ALIVEs differently.""" + """Abstract Method: Each C2 suite handles ``C2Payload.KEEP_ALIVE`` differently.""" + pass # from_network_interface=from_network_interface def receive(self, payload: any, session_id: Optional[str] = None, **kwargs) -> bool: diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 9c63bb53..393512db 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -11,7 +11,7 @@ from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.applications.red_applications.c2 import Exfil_Opts, Ransomware_Opts, Terminal_Opts +from primaite.simulator.system.applications.red_applications.c2 import ExfilOpts, RansomwareOpts, TerminalOpts from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript from primaite.simulator.system.services.terminal.terminal import Terminal, TerminalClientConnection @@ -30,7 +30,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): Extends the Abstract C2 application to include the following: 1. Receiving commands from the C2 Server (Command input) - 2. Leveraging the terminal application to execute requests (dependant on the command given) + 2. Leveraging the terminal application to execute requests (dependent on the command given) 3. Sending the RequestResponse back to the C2 Server (Command output) Please refer to the Command-&-Control notebook for an in-depth example of the C2 Suite. @@ -156,7 +156,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :type c2_server_ip_address: IPv4Address :param keep_alive_frequency: The frequency (timesteps) at which the C2 beacon will send keep alive(s). :type keep_alive_frequency: Int - :param masquerade_protocol: The Protocol that C2 Traffic will masquerade as. Defaults as TCP. + :param masquerade_protocol: The Protocol that C2 Traffic will masquerade as. Defaults to TCP. :type masquerade_protocol: Enum (IPProtocol) :param masquerade_port: The Port that the C2 Traffic will masquerade as. Defaults to FTP. :type masquerade_port: Enum (Port) @@ -294,7 +294,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - command_opts = Ransomware_Opts.model_validate(payload.payload) + command_opts = RansomwareOpts.model_validate(payload.payload) if self._host_ransomware_script is None: return RequestResponse( status="failure", @@ -352,7 +352,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): data={"Reason": "Cannot find any instances of both a FTP Server & Client. Are they installed?"}, ) - command_opts = Exfil_Opts.model_validate(payload.payload) + command_opts = ExfilOpts.model_validate(payload.payload) # Setting up the terminal session and the ftp server if not self._set_terminal_session( @@ -401,7 +401,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): Attempts to exfiltrate a target file from a target using the parameters given. Uses the current terminal_session to send a command to the - remote host's FTP Client passing the exfil_opts as command options. + remote host's FTP Client passing the ExfilOpts as command options. This will instruct the FTP client to send the target file to the dest_ip_address's destination folder. @@ -411,8 +411,8 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): 2. The target has a functioning FTP Client Service. - :exfil_opts: A Pydantic model containing the require configuration options - :type exfil_opts: Exfil_Opts + :ExfilOpts: A Pydantic model containing the require configuration options + :type ExfilOpts: ExfilOpts :return: Returns a tuple containing a success boolean and a Request Response.. :rtype: tuple[bool, RequestResponse """ @@ -473,7 +473,7 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): :return: Returns the Request Response returned by the Terminal execute method. :rtype: Request Response """ - command_opts = Terminal_Opts.model_validate(payload.payload) + command_opts = TerminalOpts.model_validate(payload.payload) if self._host_terminal is None: return RequestResponse( diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py index 8384d922..53552e6e 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_server.py @@ -8,10 +8,10 @@ from primaite.interface.request import RequestFormat, RequestResponse from primaite.simulator.core import RequestManager, RequestType from primaite.simulator.network.protocols.masquerade import C2Packet from primaite.simulator.system.applications.red_applications.c2 import ( - Command_Opts, - Exfil_Opts, - Ransomware_Opts, - Terminal_Opts, + CommandOpts, + ExfilOpts, + RansomwareOpts, + TerminalOpts, ) from primaite.simulator.system.applications.red_applications.c2.abstract_c2 import AbstractC2, C2Command, C2Payload @@ -264,7 +264,7 @@ class C2Server(AbstractC2, identifier="C2Server"): ) return self.current_command_output - def _command_setup(self, given_command: C2Command, command_options: dict) -> tuple[bool, Command_Opts]: + def _command_setup(self, given_command: C2Command, command_options: dict) -> tuple[bool, CommandOpts]: """ Performs any necessary C2 Server setup needed to perform certain commands. @@ -289,13 +289,13 @@ class C2Server(AbstractC2, identifier="C2Server"): :param command_options: The relevant command parameters. :type command_options: Dict :returns: Tuple containing a success bool if the setup was successful and the validated c2 opts. - :rtype: tuple[bool, Command_Opts] + :rtype: tuple[bool, CommandOpts] """ server_setup_success: bool = True if given_command == C2Command.DATA_EXFILTRATION: # Data exfiltration setup # Validating command options - command_options = Exfil_Opts.model_validate(command_options) + command_options = ExfilOpts.model_validate(command_options) if self._host_ftp_server is None: self.sys_log.warning(f"{self.name}: Unable to setup the FTP Server for data exfiltration") server_setup_success = False @@ -306,15 +306,15 @@ class C2Server(AbstractC2, identifier="C2Server"): if given_command == C2Command.TERMINAL: # Validating command options - command_options = Terminal_Opts.model_validate(command_options) + command_options = TerminalOpts.model_validate(command_options) if given_command == C2Command.RANSOMWARE_CONFIGURE: # Validating command options - command_options = Ransomware_Opts.model_validate(command_options) + command_options = RansomwareOpts.model_validate(command_options) if given_command == C2Command.RANSOMWARE_LAUNCH: # Validating command options - command_options = Command_Opts.model_validate(command_options) + command_options = CommandOpts.model_validate(command_options) return [server_setup_success, command_options] From 1d2705eb1b95bb369926bc5858feef7a56cf4abe Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 15 Aug 2024 20:16:11 +0100 Subject: [PATCH 163/206] #2769 - Add user login observations --- .../observations/firewall_observation.py | 20 +- .../agent/observations/host_observations.py | 21 + .../agent/observations/node_observations.py | 8 + .../agent/observations/router_observation.py | 17 +- .../simulator/network/hardware/base.py | 3 +- tests/assets/configs/data_manipulation.yaml | 942 ++++++++++++++++++ .../observations/test_user_observations.py | 89 ++ 7 files changed, 1096 insertions(+), 4 deletions(-) create mode 100644 tests/assets/configs/data_manipulation.yaml create mode 100644 tests/integration_tests/game_layer/observations/test_user_observations.py diff --git a/src/primaite/game/agent/observations/firewall_observation.py b/src/primaite/game/agent/observations/firewall_observation.py index 4f1a9d90..42ceaff0 100644 --- a/src/primaite/game/agent/observations/firewall_observation.py +++ b/src/primaite/game/agent/observations/firewall_observation.py @@ -10,6 +10,7 @@ from primaite import getLogger from primaite.game.agent.observations.acl_observation import ACLObservation from primaite.game.agent.observations.nic_observations import PortObservation from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE _LOGGER = getLogger(__name__) @@ -32,6 +33,8 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): """List of protocols for encoding ACLs.""" num_rules: Optional[int] = None """Number of rules ACL rules to show.""" + include_users: Optional[bool] = True + """If True, report user session information.""" def __init__( self, @@ -41,6 +44,7 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): port_list: List[int], protocol_list: List[str], num_rules: int, + include_users: bool, ) -> None: """ Initialise a firewall observation instance. @@ -58,9 +62,13 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): :type protocol_list: List[str] :param num_rules: Number of rules configured in the firewall. :type num_rules: int + :param include_users: If True, report user session information. + :type include_users: bool """ self.where: WhereType = where - + self.include_users: bool = include_users + self.max_users: int = 3 + """Maximum number of remote sessions observable, excess sessions are truncated.""" self.ports: List[PortObservation] = [ PortObservation(where=self.where + ["NICs", port_num]) for port_num in (1, 2, 3) ] @@ -142,6 +150,9 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): :return: Observation containing the status of ports and ACLs for internal, DMZ, and external traffic. :rtype: ObsType """ + firewall_state = access_from_nested_dict(state, self.where) + if firewall_state is NOT_PRESENT_IN_STATE: + return self.default_observation obs = { "PORTS": {i + 1: p.observe(state) for i, p in enumerate(self.ports)}, "ACL": { @@ -159,6 +170,12 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): }, }, } + if self.include_users: + sess = firewall_state["services"]["UserSessionManager"] + obs["users"] = { + "local_login": 1 if sess["current_local_user"] else 0, + "remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])), + } return obs @property @@ -218,4 +235,5 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"): port_list=config.port_list, protocol_list=config.protocol_list, num_rules=config.num_rules, + include_users=config.include_users, ) diff --git a/src/primaite/game/agent/observations/host_observations.py b/src/primaite/game/agent/observations/host_observations.py index 7053d019..4419ccc7 100644 --- a/src/primaite/game/agent/observations/host_observations.py +++ b/src/primaite/game/agent/observations/host_observations.py @@ -52,6 +52,8 @@ class HostObservation(AbstractObservation, identifier="HOST"): """ If True, files and folders must be scanned to update the health state. If False, true state is always shown. """ + include_users: Optional[bool] = True + """If True, report user session information.""" def __init__( self, @@ -69,6 +71,7 @@ class HostObservation(AbstractObservation, identifier="HOST"): monitored_traffic: Optional[Dict], include_num_access: bool, file_system_requires_scan: bool, + include_users: bool, ) -> None: """ Initialise a host observation instance. @@ -103,10 +106,15 @@ class HostObservation(AbstractObservation, identifier="HOST"): :param file_system_requires_scan: If True, the files and folders must be scanned to update the health state. If False, the true state is always shown. :type file_system_requires_scan: bool + :param include_users: If True, report user session information. + :type include_users: bool """ self.where: WhereType = where self.include_num_access = include_num_access + self.include_users = include_users + self.max_users: int = 3 + """Maximum number of remote sessions observable, excess sessions are truncated.""" # Ensure lists have lengths equal to specified counts by truncating or padding self.services: List[ServiceObservation] = services @@ -165,6 +173,8 @@ class HostObservation(AbstractObservation, identifier="HOST"): if self.include_num_access: self.default_observation["num_file_creations"] = 0 self.default_observation["num_file_deletions"] = 0 + if self.include_users: + self.default_observation["users"] = {"local_login": 0, "remote_sessions": 0} def observe(self, state: Dict) -> ObsType: """ @@ -192,6 +202,12 @@ class HostObservation(AbstractObservation, identifier="HOST"): if self.include_num_access: obs["num_file_creations"] = node_state["file_system"]["num_file_creations"] obs["num_file_deletions"] = node_state["file_system"]["num_file_deletions"] + if self.include_users: + sess = node_state["services"]["UserSessionManager"] + obs["users"] = { + "local_login": 1 if sess["current_local_user"] else 0, + "remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])), + } return obs @property @@ -216,6 +232,10 @@ class HostObservation(AbstractObservation, identifier="HOST"): if self.include_num_access: shape["num_file_creations"] = spaces.Discrete(4) shape["num_file_deletions"] = spaces.Discrete(4) + if self.include_users: + shape["users"] = spaces.Dict( + {"local_login": spaces.Discrete(2), "remote_sessions": spaces.Discrete(self.max_users + 1)} + ) return spaces.Dict(shape) @classmethod @@ -273,4 +293,5 @@ class HostObservation(AbstractObservation, identifier="HOST"): monitored_traffic=config.monitored_traffic, include_num_access=config.include_num_access, file_system_requires_scan=config.file_system_requires_scan, + include_users=config.include_users, ) diff --git a/src/primaite/game/agent/observations/node_observations.py b/src/primaite/game/agent/observations/node_observations.py index c68531f8..e263cadb 100644 --- a/src/primaite/game/agent/observations/node_observations.py +++ b/src/primaite/game/agent/observations/node_observations.py @@ -46,6 +46,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): """Flag to include the number of accesses.""" file_system_requires_scan: bool = True """If True, the folder must be scanned to update the health state. Tf False, the true state is always shown.""" + include_users: Optional[bool] = True + """If True, report user session information.""" num_ports: Optional[int] = None """Number of ports.""" ip_list: Optional[List[str]] = None @@ -191,6 +193,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): host_config.include_num_access = config.include_num_access if host_config.file_system_requires_scan is None: host_config.file_system_requires_scan = config.file_system_requires_scan + if host_config.include_users is None: + host_config.include_users = config.include_users for router_config in config.routers: if router_config.num_ports is None: @@ -205,6 +209,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): router_config.protocol_list = config.protocol_list if router_config.num_rules is None: router_config.num_rules = config.num_rules + if router_config.include_users is None: + router_config.include_users = config.include_users for firewall_config in config.firewalls: if firewall_config.ip_list is None: @@ -217,6 +223,8 @@ class NodesObservation(AbstractObservation, identifier="NODES"): firewall_config.protocol_list = config.protocol_list if firewall_config.num_rules is None: firewall_config.num_rules = config.num_rules + if firewall_config.include_users is None: + firewall_config.include_users = config.include_users hosts = [HostObservation.from_config(config=c, parent_where=where) for c in config.hosts] routers = [RouterObservation.from_config(config=c, parent_where=where) for c in config.routers] diff --git a/src/primaite/game/agent/observations/router_observation.py b/src/primaite/game/agent/observations/router_observation.py index f1d4ec8e..d064936a 100644 --- a/src/primaite/game/agent/observations/router_observation.py +++ b/src/primaite/game/agent/observations/router_observation.py @@ -39,6 +39,8 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"): """List of protocols for encoding ACLs.""" num_rules: Optional[int] = None """Number of rules ACL rules to show.""" + include_users: Optional[bool] = True + """If True, report user session information.""" def __init__( self, @@ -46,6 +48,7 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"): ports: List[PortObservation], num_ports: int, acl: ACLObservation, + include_users: bool, ) -> None: """ Initialise a router observation instance. @@ -59,12 +62,16 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"): :type num_ports: int :param acl: ACL observation representing the access control list of the router. :type acl: ACLObservation + :param include_users: If True, report user session information. + :type include_users: bool """ self.where: WhereType = where self.ports: List[PortObservation] = ports self.acl: ACLObservation = acl self.num_ports: int = num_ports - + self.include_users: bool = include_users + self.max_users: int = 3 + """Maximum number of remote sessions observable, excess sessions are truncated.""" while len(self.ports) < num_ports: self.ports.append(PortObservation(where=None)) while len(self.ports) > num_ports: @@ -95,6 +102,12 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"): obs["ACL"] = self.acl.observe(state) if self.ports: obs["PORTS"] = {i + 1: p.observe(state) for i, p in enumerate(self.ports)} + if self.include_users: + sess = router_state["services"]["UserSessionManager"] + obs["users"] = { + "local_login": 1 if sess["current_local_user"] else 0, + "remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])), + } return obs @property @@ -143,4 +156,4 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"): ports = [PortObservation.from_config(config=c, parent_where=where) for c in config.ports] acl = ACLObservation.from_config(config=config.acl, parent_where=where) - return cls(where=where, ports=ports, num_ports=config.num_ports, acl=acl) + return cls(where=where, ports=ports, num_ports=config.num_ports, acl=acl, include_users=config.include_users) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 68b45c2e..b0c48e7d 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1265,7 +1265,8 @@ class UserSessionManager(Service): :return: A dictionary representing the current state. """ state = super().describe_state() - state["active_remote_logins"] = len(self.remote_sessions) + state["current_local_user"] = None if not self.local_session else self.local_session.user.username + state["active_remote_sessions"] = list(self.remote_sessions.keys()) return state @property diff --git a/tests/assets/configs/data_manipulation.yaml b/tests/assets/configs/data_manipulation.yaml new file mode 100644 index 00000000..97442903 --- /dev/null +++ b/tests/assets/configs/data_manipulation.yaml @@ -0,0 +1,942 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + sys_log_level: WARNING + + +game: + max_episode_length: 128 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_1 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_1 + + + + + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + - node_name: client_2 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + monitored_traffic: + icmp: + - NONE + tcp: + - DNS + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_CHECKHASH" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + agent_settings: + flatten_obs: true + action_masking: true + + + + + +simulation: + network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE + nodes: + + - hostname: router_1 + type: router + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + 18: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 19: + action: PERMIT + src_port: DNS + dst_port: DNS + 20: + action: PERMIT + src_port: FTP + dst_port: FTP + 21: + action: PERMIT + src_port: HTTP + dst_port: HTTP + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - hostname: switch_1 + type: switch + num_ports: 8 + + - hostname: switch_2 + type: switch + num_ports: 8 + + - hostname: domain_controller + type: server + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - hostname: web_server + type: server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - hostname: database_server + type: server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + options: + backup_server_ip: 192.168.1.16 + - type: FTPClient + + - hostname: backup_server + type: server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - hostname: security_suite + type: server + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/integration_tests/game_layer/observations/test_user_observations.py b/tests/integration_tests/game_layer/observations/test_user_observations.py new file mode 100644 index 00000000..ca5e2543 --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_user_observations.py @@ -0,0 +1,89 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import pytest + +from primaite.session.environment import PrimaiteGymEnv +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from tests import TEST_ASSETS_ROOT + +DATA_MANIPULATION_CONFIG = TEST_ASSETS_ROOT / "configs" / "data_manipulation.yaml" + + +@pytest.fixture +def env_with_ssh() -> PrimaiteGymEnv: + """Build data manipulation environment with SSH port open on router.""" + env = PrimaiteGymEnv(DATA_MANIPULATION_CONFIG) + env.agent.flatten_obs = False + router: Router = env.game.simulation.network.get_node_by_hostname("router_1") + router.acl.add_rule(ACLAction.PERMIT, src_port=Port.SSH, dst_port=Port.SSH, position=3) + return env + + +def extract_login_numbers_from_obs(obs): + """Traverse the observation dictionary and return number of user sessions for all nodes.""" + login_nums = {} + for node_name, node_obs in obs["NODES"].items(): + login_nums[node_name] = node_obs.get("users") + return login_nums + + +class TestUserObservations: + """Test that the RouterObservation, FirewallObservation, and HostObservation have the correct number of logins.""" + + def test_no_sessions_at_episode_start(self, env_with_ssh): + """Test that all of the login observations start at 0 before any logins occur.""" + obs, *_ = env_with_ssh.step(0) + logins_obs = extract_login_numbers_from_obs(obs) + for o in logins_obs.values(): + assert o["local_login"] == 0 + assert o["remote_sessions"] == 0 + + def test_single_login(self, env_with_ssh: PrimaiteGymEnv): + """Test that performing a remote login increases the remote_sessions observation by 1.""" + client_1 = env_with_ssh.game.simulation.network.get_node_by_hostname("client_1") + client_1.terminal._send_remote_login("admin", "admin", "192.168.1.14") # connect to database server via ssh + obs, *_ = env_with_ssh.step(0) + logins_obs = extract_login_numbers_from_obs(obs) + db_srv_logins_obs = logins_obs.pop("HOST2") # this is the index of db server + assert db_srv_logins_obs["local_login"] == 0 + assert db_srv_logins_obs["remote_sessions"] == 1 + for o in logins_obs.values(): # the remaining obs after popping HOST2 + assert o["local_login"] == 0 + assert o["remote_sessions"] == 0 + + def test_logout(self, env_with_ssh: PrimaiteGymEnv): + """Test that remote_sessions observation correctly decreases upon logout.""" + client_1 = env_with_ssh.game.simulation.network.get_node_by_hostname("client_1") + client_1.terminal._send_remote_login("admin", "admin", "192.168.1.14") # connect to database server via ssh + db_srv = env_with_ssh.game.simulation.network.get_node_by_hostname("database_server") + db_srv.user_manager.change_user_password("admin", "admin", "different_pass") # changing password logs out user + + obs, *_ = env_with_ssh.step(0) + logins_obs = extract_login_numbers_from_obs(obs) + for o in logins_obs.values(): + assert o["local_login"] == 0 + assert o["remote_sessions"] == 0 + + def test_max_observable_sessions(self, env_with_ssh: PrimaiteGymEnv): + """Log in from 5 remote places and check that only a max of 3 is shown in the observation.""" + MAX_OBSERVABLE_SESSIONS = 3 + # Right now this is hardcoded as 3 in HostObservation, FirewallObservation, and RouterObservation + obs, *_ = env_with_ssh.step(0) + logins_obs = extract_login_numbers_from_obs(obs) + db_srv_logins_obs = logins_obs.pop("HOST2") # this is the index of db server + + db_srv = env_with_ssh.game.simulation.network.get_node_by_hostname("database_server") + db_srv.user_session_manager.remote_session_timeout_steps = 20 + db_srv.user_session_manager.max_remote_sessions = 5 + node_names = ("client_1", "client_2", "backup_server", "security_suite", "domain_controller") + + for i, node_name in enumerate(node_names): + node = env_with_ssh.game.simulation.network.get_node_by_hostname(node_name) + node.terminal._send_remote_login("admin", "admin", "192.168.1.14") + + obs, *_ = env_with_ssh.step(0) + logins_obs = extract_login_numbers_from_obs(obs) + db_srv_logins_obs = logins_obs.pop("HOST2") # this is the index of db server + + assert db_srv_logins_obs["remote_sessions"] == min(MAX_OBSERVABLE_SESSIONS, i + 1) + assert len(db_srv.user_session_manager.remote_sessions) == i + 1 From 21c0b02ff79ed4c469baa2874be543cfb8e94fc0 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 16 Aug 2024 09:21:27 +0100 Subject: [PATCH 164/206] #2769 - update observation tests with new parameter --- .../game_layer/observations/test_firewall_observation.py | 1 + .../game_layer/observations/test_node_observations.py | 1 + .../game_layer/observations/test_router_observation.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests/game_layer/observations/test_firewall_observation.py b/tests/integration_tests/game_layer/observations/test_firewall_observation.py index 99417e33..34a37f5e 100644 --- a/tests/integration_tests/game_layer/observations/test_firewall_observation.py +++ b/tests/integration_tests/game_layer/observations/test_firewall_observation.py @@ -33,6 +33,7 @@ def test_firewall_observation(): wildcard_list=["0.0.0.255", "0.0.0.1"], port_list=["HTTP", "DNS"], protocol_list=["TCP"], + include_users=False, ) observation = firewall_observation.observe(firewall.describe_state()) diff --git a/tests/integration_tests/game_layer/observations/test_node_observations.py b/tests/integration_tests/game_layer/observations/test_node_observations.py index 1edb0442..69d9f106 100644 --- a/tests/integration_tests/game_layer/observations/test_node_observations.py +++ b/tests/integration_tests/game_layer/observations/test_node_observations.py @@ -39,6 +39,7 @@ def test_host_observation(simulation): folders=[], network_interfaces=[], file_system_requires_scan=True, + include_users=False, ) assert host_obs.space["operating_status"] == spaces.Discrete(5) diff --git a/tests/integration_tests/game_layer/observations/test_router_observation.py b/tests/integration_tests/game_layer/observations/test_router_observation.py index c534307f..48d29cfb 100644 --- a/tests/integration_tests/game_layer/observations/test_router_observation.py +++ b/tests/integration_tests/game_layer/observations/test_router_observation.py @@ -27,7 +27,7 @@ def test_router_observation(): port_list=["HTTP", "DNS"], protocol_list=["TCP"], ) - router_observation = RouterObservation(where=[], ports=ports, num_ports=8, acl=acl) + router_observation = RouterObservation(where=[], ports=ports, num_ports=8, acl=acl, include_users=False) # Observe the state using the RouterObservation instance observed_output = router_observation.observe(router.describe_state()) From d74227e34f663799ef28ccb71062ebf690eb76ca Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 16 Aug 2024 10:10:26 +0100 Subject: [PATCH 165/206] #2769 - update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c63b114..8ac61df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Random Number Generator Seeding by specifying a random number seed in the config file. - Implemented Terminal service class, providing a generic terminal simulation. - Added `User`, `UserManager` and `UserSessionManager` to enable the creation of user accounts and login on Nodes. +- Added actions to establish SSH connections, send commands remotely and terminate SSH connections. +- Added actions to change users' passwords. - Added a `listen_on_ports` set in the `IOSoftware` class to enable software listening on ports in addition to the main port they're assigned. ### Changed - File and folder observations can now be configured to always show the true health status, or require scanning like before. +- Node observations can now be configured to show the number of active local and remote logins. ### Fixed - Folder observations showing the true health state without scanning (the old behaviour can be reenabled via config) From 849cb20f3526a4bea8f74d5b599e69e4039867e3 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 16 Aug 2024 10:24:53 +0100 Subject: [PATCH 166/206] #2689 Addressed more PR comments & fixed an bug with command parsing in _command_terminal (c2 beacon) --- .../system/applications/c2_suite.rst | 2 +- src/primaite/game/game.py | 12 ++---- .../Command-&-Control-E2E-Demonstration.ipynb | 12 +++--- .../red_applications/c2/abstract_c2.py | 4 +- .../red_applications/c2/c2_beacon.py | 3 +- .../test_c2_suite_integration.py | 40 +++++++++++++------ 6 files changed, 41 insertions(+), 32 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index ab6a49e2..28bb1bf8 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -13,7 +13,7 @@ malicious network architecture and begin to further the realism of red agents wi Overview: ========= -These two new classes give red agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application whilst introducing more opportunities for the blue agent to notice and subvert a red agent during an episode. +These two new classes give red agents a cyber realistic way of leveraging the capabilities of the ``Terminal`` application whilst introducing more opportunities for the blue agent(s) to notice and subvert a red agent during an episode. For a more in-depth look at the command and control applications then please refer to the ``C2-E2E-Notebook``. diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index d3035a5a..045b2467 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -461,15 +461,9 @@ class PrimaiteGame: opt = application_cfg["options"] new_application.configure( c2_server_ip_address=IPv4Address(opt.get("c2_server_ip_address")), - keep_alive_frequency=(opt.get("keep_alive_frequency", 5)) - if opt.get("keep_alive_frequency") - else 5, - masquerade_protocol=IPProtocol[(opt.get("masquerade_protocol"))] - if opt.get("masquerade_protocol") - else IPProtocol.TCP, - masquerade_port=Port[(opt.get("masquerade_port"))] - if opt.get("masquerade_port") - else Port.HTTP, + keep_alive_frequency=(opt.get("keep_alive_frequency", 5)), + masquerade_protocol=IPProtocol[(opt.get("masquerade_protocol", IPProtocol.TCP))], + masquerade_port=Port[(opt.get("masquerade_port", Port.HTTP))], ) if "network_interfaces" in node_cfg: for nic_num, nic_cfg in node_cfg["network_interfaces"].items(): diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 9da39e32..03e50ae4 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -191,7 +191,7 @@ "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run before the Red Agent can perform any C2 specific action.\n", "This is because in higher fidelity environments (and the real-world) a C2 server would not be accessible by a private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) starts.\n", "\n", - "The cells below install and runs the C2 Server on client_1 directly via the simulation API." + "The cells below install and run the C2 Server on client_1 directly via the simulation API." ] }, { @@ -1188,7 +1188,7 @@ "source": [ "#### Shutting down the node infected with a C2 Beacon.\n", "\n", - "Another way a blue agent can prevent the C2 suite is by shutting down the C2 beacon's host node. Whilst not as effective as the previous option, dependant on the situation (such as multiple malicious applications) or other scenarios it may be more timestep efficient for a blue agent to shut down a node directly." + "Another way a blue agent can prevent the C2 suite is by shutting down the C2 beacon's host node. Whilst not as effective as the previous option, depending on the situation (such as multiple malicious applications) or other scenarios it may be more timestep efficient for a blue agent to shut down a node directly." ] }, { @@ -1216,7 +1216,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The code cell below uses the custom blue agent defined at the start of this section perform a ``NODE_SHUT_DOWN`` action on the web server." + "The code cell below uses the custom blue agent defined at the start of this section to perform a ``NODE_SHUT_DOWN`` action on the web server." ] }, { @@ -1233,7 +1233,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Which we can see the effects of after another timestep and looking at the web_server's operating state & the OBS differences." + "Which we can see the effects of after another timestep and looking at the web server's operating state & the OBS differences." ] }, { @@ -1454,7 +1454,7 @@ "\n", "In the case of the C2 Beacon, the C2 Server's IP address must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", "\n", - "If the network contains multiple C2 Servers then it's also possible to switch to different C2 servers mid-episode which is demonstrated in the below code cells." + "If the network contains multiple C2 Servers then it's also possible to switch to a different C2 servers mid-episode which is demonstrated in the below code cells." ] }, { @@ -1544,7 +1544,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After six timesteps the client_1 server will recognise the C2 beacon's previous connection as dead and clear its connections. (This is dependant on the ``Keep Alive Frequency`` setting.)" + "After six timesteps the client_1 server will recognise the C2 beacon's previous connection as dead and clear its connections. (This is dependent on the ``Keep Alive Frequency`` setting.)" ] }, { diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 0d7bbf1f..7e9de77e 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -75,7 +75,7 @@ class AbstractC2(Application, identifier="AbstractC2"): keep_alive_inactivity: int = 0 """Indicates how many timesteps since the last time the c2 application received a keep alive.""" - class _C2_Opts(BaseModel): + class C2_Opts(BaseModel): """A Pydantic Schema for the different C2 configuration options.""" keep_alive_frequency: int = Field(default=5, ge=1) @@ -87,7 +87,7 @@ class AbstractC2(Application, identifier="AbstractC2"): masquerade_port: Port = Field(default=Port.HTTP) """The currently chosen port that the C2 traffic is masquerading as. Defaults at HTTP.""" - c2_config: _C2_Opts = _C2_Opts() + c2_config: C2_Opts = C2_Opts() """ Holds the current configuration settings of the C2 Suite. diff --git a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py index 393512db..fa0271e5 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/c2_beacon.py @@ -493,8 +493,9 @@ class C2Beacon(AbstractC2, identifier="C2Beacon"): ) # Converts a singular terminal command: [RequestFormat] into a list with one element [[RequestFormat]] + # Checks the first element - if this element is a str then there must be multiple commands. command_opts.commands = ( - [command_opts.commands] if not isinstance(command_opts.commands, list) else command_opts.commands + [command_opts.commands] if isinstance(command_opts.commands[0], str) else command_opts.commands ) for index, given_command in enumerate(command_opts.commands): diff --git a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py index 910f4760..9d12f2cf 100644 --- a/tests/integration_tests/system/red_applications/test_c2_suite_integration.py +++ b/tests/integration_tests/system/red_applications/test_c2_suite_integration.py @@ -257,27 +257,37 @@ def test_c2_suite_terminal_command_file_creation(basic_network): # Testing that we can create the test file and folders via the terminal command (Local C2 Terminal). # Local file/folder creation commands. - file_create_command = { - "commands": [ - ["file_system", "create", "folder", "test_folder"], - ["file_system", "create", "file", "test_folder", "test_file", "True"], - ], + folder_create_command = { + "commands": ["file_system", "create", "folder", "test_folder"], "username": "admin", "password": "admin", "ip_address": None, } + c2_server.send_command(C2Command.TERMINAL, command_options=folder_create_command) + file_create_command = { + "commands": ["file_system", "create", "file", "test_folder", "test_file", "True"], + "username": "admin", + "password": "admin", + "ip_address": None, + } c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) assert computer_b.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True assert c2_beacon.terminal_session is not None # Testing that we can create the same test file/folders via on node 3 via a remote terminal. + file_remote_create_command = { + "commands": [ + ["file_system", "create", "folder", "test_folder"], + ["file_system", "create", "file", "test_folder", "test_file", "True"], + ], + "username": "admin", + "password": "admin", + "ip_address": "192.168.255.3", + } - # node_c's IP is 192.168.255.3 - file_create_command.update({"ip_address": "192.168.255.3"}) - - c2_server.send_command(C2Command.TERMINAL, command_options=file_create_command) + c2_server.send_command(C2Command.TERMINAL, command_options=file_remote_create_command) assert computer_c.software_manager.file_system.access_file(folder_name="test_folder", file_name="test_file") == True assert c2_beacon.terminal_session is not None @@ -435,11 +445,15 @@ def test_c2_suite_acl_bypass(basic_network): # Confirming that we can send commands + http_folder_create_command = { + "commands": ["file_system", "create", "folder", "test_folder"], + "username": "admin", + "password": "admin", + "ip_address": None, + } + c2_server.send_command(C2Command.TERMINAL, command_options=http_folder_create_command) http_file_create_command = { - "commands": [ - ["file_system", "create", "folder", "test_folder"], - ["file_system", "create", "file", "test_folder", "http_test_file", "True"], - ], + "commands": ["file_system", "create", "file", "test_folder", "http_test_file", "true"], "username": "admin", "password": "admin", "ip_address": None, From 83b8206ce0254e6bfa7df3b9b635533ea06872a0 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 16 Aug 2024 11:51:38 +0100 Subject: [PATCH 167/206] #2689 Added C2 Sequence diagram to docs and added additional ftp_client request tests. --- docs/_static/c2_sequence.png | Bin 0 -> 55723 bytes .../system/applications/c2_suite.rst | 6 ++ .../Command-&-Control-E2E-Demonstration.ipynb | 2 +- .../red_applications/c2/abstract_c2.py | 4 +- .../red_applications/c2/c2_server.py | 2 +- .../system/services/ftp/ftp_client.py | 7 +- .../system/test_ftp_client_server.py | 63 ++++++++++++++++++ 7 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 docs/_static/c2_sequence.png diff --git a/docs/_static/c2_sequence.png b/docs/_static/c2_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..9c7ba397b8dddf6d61ebe7f7d6114849705c16d4 GIT binary patch literal 55723 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 Date: Fri, 16 Aug 2024 15:47:41 +0100 Subject: [PATCH 168/206] #2689 Fixed issues with .rst (fixed terminal as well) --- .../system/applications/c2_suite.rst | 46 +++++++++++-------- .../system/services/terminal.rst | 17 +++---- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 1fa05466..034158d7 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -18,7 +18,7 @@ These two new classes give red agents a cyber realistic way of leveraging the ca For a more in-depth look at the command and control applications then please refer to the ``C2-E2E-Notebook``. ``C2 Server`` -"""""""""""" +""""""""""""" The C2 Server application is intended to represent the malicious infrastructure already under the control of an adversary. @@ -101,8 +101,8 @@ However, each host implements it's own receive methods. The sequence diagram below clarifies the functionality of both applications: -.. image:: ../_static/c2_sequence.png - :width: 500 +.. image:: ../../../../_static/c2_sequence.png + :width: 1000 :align: center @@ -114,38 +114,45 @@ Examples Python """""" .. code-block:: python - from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon - from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Server - from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command + + from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.nodes.host.computer import Computer - from primaite.simulator.system.services.database.database_service import DatabaseService + from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.system.applications.database_client import DatabaseClient + from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript + from primaite.simulator.system.services.database.database_service import DatabaseService + from primaite.simulator.system.applications.red_applications.c2.c2_server import C2Command, C2Server + from primaite.simulator.system.applications.red_applications.c2.c2_beacon import C2Beacon + # Network Setup + network = Network() + switch = Switch(hostname="switch", start_up_duration=0, num_ports=4) switch.power_on() node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) node_a.power_on() - node_a.software_manager.install(software_class=C2Server) network.connect(node_a.network_interface[1], switch.network_interface[1]) node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) node_b.power_on() - node_b.software_manager.install(software_class=C2Beacon) - node_b.software_manager.install(software_class=DatabaseClient) + network.connect(node_b.network_interface[1], switch.network_interface[2]) node_c = Computer(hostname="node_c", ip_address="192.168.0.12", subnet_mask="255.255.255.0", start_up_duration=0) node_c.power_on() - node_c.software_manager.install(software_class=DatabaseServer) network.connect(node_c.network_interface[1], switch.network_interface[3]) + node_c.software_manager.install(software_class=DatabaseService) + node_b.software_manager.install(software_class=DatabaseClient) + node_b.software_manager.install(software_class=RansomwareScript) + node_a.software_manager.install(software_class=C2Server) + # C2 Application objects - c2_server_host: computer = simulation_testing_network.get_node_by_hostname("node_a") - c2_beacon_host: computer = simulation_testing_network.get_node_by_hostname("node_b") - + c2_server_host: Computer = network.get_node_by_hostname("node_a") + c2_beacon_host: Computer = network.get_node_by_hostname("node_b") c2_server: C2Server = c2_server_host.software_manager.software["C2Server"] c2_beacon: C2Beacon = c2_beacon_host.software_manager.software["C2Beacon"] @@ -182,7 +189,7 @@ Python "password": "admin", "ip_address": None, } - c2_server.send_command(given_command=C2Command.TERMINAL, command_options=ransomware_config) + c2_server.send_command(given_command=C2Command.TERMINAL, command_options=ransomware_installation_command) ransomware_config = {"server_ip_address": "192.168.0.12"} @@ -197,9 +204,8 @@ Python "password": "admin", "ip_address": None, "target_ip_address": "192.168.0.12", - "target_file_name": "database.db" - "target_folder_name": "database" - "exfiltration_folder_name": + "target_file_name": "database.db", + "target_folder_name": "database", } c2_server.send_command(given_command=C2Command.DATA_EXFILTRATION, command_options=data_exfil_options) @@ -254,7 +260,7 @@ C2 Beacon Configuration .. |SOFTWARE_NAME_BACKTICK| replace:: ``C2Beacon`` ``c2_server_ip_address`` -""""""""""""""""""""""" +"""""""""""""""""""""""" IP address of the ``C2Server`` that the C2 Beacon will use to establish connection. @@ -262,7 +268,7 @@ This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.25 ``Keep Alive Frequency`` -""""""""""""""""""""""" +"""""""""""""""""""""""" How often should the C2 Beacon confirm it's connection in timesteps. diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 5097f213..f982145d 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -5,26 +5,26 @@ .. _Terminal: Terminal -======== +######## The ``Terminal.py`` class provides a generic terminal simulation, by extending the base Service class within PrimAITE. The aim of this is to act as the primary entrypoint for Nodes within the environment. Overview --------- +======== The Terminal service uses Secure Socket (SSH) as the communication method between terminals. They operate on port 22, and are part of the services automatically installed on Nodes when they are instantiated. Key capabilities -================ +"""""""""""""""" - Ensures packets are matched to an existing session - Simulates common Terminal processes/commands. - Leverages the Service base class for install/uninstall, status tracking etc. Usage -===== +""""" - Pre-Installs on any `Node` (component with the exception of `Switches`). - Terminal Clients connect, execute commands and disconnect from remote nodes. @@ -32,7 +32,7 @@ Usage - Service runs on SSH port 22 by default. Implementation -============== +"""""""""""""" - Manages remote connections in a dictionary by session ID. - Processes commands, forwarding to the ``RequestManager`` or ``SessionManager`` where appropriate. @@ -67,7 +67,7 @@ Python terminal: Terminal = client.software_manager.software.get("Terminal") Creating Remote Terminal Connection -""""""""""""""""""""""""""" +""""""""""""""""""""""""""""""""""" .. code-block:: python @@ -93,7 +93,7 @@ Creating Remote Terminal Connection Executing a basic application install command -""""""""""""""""""""""""""""""""" +""""""""""""""""""""""""""""""""""""""""""""" .. code-block:: python @@ -121,7 +121,7 @@ Executing a basic application install command Creating a folder on a remote node -"""""""""""""""""""""""""""""""" +"""""""""""""""""""""""""""""""""" .. code-block:: python @@ -148,6 +148,7 @@ Creating a folder on a remote node Disconnect from Remote Node +""""""""""""""""""""""""""" .. code-block:: python From 05f9751fa81dd2f9e822b1710864cbd2f42d2875 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 10:17:39 +0100 Subject: [PATCH 169/206] #2736 - implement instantaneous rewards --- src/primaite/game/agent/rewards.py | 137 ++++++++++++------ .../system/applications/database_client.py | 11 -- .../system/services/web_server/web_server.py | 20 ++- 3 files changed, 104 insertions(+), 64 deletions(-) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index c959ee5b..00374791 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -151,14 +151,20 @@ class DatabaseFileIntegrity(AbstractReward): class WebServer404Penalty(AbstractReward): """Reward function component which penalises the agent when the web server returns a 404 error.""" - def __init__(self, node_hostname: str, service_name: str) -> None: + def __init__(self, node_hostname: str, service_name: str, sticky: bool = True) -> None: """Initialise the reward component. :param node_hostname: Hostname of the node which contains the web server service. :type node_hostname: str :param service_name: Name of the web server service. :type service_name: str + :param sticky: If True, calculate the reward based on the most recent response status. If False, only calculate + the reward if there were any responses this timestep. + :type sticky: bool """ + self.sticky: bool = sticky + self.reward: float = 0.0 + """Reward value calculated last time any responses were seen. Used for persisting sticky rewards.""" self.location_in_state = ["network", "nodes", node_hostname, "services", service_name] def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: @@ -168,16 +174,21 @@ class WebServer404Penalty(AbstractReward): :type state: Dict """ web_service_state = access_from_nested_dict(state, self.location_in_state) + + # if webserver is no longer installed on the node, return 0 if web_service_state is NOT_PRESENT_IN_STATE: return 0.0 - most_recent_return_code = web_service_state["last_response_status_code"] - # TODO: reward needs to use the current web state. Observation should return web state at the time of last scan. - if most_recent_return_code == 200: - return 1.0 - elif most_recent_return_code == 404: - return -1.0 - else: - return 0.0 + + codes = web_service_state.get("response_codes_this_timestep") + if codes or not self.sticky: # skip calculating if sticky and no new codes. Insted, reuse last step's value. + + def status2rew(status: int) -> int: + """Map status codes to reward values.""" + return 1.0 if status == 200 else -1.0 if status == 404 else 0.0 + + self.reward = sum(map(status2rew, codes)) / len(codes) # convert form HTTP codes to rewards and average + + return self.reward @classmethod def from_config(cls, config: Dict) -> "WebServer404Penalty": @@ -197,23 +208,29 @@ class WebServer404Penalty(AbstractReward): ) _LOGGER.warning(msg) raise ValueError(msg) + sticky = config.get("sticky", True) - return cls(node_hostname=node_hostname, service_name=service_name) + return cls(node_hostname=node_hostname, service_name=service_name, sticky=sticky) class WebpageUnavailablePenalty(AbstractReward): """Penalises the agent when the web browser fails to fetch a webpage.""" - def __init__(self, node_hostname: str) -> None: + def __init__(self, node_hostname: str, sticky: bool = True) -> None: """ Initialise the reward component. :param node_hostname: Hostname of the node which has the web browser. :type node_hostname: str + :param sticky: If True, calculate the reward based on the most recent response status. If False, only calculate + the reward if there were any responses this timestep. + :type sticky: bool """ self._node: str = node_hostname self.location_in_state: List[str] = ["network", "nodes", node_hostname, "applications", "WebBrowser"] - self._last_request_failed: bool = False + self.sticky: bool = sticky + self.reward: float = 0.0 + """Reward value calculated last time any responses were seen. Used for persisting sticky rewards.""" def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: """ @@ -223,31 +240,46 @@ class WebpageUnavailablePenalty(AbstractReward): component will keep track of that information. In that case, it doesn't matter whether the last webpage had a 200 status code, because there has been an unsuccessful request since. """ - if last_action_response.request == ["network", "node", self._node, "application", "WebBrowser", "execute"]: - self._last_request_failed = last_action_response.response.status != "success" - - # if agent couldn't even get as far as sending the request (because for example the node was off), then - # apply a penalty - if self._last_request_failed: - return -1.0 - - # If the last request did actually go through, then check if the webpage also loaded web_browser_state = access_from_nested_dict(state, self.location_in_state) - if web_browser_state is NOT_PRESENT_IN_STATE or "history" not in web_browser_state: + + if web_browser_state is NOT_PRESENT_IN_STATE: + self.reward = 0.0 + + # check if the most recent action was to request the webpage + request_attempted = last_action_response.request == [ + "network", + "node", + self._node, + "application", + "WebBrowser", + "execute", + ] + + if ( + not request_attempted and self.sticky + ): # skip calculating if sticky and no new codes, reusing last step value + return self.reward + + if last_action_response.response.status != "success": + self.reward = -1.0 + # + elif web_browser_state is NOT_PRESENT_IN_STATE or "history" not in web_browser_state: _LOGGER.debug( "Web browser reward could not be calculated because the web browser history on node", f"{self._node} was not reported in the simulation state. Returning 0.0", ) - return 0.0 # 0 if the web browser cannot be found - if not web_browser_state["history"]: - return 0.0 # 0 if no requests have been attempted yet + self.reward = 0.0 + elif not web_browser_state["history"]: + self.reward = 0.0 # 0 if no requests have been attempted yet outcome = web_browser_state["history"][-1]["outcome"] if outcome == "PENDING": - return 0.0 # 0 if a request was attempted but not yet resolved + self.reward = 0.0 # 0 if a request was attempted but not yet resolved elif outcome == 200: - return 1.0 # 1 for successful request + self.reward = 1.0 # 1 for successful request else: # includes failure codes and SERVER_UNREACHABLE - return -1.0 # -1 for failure + self.reward = -1.0 # -1 for failure + + return self.reward @classmethod def from_config(cls, config: dict) -> AbstractReward: @@ -258,22 +290,28 @@ class WebpageUnavailablePenalty(AbstractReward): :type config: Dict """ node_hostname = config.get("node_hostname") - return cls(node_hostname=node_hostname) + sticky = config.get("sticky", True) + return cls(node_hostname=node_hostname, sticky=sticky) class GreenAdminDatabaseUnreachablePenalty(AbstractReward): """Penalises the agent when the green db clients fail to connect to the database.""" - def __init__(self, node_hostname: str) -> None: + def __init__(self, node_hostname: str, sticky: bool = True) -> None: """ Initialise the reward component. :param node_hostname: Hostname of the node where the database client sits. :type node_hostname: str + :param sticky: If True, calculate the reward based on the most recent response status. If False, only calculate + the reward if there were any responses this timestep. + :type sticky: bool """ self._node: str = node_hostname self.location_in_state: List[str] = ["network", "nodes", node_hostname, "applications", "DatabaseClient"] - self._last_request_failed: bool = False + self.sticky: bool = sticky + self.reward: float = 0.0 + """Reward value calculated last time any responses were seen. Used for persisting sticky rewards.""" def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: """ @@ -284,25 +322,29 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): request returned was able to connect to the database server, because there has been an unsuccessful request since. """ - if last_action_response.request == ["network", "node", self._node, "application", "DatabaseClient", "execute"]: - self._last_request_failed = last_action_response.response.status != "success" - - # if agent couldn't even get as far as sending the request (because for example the node was off), then - # apply a penalty - if self._last_request_failed: - return -1.0 + db_state = access_from_nested_dict(state, self.location_in_state) # If the last request was actually sent, then check if the connection was established. - db_state = access_from_nested_dict(state, self.location_in_state) if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state: _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") - return 0.0 - last_connection_successful = db_state["last_connection_successful"] - if last_connection_successful is False: - return -1.0 - elif last_connection_successful is True: - return 1.0 - return 0.0 + self.reward = 0.0 + + request_attempted = last_action_response.request == [ + "network", + "node", + self._node, + "application", + "DatabaseClient", + "execute", + ] + + if ( + not request_attempted and self.sticky + ): # skip calculating if sticky and no new codes, reusing last step value + return self.reward + + self.reward = 1.0 if last_action_response.response.status == "success" else -1.0 + return self.reward @classmethod def from_config(cls, config: Dict) -> AbstractReward: @@ -313,7 +355,8 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): :type config: Dict """ node_hostname = config.get("node_hostname") - return cls(node_hostname=node_hostname) + sticky = config.get("sticky", True) + return cls(node_hostname=node_hostname, sticky=sticky) class SharedReward(AbstractReward): diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index e6cfa343..933afadf 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -73,7 +73,6 @@ class DatabaseClient(Application, identifier="DatabaseClient"): server_ip_address: Optional[IPv4Address] = None server_password: Optional[str] = None - _last_connection_successful: Optional[bool] = None _query_success_tracker: Dict[str, bool] = {} """Keep track of connections that were established or verified during this step. Used for rewards.""" last_query_response: Optional[Dict] = None @@ -135,8 +134,6 @@ class DatabaseClient(Application, identifier="DatabaseClient"): :return: A dictionary representing the current state. """ state = super().describe_state() - # list of connections that were established or verified during this step. - state["last_connection_successful"] = self._last_connection_successful return state def show(self, markdown: bool = False): @@ -226,13 +223,11 @@ class DatabaseClient(Application, identifier="DatabaseClient"): f"Using connection id {database_client_connection}" ) self.connected = True - self._last_connection_successful = True return database_client_connection else: self.sys_log.info( f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} declined" ) - self._last_connection_successful = False return None else: self.sys_log.info( @@ -357,10 +352,8 @@ class DatabaseClient(Application, identifier="DatabaseClient"): success = self._query_success_tracker.get(query_id) if success: self.sys_log.info(f"{self.name}: Query successful {sql}") - self._last_connection_successful = True return True self.sys_log.error(f"{self.name}: Unable to run query {sql}") - self._last_connection_successful = False return False else: software_manager: SoftwareManager = self.software_manager @@ -390,9 +383,6 @@ class DatabaseClient(Application, identifier="DatabaseClient"): if not self.native_connection: return False - # reset last query response - self.last_query_response = None - uuid = str(uuid4()) self._query_success_tracker[uuid] = False return self.native_connection.query(sql) @@ -416,7 +406,6 @@ class DatabaseClient(Application, identifier="DatabaseClient"): connection_id=connection_id, connection_request_id=payload["connection_request_id"] ) elif payload["type"] == "sql": - self.last_query_response = payload query_id = payload.get("uuid") status_code = payload.get("status_code") self._query_success_tracker[query_id] = status_code == 200 diff --git a/src/primaite/simulator/system/services/web_server/web_server.py b/src/primaite/simulator/system/services/web_server/web_server.py index 6f6fa335..4fc64e1f 100644 --- a/src/primaite/simulator/system/services/web_server/web_server.py +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -1,6 +1,6 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from urllib.parse import urlparse from primaite import getLogger @@ -22,7 +22,7 @@ _LOGGER = getLogger(__name__) class WebServer(Service): """Class used to represent a Web Server Service in simulation.""" - last_response_status_code: Optional[HttpStatusCode] = None + response_codes_this_timestep: List[HttpStatusCode] = [] def describe_state(self) -> Dict: """ @@ -34,11 +34,19 @@ class WebServer(Service): :rtype: Dict """ state = super().describe_state() - state["last_response_status_code"] = ( - self.last_response_status_code.value if isinstance(self.last_response_status_code, HttpStatusCode) else None - ) + state["response_codes_this_timestep"] = [code.value for code in self.response_codes_this_timestep] return state + def pre_timestep(self, timestep: int) -> None: + """ + Logic to execute at the start of the timestep - clear the observation-related attributes. + + :param timestep: the current timestep in the episode. + :type timestep: int + """ + self.response_codes_this_timestep = [] + return super().pre_timestep(timestep) + def __init__(self, **kwargs): kwargs["name"] = "WebServer" kwargs["protocol"] = IPProtocol.TCP @@ -89,7 +97,7 @@ class WebServer(Service): self.send(payload=response, session_id=session_id) # return true if response is OK - self.last_response_status_code = response.status_code + self.response_codes_this_timestep.append(response.status_code) return response.status_code == HttpStatusCode.OK def _handle_get_request(self, payload: HttpRequestPacket) -> HttpResponsePacket: From aeca5fb6a27efb77dd588e78f6815d056c5db8da Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 10:28:39 +0100 Subject: [PATCH 170/206] #2769 - Clean up incorrect names and commented out code [skip ci] --- src/primaite/game/agent/actions.py | 2 +- .../system/services/terminal/terminal.py | 30 ------------------- tests/conftest.py | 2 +- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index d588c018..2a0c5351 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1201,7 +1201,7 @@ class ActionManager: "CONFIGURE_DOSBOT": ConfigureDoSBotAction, "NODE_ACCOUNTS_CHANGE_PASSWORD": NodeAccountsChangePasswordAction, "SSH_TO_REMOTE": NodeSessionsRemoteLoginAction, - "SSH_LOGOUT_LOGOUT": NodeSessionsRemoteLogoutAction, + "SESSIONS_REMOTE_LOGOFF": NodeSessionsRemoteLogoutAction, "NODE_SEND_REMOTE_COMMAND": NodeSendRemoteCommandAction, } """Dictionary which maps action type strings to the corresponding action class.""" diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 79dc698f..406facd1 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -164,36 +164,6 @@ class Terminal(Service): def _init_request_manager(self) -> RequestManager: """Initialise Request manager.""" rm = super()._init_request_manager() - # rm.add_request( - # "send", - # request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())), - # ) - - # def _login(request: RequestFormat, context: Dict) -> RequestResponse: - # login = self._process_local_login(username=request[0], password=request[1]) - # if login: - # return RequestResponse( - # status="success", - # data={ - # "ip_address": login.ip_address, - # }, - # ) - # else: - # return RequestResponse(status="failure", data={"reason": "Invalid login credentials"}) - # - # rm.add_request( - # "Login", - # request_type=RequestType(func=_login), - # ) - - # def _logoff(request: RequestFormat, context: Dict) -> RequestResponse: - # """Logoff from connection.""" - # connection_uuid = request[0] - # self.parent.user_session_manager.local_logout(connection_uuid) - # self._disconnect(connection_uuid) - # return RequestResponse(status="success", data={}) - # - # rm.add_request("Logoff", request_type=RequestType(func=_logoff)) def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse: login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2]) diff --git a/tests/conftest.py b/tests/conftest.py index 2ae6299d..abc851c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -460,7 +460,7 @@ def game_and_agent(): {"type": "NETWORK_PORT_DISABLE"}, {"type": "NODE_ACCOUNTS_CHANGE_PASSWORD"}, {"type": "SSH_TO_REMOTE"}, - {"type": "SSH_LOGOUT_LOGOUT"}, + {"type": "SESSIONS_REMOTE_LOGOFF"}, {"type": "NODE_SEND_REMOTE_COMMAND"}, ] From a997cebbc69ce0196e7843ec1349057275991f55 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 11:14:53 +0000 Subject: [PATCH 171/206] Apply suggestions from code review [skip ci] --- .../game_layer/actions/test_terminal_actions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration_tests/game_layer/actions/test_terminal_actions.py b/tests/integration_tests/game_layer/actions/test_terminal_actions.py index 84d21bb0..d011c1e8 100644 --- a/tests/integration_tests/game_layer/actions/test_terminal_actions.py +++ b/tests/integration_tests/game_layer/actions/test_terminal_actions.py @@ -151,7 +151,6 @@ def test_change_password_logs_out_user(game_and_agent_fixture: Tuple[PrimaiteGam game.step() # Assert that the user cannot execute an action - # TODO: should the db conn object get destroyed on both nodes? or is that not realistic? action = ( "NODE_SEND_REMOTE_COMMAND", { From 2c71958c913d4186474ea2896c30bb23f56c888c Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 19 Aug 2024 12:55:45 +0100 Subject: [PATCH 172/206] #2748: Port of PrimAITE Internal changes. --- CHANGELOG.md | 1 + src/primaite/game/agent/interface.py | 2 + src/primaite/game/agent/rewards.py | 61 +++++++++++++++---- .../game_layer/test_rewards.py | 21 +++---- 4 files changed, 63 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c63b114..7daf1f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `User`, `UserManager` and `UserSessionManager` to enable the creation of user accounts and login on Nodes. - Added a `listen_on_ports` set in the `IOSoftware` class to enable software listening on ports in addition to the main port they're assigned. +- Added reward calculation details to AgentHistoryItem. ### Changed - File and folder observations can now be configured to always show the true health status, or require scanning like before. diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index f57dc191..14b97821 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -36,6 +36,8 @@ class AgentHistoryItem(BaseModel): reward: Optional[float] = None + reward_info: Dict[str, Any] = {} + class AgentStartSettings(BaseModel): """Configuration values for when an agent starts performing actions.""" diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index c959ee5b..b913501d 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -47,7 +47,15 @@ class AbstractReward: @abstractmethod def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: - """Calculate the reward for the current state.""" + """Calculate the reward for the current state. + + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float + """ return 0.0 @classmethod @@ -67,7 +75,15 @@ class DummyReward(AbstractReward): """Dummy reward function component which always returns 0.""" def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: - """Calculate the reward for the current state.""" + """Calculate the reward for the current state. + + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float + """ return 0.0 @classmethod @@ -109,8 +125,12 @@ class DatabaseFileIntegrity(AbstractReward): def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: """Calculate the reward for the current state. - :param state: The current state of the simulation. + :param state: Current simulation state :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float """ database_file_state = access_from_nested_dict(state, self.location_in_state) if database_file_state is NOT_PRESENT_IN_STATE: @@ -283,6 +303,12 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): component will keep track of that information. In that case, it doesn't matter whether the last successful request returned was able to connect to the database server, because there has been an unsuccessful request since. + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float """ if last_action_response.request == ["network", "node", self._node, "application", "DatabaseClient", "execute"]: self._last_request_failed = last_action_response.response.status != "success" @@ -295,14 +321,11 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): # If the last request was actually sent, then check if the connection was established. db_state = access_from_nested_dict(state, self.location_in_state) if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state: - _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") + last_action_response.reward_info = {"reason": f"Can't calculate reward for {self.__class__.__name__}"} return 0.0 last_connection_successful = db_state["last_connection_successful"] - if last_connection_successful is False: - return -1.0 - elif last_connection_successful is True: - return 1.0 - return 0.0 + last_action_response.reward_info = {"last_connection_successful": last_connection_successful} + return 1.0 if last_connection_successful else -1.0 @classmethod def from_config(cls, config: Dict) -> AbstractReward: @@ -346,7 +369,15 @@ class SharedReward(AbstractReward): """Method that retrieves an agent's current reward given the agent's name.""" def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: - """Simply access the other agent's reward and return it.""" + """Simply access the other agent's reward and return it. + + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float + """ return self.callback(self.agent_name) @classmethod @@ -379,7 +410,15 @@ class ActionPenalty(AbstractReward): self.do_nothing_penalty = do_nothing_penalty def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: - """Calculate the penalty to be applied.""" + """Calculate the penalty to be applied. + + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float + """ if last_action_response.action == "DONOTHING": return self.do_nothing_penalty else: diff --git a/tests/integration_tests/game_layer/test_rewards.py b/tests/integration_tests/game_layer/test_rewards.py index 2bf551c8..e945f482 100644 --- a/tests/integration_tests/game_layer/test_rewards.py +++ b/tests/integration_tests/game_layer/test_rewards.py @@ -76,13 +76,16 @@ def test_uc2_rewards(game_and_agent): ] ) state = game.get_sim_state() - reward_value = comp.calculate( - state, - last_action_response=AgentHistoryItem( - timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response - ), + ahi = AgentHistoryItem( + timestep=0, + action="NODE_APPLICATION_EXECUTE", + parameters={}, + request=["execute"], + response=response, ) + reward_value = comp.calculate(state, last_action_response=ahi) assert reward_value == 1.0 + assert ahi.reward_info == {"last_connection_successful": True} router.acl.remove_rule(position=2) @@ -92,13 +95,9 @@ def test_uc2_rewards(game_and_agent): ] ) state = game.get_sim_state() - reward_value = comp.calculate( - state, - last_action_response=AgentHistoryItem( - timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response - ), - ) + reward_value = comp.calculate(state, last_action_response=ahi) assert reward_value == -1.0 + assert ahi.reward_info == {"last_connection_successful": False} def test_shared_reward(): From f595f44ce97d7f6aa2648ab274c6bdf6a7ed172e Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 19 Aug 2024 13:08:31 +0100 Subject: [PATCH 173/206] #2689 Implemented fixes to _check_connection following PR --- .../red_applications/c2/abstract_c2.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 82e740c5..3d096209 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -463,26 +463,26 @@ class AbstractC2(Application, identifier="AbstractC2"): :return: A tuple containing a boolean True/False and a corresponding Request Response :rtype: tuple[bool, RequestResponse] """ - if self._can_perform_network_action == False: + if not self._can_perform_network_action: self.sys_log.warning(f"{self.name}: Unable to make leverage networking resources. Rejecting Command.") - return [ + return ( False, RequestResponse( status="failure", data={"Reason": "Unable to access networking resources. Unable to send command."} ), - ] + ) if self.c2_remote_connection is False: self.sys_log.warning(f"{self.name}: C2 Application has yet to establish connection. Rejecting command.") - return [ + return ( False, RequestResponse( status="failure", data={"Reason": "C2 Application has yet to establish connection. Unable to send command."}, ), - ] + ) else: - return [ + return ( True, RequestResponse(status="success", data={"Reason": "C2 Application is able to send connections."}), - ] + ) From 2413a2f6a8d3f12815cd142f46eff3b5e2d02999 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 19 Aug 2024 13:10:35 +0100 Subject: [PATCH 174/206] #2689 Fixing oversight on method call --- .../system/applications/red_applications/c2/abstract_c2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 3d096209..354976b7 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -463,7 +463,7 @@ class AbstractC2(Application, identifier="AbstractC2"): :return: A tuple containing a boolean True/False and a corresponding Request Response :rtype: tuple[bool, RequestResponse] """ - if not self._can_perform_network_action: + if not self._can_perform_network_action(): self.sys_log.warning(f"{self.name}: Unable to make leverage networking resources. Rejecting Command.") return ( False, From f344d292dbfe43482e1e021a571aece2105e948b Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 13:59:35 +0100 Subject: [PATCH 175/206] #2736 - Fix up broken reward tests --- src/primaite/game/agent/rewards.py | 32 ++++++------- .../system/applications/database_client.py | 2 - .../game_layer/test_rewards.py | 46 ++++++++----------- .../test_data_manipulation_bot_and_server.py | 2 - .../test_ransomware_script.py | 11 +++-- 5 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 00374791..8ac3956c 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -255,29 +255,26 @@ class WebpageUnavailablePenalty(AbstractReward): "execute", ] - if ( - not request_attempted and self.sticky - ): # skip calculating if sticky and no new codes, reusing last step value + # skip calculating if sticky and no new codes, reusing last step value + if not request_attempted and self.sticky: return self.reward if last_action_response.response.status != "success": self.reward = -1.0 - # - elif web_browser_state is NOT_PRESENT_IN_STATE or "history" not in web_browser_state: + elif web_browser_state is NOT_PRESENT_IN_STATE or not web_browser_state["history"]: _LOGGER.debug( "Web browser reward could not be calculated because the web browser history on node", f"{self._node} was not reported in the simulation state. Returning 0.0", ) self.reward = 0.0 - elif not web_browser_state["history"]: - self.reward = 0.0 # 0 if no requests have been attempted yet - outcome = web_browser_state["history"][-1]["outcome"] - if outcome == "PENDING": - self.reward = 0.0 # 0 if a request was attempted but not yet resolved - elif outcome == 200: - self.reward = 1.0 # 1 for successful request - else: # includes failure codes and SERVER_UNREACHABLE - self.reward = -1.0 # -1 for failure + else: + outcome = web_browser_state["history"][-1]["outcome"] + if outcome == "PENDING": + self.reward = 0.0 # 0 if a request was attempted but not yet resolved + elif outcome == 200: + self.reward = 1.0 # 1 for successful request + else: # includes failure codes and SERVER_UNREACHABLE + self.reward = -1.0 # -1 for failure return self.reward @@ -325,7 +322,7 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): db_state = access_from_nested_dict(state, self.location_in_state) # If the last request was actually sent, then check if the connection was established. - if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state: + if db_state is NOT_PRESENT_IN_STATE: _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") self.reward = 0.0 @@ -338,9 +335,8 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): "execute", ] - if ( - not request_attempted and self.sticky - ): # skip calculating if sticky and no new codes, reusing last step value + # skip calculating if sticky and no new codes, reusing last step value + if not request_attempted and self.sticky: return self.reward self.reward = 1.0 if last_action_response.response.status == "success" else -1.0 diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index 933afadf..0a626c00 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -75,8 +75,6 @@ class DatabaseClient(Application, identifier="DatabaseClient"): server_password: Optional[str] = None _query_success_tracker: Dict[str, bool] = {} """Keep track of connections that were established or verified during this step. Used for rewards.""" - last_query_response: Optional[Dict] = None - """Keep track of the latest query response. Used to determine rewards.""" _server_connection_id: Optional[str] = None """Connection ID to the Database Server.""" client_connections: Dict[str, DatabaseClientConnection] = {} diff --git a/tests/integration_tests/game_layer/test_rewards.py b/tests/integration_tests/game_layer/test_rewards.py index 2bf551c8..83b04832 100644 --- a/tests/integration_tests/game_layer/test_rewards.py +++ b/tests/integration_tests/game_layer/test_rewards.py @@ -12,6 +12,7 @@ from primaite.simulator.network.hardware.nodes.network.router import ACLAction, from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.web_browser import WebBrowser from primaite.simulator.system.services.database.database_service import DatabaseService from tests import TEST_ASSETS_ROOT from tests.conftest import ControlledAgent @@ -19,32 +20,30 @@ from tests.conftest import ControlledAgent def test_WebpageUnavailablePenalty(game_and_agent): """Test that we get the right reward for failing to fetch a website.""" + # set up the scenario, configure the web browser to the correct url game, agent = game_and_agent agent: ControlledAgent comp = WebpageUnavailablePenalty(node_hostname="client_1") - - agent.reward_function.register_component(comp, 0.7) - action = ("DONOTHING", {}) - agent.store_action(action) - game.step() - - # client 1 has not attempted to fetch webpage yet! - assert agent.reward_function.current_reward == 0.0 - client_1 = game.simulation.network.get_node_by_hostname("client_1") - browser = client_1.software_manager.software.get("WebBrowser") + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") browser.run() browser.target_url = "http://www.example.com" - assert browser.get_webpage() - action = ("DONOTHING", {}) - agent.store_action(action) + agent.reward_function.register_component(comp, 0.7) + + # Check that before trying to fetch the webpage, the reward is 0.0 + agent.store_action(("DONOTHING", {})) + game.step() + assert agent.reward_function.current_reward == 0.0 + + # Check that successfully fetching the webpage yields a reward of 0.7 + agent.store_action(("NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0})) game.step() assert agent.reward_function.current_reward == 0.7 + # Block the web traffic, check that failing to fetch the webpage yields a reward of -0.7 router: Router = game.simulation.network.get_node_by_hostname("router") router.acl.add_rule(action=ACLAction.DENY, protocol=IPProtocol.TCP, src_port=Port.HTTP, dst_port=Port.HTTP) - assert not browser.get_webpage() - agent.store_action(action) + agent.store_action(("NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0})) game.step() assert agent.reward_function.current_reward == -0.7 @@ -70,32 +69,25 @@ def test_uc2_rewards(game_and_agent): comp = GreenAdminDatabaseUnreachablePenalty("client_1") - response = db_client.apply_request( - [ - "execute", - ] - ) + request = ["network", "node", "client_1", "application", "DatabaseClient", "execute"] + response = game.simulation.apply_request(request) state = game.get_sim_state() reward_value = comp.calculate( state, last_action_response=AgentHistoryItem( - timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response + timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=request, response=response ), ) assert reward_value == 1.0 router.acl.remove_rule(position=2) - db_client.apply_request( - [ - "execute", - ] - ) + response = game.simulation.apply_request(request) state = game.get_sim_state() reward_value = comp.calculate( state, last_action_response=AgentHistoryItem( - timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response + timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=request, response=response ), ) assert reward_value == -1.0 diff --git a/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py b/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py index a01cffbe..2e87578d 100644 --- a/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py +++ b/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py @@ -146,7 +146,6 @@ def test_data_manipulation_disrupts_green_agent_connection(data_manipulation_db_ assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.GOOD assert green_db_connection.query("SELECT") - assert green_db_client.last_query_response.get("status_code") == 200 data_manipulation_bot.port_scan_p_of_success = 1 data_manipulation_bot.data_manipulation_p_of_success = 1 @@ -155,4 +154,3 @@ def test_data_manipulation_disrupts_green_agent_connection(data_manipulation_db_ assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED assert green_db_connection.query("SELECT") is False - assert green_db_client.last_query_response.get("status_code") != 200 diff --git a/tests/integration_tests/system/red_applications/test_ransomware_script.py b/tests/integration_tests/system/red_applications/test_ransomware_script.py index 2e3a0b1c..97abafb5 100644 --- a/tests/integration_tests/system/red_applications/test_ransomware_script.py +++ b/tests/integration_tests/system/red_applications/test_ransomware_script.py @@ -103,7 +103,7 @@ def test_ransomware_script_attack(ransomware_script_and_db_server): def test_ransomware_disrupts_green_agent_connection(ransomware_script_db_server_green_client): - """Test to see show that the database service still operate""" + """Test to show that the database service still operates after corruption""" network: Network = ransomware_script_db_server_green_client client_1: Computer = network.get_node_by_hostname("client_1") @@ -111,17 +111,18 @@ def test_ransomware_disrupts_green_agent_connection(ransomware_script_db_server_ client_2: Computer = network.get_node_by_hostname("client_2") green_db_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + green_db_client.connect() green_db_client_connection: DatabaseClientConnection = green_db_client.get_new_connection() server: Server = network.get_node_by_hostname("server_1") db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.GOOD - assert green_db_client_connection.query("SELECT") - assert green_db_client.last_query_response.get("status_code") == 200 + assert green_db_client.query("SELECT") is True ransomware_script_application.attack() + network.apply_timestep(0) + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.CORRUPT - assert green_db_client_connection.query("SELECT") is True - assert green_db_client.last_query_response.get("status_code") == 200 + assert green_db_client.query("SELECT") is True # Still operates but now the data field of response is empty From 7b1584ccb7a33fc7854d223805a81c786b2935a5 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Mon, 19 Aug 2024 15:24:24 +0100 Subject: [PATCH 176/206] #2689 Updated following PR --- .../applications/red_applications/c2/abstract_c2.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 354976b7..7316dd63 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -472,7 +472,7 @@ class AbstractC2(Application, identifier="AbstractC2"): ), ) - if self.c2_remote_connection is False: + if self.c2_remote_connection is None: self.sys_log.warning(f"{self.name}: C2 Application has yet to establish connection. Rejecting command.") return ( False, @@ -481,8 +481,7 @@ class AbstractC2(Application, identifier="AbstractC2"): data={"Reason": "C2 Application has yet to establish connection. Unable to send command."}, ), ) - else: - return ( - True, - RequestResponse(status="success", data={"Reason": "C2 Application is able to send connections."}), - ) + return ( + True, + RequestResponse(status="success", data={"Reason": "C2 Application is able to send connections."}), + ) From 538e853f26591113cf1844d28bb7c6db7677fd0d Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 15:32:25 +0100 Subject: [PATCH 177/206] #2736 - Add sticky reward tests and fix sticky reward behaviour --- src/primaite/game/agent/rewards.py | 23 +- .../_game/_agent/test_sticky_rewards.py | 299 ++++++++++++++++++ 2 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/_primaite/_game/_agent/test_sticky_rewards.py diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 8ac3956c..321df098 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -180,13 +180,17 @@ class WebServer404Penalty(AbstractReward): return 0.0 codes = web_service_state.get("response_codes_this_timestep") - if codes or not self.sticky: # skip calculating if sticky and no new codes. Insted, reuse last step's value. + if codes: def status2rew(status: int) -> int: """Map status codes to reward values.""" return 1.0 if status == 200 else -1.0 if status == 404 else 0.0 self.reward = sum(map(status2rew, codes)) / len(codes) # convert form HTTP codes to rewards and average + elif not self.sticky: # there are no codes, but reward is not sticky, set reward to 0 + self.reward = 0 + else: # skip calculating if sticky and no new codes. insted, reuse last step's value + pass return self.reward @@ -319,13 +323,6 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): request returned was able to connect to the database server, because there has been an unsuccessful request since. """ - db_state = access_from_nested_dict(state, self.location_in_state) - - # If the last request was actually sent, then check if the connection was established. - if db_state is NOT_PRESENT_IN_STATE: - _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") - self.reward = 0.0 - request_attempted = last_action_response.request == [ "network", "node", @@ -335,11 +332,13 @@ class GreenAdminDatabaseUnreachablePenalty(AbstractReward): "execute", ] - # skip calculating if sticky and no new codes, reusing last step value - if not request_attempted and self.sticky: - return self.reward + if request_attempted: # if agent makes request, always recalculate fresh value + self.reward = 1.0 if last_action_response.response.status == "success" else -1.0 + elif not self.sticky: # if no new request and not sticky, set reward to 0 + self.reward = 0.0 + else: # if no new request and sticky, reuse reward value from last step + pass - self.reward = 1.0 if last_action_response.response.status == "success" else -1.0 return self.reward @classmethod diff --git a/tests/unit_tests/_primaite/_game/_agent/test_sticky_rewards.py b/tests/unit_tests/_primaite/_game/_agent/test_sticky_rewards.py new file mode 100644 index 00000000..58f0fcc1 --- /dev/null +++ b/tests/unit_tests/_primaite/_game/_agent/test_sticky_rewards.py @@ -0,0 +1,299 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +from primaite.game.agent.interface import AgentHistoryItem +from primaite.game.agent.rewards import ( + GreenAdminDatabaseUnreachablePenalty, + WebpageUnavailablePenalty, + WebServer404Penalty, +) +from primaite.interface.request import RequestResponse + + +class TestWebServer404PenaltySticky: + def test_non_sticky(self): + reward = WebServer404Penalty("computer", "WebService", sticky=False) + + # no response codes yet, reward is 0 + codes = [] + state = { + "network": {"nodes": {"computer": {"services": {"WebService": {"response_codes_this_timestep": codes}}}}} + } + last_action_response = None + assert reward.calculate(state, last_action_response) == 0 + + # update codes (by reference), 200 response code is now present + codes.append(200) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # update codes (by reference), to make it empty again, reward goes back to 0 + codes.pop() + assert reward.calculate(state, last_action_response) == 0.0 + + # update codes (by reference), 404 response code is now present, reward = -1.0 + codes.append(404) + assert reward.calculate(state, last_action_response) == -1.0 + + # don't update codes, it still has just a 404, check the reward is -1.0 again + assert reward.calculate(state, last_action_response) == -1.0 + + def test_sticky(self): + reward = WebServer404Penalty("computer", "WebService", sticky=True) + + # no response codes yet, reward is 0 + codes = [] + state = { + "network": {"nodes": {"computer": {"services": {"WebService": {"response_codes_this_timestep": codes}}}}} + } + last_action_response = None + assert reward.calculate(state, last_action_response) == 0 + + # update codes (by reference), 200 response code is now present + codes.append(200) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # update codes (by reference), to make it empty again, reward remains at 1.0 because it's sticky + codes.pop() + assert reward.calculate(state, last_action_response) == 1.0 + + # update codes (by reference), 404 response code is now present, reward = -1.0 + codes.append(404) + assert reward.calculate(state, last_action_response) == -1.0 + + # don't update codes, it still has just a 404, check the reward is -1.0 again + assert reward.calculate(state, last_action_response) == -1.0 + + +class TestWebpageUnavailabilitySticky: + def test_non_sticky(self): + reward = WebpageUnavailablePenalty("computer", sticky=False) + + # no response codes yet, reward is 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + browser_history = [] + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0 + + # agent did a successful fetch + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="success", data={}) + browser_history.append({"outcome": 200}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # agent did nothing, because reward is not sticky, it goes back to 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + browser_history = [] + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0.0 + + # agent fails to fetch, get a -1.0 reward + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="failure", data={}) + browser_history.append({"outcome": 404}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + # agent fails again to fetch, get a -1.0 reward again + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="failure", data={}) + browser_history.append({"outcome": 404}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + def test_sticky(self): + reward = WebpageUnavailablePenalty("computer", sticky=True) + + # no response codes yet, reward is 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + browser_history = [] + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0 + + # agent did a successful fetch + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="success", data={}) + browser_history.append({"outcome": 200}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # agent did nothing, because reward is sticky, it stays at 1.0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # agent fails to fetch, get a -1.0 reward + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="failure", data={}) + browser_history.append({"outcome": 404}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + # agent fails again to fetch, get a -1.0 reward again + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "WebBrowser", "execute"] + response = RequestResponse(status="failure", data={}) + browser_history.append({"outcome": 404}) + state = {"network": {"nodes": {"computer": {"applications": {"WebBrowser": {"history": browser_history}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + +class TestGreenAdminDatabaseUnreachableSticky: + def test_non_sticky(self): + reward = GreenAdminDatabaseUnreachablePenalty("computer", sticky=False) + + # no response codes yet, reward is 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0 + + # agent did a successful fetch + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # agent did nothing, because reward is not sticky, it goes back to 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + browser_history = [] + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0.0 + + # agent fails to fetch, get a -1.0 reward + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="failure", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + # agent fails again to fetch, get a -1.0 reward again + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="failure", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + def test_sticky(self): + reward = GreenAdminDatabaseUnreachablePenalty("computer", sticky=True) + + # no response codes yet, reward is 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 0 + + # agent did a successful fetch + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # THE IMPORTANT BIT + # agent did nothing, because reward is not sticky, it goes back to 0 + action, params, request = "DO_NOTHING", {}, ["DONOTHING"] + response = RequestResponse(status="success", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == 1.0 + + # agent fails to fetch, get a -1.0 reward + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="failure", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 + + # agent fails again to fetch, get a -1.0 reward again + action = "NODE_APPLICATION_EXECUTE" + params = {"node_id": 0, "application_id": 0} + request = ["network", "node", "computer", "application", "DatabaseClient", "execute"] + response = RequestResponse(status="failure", data={}) + state = {"network": {"nodes": {"computer": {"applications": {"DatabaseClient": {}}}}}} + last_action_response = AgentHistoryItem( + timestep=0, action=action, parameters=params, request=request, response=response + ) + assert reward.calculate(state, last_action_response) == -1.0 From 15b7334f05411957d37ed8a19f7a57cdda0e59a5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 19 Aug 2024 15:34:50 +0100 Subject: [PATCH 178/206] #2736 - Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c63b114..5aba9e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - File and folder observations can now be configured to always show the true health status, or require scanning like before. +- It's now possible to disable stickiness on reward components, meaning their value returns to 0 during timesteps where agent don't issue the corresponding action. Affects `GreenAdminDatabaseUnreachablePenalty`, `WebpageUnavailablePenalty`, `WebServer404Penalty` ### Fixed - Folder observations showing the true health state without scanning (the old behaviour can be reenabled via config) From 1833dc39468fd5673869149a388b070b01a97693 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 20 Aug 2024 10:41:40 +0100 Subject: [PATCH 179/206] #2736 - typo fixes --- src/primaite/game/agent/rewards.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 73bc7b11..b97b7c5a 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -208,8 +208,8 @@ class WebServer404Penalty(AbstractReward): self.reward = sum(map(status2rew, codes)) / len(codes) # convert form HTTP codes to rewards and average elif not self.sticky: # there are no codes, but reward is not sticky, set reward to 0 - self.reward = 0 - else: # skip calculating if sticky and no new codes. insted, reuse last step's value + self.reward = 0.0 + else: # skip calculating if sticky and no new codes. instead, reuse last step's value pass return self.reward From b8767da61ec9d9d11e2528d817dbeedeb1d63be0 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 20 Aug 2024 10:51:29 +0100 Subject: [PATCH 180/206] #2689 Fixed merging errors with actions.py --- src/primaite/game/agent/actions.py | 116 +++++++++++++++++++---------- 1 file changed, 76 insertions(+), 40 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 713c4eb2..42ba25b4 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1096,10 +1096,6 @@ class ConfigureC2BeaconAction(AbstractAction): return cls.model_fields[info.field_name].default return v - -class NodeAccountsChangePasswordAction(AbstractAction): - """Action which changes the password for a user.""" - def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) @@ -1120,8 +1116,11 @@ class NodeAccountsChangePasswordAction(AbstractAction): return ["network", "node", node_name, "application", "C2Beacon", "configure", config.__dict__] -class RansomwareConfigureC2ServerAction(AbstractAction): - """Action which sends a command from the C2 Server to the C2 Beacon which configures a local RansomwareScript.""" +class NodeAccountsChangePasswordAction(AbstractAction): + """Action which changes the password for a user.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) def form_request(self, node_id: str, username: str, current_password: str, new_password: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" @@ -1145,19 +1144,6 @@ class NodeSessionsRemoteLoginAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: int, config: Dict) -> RequestFormat: - """Return the action formatted as a request that can be ingested by the simulation.""" - node_name = self.manager.get_node_name_by_idx(node_id) - if node_name is None: - return ["do_nothing"] - # Using the ransomware scripts model to validate. - ConfigureRansomwareScriptAction._Opts.model_validate(config) # check that options adhere to schema - return ["network", "node", node_name, "application", "C2Server", "ransomware_configure", config] - - -class RansomwareLaunchC2ServerAction(AbstractAction): - """Action which causes the C2 Server to send a command to the C2 Beacon to launch the RansomwareScript.""" - def form_request(self, node_id: str, username: str, password: str, remote_ip: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" node_name = self.manager.get_node_name_by_idx(node_id) @@ -1177,6 +1163,43 @@ class RansomwareLaunchC2ServerAction(AbstractAction): class NodeSessionsRemoteLogoutAction(AbstractAction): """Action which performs a remote session logout.""" + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + return [ + "network", + "node", + node_name, + "service", + "Terminal", + "send_remote_command", + remote_ip, + {"command": command}, + ] + + +class RansomwareConfigureC2ServerAction(AbstractAction): + """Action which sends a command from the C2 Server to the C2 Beacon which configures a local RansomwareScript.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, config: Dict) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + # Using the ransomware scripts model to validate. + ConfigureRansomwareScriptAction._Opts.model_validate(config) # check that options adhere to schema + return ["network", "node", node_name, "application", "C2Server", "ransomware_configure", config] + + +class RansomwareLaunchC2ServerAction(AbstractAction): + """Action which causes the C2 Server to send a command to the C2 Beacon to launch the RansomwareScript.""" + def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) @@ -1202,15 +1225,6 @@ class ExfiltrationC2ServerAction(AbstractAction): target_folder_name: str exfiltration_folder_name: Optional[str] - def form_request(self, node_id: str, remote_ip: str) -> RequestFormat: - """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" - node_name = self.manager.get_node_name_by_idx(node_id) - return ["network", "node", node_name, "service", "Terminal", "remote_logoff", remote_ip] - - -class NodeSendRemoteCommandAction(AbstractAction): - """Action which sends a terminal command to a remote node via SSH.""" - def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) @@ -1240,6 +1254,27 @@ class NodeSendRemoteCommandAction(AbstractAction): return ["network", "node", node_name, "application", "C2Server", "exfiltrate", command_model] +class NodeSendRemoteCommandAction(AbstractAction): + """Action which sends a terminal command to a remote node via SSH.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + return [ + "network", + "node", + node_name, + "service", + "Terminal", + "send_remote_command", + remote_ip, + {"command": command}, + ] + + class TerminalC2ServerAction(AbstractAction): """Action which causes the C2 Server to send a command to the C2 Beacon to execute the terminal command passed.""" @@ -1270,19 +1305,20 @@ class TerminalC2ServerAction(AbstractAction): TerminalC2ServerAction._Opts.model_validate(command_model) return ["network", "node", node_name, "application", "C2Server", "terminal_command", command_model] - def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: - """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + +class RansomwareLaunchC2ServerAction(AbstractAction): + """Action which causes the C2 Server to send a command to the C2 Beacon to launch the RansomwareScript.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + + def form_request(self, node_id: int) -> RequestFormat: + """Return the action formatted as a request that can be ingested by the simulation.""" node_name = self.manager.get_node_name_by_idx(node_id) - return [ - "network", - "node", - node_name, - "service", - "Terminal", - "send_remote_command", - remote_ip, - {"command": command}, - ] + if node_name is None: + return ["do_nothing"] + # This action currently doesn't require any further configuration options. + return ["network", "node", node_name, "application", "C2Server", "ransomware_launch"] class ActionManager: From c9d62d512c174ef4edca44832ef7b0c60bd2e8d3 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Tue, 20 Aug 2024 11:15:04 +0100 Subject: [PATCH 181/206] #2689 fixed mismerge --- src/primaite/game/agent/actions.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 42ba25b4..2e6189c0 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -1166,19 +1166,10 @@ class NodeSessionsRemoteLogoutAction(AbstractAction): def __init__(self, manager: "ActionManager", **kwargs) -> None: super().__init__(manager=manager) - def form_request(self, node_id: int, remote_ip: str, command: RequestFormat) -> RequestFormat: + def form_request(self, node_id: str, remote_ip: str) -> RequestFormat: """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" node_name = self.manager.get_node_name_by_idx(node_id) - return [ - "network", - "node", - node_name, - "service", - "Terminal", - "send_remote_command", - remote_ip, - {"command": command}, - ] + return ["network", "node", node_name, "service", "Terminal", "remote_logoff", remote_ip] class RansomwareConfigureC2ServerAction(AbstractAction): From 5d209e4ff9c6d4d2c81c6a60183874a9c0399e28 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 20 Aug 2024 15:33:39 +0100 Subject: [PATCH 182/206] #2686 - Added a new Privilege-Escalation-and Data-Loss-Example.ipynb notebook with a more realistic scenario. Made some minor changes to multi_lan_internet_network_example.yaml to enable the new scenario. --- CHANGELOG.md | 2 + .../multi_lan_internet_network_example.yaml | 42 +- ...ege-Escalation-and Data-Loss-Example.ipynb | 612 ++++++++++++++++++ .../_package_data/primaite_demo_network.png | Bin 0 -> 341867 bytes 4 files changed, 641 insertions(+), 15 deletions(-) create mode 100644 src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb create mode 100644 src/primaite/notebooks/_package_data/primaite_demo_network.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f7b338..9d08974c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added two new red applications: ``C2Beacon`` and ``C2Server`` which aim to simulate malicious network infrastructure. Refer to the ``Command and Control Application Suite E2E Demonstration`` notebook for more information. - Added reward calculation details to AgentHistoryItem. +- Added a new Privilege-Escalation-and Data-Loss-Example.ipynb notebook with a realistic cyber scenario focusing on + internal privilege escalation and data loss through the manipulation of SSH access and Access Control Lists (ACLs). ### Changed - File and folder observations can now be configured to always show the true health status, or require scanning like before. diff --git a/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml b/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml index 09e85d03..61562418 100644 --- a/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml +++ b/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml @@ -25,7 +25,7 @@ simulation: db_server_ip: 10.10.1.11 - type: WebBrowser options: - target_url: http://sometech.ai + target_url: http://sometech.ai/users/ - hostname: pc_2 type: computer @@ -39,7 +39,7 @@ simulation: db_server_ip: 10.10.1.11 - type: WebBrowser options: - target_url: http://sometech.ai + target_url: http://sometech.ai/users/ - hostname: server_1 type: server @@ -221,7 +221,7 @@ simulation: subnet_mask: 255.255.255.0 acl: - 2: # Allow the some_tech_web_srv to connect to the Database Service on some_tech_db_srv + 11: # Allow the some_tech_web_srv to connect to the Database Service on some_tech_db_srv action: PERMIT src_ip: 94.10.180.6 src_wildcard_mask: 0.0.0.0 @@ -229,7 +229,7 @@ simulation: dst_ip: 10.10.1.11 dst_wildcard_mask: 0.0.0.0 dst_port: POSTGRES_SERVER - 3: # Allow the Database Service on some_tech_db_srv to respond to some_tech_web_srv + 12: # Allow the Database Service on some_tech_db_srv to respond to some_tech_web_srv action: PERMIT src_ip: 10.10.1.11 src_wildcard_mask: 0.0.0.0 @@ -237,7 +237,7 @@ simulation: dst_ip: 94.10.180.6 dst_wildcard_mask: 0.0.0.0 dst_port: POSTGRES_SERVER - 4: # Prevent the Junior engineer from downloading files from the some_tech_storage_srv over FTP + 13: # Prevent the Junior engineer from downloading files from the some_tech_storage_srv over FTP action: DENY src_ip: 10.10.2.12 src_wildcard_mask: 0.0.0.0 @@ -245,33 +245,41 @@ simulation: dst_ip: 10.10.1.12 dst_wildcard_mask: 0.0.0.0 dst_port: FTP - 5: # Allow communication between Engineering and the DB & Storage subnet + 14: # Prevent the Junior engineer from connecting to some_tech_storage_srv over SSH + action: DENY + src_ip: 10.10.2.12 + src_wildcard_mask: 0.0.0.0 + src_port: SSH + dst_ip: 10.10.1.12 + dst_wildcard_mask: 0.0.0.0 + dst_port: SSH + 15: # Allow communication between Engineering and the DB & Storage subnet action: PERMIT src_ip: 10.10.2.0 src_wildcard_mask: 0.0.0.255 dst_ip: 10.10.1.0 dst_wildcard_mask: 0.0.0.255 - 6: # Allow communication between the DB & Storage subnet and Engineering + 16: # Allow communication between the DB & Storage subnet and Engineering action: PERMIT src_ip: 10.10.1.0 src_wildcard_mask: 0.0.0.255 dst_ip: 10.10.2.0 dst_wildcard_mask: 0.0.0.255 - 7: # Allow the SomeTech network to use HTTP + 17: # Allow the SomeTech network to use HTTP action: PERMIT src_port: HTTP dst_port: HTTP - 8: # Allow the SomeTech internal network to use ARP + 18: # Allow the SomeTech internal network to use ARP action: PERMIT src_ip: 10.10.0.0 src_wildcard_mask: 0.0.255.255 src_port: ARP - 9: # Allow the SomeTech internal network to use ICMP + 19: # Allow the SomeTech internal network to use ICMP action: PERMIT src_ip: 10.10.0.0 src_wildcard_mask: 0.0.255.255 protocol: ICMP - 10: + 21: action: PERMIT src_ip: 94.10.180.6 src_wildcard_mask: 0.0.0.0 @@ -279,10 +287,14 @@ simulation: dst_ip: 10.10.0.0 dst_wildcard_mask: 0.0.255.255 dst_port: HTTP - 11: # Permit SomeTech to use DNS + 22: # Permit SomeTech to use DNS action: PERMIT src_port: DNS dst_port: DNS + 23: # Permit SomeTech to use SSH + action: PERMIT + src_port: SSH + dst_port: SSH default_route: # Default route to all external networks next_hop_ip_address: 10.10.4.2 # NI int on some_tech_fw @@ -332,7 +344,7 @@ simulation: db_server_ip: 10.10.1.11 - type: WebBrowser options: - target_url: http://sometech.ai + target_url: http://sometech.ai/users/ - hostname: some_tech_snr_dev_pc type: computer @@ -346,7 +358,7 @@ simulation: db_server_ip: 10.10.1.11 - type: WebBrowser options: - target_url: http://sometech.ai + target_url: http://sometech.ai/users/ - hostname: some_tech_jnr_dev_pc type: computer @@ -360,7 +372,7 @@ simulation: db_server_ip: 10.10.1.11 - type: WebBrowser options: - target_url: http://sometech.ai + target_url: http://sometech.ai/users/ links: # Home/Office Lan Links diff --git a/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb new file mode 100644 index 00000000..c28d8bd1 --- /dev/null +++ b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb @@ -0,0 +1,612 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Simulating Privilege Escalation and Data Loss Using SSH and ACLs Manipulation\n", + "\n", + "## Overview\n", + "\n", + "This Jupyter notebook demonstrates a cyber scenario focusing on internal privilege escalation and data loss through the manipulation of SSH access and Access Control Lists (ACLs). The scenario is designed to model and visualise how a disgruntled junior engineer might exploit internal network vulnerabilities and social engineering of account credentials to escalate privileges and cause significant data loss and disruption to services.\n", + "\n", + "## Scenario Description\n", + "\n", + "This simulation utilises the PrimAITE demo network, focussing specifically on five nodes:\n", + "\n", + "\n", + " \"Description\n", + "\n", + "\n", + "\n", + "- **SomeTech Developer PC (`some_tech_jnr_dev_pc`)**: The workstation used by the junior engineer.\n", + "- **SomeTech Core Router (`some_tech_rt`)**: A critical network device that controls access between nodes.\n", + "- **SomeTech PostgreSQL Database Server (`some_tech_db_srv`)**: Hosts the company’s critical database.\n", + "- **SomeTech Storage Server (`some_tech_storage_srv`)**: Stores important files and database backups.\n", + "- **SomeTech Web Server (`some_tech_web_srv`)**: Serves the company’s website.\n", + "\n", + "By default, the junior developer PC is restricted from connecting to the storage server via FTP or SSH due to ACL rules that permit only senior members of the engineering team to access these services.\n", + "\n", + "The goal of the scenario is to simulate how the junior engineer, after gaining unauthorised access to the core router, manipulates ACL rules to escalate privileges and delete critical data.\n", + "\n", + "### Key Actions Simulated\n", + "\n", + "1. **Privilege Escalation**: The junior engineer uses social engineering to obtain login credentials for the core router, SSHs into the router, and modifies the ACL rules to allow SSH access from their PC to the storage server.\n", + "2. **Remote Access**: The junior engineer then uses the newly gained SSH access to connect to the storage server from their PC. This step is crucial for executing further actions, such as deleting files.\n", + "3. **File Deletion**: With SSH access to the storage server, the engineer deletes the backup file from the storage server and subsequently removes critical data from the PostgreSQL database, bringing down the sometech.ai website.\n", + "4. **Website Impact Verification:** After the deletion of the database backup, the scenario checks the sometech.ai website's status to confirm it has been brought down due to the data loss.\n", + "5. **Database Restore Failure:** An attempt is made to restore the deleted backup, demonstrating that the restoration fails and highlighting the severity of the data loss.\n", + "\n", + "### Notes:\n", + "- The demo will utilise CAOS (Common Action and Observation Space) actions wherever they are available. For actions where a CAOS action does not yet exist, the action will be performed manually on the node/service.\n", + "- This notebook will be updated to incorporate new CAOS actions as they become supported." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# The Scenario" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import yaml\n", + "\n", + "from primaite import PRIMAITE_PATHS\n", + "from primaite.game.game import PrimaiteGame\n", + "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", + "from primaite.simulator.network.hardware.nodes.network.router import Router\n", + "from primaite.simulator.network.hardware.nodes.host.server import Server\n", + "from primaite.simulator.system.applications.database_client import DatabaseClient\n", + "from primaite.simulator.system.applications.web_browser import WebBrowser\n", + "from primaite.simulator.system.services.database.database_service import DatabaseService" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load the network configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "path = PRIMAITE_PATHS.user_config_path / \"example_config\" / \"multi_lan_internet_network_example.yaml\"\n", + "\n", + "with open(path, \"r\") as file:\n", + " cfg = yaml.safe_load(file)\n", + "\n", + " game = PrimaiteGame.from_config(cfg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Capture some of the nodes from the network to observe actions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_jnr_dev_pc: Computer = game.simulation.network.get_node_by_hostname(\"some_tech_jnr_dev_pc\")\n", + "some_tech_jnr_dev_db_client: DatabaseClient = some_tech_jnr_dev_pc.software_manager.software[\"DatabaseClient\"]\n", + "some_tech_jnr_dev_web_browser: WebBrowser = some_tech_jnr_dev_pc.software_manager.software[\"WebBrowser\"]\n", + "some_tech_rt: Router = game.simulation.network.get_node_by_hostname(\"some_tech_rt\")\n", + "some_tech_db_srv: Server = game.simulation.network.get_node_by_hostname(\"some_tech_db_srv\")\n", + "some_tech_db_service: DatabaseService = some_tech_db_srv.software_manager.software[\"DatabaseService\"]\n", + "some_tech_storage_srv: Server = game.simulation.network.get_node_by_hostname(\"some_tech_storage_srv\")\n", + "some_tech_web_srv: Server = game.simulation.network.get_node_by_hostname(\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Perform a Database Backup and Inspect the Storage Server\n", + "\n", + "At this stage, a backup of the PostgreSQL database is created and the storage server’s file system is inspected. This step ensures that a backup file is present and correctly stored in the storage server before any further actions are taken. The inspection of the file system allows verification of the backup’s existence and health, establishing a baseline that will later be used to confirm the success of the subsequent deletion actions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_storage_srv.file_system.show(full=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_db_service.backup_database()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_storage_srv.file_system.show(full=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Extract the folder name containing the database backup file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "db_backup_folder = [folder.name for folder in some_tech_storage_srv.file_system.folders.values() if folder.name != \"root\"][0]\n", + "db_backup_folder" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Check That the Junior Engineer Cannot SSH into the Storage Server\n", + "\n", + "This step verifies that the junior engineer is currently restricted from SSH access to the storage server. By attempting to establish an SSH connection from the junior engineer’s workstation to the storage server, this action confirms that the current ACL rules on the core router correctly prevent unauthorised access. It sets up the necessary conditions to later validate the effectiveness of the privilege escalation by demonstrating the initial access restrictions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"ssh_to_remote\", \"admin\", \"admin\", str(some_tech_storage_srv.network_interface[1].ip_address)\n", + "]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Confirm That the Website Is Up by Executing the Web Browser on the Junior Engineer's Machine\n", + "\n", + "In this step, we verify that the sometech.ai website is operational before any malicious activities begin. By executing the web browser application on the junior engineer’s machine, we ensure that the website is accessible and functioning correctly. This establishes a baseline for the website’s status, allowing us to later assess the impact of the subsequent actions, such as database deletion, on the website's availability.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\"network\", \"node\", \"some_tech_jnr_dev_pc\", \"application\", \"WebBrowser\", \"execute\"]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Exploit Core Router to Add ACL for SSH Access\n", + "\n", + "At this point, the junior engineer exploits a vulnerability in the core router by obtaining the login credentials through social engineering. With SSH access to the core router, the engineer modifies the ACL rules to permit SSH connections from theimachinePC to the storage server. This action is crucial as it will enable the engineer to remotely access the storage server and execute further malicious activities.\n", + "\n", + "Interestingly, if we inspect the `active_remote_sessions` on the SomeTech core routers `UserSessionManager`, we'll see an active session appear. This active sessoin would pop up in the obersation space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "game.get_sim_state()[\"network\"][\"nodes\"][\"some_tech_rt\"][\"services\"][\"UserSessionManager\"][\"active_remote_sessions\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"ssh_to_remote\", \"admin\", \"admin\", str(some_tech_rt.network_interface[4].ip_address)\n", + "]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "game.get_sim_state()[\"network\"][\"nodes\"][\"some_tech_rt\"][\"services\"][\"UserSessionManager\"][\"active_remote_sessions\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inspect the ACL Table Before Adding the New Rule\n", + "\n", + "Before making any changes, we first examine the current Access Control List (ACL) table on the core router. This inspection provides a snapshot of the existing rules that govern network traffic, including permissions and restrictions related to SSH access. Understanding this baseline is crucial for verifying the effect of new rules, ensuring that changes can be accurately assessed for their impact on network security and access controls.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_rt.acl.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"send_remote_command\", str(some_tech_rt.network_interface[4].ip_address),\n", + " {\n", + " \"command\": [\n", + " \"acl\", \"add_rule\", \"PERMIT\", \"TCP\",\n", + " str(some_tech_jnr_dev_pc.network_interface[1].ip_address), \"0.0.0.0\", \"SSH\",\n", + " str(some_tech_storage_srv.network_interface[1].ip_address), \"0.0.0.0\", \"SSH\",\n", + " 1\n", + " ]\n", + " }\n", + "]\n", + "\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Verify That the New ACL Rule Has Been Added\n", + "\n", + "After updating the ACL rules on the core router, we need to confirm that the new rule has been successfully applied. This verification involves inspecting the ACL table again to ensure that the new rule allowing SSH access from the junior engineer’s PC to the storage server is present. This step is critical to ensure that the modification was executed correctly and that the junior engineer now has the intended access." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_rt.acl.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Terminate Remote Session on Core Router\n", + "\n", + "After successfully adding the ACL rule to allow SSH access to the storage server, the junior engineer terminates the remote session on the core router. The termination of the session is a strategic move to avoid leaving an active remote login open while maintaining the newly granted access privileges for future use." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"remote_logoff\", str(some_tech_rt.network_interface[4].ip_address)\n", + "]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Confirm the termination of the remote session" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "game.get_sim_state()[\"network\"][\"nodes\"][\"some_tech_rt\"][\"services\"][\"UserSessionManager\"][\"active_remote_sessions\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## SSH into Storage Server and Delete Database Backup\n", + "\n", + "With the newly added ACL rule, the junior engineer can now SSH into the storage server from their machine. The engineer proceeds to delete the critical database backup file stored on the server. This action is pivotal in the attack, as it directly impacts the availability of essential data and sets the stage for subsequent data loss and disruption of services.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"ssh_to_remote\", \"admin\", \"admin\", str(some_tech_storage_srv.network_interface[1].ip_address)\n", + "]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\n", + " \"network\", \"node\", \"some_tech_jnr_dev_pc\", \n", + " \"service\", \"Terminal\", \"send_remote_command\", str(some_tech_storage_srv.network_interface[1].ip_address),\n", + " {\n", + " \"command\": [\n", + " \"file_system\", \"delete\", \"file\", db_backup_folder, \"database.db\"\n", + " ]\n", + " }\n", + "]\n", + "\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Verify that the database backup file has been deleted" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_storage_srv.file_system.show(full=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Delete Critical Data from the PostgreSQL Database\n", + "\n", + "In this part of the scenario, the junior engineer manually interacts with the PostgreSQL database to delete critical data. The deletion of critical data from the database has significant implications, leading to the loss of essential information and affecting the availability of the sometech.ai website.\n", + "\n", + "* Since the CAOS framework does not support ad-hoc or dynamic SQL queries for database services, this action must be performed manually." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Again, confirm that the sometech.ai website is up by executing the web browser on the junior engineer's machine" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\"network\", \"node\", \"some_tech_jnr_dev_pc\", \"application\", \"WebBrowser\", \"execute\"]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Set the server IP address and open a new DB connection" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_jnr_dev_db_client.server_ip_address = some_tech_db_srv.network_interface[1].ip_address\n", + "some_tech_jnr_dev_db_connection = some_tech_jnr_dev_db_client.get_new_connection()\n", + "some_tech_jnr_dev_db_connection" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "##### Send the DELETE query" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_jnr_dev_db_connection.query(\"DELETE\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Confirm that the actions have brought the sometech.ai website down" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "caos_action = [\"network\", \"node\", \"some_tech_jnr_dev_pc\", \"application\", \"WebBrowser\", \"execute\"]\n", + "game.simulation.apply_request(caos_action)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Attempt to Restore Database Backup\n", + "\n", + "In this final section, an attempt is made to restore the database backup that was deleted earlier. The action is performed using the `some_tech_db_service.restore_backup()` method. This will demonstrate the impact of the data loss and confirm that the backup restoration fails, highlighting the severity of the disruption caused." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "some_tech_db_service.restore_backup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# End of Scenario Summary\n", + "\n", + "In this simulation, we modelled a cyber attack scenario where a disgruntled junior engineer exploits internal network vulnerabilities to escalate privileges, causing significant data loss and disruption of services. The following key actions were performed:\n", + "\n", + "1. **Privilege Escalation:** The junior engineer used social engineering to obtain the login credentials for the core router. They remotely accessed the router via SSH and modified the ACL rules to grant SSH access from their machine to the storage server.\n", + "\n", + "2. **Remote Access:** With the modified ACLs in place, the engineer was able to SSH into the storage server from their machine. This access enabled them to interact with the storage server and perform further actions.\n", + "\n", + "3. **File & Data Deletion:** The engineer used SSH remote access to delete a critical database backup file from the storage server. Subsequently, they executed a SQL command to delete critical data from the PostgreSQL database, which resulted in the disruption of the sometech.ai website.\n", + "\n", + "4. **Website Status Verification:** After the deletion of the database backup, the website's status was checked to confirm that it had been brought down due to the data loss.\n", + "\n", + "5. **Database Restore Failure:** An attempt to restore the deleted backup was made to demonstrate that the restoration process failed, highlighting the severity of the data loss.\n", + "\n", + "**Verification and Outcomes:**\n", + "\n", + "- **Initial State Verification:** The backup file was confirmed to be present on the storage server before any actions were taken. The junior engineer's inability to SSH into the storage server initially confirmed that ACL restrictions were in effect.\n", + "\n", + "- **Privilege Escalation Confirmation:** The successful modification of the ACL rules was verified by checking the router's ACL table.\n", + "\n", + "- **Remote Access Verification:** After the ACL modification, the engineer successfully SSH'd into the storage server from their PC. The file system inspection confirmed that the backup file was accessible and could be deleted.\n", + "\n", + "- **File Deletion Confirmation:** The deletion of the backup file was confirmed by inspecting the storage server's file system after the operation. The backup file was marked as deleted, validating that the deletion command was executed.\n", + "\n", + "- **Database and Website Impact:** The deletion of the database backup was followed by a DELETE query executed on the PostgreSQL database. The website's functionality was subsequently checked using a web browser, confirming that the sometech.ai website was down due to the data loss.\n", + "\n", + "- **Restore Attempt Verification:** An attempt to restore the deleted database backup was made, and it was confirmed that the restoration failed, highlighting the impact of the data deletion." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/primaite/notebooks/_package_data/primaite_demo_network.png b/src/primaite/notebooks/_package_data/primaite_demo_network.png new file mode 100644 index 0000000000000000000000000000000000000000..08a379f9515cc8f77cdcf59e66b9583e94065853 GIT binary patch literal 341867 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�M?H>u)qr-Gum^s80mjQ46m$=SxH*S#+y*3yGe)*Hkz}H%qjc$ z`aW7+RKt*g`WHOAO*Yj*&`tRMc~FZShQEB*jCM<%Aho=|PZ{%kqZ24XE5c2SQ zL`J#=KVS59^AA!JW*Qn4*zjN6Wb0AglgD(a@X)L%ai%Am3(SDswR{Rkb@)h>U!}$JA$4}4S3Qmc zvmz%&dE@cGCR_N_Z3Enw3)WZ=SOoa&M{p##j@z1+V{5hL*WYXO8VYssGG1#FDT&yz z{+;+Z>KiLD+WRuW5jvc4ZWBlm1y!$F)~3&aTmU<0()6$3u7&)p^+k=AfiqE(VYT>DEO#sG`XM{@Fck5G67Cw|T}^6O`$IhCXe(d_AD;1!Ko_ zt-tK=0Z=hs{LP2$!E^@}6!fdio_Z!HQ=$KT-MalEs`)``k^jKWaKifcgM!Ye`>3FE zF|SB(>i(88^_(!IFln08*NF;O9~x;ZPeyL7XiYX@Lq<7Nk*+U+yn>&n)>*Jv-3>gw zBqh?Fv-S>%G2s(`{XqM3CC)gE{(dJ@Ry$}@uV!3tRuUG!Co1V`Bblf5Pw{qpj4_*+ z;?{=mOU+kmJXsW8KERhwM6@Do71$*xA!9ZETpC`e#LLeSt75u;y5dSv0YN=(3>AxI z;2)?oHWVILIiKz8+gyVa5I(ob0k@+Hl7hun^d|>dAAT3B9+#J+XUXXn_E}dSzNhsM zd@Y`a@N;l%XEtPLxQI%`sRnjyEYg!TJ#kR2kxq0#7dWMF5>CAXCz?#E053kIoV@EO zlG|)uu|C(J;%sE=M_BNJ`*&~X1_A2S+N}_a_6vm42QzOkru^+kQD@KZ2+h>MWb8h_ z@vwi^x*mjbwLzDcJyO)$?5EFe$I&22$9!&V&j_6wAleVQOKiuDff$gr#ndX*=%Hx6 z(*-v~?mMFJBHx&mGF*Q&ZENj$nLbH!$GM>920|Hg)6~mRTWGPU0w@>SLV(aS7!hvm z-j3XCmMRviXx6p5d1brAX^{YGu3VzD|u^$)WCgg;1=K{OenTB&=Z1#KruXp zb>z$aZA_*aB<*j?q`K<#hIr#b8d`bCT`r-5y#mq0a`BQHV0{}U>+c~ zO2!S3PYzd7C$Fpak)@wL?^Jv899)dyvC=3(IS~f|7r0vp~ zBr1}1ds5i#i6N2|Al;owLRe!E3MP{?TY(3HJbOBcN-*7!#IEky^MV%&KMM$>r5f0{ z{#8`-82bD4POlBCIdYC```=5*k}be>L6bRgJWd6)}5`NHn;f ztS3>kk}fK%2t4MOEAWHNWQ~Q2%1+tcCJ}A|+2gmx1MJ9fc&M zla?80K`xTkkQUQFzz13i5i8#I-bMy!<>&TFi##Yrsx*L-5subJ|+Ze0}ZuT{bPmifaUnH>vy6Z?BUYCRzR5KU|(OVj4r61d;0XG_`Gmhj*4eS zul)^+&14;-$3e@Dtn`j&Vf5a$C)R8q5XiQeDFXO2cy|R-q?hQd2?>&WUJq-QKA+8T zrcBE3Kt+QnMi?z2_yL$&2)Q1-r^lv4bZf?}3Vu-0^)v;*EmAs{;s6TfTP>QW+48e6 z2gpLQ)ua>BKd8J660+X?^3STiQmRSLa|)p<={*m0t86r@ORu2r{4!?{9(Ai-4)c|}GoR5&4Rda>@(0QYcwxm5V-cuWKk&M6VmC6L`kTPn0!x6^uLBMD^U2X)~dIqH$e}n8&V%Cn#$_= z{G(}K9^GUC+Se>s-@Q@(8p*IbZTMo{o!05m$tk`P1-*`sPkT}lmWHCePKZQ3SoVV2 z#-{YUW$6k^A}jRZ##+@cMQNi4A}?w;Y$Mdyt7RPFfAJ76Xhx2%M(GUhjPQ<7N9@kM z2lpcw5yh5a9h(Vj9E#oVB6yXjzVvJ19#FCO+A4i1$~JG>?}%k}jw7qBc&7H6&R@Gf z@OS(&*ke|yjT9u@xi>Sx1rqSU6m+;MWH4sX3mw@A9iUS9&(F6bSOU(;vWy$sp{rPO zYzW(%&JDYNPod_Kq5 zKOpF1Tc`Kr1@DEC#8n{N0m!`%Qin-!A3V;(-y8IVk8|h*eFmn^N@_5Dm0JpBiEQST zcRueE-H#}aq5aCk(|{#=p{UT${1)1NXj`4N%g9nK9l5P-E0CtDa%l!DaoEscrF;s= zLG8lfZEdwHfu@xs+6SuBU(Wsb_+>CB!$dvgO9!tR(}Q+cc2xPG?@YRPO{Qk9F2jEi zl3phoSs*pQgG9{P7{7j7hU1gi`1~{bLjpmO6oZ$dEwoMF%0@5>h z!8<;Y5a*l9$aZD-ppk7jm8DcD^6xc7Zsc4E z<}1vWb{zf6DeGcOSXJZYflvrt%ZpNrmx{8nQh)qbavKhKL(SYadlvONe?2hd^5#LN zTeB)%gUBv+ao~3XcX@={396-?j)C&XCo#Jw8ShowS6>$N+m%rbXtjL+61a6o;#U+x zR@$2!Y;6Z@dT1y+s!{6!on5A=QEmUWMH&ZqIw_1-I&lZmATe>`<$RH5y9ME-HKKy= z9&UvI2qVEDf#0CT>5n+rdfiFRi@dBs?txMsIPTlP^UTEJG3)3_n^o#@Y7QJ-UPqF* zj#)|VH8J9!LSu5W~WhFtGveJ{_<9UD!=x9g6K zeY{2F1`pMjcHPsvN4?@Z&5zxVvTv_3k?wz!u;w)|PzqI!Z@mWk@;tB7r)Dwcbp;V@ z5#`d0q3(9VuGp+4BE3>OV=!En!@}sw7b#aB+}u&t<|Wc&jW;U;ZqAG-;nsnt?ZE=p zc4G8^&7}TCQm2MIOK~}M?A4JYPkH4Ex&d+C z7|@zLtJfgkNdOm~X-GT_I4dm@lR+b8)QQ={31j zDeC2{uWJY=bpU#vD+`uba22)Aw9#gCO}d-Br9<^@HiPEzw%%?%JJEUh8{_58MLZl3 z)mS#oSH7TEke-W<7SHv$SGi_sX^93^6thvDTymMEz~pCPu>qX0#AO8!DTsD$Uf$y| z-Ge2S4|PoZRZ)PJ+~4@uI}i>9Xu z#$2{Gb*!sDZ+4!WyHUjrh^Q~mmYH?t_^8%+cdCatTX#%^_{Hs4UThUW>U*_h5oFrE z<=3FtV(Hf@{&8*s6Ur1pgVyWfk9JTwPZt-_7qs(-*9F4ns^GIOJ|kd!4iB2#m%K^d z@v;mUh;Ch|F@uVI)lWhR;)cA2kSE@hkrPLbjQCiZS1y%2T27a3BKBrlp`)*Mtr%`R z6d;(tez~O?`zRB&k zlZJ99g-TFr0*>3~Tld8E@WyzP3ms9RFE!$b_Yo+Gg{mz#c`i9XJhx=Dnp44ubs_l>ai-;+1W@UEBNV1_oK4)o=QXS?2EUnFnLi&V$J_R%X2Ft8fJewZre$mE%xz zp$e)MDNt{ENF^&(c{Y46R|~Zx+@SdbDy@imFr6ZyW>(%F6u^iyq#oZ%NqPPHh$*J> zWKulKOK%XG!l=WH$UwnanYDx6cb9#r^nduLJ$aVx_CqSiVn{Ybc1m4X`Z91xmy8?vRgv(fJ7P@Qek*;LQ#5 zG3EhL##v$F5+#I|p$Vm|=(rEGQ{FGY56|e`{Poiws^LCXLlV-w8mD*R16L&q$znwDra>hrRQD3_uL6kosw|E0I zPePTL-`d-lDwP{6#WtfA`}WoU&2d64w~E$Xx_}NLv>d4ZO^dtgpa{|MS!tV+H@hn9 zl1d7(W_wDDJ+5MBfVx zW^NGN=JH-}F8labe?qG)|spJwA|d`ic5+H>#a_g=0}&HOt$< zPOlLh;Ad2vGjWp+byIFCvOLp6gB{buvI$HAoG6j>r8?IyXsFVy+U7GRp$4UZvW(5| z*Z%en*dvnj%MpFlBW>np1l7N_W*c$X8HS{_Xix$#%BCTy#4g92hG zdbK^GN-&=*@%opn9;l4zy=8QrGFu&eOexpNv*?kRLz%OV8Ero|gZwDSTtum@G<=~1 z)8#Q3uCb15Q!MaRB%Jf0j^cK1q(ZPCC@#B6G95 zgfiR3W0UhT?1X*eB;}*UjQFZI`@8u*6}B!^vJ}!UwbSklvm1~kf%BuAdf-g1yx4H6 zN~Ty@xeqx_zeYQ*bgt7ut)epbrYL`(cv&+U5G{eHJCRjFu^E~MJa;ea$4VdUO3bgG z`}a?%$~*vT9;%h&aoE%-Kd-GFlwJyHS3G5zMa)(89S&sone0;92We8_;_x0kuMLrP zCe4^B)GwHiCP+E?!SztR6fmqWvyE6EJatU*$3;ht;t_E3B*z{gjflUaC`~KN%O7B1 z@6u;hNa(zg)L>p!ftl5~^bKt&w6ol&B5B4fz_skVX-MsTRyx)BvxL>$nxV)(%Y14< z{$>xSe(|OR;SG)OWNaunH^+J1g6(Z)?s=cR7me0>pG+>p@IfC%XgU>SRWf8eE{BTu zG&qnmTx&`?q1_y0Zl)U`!CD;~FXDr1SPNq94nx4kcX!rEsIIn&gTE-$ons^z zjY9D)Ix(XdN_%V7<0SiU;EQ@Me{6~NO>pdwqdZHN-;%G2^BS7VEX-24CNtjTvBRsx zmE!MEuNFC#9^z<{4TqZ&x8tLxMBT)(_sdh~i){kj=uGSmRJ^h8s~wfXy}o-BUpCPb zD>qBuxx-7u`_?T!-4lt>&EtG^e^_?$>GIaETbW{6aQ9oWUSIqiMGcD19bm4%%R1;R zoG3@;)2Gj00AejpZKEZZO?LK~i0~~&1(f%9Y`ny-0gXe($t*Wnh#-!QuTDV=C;*F( zo+f|ob z1?p^f%x{C}gxRLJ=PA}C-h@P7Jv5Czize-bFyI zF@Nq-*9VngZ1qESIMb!UPf(jS(XIBQPxpu4%Q{e@P2A?#7M^?g=lR{T_uUd47mrJL z5A*~b?gryKo~x~VKqL8VaZ`9UEy=Uz)C7&ODxSGTA*T^zc`lCtT6(^ofoiuCiSe?r z@?Sq{cG!?vRbkaczr!6B#Fw$MM~ zK~pc8I_8_x8gli>g;2f&Dd*Qt-sRf%;n$yE2i8Y%)t!kwl=3-Hg0E_TFNjOWpF>WV zFQrLMBPil6&u_-hc>X#hq4Jz9KQMON!0|Je?#sn~84{ABP}sW?b8>X6Iv(ub^r<#= zH)*5Two`kp$ldR>75Dox{?rI=7iF7>)8_We!mp$T?^P*W2!3MLJRzUIND0O;&N+Im zlsL4Mw=*b1F&&H&Xi3am=v>wr!pk@&pYT;UO3>{-sqAHIST!78?NS$2CdLIuha;0X z5-p?j9@7xw=U_+N+kHq}UN!hJ0eTiG-xMf>`2L-sH#F$@K5^r9R!P+it zE%0LHGhI$RHe=r)#dbx^)wlYFY9t%KD7C*+9VW5Cp(t6eRZ$d9%2hb*m~6LSZ+Xhd zDBf5D7h5Vf%Fm@^e3OSV?Bld+K8Eq>t*Fra=l&tEfNZnn2laj{VbHM4;`$@M`d^bf zzBBQ`lx(fW;$?VGM}!S0$bHs5+ZK98BWQoBGCB8uCb z82k$gpq6MgoX9cSs?QHuorsU&{;WN(YMgCEc*c0mF9BTDaDWigH)uRK#x5*0o)w3y zE`DS;MipW?pK)7!?Y3SgUb{w4=8bfqUon=yd(rS^aiI~dmJunpD2&@L2y-BlL-_iF zmfQDCqWJE3K+rUw+-75Ma-X4~yO(<}4#R`9?PYT!j%cV_Y3huXdkR6*JnNb3#<9Jk zrNtL^u-BP$Q`*YOPo$OaFPs9G#+D!Z=YlAwOBiFa-S-)8SlQRdc;CK#_e?O?um6fU z7*o}utE-Y7P$**Exj`GyYFCbw8JT?hjORh;DFcdpe$wb_PDaN4437E`?Opq}*E~$S zn!~}i(iNk`dbG1dmgH<%Px0OSW2`(;zN=!NXl8okf~At8%$)OzWk8xJh2Bk+KVKx? zoGX34sCBdH^QMmC@Z;b??uNt??Izp{D0pNlHxc36SiVSPQie7+Y9sSq*0P7Wij|H& z77%deu8hA>TwHn1<<>%2@grZ~%GIrBpT&RQEt{wHU~zLfWcg7rb6<*vVC049ee}>k zo|Rd#?DO$WbEckljBuK26>UV(>i*Tn;;s`)2Q&^c^DKJK&L#L}xO(Sm3q<3FGo*~h zLv$;Odmo^sXt3*`Le>XA2i4la%mUx9rPGev`G%_B6l(P1v@pI>ZGuv1AFwyI=6DiB z-rYQH!%f6`ZrJ_Ff+Ey)V`?1d>RFIindx%4?oZPvIo;IUwYm01-C++KP4Y12P)Ad; zV%Z0~YX{xTdLtaD@{=9eZ{Ao?t0=@=ip=^cSl7A%iO)!S>s`WIT}K7MXRqG-WRP?^oM+pTzBfqUD;|w*c zNQ3(hKIf?q3+yv3%ftzDz>vHpG`3?pM=w-lWaRHOfEMky9Jq%*ZfXc|M4>QcMg+%K;1+ccR<{I;RfQQf2Mu-fzL_ddtWN+B+)ezp*GQd~ zqns0_+a{CSG3Y9raf}ed!Iqt7Wrt<6mK|IARFE_HUZTpgn_ncKm7w``+sc%=x4S#N zWpbRvx#>bO{B4i&QiLB=wmEnw=fxN0%UXOwGA8gG1ygj1W?|6~=jydp9$F8k9?WNJ zxp(ok!XkOH@B&8uW&FNFm$B}6?WGl%N^ma1$nu{HivAS+S89ICdFzgg^Q*8pug5S1;$*|vI;9ZS$0qk;q zYaZ-^jrnKSo>VQ7~(8$ zy*31;Q%_8A92+n-=)ZFoH{F&Ps$i&^o1}g#@b0E{H9c+ECK%&z7V5pf8Qf^G zIm+VX^|_X#1#6QF-ASkY0(PwtYvwvvJ)Y>w>U{f!9KFXyeL74?n_ooibja?yM|s&t z82Wl6TUfO@|)Pkn-iSB+^4s2y1OyeCP?`}cHX?Yo7dwvlebTwG|{ z+GL7waOw}w6Mv2Z!t=|i`8k72rtU4; zzp{PF_}lBDYA$^vipRapecismtmjD<$;0O~gWs!RT}g~o>`+xD>_M52!g-&Ldh(7! zjJemjO5KReWnz_ z{CYTe-PA`uLh)s%(E=PR78#)*Iy%9hccN6@rcJ1;eu}kGwb?CE$2%yz<3VIt8SI|d zEJuxFy-)WEwmo`73JbiQPj5%dk=G>L#t3h%r9h+mriOCLI=1Jw?_zv@+EM&v9Iypj zT)X6p?b;P8!DC-uk8Nt1tJRm+1s|yG`2LNe2Nvr3V;7PKYZ#~FVY|^@ z+d{~(Dk`%geqG8UH4}0=Ko+~PE!m7M$cMhB%lX*LcR3SdVWNL~`f3w2@MZ-O^nEJc zkGxzoa>#8O*s)pcFdP?2eL)8+pF{HXs@{Ya787s8c6$|AC>=LiStu@7`eA7%73JK&3?wlDr1Lb1TU zdV<11QY3OHw=ziutvVR?F+g=Q9TgZeKX;~<^VnY!<5C}g@H^o0Wkl!)nP2s6a{~e8 zwU_(!%t>Pz@{>WNa-3+;hT5#M_zi@isGW5|gDg5#HV@FELHqg;N4uqo>+h^>*-Fy! z7aZC=7Pz>iO;3!1LlxT3FU^t?gq0#Z%t)4)m`^wBbB7LNzV1qR$3X`y>>mChRu!lG zZw--htLpdmEcSBNxSSOG%=hK$+){kE&gS!W3s$~`7IKX(`gTp_%-U^mZ*1ep8 z*lEM5nd_<7lnl%~ZRj4{Rw~Q)iwMGiw1CKKRO4QpIQ@b8n;Y-4NV@NC(&@7e1v#uW zOYIgO`T4+k%v=JD+ijZ5%Ji%a(MQszVRXu{z}mnTdgYO}+7rAydU2WhXPU~zqAu56 z#n7CLsZq{j&AADC_YAGKZiWXe2C>{`l04ek*@SR%)E^9n8D!n@y~&%Z>M_F$QBjT! zCb|%|h+`X$hhf~9JC;*~PWT2dg;k{0e)s-q)$T8rBVQO)Y%*8NjykW1)(|V{@mZuN zfDewcrrBHNau%yW*vYcOJowY>q6?UC3C6n~$Jy8s14{PWtS9aq(6=T=^s^Df8Ujx^ zutsWnRX9$eIK`Lo3w=0F1?FB+C5%b9+<8*DhGo_h@-^AxjwbPc`Hy8RGt5@H)p$w8 zdN=EEM33W(<&y+(){`2koHU%e)Llxn5r?y z_Oj8jtcN4E0!~Mdq$!>~UXM}COG_aG?1rT)GWL~bZ?_>O=yYGF7q{v|EhwE4i8+zO z5BPk!bG7}L>G&rK6n~@{>F$-^!+J1Ph|I!(g1YKK!K3R|*L@mnjc=)>M8?QZNZ zKT7V?F>t6U!!Jp*2|*E+i&Z%^bpwPLLipT$fP{=^IrV$vmhmHUxMw1$~(cQ+n| zl(7|~!y&kErLuD)?qqRC5=kh zY_D&|c8=Y*`lRH1&+Md}nf6(a&S928f!EhE);@1fp=An5iTc)Ur2+7o z4b$S#q&#s|d%4ZiH|TiFZ;`j(Lw1KvQ}xnKmN9vqbDkjK;XU{MKsDa0a7mfsypxDAN#t1x?c003ie#j(0mS}m4YE((g1@o`bwDZ) ztVh>hSw#Xcyvh787>ozRZT;Ywul_lg3GJ|fzVmaf@=*jvp4PjTfKZj)Uqp0T_ z_dC}oI#-Sdk1YqJUvRIM%8afPWzjmbNJ3j39A)_PY%6{d;t_8FepvrrtVO)tduOzG zK%Zszd(|+sy_^5C`4!vs+RSr>KfZqFJqKEEHpC#u2wjkQ`*m;q`qf=jb=A)%OZL6E zelO24&2=yhKPU3-qHZYXFvTi4cRbS4deT`wR{R_y4ny+RfV0#@tyo9Nt)mkt>Ri`P z`c?mVtJaV!?7ChyM?2Vn02M+@+x0s> zwP7Pe`gWyT$6s07W(Pdh7P+@8DHdwtw%2HcYD@a5x?U+4-4Y_^YSO$WwYVD^o!c^9 zd)q|j;M9!7&robBa4Fjm+>75mjzFV7Q8vo`M zn*;WT@^#!6gqQ9Xvq~>A(c^-BJ@3iFSA_?@iws!oG1g`=r{@-)?}F#NZudnqy|z@O zd1Hz#wMWHv!u06Jk32GNw+nJf&H004&n49_ zc(MemXmvn$^zeJ_WCH|q=9gc7iQBhUwd@;HSBE*+_112Xdw-okqAI zdJZt<7?IUp(E3Yll2d(S>H3Hj=T0x1N zkgnhpmEb4wg=AT7<1^q5L%Ub1D}Fk7Hp3r}kQkn_q=LAX!gXX}k_8`_t&s`J1Kj(i zsC`GCudI=TQa{z1v1Q1yyJ-Mv^qJdD(*p;_bcGQuJI1g9VEdD zv*)0>LKqvdT(`@|+LycMOAz*7Uz9ZK>yX%3RZIc;ML3bv!o41%j~R?8wePbGv;C6= z+pbe`<{`npb=ODr`B@Jd;>1oQ``9CX>-Gxm(Q-`llUln9pv-`$ z4jN}q+6P{ybHbTZZua?%&y{xQJGFOtV{VdndKMT6FUO%M9glgUvNEf|Kb{a(BKKB) zv`b|?3ZpC(%q{wd5fRqmX1ZV%iIYpn{if!S2f6Sz{+yq}oUi#PGLgk)cYM|tLHl~`%f{w=7A2=;P$8uroCvrT6L#6L+ z%0l3?xko7Ynm~MS&*SiDz);~8EN|?bQ`>{ zJIWqyoe2oQ1@LCUbn5H;(-|2zxbjX!3>JTJ9h5!0OP2O7@Eby0F<6&;G00@($;|)6 za;0h1Abx>-l-BpD zvs_!!(FY2i8j-zj#s1|!EO6R+H>2CPc@pVD6ri-WrPb{dJ ztXacdY_zZl@%Kbe=k>W*=;xK0poLl?`b}kIT&^41K`w4C6AmGf^d%tV^$Sn16Qau= z5VuaH*G!B5yyp&E9|7zSW5j(rfgiTxn38h)38m#AW6WvzI$GX_8hgW={14eKV(_D1 zcpPl{5FlWSn#*=u0obj5B{xSjed%-9GEC)JLIagI{N`FD=lN!+OGlA<#4|8pKG)6 zx0OfX?>~P0ICJwh`oMoLKe^q_I26?Xb6u1J1^WM7jgtTC*L*MZ`Tf_2+7Xud_qv$$ zztj#>_@Bmt=nd}b|2COKKdpp-@biDJ3EuyH;lG3N-yuc3>Oa?i7tOyT0r|iGC-xyb z^s?WYEdP=+^lY8N@yKEgEN;*bct~?|2#Q1T@xXoF-e1&&w^wC?ot#M=SRoMZ`TL z9+cN8$7&q&t2Y=ve*(&NoYfPbo#A+& z+-FcEj~J)rnMjeNA8D5mT)3$mX#gKTw4R-bTiem6Jr5)*B5)uJqLjkE5*|U`mR0Jl zqrmEV(ebKOrng#G5OYvo*n)n~;-((;PwqoP+2Z2iyvi?lSx(eV1TWx06P;|<7P>?T zsoU6SFRH7BY=vxEM8=22848H>f;3(m&I-CURrq)+TyWO&V&-$3AsX-dEeZ87Lwr7Y zPA-RvG}HzcSk{ZdKun}6SLq9iw>$t|v4xA7z{uXFLZ zBSoNO)8!D`d;Mo4dv*guFu*Nv5GB$`D{M+DBgDc$-1j#{pl)nLvShm2DdPp zattF!6?{$#LjUBK7JEML%#jBn2t(b!`d1a(m#zP6P|~+_HF}C~my+QH`+I_u9>2JW3 z@a*U6Nh8z!Q_gkGJ-|xG-QKESISFlk!$9bUzNq23i$e#k0Pi_jI--bew7h29U6pS( zIafHjjX@K(FL5z(KeM?&9!qp3I`mfEa82rssphZt_0|M?=B}!m;ItCKBl8k-aJ}g$ zWp2dJ`_g~F@BRH?lO$Y6{t8(xTRl)a#GPb!_N~4v5r*Rmhq8sdkFyHC=V8CC6oKh0 z&hP3NQnks^k@oUsl%>>gw(HQ|;FvQlJjrL7&jK5Fon5?^YPJ=UKGxA-c`qx|J3&Jz znG1ObBM1E5+`l416T4_aUdPmT2^}Do&z9H9ULB~G$Zu7_R-SkDQP^B6guKDi3wU@M zm=;q>?bKndu|;`%E2ooNPp!0%nd%SIDvO8T-86O`7Y)j#jva5YHYEfI#SYxLUV1pR zOy9cNg>y%Sezs|(V`7oiq^Z>mIT!D4SdmjX6_<9ctVjnVZtkR`;retV5Ymqu*5GH4lgz9KecKoO4OP(VF4l zdU`+nkl*1f2V++XZ`qhlkm}9qZ?Bs;r7QUZUK4CuxNpTud0$yUMn?mtB1gfZHL2@O z+#E+vFRre~JkM(~-cyRbgy@b*AxELHhvC#ESKS!aEHPZz-3aSC{z0z7)@Deo()hco zr1botlErm}OIrW7K7wa(vhSUj&2#LUGLF$1|3KExoNXI;ZnHW3n&5nc+?J@fJo@P> z2XA{E!#Gmq!{F)DaRL@lpuvc5|FdknB#aAKkuG|U(O!n zFIs>R$vIU~)`T1OnfAWw!N@5s&+7NZink^bCl;H~tn*cd28LAUiY3bTd~P7*CihKv zPQt_=V=@$B{DM}yVaJjbq}CmTHli++p{Vowb?C@gj;Bx{DApH@W4J1G-__=1#DrGJ zw8@PZ%8Uhm8gK2f`L=@oX7`n%Ov+d-y}Xr6&7le2zj4yZ^>h@Ev4EoWy%2?IXV^c- zqI10@lNkN0ykFZ43Z`p|^i4+IY@IIF7v3qFTV?%wg3DY}N6zr!N#@O??Nl_!EMwzxZLFQJ2g>PiV>8G(-utSY~TceQG@5>wiwAtE{d++l!tTZ#Q z0rv-s-;azd!_!^eUAQ=85Vc&6Y`Ny%7PPbHhYa>}U`+N!reC=_IK?f_5Ec`VPit~s zn$S*a;^7DD9wM+xJ-J}|n++kVA%WW`YS~qiHZ3lkB!0@)SW(*{2iEPL2PehCwD9yt zGSi>C0I<#Si;T6sBeuqtl#DR314K+G(J5eTY;$8GsG~$XH*59$^dpQERWQK&(svhu z#`uspvQTJb26)~!+szjx#F@87}S?XK2(Jb7v86Sgb;;FD5s*~{O zb_f01$$@?QY5`N#trLu2CJ-!raIqanirwHxw0KJ2&69WstMa_` z2An#HD_6}i;m9kR9e(Ge#?0atq_b+kYSWmBK zRMwi*S*BZ0MN2zmA>&ibRh)5hlIXc)9Aqg&z;>&{T*l?W5U4rQUWeFeBsp8u_dSWq zED-br14DQ|oNysp^ri_xx43CsXN`u$pSF3 zoy9hQRd{Y(`$evLeM6`#I-JCy5Xf(}U7AYuGvqgWds&;~=jwa2eNWiQo;sV*ZB?q*TD~KIRvys=c8?>NH>nn|H)bO(9OEHe=@VILFyv~mip*A8rMoLk zZUl558x#@2t~`Hdg^d)BR)fHbQr=s!=y^!=HhrOK?>JV-W^mL8V7~3rRz_}~{h#@; z#UgE#VM1&FG&j-Q*4>e!o+j!*y+bl>H^S~O5x4G!CpqJV)2Y9GKd++(E@^( z&61bqaELf{g}XDF+RbUr>z(RCpDtoso#K2-ySM4DU%U z;UB7A#KU8ydM`bHKu0TlYx~1v{L*$|8fl6; zlKYjUb5f6Y^CnQ*WqjzLwI%EGAN@pmL=^QQRL)0?(tohSBzi!1z!=aCe7#~Td; z(N5#7CFUWaU=yuc;x8~{9f{y3D>t^DZT1YsfEj5V-J62I$Lx?*GEoa|r0u5>b=BUQ zb+iY!bwiN5p!dGYTgS=yVf0VWqr14*vUzPQL{GE2YC<<;Ncv6H28ipShMZ&Kp^M)%arF>WrL_YPKBU?CZ zB8=7;<99$ED9ozoju(2dp=#{eGg&4kp7~@UDyZECU{8f!TQw{+!^S)nM@O&m@=VHB zDH^}^Npd|1Hl;?0?+>T!wN|Z_wM-g0Lb%Sbxh--lCoC{fFOyJ<9%>K_seQL|&KvOt zf~n8u!*{f`7eo_^QiMb=_$7&Z74lKdGxPLN58<`tz737jlg-g2sbh;8ie`%r2 zP&K1GHiol+W$HV;Y%0>`r(&5iDxzE0QH!KInAxFs*bqUfbE4PfY&H@$$JC}vtWR{- z4ubrmVoXU57P}wCw^&YoSQs&`ryt_*)V@fw+DFe0Zb`!B^R!Wxh`NG__KHE$9YY# zXoj!gsgI3gqSMSg&4)Z16oVeZG{(m9_$h+N$3p%9o}XKa)zS~XG5wJt`|g<%?PPNt zI79N4s+1ld*$h~|^wAHr{KrA@gF(exvPN1;k}Vq?#t2`2P2b(hLoBs&9$3YrIFZ>{ zU=!`Sm}Q+eVE73Zu6^|?zBXdjd|d3>F7awcLy=XBXBVR!@0prm&5G0d<6lhZ=XKt_ zxx3QsC4MWrM6w=1Zw?>D6UcL-7m7c40DIKCh}_CcJze{00eL2 zS6Yo@ZehpMS|v8}MfPqF-J^UKWH7T)r5H$O&cMtMu;Kb#k4E<=se5*>6y@@;tPx;xVeJ=uN88HIivI0W%M~!~5qBrIsOfw5AwpaX z;vK_jI2|`#%PlUaynJHPu*IarS0u$#202dlu(?&Y1FSPLrV z@>l*t-m*3c^Fc00yS>WC{uZFC1uuZfD9AYXR|0avL%$_Pl7I}eW$ zCxM4Rz=B{3>_fe&i@8}J4fcBNjL!7&#S-{UDeHrVH3BRRdKdv>7T<=;NWd;7$J`AJ ztl7fqjsb{}q^|KPoFw7pU2mCxHo@kkFWTT?dFdoq9~-}2GUBN1MHz9>P(8LY`>yXl z*%TJGyV2QfqaAa_^Q;%4YEQSqzP3jIzWCR>LN!eSP{i0ahw@$ba{vLD|Lrd17x-9J8f$jb zvy~v`@PIT&)-zd#zsWqRpImfns{Dy!2ql8#UA(wcI+GG|jBCCQb9j<_hp`Z4p+GNt!#TKBr^H{Y$W+mDq8v`(LUOT;7G`9&7OQ0wT>`c>Jeg{h zNkDH7O1y){%~;Omiey+{KEMr!2Qy_Zu25W6JSb4{a3fe>9X-AM(8;&0w#FRpDui(K zi09kF!^lGhHhSR4Hq<1n%na%)#l5j=1wXu+Bv-~uxHKLXf(khKJG|ogH|(2j^gRa= z=YfD3pwL?Tk2vtjQdC1y=FwB5@?c^%d#@6tGMw#)Z5DmKh_wq4_~x0nuuHXQzM4nt zy1MNER~{HjoARF#@L3qa0HHEQwNB<(LG>l!>OM#Beg#2jSEpcvuXE?~!sP9ri{S2; z6UmH2Q*A~OxOgy9D`}=^JksWnUj);zKy63~Zw~~cmwij6I4m_qBlL5yA9+NM{88NU zZ&e|mJJw?{!zEttak*^>jfTife+2eITp^w(jL{M%U=XecdjQT#pJ(P{HL`9O$sO7F z5fk}C18kx~Kg*({yOpz=)-6e+o}^akLTl-1>l7w+zVJ`dv(y$W1enWL{sbO|m$sX) zuexbE6PZo`;u1=KUaW@J4M$(69d#@k!SU_49V(PPdR>q!Z}w9El@BP9c~NVt9;a`qu-)p)bZ7KRtETR`Z%#?y8|M`nu2&*rrK$=rJwfTy zJO1B50riHtSVe5>+mIF)|IA~9x%s?UVK&V3T=5IAlyKq=JQc*NO*3O#gJh47w zP3h5kLI#$~Y$oCZ?$Z>(q7e2P(OPp$G zr=MRQp?y`Mq}mbU9(!-RDI|!`@=k%@5roY>-~(p8<*lsM?hH(YHIX{9=Qou$w7G?) z_}>m{Qc9H+yP_lIHq=$eR{OzUjEN`oEN*%YygSjrfZATg_(kuS^N9L!+8FrV?w>}K z8W1|1^Qm9>5RiV{=X!)TM5VPg+Zba)%w-`Y)kX(XY#{E+fskMQVy@H~4AsKytus7{ zg!thlLxQ8XN7|$PCAQQfLYl`}LEO=MArUtR2>yTCv=nm3%x2szg@1(#7mC&$-U#;>pzyI(9%EpR`!W=7+fR zxwpOGH03TtMNi@SV=0uBd(ATzpZ)rXjpb9XJOm~jaR!LU&|!JGm=e(ReBqf6SCCI~ zTX}bD0gjg~&$lOOA9S!Cb?dCn&l1pFRqNy)KDzOvHz};A2k<$>KSOU;LxF4j1}!V2 z3oP0-cX1Iez#c55(m&3uN?IF(`(E^2dg_5mnQJS!icC`w1%sVBb;JyCG9n#>6ASXh zQ23UE*n%qOFYw7*LcYe4?W)z!&xYhciARLi4wOM+6$5Ew-NoZ zi^Kh3?oR}ls3`4E$0NT zGV4ILwqrT*o$Gn3vF3-N$enwhfTfJ{N&wT*H&G78uc%3?BpOAk0O*EhNXZak$TCv% z%T2=B*U0guCO{~}%1!nP3snPhHeT=f%rZr+mpbC2Kkd2FO2RxCPx*&Q&+l6;@R}6_ zn65fMtMpU^QAEz^VIA;fu=?~v1aYS7UqJ9_`65w1)9Ka;xc!S-GT7Qvh=GIKj~FNx z4e8fbe!6Tj$f(MQ{{!b&>EboTxz)u zOUKR&yt>klNN&9XXE9!f?9N&U=G!ee;@u$1YjW}EkuXwM!H02I*nz?X*?UVt>pOqt z?~E;P57(^`Q?gYUUd4xN|16fq|7ERBAIgKBZDlJYM@ohy6xQ{~>qal{noC#`C z8}x~Yq0BJ}Z|3u9y}(|S=mKzkxS+wb{SsB4oE9p*Hk1!DEtN~|l)eR-Do-Z=b?@9= z%I4Z8DgnFfZ%z+uHG~D}9BmJxjIeZpR?KQz{jqGRx;GcYVwHoAZ1l0D1stRW9a%Z@ z;%rJ|h$s67t0Aod&--P5963t$@UruEw%>$e>0wq_uyj1@R4p^um#w$KuAyuP5bU=k zF92>q>(nEh*c}HU+>=MSVo$2)`*c>>oJ>CitRVTqWb;w|^;vs?7rmG=w=`jCr-HHo$kC|;$fS}{Ce zaL#QkTsJ-l$Az7w-~b_9HKt$S+y`8IuV8{utDalRWT05h70JMFNC0zCi_SiloX`#d zylqrvf!+(>{SOs!)MT}&#o1}ZVfcYbcqn0_mx>oDjbzpmy$5=9wy+zGf7-+1Q&h&;?gN%Cu?cNC$j!=r_aA-j z2obd<%Bib%)8V`TTs03TU;gtw-+pF$1C1=c{~!F<@4rf}{}~Im|7z8~t>mCY2WZ*RQYi}K$LOY|_@7fde z`F;wACum&xz!FL8=LQigE6+}~`W`RVvuy`;RySk^8xg;)@l7}P&9$OGict9-FT@3@ zWFeF*<+mZ^m>l$+mnakC96d^2C}BY@$oas)K*YOc^*bXp@Ay6XR4WtPgFXOVjoU!S zHH4Dfp$@}>2yz7~ei(VBqRig_UNvmK^EhF0+x3>@3$}qVrdv4r*F9Ru?dIK^<(FV| zkU6&gRSMT=M1Xe676ShROH~uka%(~J=WKF-&EQ~vsusPaRLg9_eY(DiGAuNBy11dQ z1<$AKHA74Rr~LoBoBL|)ljUq1i6r~V<47p5Fi;dMpYCN;1F|&7zPkZ+0wvmEf{J0G zcKZj%>*F1Q5mVao?=3)wbGQX-5FpveZTc+OU_xCMb~nKYo`*9im*|7lVc{+d19jl{ zmkVUYr^=6g9|AbcyZ>jZ;LAlun+g|~<3_(_-BXkDiSmPNabHLM_k@XK=&6B$ZV=@= zfoSoc>&uhBz4ly093^}l{MEpcE|N5Qe$`Fhx~Zya3uRZR4)XuL`_Iw6pz_lTsCmpO zFS}c5bM%`w@FWyKxcliEq+3wl7AO1fyDy3$@ng1OmGF@xmoJx3mG5IStnp(qAOqFL zQT-WYi~n5T-)IME#6aMA1W*Vs`f^K)zbA3vUwiJiJsHO2ep4shWCz|`=bMlcDq(cf zJ%L?E;;DbG`#XNev%dt^@0*tLpMUhL`ktNp=g0plcmEx&|5uc||9RdB%l~WCZ~w=K z63}4_Hn!24yzj~RECaDKXF`AlE))9~5^TsLD$P9$V$_>@dTOewV3Om#6Tl4F_OD!O zT>)Z95Mo*~+z};{AVnhCi*iL_NKz-{?&6=@f$TkymwYH=Fl*v=CN>#xR-g8=PbrA5*7 zyF5xW2s*o>D?sr#v5I6eq};M|5L@+3l!0;rYK>wNB`DM-;=cy!{x1;d9BKu!ce6i% zo3v2*LJMgYdtr>~w4x`=pYKA(Sjt@b#A8jPxdEWXe|4S~4McG-Z4obOGwOtdsqv9% z-9{~lF;+0QMp`+Pi2&JA6GZYiHp^vEvM`(9cDVeTfDMxEaB}jUf&a^Y0&Tto|N4WO zc|Zap4`PvBm1@p$Q(Dd2*~Bv`gB%4(wVMtl!{tQ}L}cacs7!X>2(G_!ZGe6#9h=KX z+QBM{`!keVlQ{IR2d2gQJDGqp_eCTeBDx9YZ~9>X&=i+(hcs3Ak%1D1U$RTM(4$&+ zZ>p!cIHBd%Oe zWqga|xvL?u2&X9rkZb;?xGO=~;>=d%@9UUt-UfEZ#n(uGLO9<27AXH1Qy*OLkAD~q zpkRA$HRk-O(%1=51QvWJ>CIw>Ve;xA&1VrpMEUHmy^smfV06yV!((3O8 zb4j2h;8C3Fyfu3WL<=a{yVrcL!*J$xcOZFB#=F^!l>;MD2Rb0Agk_?i_m z)G=XTx@AM!m`IX`%(Qwd>7T@~1)TW*eogSBmDzy^iEl0yBktfXDlP)j)-O6*i_q7DKm<{`D1(mZ$SXX`V&im&Gnk&Na#B(*{>$9RK>nnE$YK@n z7FRgBFVxSKjI{BHfZEO(<8}dwah`(ZH~qLtxcwS614DeXDkVHY zgRN0~xe?VMM3-SOY6qaFG0flVeTuf{_!?J;jmw}D8$Z?WR>s{o`;>X|FQQTY)z$IxG;{RjHBRS1EeX4j0F&p-gGR8 z2&kxZP*5;JKza>076fSyC@9rJQEAdkC@NLyHIM+2UP1_=B@oJ459qwU=lg!ax!znC z76?0gKV_BszSnwWbb=6`e=`t?vM>x|1d1Zy&?Z9skL9t~gywMThEFsz=omE*0NK9S z^a*LXc(VG!=46!Ga1SU9-;(&sc zLP`-9LV^&w_P}tZ6q;0?j>_Gj6?-48Zm6YrqVH%S4l3uPl^&wM-qgfLw6Z(|;1awR zk8-Ueq~^A+Ft_?w*`^`nRW1#$D4ty?X#%1Hd7pt!P%Mw8>zyi>ODl!C95~q|My7WR z5z;mhGrM=>*~PilifXo$l~&D;8cfrAR6I}7Vm&A70C(g9sfV`m9MC(NUkH~Nn>V?V z?KRQX9?};7K0*UXMEJ7u0X?F8Vam0+@Li9>EMAH=Di$fQ&t?-`l2=DfA+_yum=+T6 zya5fFp_lhY0O{p>5*(8PGXMy_x_ycYD;tT(ElpE8 z(`yM=2U$akyB5(3Mbjh8i%6+yBR3T-9d(5@gV~x>&3_C**=R#C4qg!8!Z0*&IXZFl z%at9VeW~Xmft5zyFsO~wm{81}<=h{uGW@XIx_$^OTg!5kGlI<1P+JA`n$tr$4ci`{ z6#W9lw9=uk*%`>Nq6{QE4FJ2Ln{aTaj=Y1}uP-Ssr?40e6YVIzOIZIq6!6lR6Lo$n zF=oK&gqi^$yy^ykiv(Xk+~*o#8UjQ@rl1`NPBArYChuI6En*xEtKD_2V$VTZk~i(?RcEkG9%IT^Stm}Q81*EsRpph_yOd{ zUm5xyp|H)##CWZ{dkjQ#kI}*aTX(w>IV_~FotC|YFHP>IW)s1$g3*7$HDtU{`8W_e z3_du_uH;qIFhX~;33=2tSld#PqwD5EqxWFnXo9u+pb(iPN+q( zCS0pXMtJUl4rSwGkvXR%Z0e*<2@;|2UyODN0fOae)Q%pL3AOZS&aIKcs_Z2E2GF@385S{Tb`A7UDqag2cn}333SMeP&)oyBs$G zVMnDtH8@RU@5pTw>_J@NX+Pm=4&;skes1X#u*Bw{nVN{sGRQN4x{B<<%8jcMz@(M@bW?E0sK_M*d&tbDuy5{J8|%nC>D1zsC8VT=voff;eW)xL)U zOy60GK9L{D@ra!Jv>A1w$0*v4y>|+^_t6%?$HTF>qeCT=Hc0jq=uU)DPcJUnhqZw? zV3qg)1U+L*NMl1sySg0zNkvZJQ{(6u4jBbu%Ef zmvQTYkR2637_2X0bmNT7nTrrfY8aJ(%Gtl$`+N3$%?HU1_T2^!`VD>@$=4xJH*bzr2N4s{!^;6!LxCCKNR z6Z*-7-{(=02i&mq=VGL7Lu$wKadQ`td-U&qPw`b(yAA9ujEw3o^aU!|3YdfD_H?MU zfrVH1+k!1597LpI-*_?);ysJjVM&cGA5f}b{gHtT(Ht*`<)e?eT}Vs%0b3ZZ9?jD> zsr$&%No8p${y+wVLQvLI>+H@R4YsIqJSh%@-)AgqSr7;zx0w%2qJE~6`=%*xbCpJqM06?F1S&d2q#y1mJE~%R&Lr(+{$vjTC}t^)$_#xvaC^j4;GcvQxU0+&JyHxM|rP-2Ay>)0h&>#P($hvs?9 zIi zP7TYJJcz^&&;>CJ+9IY-z*XpjlBPRgWet7bD0|3=<_tOtQKQ1_*gpdC6!ujM=@LAR_)pGB0$Fz%bM4bau1te6Vq?31A3Hm&)=4w!lSyVWZQ+|95 zqPKYQQc%p7E20egO`)Wm*vH6Y!x{uqBR|AtQXp%HXy54qh6Gj1<-2OV5*s1Y@|WJ?W+HQ*sRw6-Fga? z@oHLj*T&jy&ZcL5$IMD&Tq|99Gty`SJ!fDH6rTcwVDVzV)iPXQ^GAbvG=e z^!$k9cDt+!Zsvp_p4koeda6aaHfF<;cYM|6~6G0O$}a9G&>I=S?zpWz0Hg= z%3FLvBa2M|-vFa4linPcaEaKL;BBJmnW-1`qO2L&Zf$kd&^{ zxE%?e&{{JHao%&t)@(;hlFAN5oFJP~o>tO^#5Uyo<*~rDGH0_lwxa4*<9NUT(gsDwX~lYJVRj|mD9@I|R^FmAkVo3`ya=a8+M0age93IM{P>45USFv zN3$Vn3hB4`p}2ID*;zk7l!6A{?e0O2i|7Cu#+irelnHL=Vyn*s53{=~O<@)VQSaXm zJ^Garn`LXFk&|}@I~#*^?nb#@e;m3xF&avI*CO^>uR#!!v1@VlSE!yZ3w?cWYkR~P znRDJ2@9$!#PCH2z;;pQacGWF+7RPu zrcc-qF0t&c+b!3xt5*9?Mg^gLw7jB%hHjj%?Zwm8O6yB(*WwOB@w``c``EzdNv=y~ z3{%y}NIo9hac}XRXM48SS{K|g)!dk1RG54FyLL(|Ek~5{F74G`afP_f;8MBaw>W{- zwPpudILAgZpOTKab4jI5i!oX;@t^KTUbeHicST%5U8^7*N6*`|HhpZVAkq3J@=~hX z^9M1)RxJcz6}$XT-%KY13Gt&AW;~0jXFXJ6|9zEWC{Y-P>@z z4^ic2P+?lCQC?;AaIB889zJ-Vl8YCQX82+M!9!Q~)#LCkRP&L>hDv%BBS-}Agn@%{ z=*;aLrY%HJMOK(Cz8;D;DRlCe^jd!HVU6WL|E^iVdhxq-Tvtf_o?QWsy|8Kivud_Q z)gviNr&ZQQTWcfO0g=Z?Hi>Pyb?G-S;W~V><%|->Qb&e`dND~{fAZX6_(G$A*g3l+ z7m93`7Rnb`Gou0tO)AUT*H3o24|hAKd8)$!X{v>;l<-+1`r-YJoHngFOUpTgEWzPG zY3+tWG9`e!YP-(j+a3#AM}~;07oD5l7f|Y#)u4Jv)wObz!#6Ig?dVjIqe$Nq{SV)s zxmmVnVJNaT9e)5Fc^WeyG2r<{)C@Bd{qNH}F+!_B{sOgwe*JvGbQ%;PQ5MRC!pom3 z(H^T|I~u>`!d%6^n6*Loh#JA6r_FbsqGrGdcsd7>Sv`!bG$CbXivdC0aE1lF>Uuwu z_XoRPxgEZz1K}7OPB%7<+6}d2cO-Zi%v1YONe}nvKEUzvS$L%5M1{! z34eFV?}_C2dYG=q2V8sF;v2Chh~!m*qq0A}q;)g8_+SMzk2|9K?I?7%4r7Q*`iSiU zq-NH%{}a0qzlsNcRK9IpWS#}E#T>E2Fd;Wos-A?YhDqhZ>deRV$ypZ)%e6(y*i|uv zyyGwCwB=x065Yl|ts_d`9wQ zuz*q@yIOSkEPtmBG5g)c6U5%6*idIYgS?{qG~6ZKbw#d6Ki1jgQqnhat)~KI4j#!U ztD%aR8x+^zu4=#Bp5m_LElnGgXQfC-9TS6X5jQ(qy|Z^!SBGx;?ot>UL`r+Q4nM7_ zza#(F%tMUDBLNrvExYB9{8)6nESruGd(p;1KOwq7vt&$&YIu%sI*yubeEI4}vq*aVFAN$iZA$O-+VwkHMGHc!wYu1_ z?b$}Pd*}0AKfkxcoI@5Ys8+V5?(%{Kud$hVmC}uO^#ZnBYs>Z(*ilhk1+wNkZZ3;A zBP=;6f?kuKq-;9AWL*1nh?lYpOpnNnF$vbLB{k4<&cNzsV4eHKd`eZ}a-p^KU!UMs z(Yv|0cu$Z8lw3}^&nt2GJ_$oRJaE35J8DNHSjCt0o|QPpV`z|8{e#P9LF=&W4+2W6 z(40G>bm2UZv%$r?<;3c6*2oH`?7nJ zEPuVu=kRI6upG|=fyPoF^J<8m(8^B5dK)QM-K33-^oc)=2`t*mI}HLcK{LW*eMQ^YMh9!p{-8kvLh%BA|rh~18HFE+V@O@8>r){$;IaQ z4#lGp2YVr1skS&-w%=K9dB*?#?qUSmYUO>)D}|OHfEOT%0(#xW!Pz2ne@oZPt5{d( z5$|nCFNd$z!*Q_qMtV|>7F(oNcBs3IUZRy;nRSXt<>Zp9`uZ$+ciW3R!-$PzDO6@Z zYkzyr^)g@7HX!ijbg1Z;X#xyo`TRV|Y$bfQYpdE+nPnaHGb)^=M76%i!?EV-R8g8d zi%?lq>h9-<8qWMsHe8E?{&=`=)e&b;5-(vx>gz#qK4Vhs+8 z`4-`}fS^_aswgasx_kJ`mfTnicKE+J!4r?b6ZZv^#H!LkwR6~L?o61{bh3|1eXjRiQ`v2qimfXPokl~QSuHwh;u|^mpHphITOnv^LNoti8g|ry zbDDUV^rDDiu?Zh!c2ry#w%vU&ouT=*wGky918HSX(vdnE$hR2AUm$aV#c2LLZbmAs zhUM}3$cbVut{J+DD2O|GJ4AYWs~Wx))|M90uQ~tZVc0 zBbb^+@0t>~IU`5%vR@cYs(@JIPYW-w>L>yOS<%{j*=cAHQmU3Zs?@?Mn)k2DKts?~ zJ&j=DU)uad^kDD}IVW(?inZO?7)?Qv=evcI9yZ7CwWOk3%GCLi0HFgMep6Cf7L zyx2EqS3G;Q{em^P-KbKTcl+hL-c)7SSMT-wrCOvxDesD652xDf)AA=Ynl~ek{z&cc z7alvy?cW?P(UIsah$##5)t_dep^2A7dTQY@UR zSmTbre$er3cfKk8N!al5Z_B~2S5~h(lF2Hj;nXg@yWGA6f7E)qd-oN3F9kR7@EyBz z&729oX2ps|D@%D#S)#AZV&&^D1Ri<0?#+;V*pqvSzn$UX6n2dabpB#?)oNedvns?^1&WVyx(tc*=?@1^6zCDdwLRReQ;R9ta_DT$NnFT~_69=UA|4GhwglLO)woh-k01W2a&ahn-) zMag1`wDl!9IkXDc0n*D6U&6&{H@d_yg8$}Cx1dSZUrOLd8uqz_rKY9^8QI}gf)(L( z$V(2Vo(j6(_`0CJJU#5+jTI4IU{E6DmmfC73Ay$-yY>|-(F&EcmF+KV;g{dzv1>Lk z2Trs9`b;wEVF)G0b3!3z*R47@qC1|ycpyEYhv1q$LUv(}M|8=ULf$hT$-3_XH_Bj5CvsdbOL2(2)yjBw+hKik?5 z64L#qt6~UWmwxYm9Z=GI{M%YLxbI1zDj^crN0>G*pvDB0gaR9(?fNoD&w*Y7pEtDWgx=8JZAjDrMlkJI5czC0Si;uvu+W zy!H;(*=p{WVNahuJ7e(F690ok1mCb;sM_V=^s>aXg* zIs*s)S7%`U|LP2^@LwW4hyU&j%=5nw{4tAr{x_sQhJuale~acnWAVRz_(MPd7V7_F zX*}6>D~~X3BG`^Ay-!ZIW)|4R^>qG6%s%of60?Vjs6wY-$fH83m)LHxlk)5uTz&w; zY{$irBp(VnCqdgkSDX{9~AT>Z!?<#K7p1LL?R;- zl>V%d7YUjoz)FP(hQsB*zwT-aL(AbYV$Pn8TNPD>g|DXQ|3h_P>tF}oAc)(nYg$5W zdZq>a{`Xg-2GkK;FynEB8cbzHhE%uLx;b5NWAtjDWo2REGeL(@n>8!^e`*!NeuG*C z@E6UEb*}B>ofq}=BB;=KH-W4&%hJcYn4CIgEK7fF6Y(#Q=_Cv#9vx5l(jZsQu_jcc z=5?pNfnL7m5HHh(vMj40RDzfo$2d=@_(3?c^?=-a>$TG=g;jPwvu9@xk8wd;3;U%3 zTKPS(*g(h;Z{m-!Z7VXlvU78++tI}HOWpv+JO++K6?=|fZt}U!zq}8dQ>H|x#NFU4 zt#3S)XFi7X^O2+ZcTx^ne7YxQ4qvY!=4yhR1Ua!>&0pQfq|Ct>{lb}$i$+GIBUi5) zZiA*IZ#aJt%xsgYq5~||3+QUc9M7Z}PT%hGCIr6KH{*qPBf#(0$c|0NZF%He&PT)C z$`6e!H;GD0k_LYaIGcem{JKNeM~l8!FQi|4cxr*lQvmL#zne(ZU;1j@3H39$WOc@b z)VGo4i}3L`6GVSnTGd>CmC#PYz^Gpgnc%pdX>ZX`@1pVV)!D`e*LO<%=f7-cMb}D1 z*S_^X3-_O)`M)h(;m^_0x22m^!7&*#oi`6yB!%q$hnl%3>8Ic$#@yA!1SI#`M7NNM zZV&_Bvt%GGau`cgpcE3=#~x0!4+}pIuRXyT&xa0Jt#@c~AMwQ9H8aZE1Nw?vl zvbSAQyoE{;7^Qwmb2(|pMPIFw{IhmLe+z3udDXBymk7hZeP@qHelygo+BVm6XM z#KnfDB_}j=OcWNn^#|qc9-8j>Tivx9XZ^mX-K)pc!`=COjBN3B_f|Nn`-$C>bn#$J zY6hl-yt^_)z|AR2l{{OOTw1Lw_8JjmJGWhnpT)t z!h>JJg4MtxoiFY!#HHatVoXovD~Z^4R_py zZNbPai>i8NuK?qif{seSr}@2VC|w@TGufs8-UI$^>6`{AYfoao5cOCEG%{yO;Z*x}3xnrL-4aXCP=1+zmLIwd}TY)}g zxLzO*S{TO z{_!)aqE4gWf5PUwDR@T7iwu041nFixGE>}U9#@j@XLU}&G!ouZ^-|qs61Agx-s6Vq zql6rXmlCyR>tYyD&z0=FljmM2Vm;c++5nCWOcv{Ie2)uH*YAAqzJO+l&k}1hQAEyW zE@P9^g6+H#L)kl7kFec{5^Hjf)n%xWG zVa;KSQ5}Llr2oA2?qb{8=#9~4-)wDQ-}iY1^C|7uR&sVLf_4h!OVU;e%G;3zpXa_>{^8Y0Llt)Pzt8@9 z*&e0mLqF$(Y&F$mEZ`_HM^*+c0GheKc+#R)jyMtg&lknpa@G_2Vq z$Liw6ER*ZWd4zqw`?yt?^_{4O(80dUm(yR$3MVVxRREviGTzbo!9=rYW@;=uV{OZ; z-LFLNf#t7F*}-bANsd%mh9P>~q8>EzWZq=4HVb3H=$2Lq=5srLvYXp<^5LH=wa>Gu zura#+FxAS0&6|I7p_`*dyufw!2vJ$=c;c6^OGDq{_ue-jAC^3)&?7mtTv)f4T>K0M z0mR@u{gq~2i3&JMx?R%Yt%6QoWep9>qx0ERb%;CO$r%$qDBFxYahrUl-u(GvvYETM zXtYsA$x*DTDuK>1RtKi6%~xO1KQA2WI#Jfsg(!h$l;k@gM-_nx`!VQN+txEzzWO$W@~`ac={|n z9*Zwd+;7*1Aj}?^YdYomO;sqKL8VJNPgly!>ImS{%<|s!e}ou>YY_S*gN-L28SAu8q4` zYConwEg3u8`ndwxPv1ef?LVqGl?ywUw1s3|_bwuo-OukNr$TQ*Vo$U9 z&ti@35{a(hG1g}d#Rpl9Ef@W}8;~a>5lnM*a`tQdZcz#rBYQ!Es z?cYV@7%BUlmjoT#t#rc~-sz`K@f!dLm_ZcD-y-l+m-p&Os*H?1tWw?2?~#Rh_b?!f zcv_mUdTP0CiTKU=v{5;lBcay=+3WW~QJ3z$o&Bn&lG2@?0Dh%tmXOz?*m~wv^syTk zvRLV+(}s(SO=e;fYPRFg^KJT03wY>os}vstnv*dwIRbVq>DLSCaWg@?2moZ)XJ=Tj z?{l@YO&@-xGHeT`8`Dc_ck)H{GzBhoj(fmQ<;3XRINS1g=CJdWDCK7#q=;+vV|F$O zX^ldcQk=BKz`HV%c)Z(7_uIXF`X%N!%cj6ZQ@7nwW)(>$Z8*YkK{FtJ)&S;7)pddy zNb%gYv*KBVj!l#F2iLPj83ofrRj(^F`(LqJI-RX*e?0rlf4KC<=MDb79q{i1*9Sh` z4-UD0@=-R^i)4MfxsvnXVH^5#CujOqY}eCFS!qLe(3Id;!S^CH69%yRp>iD@`oJWRf)H7j(7ukTXO zPwU+o5(Shx9yZR7zdjD`f~cY&$$?U+R+lXf%ag&Lm%`C(YQJ~`%P7W0vIoHP6-LMk zw@G2*BQL$uDZ@V1?|$(yK*|<#4c`Ck((k!yBEV>f?zUx^w?*ppPbKe92x1&Q`1q0F z^MB;^Aoj65XCkK+`?T4_4T8UzqotiQU#+cm!`b<}OY5o*9wbV;zKGq~LB{PNtx->C zb3&51G^+^~jNOn-L($#2KR^mQ#Utx<_E8-2ljdE_0OUbW&CUf=pA^E_BZJ1{-0p7Q zQkPc&(XS<=05cW;+@s9de&H>blwsgP%BpjPGCDYFtNE{N!OlP2}L zVKxPDyo`KUVf2qF3LF-jxlw3qx&vH$y^Fg6+8@aI9k9^&G=ig#zpfGyMxh+qa{Ump zoT^y(>N|_}EXqE&E)T?xgyz}B=(6TUyYG}hx3Mbl75(9(u1QXXsapppx^b>!90VCs z5U4ZmG3RphM14J09r_(3lzDiqgez?eF@ogKm8>dG??7?kn1)<@V7JF~5H_+*u$y$% zvsgscqeGQiDhZKng0c=5R|SrB3{M_?bEX`=PL;=64R)%j05jt0^HtVyxGtn@>!Dys zCxiWNO{@4C3;!o?90=Sj$^5I9mA>J?i3Ypw$zqfs@W^Bqbt`5}giORKDBUzgc!R0_ z@+G2+PwM+~e4c&p)XKqn1Ddfo@Tw`ty2V60z{4rzwP1IDdz4)uMAg~`df>}reXgIz zyZkW$y8zCxQA&da`uv#txvzz}8;kKS;(ll!gGekf(2jJqdx&i$hg>K{b>rgt;ms^= zFYq$}I}Z1grnH z(<%dWvL153y7RH)Xt22CDS-{y3(qxz1@1Vt4{aJ-Yr(WO`EN8$O^w~>%b$ZEDbt6^ zbOYPbDIreDCA7vLmNd&aDs?CF25q7m(x49&d#mBXRVaDt0&$UAo?~@h55$Gr%DVUt zl5KS5h5;FhlvR&f$rb&2PSX&V!Y<^o&%Pu{C#reexQISvrW<~!T`#c?vmcfj=6ML% zpmWiWDyz~7@|UBqaE#c=rBzBZEes>!RJ zbNc%y3t_7a(|Sy|E4fZAl8NL% z5lz)dDYl|p^ycgg_&0HE-UDK|OZS@LbayKcgwI?RvNMyJxKQ+JDyW>ATa+b(O%Y!= zJU2zAnCcj)x{qias6jq##cM{7;}r^HPQjxgz=fCr@k_;I*rUXUUNA_q#{HY(7yYJOdWkT~&B{7*$HqFYniOwUziHPX;i)Qq zmQ}9n;};;wAD2sos64T=9}6ANuqmo;q#WxLwUA>pPmrg13)iifPo%e0j^W>5g=`Ug z!#|m65Q_*deo?{v-3#8QPF32I-IpTZQ2psZ6-pfZ*O5_3b_jJgMsb8_mm#eDtB`o5 z#*%2~II89jA3V2tet+Ewqr&}pwj5bsdgtwBn=$!d;QiT22zXfL;F`1cMK z@7%HD6rf!!Cz=F!)MyBWp_#}VPXx4J&E^}=y+J>u_5?j4SQ zIRQ9@AJk1{^a`rwjX%t$2mzpH9)TIL`3W@9E`*rLVDo&B(=--iLCPb<2Q7^chg4?O z#Ogpk@UaOwC5{@{6zB25VUJ>4$DY{19db1lYY`l8d{NiEmke=Wwc|0+V{!S0*Bp= z@oy}YTU>F45Tbm~=+w?b+wa!}A%q2@b=$a!ZZN}`7-ul1XE&FjS!{?=D*2-D=gQ0n z*|sCDJ$B}hOUmH8A4gmj^>#tgz4;!GhNAe}T?zT#C6u^lzzd$;a!-BS{VJ!zz&GDP z33qpRvgc2&(!p8;^7+_@O*;`Go;#+~*z;4&7u}ktL;K+|W4tHAeFI>_?)N>;;G7?N zoo2GRk6l+x?tzDq;}XHx-Qc8pTR0RLxf(kT-o*5F`S}43pcPAl_o$<#QZpRLE1(%? z63#PIyJI=9qa`LM=hGOL^xh~3XBFuHQ9!S#Ui`z$(siFiw@cib#UnE)1H|{@jSCDdcnhAe@rJ8r~UGc z*Uu5ljVXxB>RdLlr}S?W+}@I;H2b&8>PKY!}t76^8wo1`TckOeTM9`p=RSXx*YE1hl zIZP~y9hC9o^G_u{%{I8E!9!A4NFT13tJhimS8~~72I=06MG6$E5u1?$GWVIg9+jQL+nLeHiMLL|Nc33)O zya#Gf683&KpV}c7wBmG*t>$?4?Abq?I}KMAOlzOx2i4DEO42j=Nz!*n`1P(c^f~p_ zkZ+SeWXR@mb9OY(sO0HiJz2+dfAnZUzj^7W;Nky=KKkjc|IZ@w5wh+edA`f0aq7h7 zwZVix|F5S#AMq3VI*`^4@`;-@N}Uqx*?L^pEQ$OcYDb1NL|NryGU>pP-DTuhunP)+ z77Yd72cWhg2j;Y6s=mD=d(^H|VuK+lP$TnMRcb+hS9vMdD-DmkL5gV;@hi4DsX@K( z$$ih$-w}JDs2UHic}z|oh?x4e>Zol83$qLKmbESlY{xD^d~LSeH!lTn%?}dC=`GwO zeazarf2)pU(CL7PN-!gEb(T$C@``}9=@QK*Vjb18t3!%jG2K9~xV*<|Z7F;pCxa8# zg)CjXgH-BMIs*Ep+?w@nW}dWShB=k!j4J0#jshql$mn6vAj@A7gC!)psk4B~sYJj(`d$aWBPTNiRsq^5r?-E22xeO7qoyF1pr z)i~ihfSz!@0N5^g9(0U1?(o#~be4%A+X@g@CU9Wg7_%+iTY&UFuL`oE{&e8m=nwm| zOm?#!$9HB}Sb4GVWIN`zu@l_^=1k0+{_Xc4RlIr>aRaZ+Ha9hA^o-uRXjVCOQ`5Zn zwP*%JiO9tLiu*fyEs^->Z4K!o-!COgEJna4Zj&7kA1X3_8F>9e7X#uZX18u>2FJkL>^Q@J(2?74q(tE7Lrsl7?y{34vX)M{;E z2YaGm@(WXWIXA;xxUCH{>_wFXLh!0?kUcC7v!bLU+7u z5a*F?9P&pA(Et1<~L`WY&(8x4GI1rWC$of!tlAc zRv0Vi(JIYuN>al)?BM3E${nN90kzEna`$*tH*k^=9#OhB_cfMjBElE@#O%T!+~>Qd zjjSwJ#<_t~+#|fU$2zvE9I*!e7s<8;hKH^2{A|QOB_$=b1?S%R`7tZH%+R;)%D&S# z>zq8@+ETA92Ylv-t--1;7f`FYG+Vd4^*5lX0c~?u%!1k9ii<6AXm^gkIdOk(as3z! zqXnd`xo{fmt(=8FI{aFyPI;d=0sp@yG%z?=f&PDr8629+4x z=Uv=o{SNWR0RcdFw-MSc(al`-9Q0X{JFj7|ViWPpaV4IaFTIeJtkA`>On$%n{Z1lc zlw8l8I`->lwvLq;<#AmuE<}W7LYKPZ$=LMH>6@*w>^}QQa40Y5SZVe}@;iY&QQt=- z-R7+qR4^^fQNs{-FDA4w`3U)%n+@P=;M0xh&F|sV-HWY&{oNG^8k0{|E#Q8uxnIZ zg9mE9Q{go(25hN_FzPVXZTVmfNb0J?f_kD3$seRw<2Bkf%y(DKsYC$84{_AKgTb5` zvYe5#)E0bM{Z6inYhfNQ7dEP96;m>0GA)`CtU(ISFm5ME=%3eBWJy1($)PT}(*pmn zwkkG><3+CAekmVXY6lF*wMY<3AuHVl z(E3ENoh6aK6_0R4$S1H7jYNoB`{`mnLRmg@0p-)Pkn|mrjN=i}5$KV)-aa(s0I(>+ z=IiRAIJ$%jkY=hv1j73W)7<4_dZqz@jl`$$ff@C_C06UWa8>ukEQlhM2gU+r`Q?PHuGeIPQh8p16?ZgM!ax#4NBV z`k~Ib+kG+injSmUm;ZNFcFk-mNM4lZC}c}zGflE&DoVH!MS}R^uWtQTsRt06a&!Je zUms^o4LC8d%H#?Jx4Ls9+!#_?Zp2w`;Zuz79{P=qO)>O4Msw$5zR%G3n%6(`8kpQl zM4egOJz!*8IG<~29SNUM8zWa*@u}=QV&^L&OrJ@y8PNbP?n7TCo{2PF`80rCy(YR6 zDct~ELrxUpNsHnI5BH{SZ+o~Y>GSE;0Hv;e5a6yg2Fkz`W{p{|)pFF=ldoZL1_SbM zBKiP?H3CKVBHtTmb;OW8XP9lC!vx7H-|HzPp( zm;HQM3vHo^0TDH*9XuG4tHx^_)sqaS-x(>fg@+zT`9n`b-b`+a{oZxMbX-lFb z6?i=$@*M*5$S`_j=Fk8{H-rO(IRL#;(!@(xVH6T{!$Sln8!i|F;IM0h7ef}{qw^FQK3I$=0H4aUqJOHhO{5yR+S^Sew%Vb5AS_LH5Goum7K;gyt(7u*rn}(n7*?GJ za~P{=c4@=~1^$h-yy(jwD6H3zzkgkTO^`7%nvkkWfXAxlxx@!xQlIh}IJjLF&VimB zypkS&n4$DOrFR&Oec$wDNIU@F*1)HEFcz8QmP!O@nSXSJi~BoX77C8<7YB~#$|rho zHY7g?5ZX=vyeF2Rp8>81 z_YONsYu$YOaeF`j5ONVl8*6{?gpmuf66!+2Qvi3HZ>Q5f^0Cla7-D{yARrM=pFz2Xd*vE7qn#0GdD~Wey3t;avy#+>7&-QQ~9K z#D78yO?z;xY+o}oGQdR$&#Qc!Rj=7;3Gw_mOnIS;_$_(?s7m5DJVqQ~cizgibaw@% z1%WH!yP7>l2d{`LR`rDbm4~bwObGFVvCyBk{6{0)QCFUu41qW;7vJIxmjKuzQ>@V>;Nylrb;^gq<2eL$+j$;tu|=LJW%?T%etYI&us zrR?GHQbMsHXe>rQ5JOdI=#~I&CVz+?o$d)IuF}j6^x3eIraRGyKd@gP;t#y|vpEB# zkB}>}FgiGDf`h}ZWW9Q%i<3p>iX;s^ORBt=NKYvgxHpRxXQ$*sd0Hx+KYG%?f4BGY z$|WdtkW|y`%;pfKC*kfD$GEsGenVf2;s<2FP`GT7D6V(_Fj<64im< zzhfYG*Vnt+Mz$X)6)uqhs$x0Mf|}6u=Gm*{;70lQb^vw7x3mK|4*8&`7Nzjwu7TmC zpdfxJW$gI#H>{)}kW<;r7LPCebF#ToRcWj>J7Y>`t0rHvSAX5rm_x$mO?@*ji8r@P z-u~^~>G2met8gv8&DvCxPjhU}z{k#%MRUebDuV>VcI^;!zrC<{W_Pu8axG?dG#v&N*of z3HOQyA8X*r35neo9|)eRqumOc=X!qib|a20RYEU!@+-|Ie;p#s9oz!cK?}PS-e55I;X^aJhBNoz?iL zFfquPGS@p}!8m-5?~p>@n^ZXZwXiUMYRaFZ1_-*ZZgoO5*UJIH^X+D2ZG#@ABG;KJ zRs}5mKtG?258z0_z4j7F43(OEJ7i*|sKLi=;YIdRFh~fA`jB0>1kd6h>bTx=ZD(Xv z(z3wCc1qAfaQ3h|fmsZ9&8hh79fy(zoFvsoz(QDm12H_!qqyE`ax zQ3!rPDSo+HT(WJqYIKJ?^MMY3l&nj+`c`YEJ7k&2^W9u}xjk5OvDR3AdHR6Q9P&Ou zvgXy~{K9rzeJ#g?LKIIF-_Sv=pnFdVMGC!QTeY?rxrBoxY*lug>|^2~i+}_07KwF) z9>?7oVlUeS30ce+R{edOtC9k#-8XYhFEL2D%(52Eeu$6a14W%HRK!oRcbx~eDM$Xf z8_*)K&aRhNcB`t{cir`kW7ICi&f6V)dKKK2_@o>{(pmV0XEPF?QFPo#-_6b;ce$kL zz5DAy+v533q{{Ri{4R=%$C)bXE@icYsmyL@>X}$*(rK=&jGpb(PIrYE1>?Nmxz3h4 ztP(~z)SeWRZlBcYH}K)MHIg-g_7p?wtu(PGEu@v)Ssd<1{fr_m%)_$Cmh^9Bj{&4M zhgncKT(uG_E9+9LRyvajU>t@L0(4JbMrg^e5Pqj3KxrW5-$J6hb9X>i*<6V@;A9y< zqMv{tk7sKI?MM{_w$L#i0!rw#WY?Ulv5+$=-o(`V0(=(ISO&vP0_FwrJKg)4kB5G8 zLmN;!BL+{2pB$R{5|FBqV>nsg>;+Guie$IDbPbKqRn2pK(Oi zwP%qDRL@9b#5GVZS|B;!uT4K-JRrSwHbZ;wKp=6Dib*zx7Hm|?jg*_`samsByt?AdSSykj10I z&2gO`dZwxwWt0)9GC%^B?1?HZTo3Zi$af_+c!D`QA15mc(%9xBxezs@75pdQ{bOU1 z*&h+l;Khp4%Nbp$3{p|*S+Kr>V*2YT1+KF|G33pgbjR*?)j|Ar-9#2l0=*;MhmY$; zT(X@h?(}LMrD6~iuA$e*b^V&daZ&9}^hbH*FlwelJKSvmYvM)hu6V7nNG1efQnS7k zVypjAc)$*J9|s*2;1E@AdzAoEx*Ed)H(^)7duiwFh~Q=+pTmfd`N{-LH~S+QcqG4v zA!@t8V`CT!JtlhG1aC@ZcB9!ThYP7CVN*SZ+b)^q-_iiV%|0p-%oNH%s!mE<#Z$9X zs+7t~;t(~1xUH}H%}aHO3-!esCu-3WKw@bCcVFw?9tFlL8CIjWf@hp64e2i-v3%Zc zUjQk^SF7UhWRfy7%yVwEWJ*z5?@@Sc<7JC$|#>8&=HP4 zbwnl_9;IT1RkcDIw!kq@3f*>zf+d4+{g}Bl+xk^nd;(PSpnCv62wiv${Cfv+9HnYN z8>Q4j1$Fj=@0sKWG6`?=4YcD)8~Rb`9=%Cr@G%&`RqfEAp4Pd|XE{J^`gIB>8c8~g zy+*HKIdzel43I272*F$|GkKiP&U5}sSRVv>0b{Z*Q8?fccv$lRDA&AH3F|!}#txuv zDUe{>gZ3MfHyn>rPsp{TH+o4sAFx#}74v#MZ)Qh+iuR+0n$JaLW}^s((9TrNYcFxy zFPgPrTW>No)m)N&0ek_dFyu+1vhS9sXM*g!lUhoyp;(4KFR62$RTKd#)Ft-|(B;|c zwJ6F>A-RDZ!7RERD|_JS3QEu5jW*M`WFI{AuFUI?a_9At zW(l}o)e?FgZJ*0wGPn5X5h%;-Y!>~Y%oshoeYk-?VEZK=a751Fu?x`G7ow^2qG(fk@p4}A0G#;yE zz!bXyS?<S7D0C#9f7UDEL5e&u#cs1XIjdT->5D=oK!;*xjcXx|#L&i2J_T(P zqz}->j1tK)Q!h#(<)xF%RJNTqScn#LUa-{=7k5vSh;}=Tv^u_$yS+wEnq)ZyL1PTw zfBlVo6^HWv)UsX#bSd5_A*1|R{Fp4tUm-2x=>3f5uA_19DwR(K{Hh8bu;j|B%WW@a zyq5IYyQ=iQkO8bQ8t81cF2M1Px;bWmC<~KyX(KG_oJ75&hpRvd_cXT~Gg!?U&;1;;~sE+|j0_oMRPW9LA=z zzpE^5B6dQ|jfk8#0C^xH3>iHC?pNutPA4C=(?P0pmXDRD>!_P$u4-Yt4VgwjKulsig3!ad*8Ch^dKqkMYuAZITai?nwr= z#T?J<5|OID=k?NVH9dsVlwX_4vt^pLqkh_o-#HGpJI5F1Yt6)Mvwt?6#%9tz6Dpkmp$)YIPm%Ve8<|0O`DLMt$BUi7z zhJu2jJ}lHlQs}viRov$hftz1VV_s9hl1?-l4lfbzJsmtB#{k9M_A1D9lPZQm1{BtO z1-dJ7ljGCAR_$}@?ZJyUY8EXJiP;(FX=AI&8Ob!P*eZV2w8t~FG|`R)A&eBZ2s~05 z>)@!$ye$^nM_*oM2w*G(Ix)~^f9~18c2^0cTNn!nb+jpe!GQ&ffuG_SVPtVHMeKNW zXvltBPpEimu9QXb?X=9qDPCwk*dcCD@>YMA5TpWjd{>1f(T87E}brMm3-!AVNTDXdx=2f;0<)(xRfERH>mON|D}#00ANd2m%Sc zh9vLW;LM%-zW=|^uiX1QJa?p=oU_l`Ykk(*pY>S~J#3EV0qNvR4i|Xvk5sZfb%%ei z+4y2*5lWd6E#VloHtx`Ru_fA+5TdjoJc+&#kq#A!0wofjs&5&UXpje5Q2X{F<@G8v zbM%zAnZ=NaLCWF!st^iP-$Or4BiaBun2gDfgtJ*xuIkN(9V+eifygSRx}L&AdaSpm zTu8kqCy*v({Ddcu4x6`ocOva$^NL4ofd)Y+9V&b9JWn4T79zg01^S_Y zKt%K3e%U1y?Xa!CMRPMo_z@ht;YKa3m-8d!m=H+f^~4+&4^VeM@paOL{4aECDd;^> zHm$rxa`&|9Fgov-uS`WK4ApDe@Xq5(^>ING8XsIGq=8ThfY?R!$F_RPG2oU zXUzA$>PdQ@M+}jU?0Vk&@3*FaVF$e&{_(sGBIbuNmUggS&Dk2GQT16jgWFW6d)lE& zXS2-|ppeOjwB2^8r%6R3@+O-QaxES6Y_i_p9R5EumyRA?m`C5H7xFs5W1fbw)fPB%rq!~M9;Ck3$GF3QGUzLIJAD7s?RF3R3oXU zd-YF?qI0-y9z$c9UX=Wll=hDkZ6t~4nR&#ReXA9VkiASbROpiPVAyR#``^CN2-O52 zoP-^0J#3eUeLw1+GfToN?&zU@Q6Rb*_hyrPuAb;HVwmUq0LkP$4`Jl&cEb2sUSj2l4??J?GCowV|;;y1(7WI|_ z!SCtV+QzgZ7hbMSy_^Q%RC8wGS|ip|rXL=33H1Rp zXhQv1zt&L^0cOm(i3y+ag)DO#$l=lHi*Rn<6jT3( zupjcnO@Ea(R(2qQ>Ij*;>p)L8B;fI`)s;U!Rg zxL~UUqy2=}@1GP3qdF`Py*Gs^De5wabzbU>z+#kZxpgl(s6nfzX~^)Jz30e$gXiQr zS5%-(!FTl}FJ=(XMug$g*(2JvohJ@otGAv*g?A_(InG@zQ-qUh;gv2~I#Q1%>7*5aj~7)J6ny&pjfO$0s3YYf7+IyFf(Myg;?#KtxwA zor#EW2d4F}MIsoeEg0ZvjZ&lGDyXu6KprZ42}`dN{o0qWF7s<=)EjI7&*aiGm6?(B zDZL)zg7>c|!Xu9Qg>tb6k-K+6$zo4;J^(W;q6jF?m}?`^D`W^!V&k4=)h&80}pD71xJB-Rt)Fkq=EA`oQ{Xz9kCjZ-1M zqs9+tNQCf9TTGDzM2om_iK(e?Nmfw3|5fggDL)t`Y%+`&pK9qvGYOeqBIETOYUmrU zS>SUDkLt$R=0Solnt${d6mYzhW9GRI!c^k)!{Kxw#e9(Zn{w8~Wk}`smWdet%9{-# zja0dLj98jZX9v!%{vrVx<>`@!3qEc=KbxN#Nofm34Juv3srcz!#D)%qd>Ouz+<{gY zfjk3pDk2H~ye1Xkb&{4eihg3%o9!`>%b}+B#9%BSNdMWCALK@pDOo70pk|Pro5N3_ zydl!i{PD9%%+$v~0}{qQLi7D+>;(jQp})p{+6T`P_eQ<%h&Jh24Aje5UGokE2JxPT z{4ow4?yEzGAzuuYL1}yBS(n}(hCH|0oYoI%A zKVpb}41CMyp2uh!baXcQsY1&3+%~j4=-33mdb0x%Nh57LX>fele6#8eY7B7he~S)$ zYVY&W2_T#wq7QsD-rXWdtbp3}csJB>fdNg!Z7NsC!rAKj;1US%_C+b_!?Wae26B|^U^V0xHL)LU~1!(xpi1F$7*NO2++8}L| zAy)T7NxD2_7VCmkC%&X0CWb|j3oX(zLV6b#wEgX6mlM`|EpWzWi%0#S;15*rAH=>acX}aUhcm~iLp#5;)bv5=Q-xT``JHd$h1}FGkPS;~0diO>VVXlX6+J z&gWBM4TE8bsulfNEit9TLXT3Ak=3Jgj_={X7C-H(Jj+g9?t?h1W)8N55;M~R2irPB zeo+^+LVoU~7AKnKjdq(%FwJ!kuN_?D98Zq$CXh6{U~9U_w1z`f_p9?dG8X7YbV;j5 zVF6UT-48$tdXkKh@{g@LqanNKy-a@f;?G=}QZLn*;wf7p|?; z?UQ&gp*z9Oi0v_^{W^)Rh{x^_{PRFjIloWXRORHPu%<-j)n)o~3 zcnEGIs7*_}O6Gs*XySGZOF05{{08;NIc*-piJL}q3T|aL?@>stWzxLpVI3UIA?hy~ z?}@bZqPIvEr1uYprYO{Wus7c{`y^1E@hSIOZH6bC;aVJtObXFMPdz`OV~&E|9@N>Ck4{OUj-9!j(Q1`e^jqtA2P7>yP+lriJE*_SFvvUb~=Y5n1Ne`|AS+%d*-8vfYXmYxxSYL1qFjG ziH6zL=y4++10`<$XNl%lQNh!_>DKAEp9Btl|N9Ufe&c0^R{mTi=)4H%E)%zI&-&v) zpc2V5f)uy08bCB$cKplQbVci+mX?+>P~isYt{?h!8Y6Sj?|Guw4Jpw1=FU$bQub!s zw~J?uwqEb=92JEBKi*>c4AEU9zW#;vzyE(Cr&UNA5H#mLzmB))uL_R^xj4s29+y-0 z<$`u;1!g71d$JVyN7}-7<8arZ)^@g%5~f8oXf5CL;Oy4s=n?gcP%jL z`Lz)2SU4DR_lUS<*euA}hrC!!FZL{Rl~XULIb14t4j_pf;1wL|PJXY~X;u2&;#!0B z18pD#eyOgub;cD22n94+RK(j+dh2y8#xG1>$QIMg)C|@f8azBSXc4PF-Xo`Ot(^$( zWB{}HwA6Ll8E0rH@@$JOgPCD<=uh_mAebf=X~|jA6DV}hU(KT$l#yz3jb5J;t-WV5bnkD7 zOgw(t__UxEJGqVSFc_@Tg0Qut=Dq9p9dU?mI8Ctf2lr|HJ&(& zzAn2o8eaSItpu;G?>w8G0(~ytbnd#@z0EwGaGqijZ#p&WaKQq2G=1@+ExX{0FuO4u3CGeaheNu72%-=obJnMtmxu0p!#m2DvL}IImuPeVQX%64aF^f zT{MEP!|%M#dArq!Af9mK+HRFghQu#~k^=4Car4^KomLR4*KuZnLVH>9yO*)h0?&e4 zXT@B2#Th+CiyKO=+B@4t%U5o(p#=fC7dfLN%)h}-hd!R=rN|1C%HH>l*7jsieQBnt z&o+=Z!cWeF%w~k*w@5Qv{Mr2e2j@&VW~~FZ4$~zaaX%G#MQU++-n}!TUv<5{dXAl( zovmV@+u!Q0aV&nIP!6UmKZl=N9ZNgSME~LL!nOcqBPyFp+lns?106v0U8Awmr}&~U zP++l2uai z)#59PyVTbWH!PqGD^8(1S|`@4!U`bCe|2Kfc3YbdZVpJ`5_EQ%Zi!cc0PjB7L(a+z zZqJaicjB(84o4bGBNy;GVVc6&qEVh(ervV~JPgMhnL}foZ9AOpxla&^Z6}lrpdbT^ zj}Yg9lrFa*I8I4SI2glFBCFxaDH6!Tq9-G9cyEa6~tiu z1Ou`|`IMN3Ym5oQA+kGITdIynH(l{k%e8V@_wNYDDDB#b@+((7=4Jj;?L2-QnPHe5 z$GQBs@2Nh@3Mq>VTawjDjP*Iw%*z=A&x|N+5bSJeZ7ok|Nwk1%U>unb9O>Fsw!^9M z=@UUlz&XD^Y=hrHkwM<4^L%dF%5OVxvp{)DRLj%Fp)m_-O@WuA%q?!& zvPCYB4*v47V}4bYYD9E#xVhOg$1cYTN!rE>(A(jXPOgu5?ASGLo-4bacZxjdHK=U? zaxUWwwuy}PGX1@7i$qGCx~$kJpR*YCpbaEURUnp)HK!bH?%?1!dypx_z9rQf1XCybNjxJW&}nG3Y*CJoFI1> z?grZU(9qDe=7HMrSSjvd3!?>)DHHx23mdf7y(pE$wUyaPMLLzn#x_gD`&n(JO{0c! zlP>plBUP<0&(_+e`oqunm{n(*+Uu@8gPrXpH(|tdZ8W7|bFGVZ<@2y4hi&|}3QXAe zwgYCK@7w21m-e1?%;9~|(V%hdCTK6~T?AdSZEZn*&!K4`^CKG?6-xb@>%kUzr)7O^ z`gCCQxigIh54?w?`Om7am43HYl?^$}9eD@wAWpYvm!g0ejD8XdsTfRSWM~E1Y_G31 zW}0nyFx5Rt=&pL0HD0oK7j&$t%1QhXQ3~>fO~K8hgN*apD{tE^{M8IJ1M7$@7ho;v z-ui&Zr(>_b@A+~hK^q0(bglyH2D^0Oh@Ax}v5sd|D+Khe#a&2DNQhV)>-GkK##`6= zWJ4pP=bOg5XoE`ipmknvH|x22%^Hh|X?pOnfB><&$nbCxu*-^49r?N(dY)^lbZ{!{ zB*y{}58a#Jrk6ALI<&vHxA*XP=~2)*8}_BM`V~_7dmO!|29+-SR1}0;AW82vtA2o8 z>``e!vtX!~xk1mpxfB#@6t9ncLlUG_pp5G02hAFp^_L2_95A~%fk2MsS|=3DE6xIQ zx8c3YrNU>2&Ai=DKfT)JT%jN6n+5CQ2HkD0n}9khmn^! zHuqa4TGsMtf>~tVjEWmO*Ftz@+D5Ot3FP@2o8_d0n&k1eQeERr-1;wGDa@!qs_;ff zP|c+kMvjj4kn7wPs;bT)tM|XdhEUm-Wou{>PlfZy)0(X)vWS8zdAaiz;2fC7ad%** zUZv>P9ks67Y-9!ohc2Y#mdgS(c{bX~VrsB47MIfB>0EDUOEYv^R%4e!nEm$|JXBC748Fb0Le#l|?|;smU3S zPEMk=V42j9>67v*r0XoGv(*@%M6>fn9AGE_W(cNFv)S!TUT9lZUiM63Ah}21yEn(J5b}^6UU^&G>T}p#5kn{- zx(`u8%6~bg7p%Wla;utZ&kKClZriY$6)(r#TEQ@uP(#f5P4PGz;8S4s$g%roA zk70RCW&247VRJg%PC(!aALUcl(OyS2d5LPGl=q&?3255@v`{sIbG-JYGQND{Tkh+zmuxvL z+-qMU8{V2Xy7wOQnS~umHMo$N7bH^{q#TfbURc28*X0E7@Da!rtVYBmD*qA*52+){ zf(vB8Uh{|BYlvgV%dvi=C7U|Mcf{R|l#sbFm*qfjb**8^;=kY5?0FqRY)hQ1RlWA{_6iw`ddzHm zDI@cZjtY$EAgQrxoEZwX=KfhQ4y|zpVLR0I2A~$`OyK>z9Y1Y3*E;%KCAxS|^qt5K ztIxx!(BV|ip7O9A;+r>)-Y8!2^X--Ofcz+P@;`X=Cf!_g6aup(%H<5zPGz=ZVqM5}BCU$-!bKg;lU@H=6j;Qs0z z%`67zxs5m=ip0rZ!$x$67uP#45>^2QbEA=mWH}AZJoZ2m<6+&d8>y1@uKIg&PxzTaBP(plMuJ6ssDV zg73^6DE2w=6%ziq+DJx!xFM2}r_7o7OvOqO=CY=SS`ILj42(D+Exb^tf ze8Mc1`1l9DcvV)0%x(lG`0y4$5Q>%Os=LY(LL4fD$gOrEgcYV*PQ9?s95Pu(l)=Oq zz7-v?;elP(8J4-~2$*tf^0h-h+hB_z~t1ygVeXUZmdh`nh_4 z`94|Mvz_c|D!(a#f#GzLBf6}HTWgMfv%BXVnGPmlMKOki!A*k>TJMMQYiC$AjXo0; z0+W&sog{mlghbp_(bHvMeR`xyzgsw?Fty*5O3bLVou|=xtv%CgVKbGEFIY1bNT+#8 zL8x`Izu)y!;0l2T11xEdo`L&G@BH@ibgiWCLNV-qpZl` zv5t#=%BB)Jg6qW}rNxe?Tatp&-~zTO_~oyytyXW@*$B`;6QWmRtagai=U6bo-Me$yPRtf{|cCO>1fEDO9a#^+KO1wg9dF{6KhsSbh<$-8^?~elta!-&bI&I|Ln&- z!g*JZqPjrDBIhePW}aB?N)+PW$c|6>40nuLC2#y?zPClf6hf43&HRJJRNwyT9Iuy~ zR@s0*e*CD9Pp><~I}=>RZvOlvN=Wwbc3%aUtvR0`e~|6fnO0$C$;Nc^8KP7zc<;T< zWQ-aE>xr4qr(3=qI15^PHY?pGVB8vm~sXruyk1V6M2p?FLVl#a)o(9+;` z9K==ssDHm}=PJ2Al$V!hhc0NxsP9s|zBo~xxTzu_Ab`=JmjKL=K~30(r2*A3{_9eU zw+@?`Zh^1>V|vDW_KdSe>CGb|2eF-U^OSLMkFSl%2@Jpmb6tz(rI1|+Qysd})eyLI z#KEsO4nOKPj;RdJn0fXL0lk$A+gG%a{_0~E;MljPW07M!HC*JykszEz2?iIXTs#M# z$ea|pS0GkHElVBXsylXtjOJr4s5D`H@y+w1pUNeYA$*j-w1yD4I9J3GX5K0h)-MO3 zd;RS|E1IZ~saFO&!!~;cQvPdQc~oJlgj?IIXdej;0_#jc7#h%#S}<|uDaYVqQoXwz z(UVYz?eH0~kkS#?9|{%U=s58~-}MZ%{2gH~d=s2xG(H#&>8}nugr>qmB|6Yt>tIGD z(UzWEnNxdCo5C9%3*q0?y9j4nDe3VXNTh2kW`l?vk>2_wG`|HY$>t zQs&;zr;W2z85Uf@Qtc4U{R@%{jrU!}=srpVH3b@bwiQq?We+ItV^Ae#5c_Rz?yC&h zA32hOY|j%Y#eK>VSG@t7D-9=39C;Z?&4VkBcjqgwtB&^`a7GBlE`04r&ZA}Sj<^80 zXQfp;Ao+X^CFU?$oB44l15t<8$3w$>d&9F#_^xC$$0(rwl7T@)VP$|8&S1@&HA-&V z>s++%-Mfbncyvc2p*c=Bxr5i~Tv0AZ>5Rx>!@1-e*K&NF2!>o3tZP2EIPb{B5h%1Q zNdIf}l1UA}$axvSCxvI#wSwhcNd%pXS51F1M4 z<3#?Qv8U4O{2OCUMIw^0hS~j0W<}p{u>?E@9EJAo5vllal|1y-1ZF)pC$NNIx(6IE z*iC2c$pxK&>&lOC1_s*^u;I>_oH-MGqtPu|0t22<-LU6qm&HV;D8RrpYJTfBl&%9{B8rFzZt9uJ zgB{u-m<(Mc8U^LpQb_-AI5tp{ul5F?H)>11bd4AtGFNxBGFbN#$@{{xXl^!dxEYZ4kUgIGPjOQ61W}3o{7_n zo+EQAED#p(NQJ9s)D^z0WmZ{=n7QEt4-?PAk;(1Azl3^sbO~aglNC$nN`zD-_DlR2 zY|@>Q@Q@k9xqyn*PiZ2cNl+X-f7u&`!8wF~WTE1dOS>zwK`%JOtYIK_m!abtgfd?~z)hfwR-{Te^Jzj@}Zwq(0qkZyFU%>4ZP zI4}vyP#u6b5nfzMs^#OMn68|w50ynEbI}OnRNSJO#7nSZJthXpW)j7Gu2e)}7@Cs< zH}mEMwdo0`!GNr3bIPu}K#j2g<6rtZnV|iD!=&C9+Y}f40ZKDyW#&enE0o$M8kWA6 zCP^Tx?8Epz2&<4G&tDFm*03Vq!p+s5tOj}?%qvlO#a{n35bgkm;OQp5D8%2$+xAFT0(X0#UehyZ&1I! z9xl{;Yb4oJg*hcIFvf#30JH$3E8%4r50~djpXyY++FSR?7H3DJ@q9?@zuLm*cNYWv zP?v3>MO_|hi_{%har^T4KlQx@1l}|)>*Ris;Vas@#Ye?ME{z3kvL=4@gZiO!a;xazMII#dXe;6)&5Abcw6=gM)MF$os=g7<<~cpb+2T#@=4oS?fC5O}|wG%o++{}d&I zWe>glO#dB9giaTh|M!2mgb9y7(nSn?W?6{k)l(hSOh%;iML)12e~S%Nxqa~Vi`B93 ze!VWaW#rD8vubbOOXDmhw4RwrCMA(0B3jRd-01l3H<C#m3v{UQ(2J|cGi{NY(g#S=H$UPyZz^X47mF5zy7kh z{{>g#O89x4a(d?;frih#1v)9n&u_O7HmMT|JTC2}dB>`OS)%nWD=UjjhryT!2x=eg z^_F7#ZCur(oxTvi=O^wbDRi!Ab4@laE`sX_xX`|EvEmyy9*FUZgX=V`R4L|$JB150 z{QZaDj;^FxPvY&ovpW0X=iT1U$3VnM!ueLN&I*CSbc>J|@aOtkynLtdpN|FSrk9s%l?Stdu;{5!!!Dz|cYIK#AiHW;*z5b3B z+dWCj4h88YwkJbJwoA7>#&%YFMRs>u7jP0^RXxyY~vWb1)Ioktv7u_hVs? zODK99rA$;`U>$6t2o!-^S8?TOS7HFH6aGZMsKjlxy zW2ah0Z{r$9NBNhM%%;7M&+Xj*NPIa2j66w5JXxM}K@UJtVrs6+R)v`}7jNUr5-ufP z>wZQLIlEU_czAt~gZW~XQ~19GJ~Yd7T2*)Lx;CA_WN_#_L8XPCMs}HWdr0%9?@N_C zh;)ONmnyGsKlb>~n=I;`mT;axmut_wdGMW;7aXhcgz~O6Z2$Ne-ZAfm3-KphT`%S8 ztbFMk78d5-|IDzru|hJWC6NlxC1tlbCrA&{Ik)V9y}*98z~xkqw!{?*2k!!1oYgZ}!Fg_vePF3jSHc+ZmKKqk(k!0;QWbg@P{Lyb>r%f)@pU zU6_?7bD&KujjR0*o71adHTLr+?H~K3JLv*Olzg~nZmz#;4I_-k4uqwy4p$A$mlaTd zm$F?{n%Mj2;hE<$(6jpy>`$QKz$&5b+bDGJV+A=AZ|547RZBZugVH=(Cc&cYzV|wX z3w~U07439h+^eQ&e3F{b)h918&o{>!9Z5kFc4@frUYZw$3 zsH-@%pJz`9xS3km3I$L3^(gWlrKQzl%r2}G7{QbW&7bP@nOv7w%Lm*#bc~>pF&{KbPgf9K_f17_j*Q);avk83gGR{v?)FXS@bC^R3j~zL+ z$QDj*{`Z5v)VJ#>*prgWvsSAP(OmrS+^w@Qnoat?1zWSAIoT&{(fK@Nvlg$d!`Gr! zhTQo+&;0WI`A%oL0}KU~OH1mRpXrC!6moQ>V7axPh8lVoOxC=at!*cP|Jo>RsNaY_ zcQXk`Z8M{yYb@@xu;6p~gc0L&jmZqUCkF>Yk*TxpUThG5sV^Uu@=2mG-BYua&4|-j zMlKACd3Z^o;&X^RyHRD|_2vQSgOcEHjilIBn)u99iq1_ac6WYI<2 zExS;d&L7vG=uSOfKMpJH&5EQ~uKqG$>zh$GcNwIgN1{`5Ykh^VlHv9F`$zN6A1ews z8~=QlS_H4lsCCR+HfNTHyaAPW!>pt@$SJeL3nD_7zC-ZOv)yC@@qK+oLb#zdF-0q9 z@9-oz1jqS;M9;p_)XVj&N0R=zd5#o``c&J{vI)I9OrPW{A3wxCkivK zMmxUesp7jgnR6GgTh`8n-7G0AmY?r{vTI&Q)vJ+-W^Z3A#!6 z#^$wiZe`921<$8FDvVv|U>eBwLl(Ku7{+&E{vp>N+66Wxuhu{n;f>Si2e!RN^_lK| zben=wJ_Uxu9oektoQVY6HLcH5vn|)vSFi}Ygg)oXv(1q@t2YqhXLM5Mu2!z-zh+-N zY3Ok*5)32b(`&qaF00O-9yx1bY5_MLEUX`mJO4-Ka}mR$&4%WL3$5wE9I60$V0rn% zzSqx<2si35$OehC3RT9umQ?d66p6&dlLN%{DMjo!cjwRL_LPzqDpz*tF*LXD^u%a@#~Cjq_P zB(l^jkF%IFwwU2OlRt~y`s`5TD6F^EK^%93O+N(nMzk9 z`cyJ;2GA8Y6&F4kCxC^8tXj6+IErHmnQ9$JztT^KZd*ycXHzc{iDl<@+rwRW?jT7A7rYT-x}rax!3!O!ZK$6J^Ax?mm%D5lIZEw{PBV3o;7_@ z-z>~&isJKQu>lqXmC^%tN&q&{TxWw@85Vv@*EmUu)XV&gBugcIj3NR8;louTyXr(% zbLmnY3fAqrL*!&U>xO+ioej85vXZKS>!!8r;2&7&BwAz#`?j`zt_XJCr7rz_#o4<2 zB*sWXec^{)!H2!iQ-(11H;I>Tb%$sC^ctsl;|kj+OOVoluGq>NvAy~FyqIs!>3 z@*?%ShfmhGqA1IG-l{l#4WP)f>XIxW%;cGpV*kUC(&yp*`Wt7|+wm8{6dXCx#SfMA z$++wY^w<`&Aov&JFp9LF!F9ufvLqt zxa?auhlbUMxPZ(}Gi&OFq%9S~dfK-X)5-hi@^{hg%IANNpAX~mdMfh7ClE0Sy>?d( z@y)h)sYDldrD5wt4!RC?_8Oz z+jod&XXv{2tcdL-yUr*-7WN$5B4kqgJUhKJ{zYl&7DlTX z^0PNB#Y=nMDrVWi&kwXopAB2lybQQroX zsr;mGYx7Qj02JEkM2lWVLS|YHVAxT{m(8Jby)G~o*bbHL^4Gi$r!xZ6XS<5`9T4z_ zojGlEIJ5I)P$$+wBec5SVa~C&re)(s=@|8;9PUA2VvrZ#;+)>Le1HuGMr!o4#bA!1 zmyhLsPMjYa9r2)Ldgy?tdoGN)z4=`!t!TU@%1s?CxV!yH{sDSmV*~Xct|@&xBrMQn z$xQz}LkOF9SdYwoFZsehD;^IZ&U%36$I8gm)3S0(jnOc_G5xqAtWbn zFZuidIn(sJkHF-%pYC0zTAZHpTI0RHPanA@{$hj;!4-9Bh=0BBwNc=}ZriwjPPgJ+w z+6=$A1!WnzI@Rgm*bj4)oSm&U2Kmh~s}(9&cVr_|D!rR;Uu&Xr?5F3?ehP?b!R@f zNCT|8O<#eS-T5}l%dua_vG;w({YqVFJ(O{XV9%8~HF~4XcfCeK7#Kw*N$-f+7k&w?@7H&D~)Hgb<)eV(CMw#u?dOWqGaZ z6})mlU#ix72M}(Q%^7ZWpQFr{UYeAzem{<$$Q4_do8&tCCYXx7+4n%B%X;LwFQZ9E z{-KUS?mh$=ve@Y15##`%BLQ~RR^&}5V*g<4+>74(@6GmW`P=a8f;Myjw(NY;x#P#X zMeR7Bk}33OTm0}BAhh^ojHO`K&7C|B@lTe=6;Tygn`B@Z6i9Q(-MMQ0*?t@!!Q4qx zdaa2Y6evn#wrnAKN_4dtfAr9f)Tm*r-)Q)l-cktLw@$V}B!=XMK=4d^bO^))l_QkkLTmDA63Op)SvFRlPQTY?jI8`$~|9qlqzs z>tyK}KjCL&|6wM0RNHX8Fq~n=l+4mx?{fmVzKYW1~gs_Ay*`A=Z#JNAaKK zTCOQ)XViB)vO;)j;P`?l-3vNN4&HM2iMQx&BtE~(tT{2YFeZau#PQkD?K!NV#&Tci zbks8=?aqo|B9oGw_iyX_aN!CUP4yg#qa~c!?3yZsP=r$E^lBoAR;?2#v#4DsmM{Ki zovNujt3~iV#GT*W_3GKc9E2fB0}4l#j%r zCIsn3yIC{1a^s_IeylrHzkV&qP_O%7vTv>sPt#DVE^n2*oSiqy4LPA!z53p{$)C?c zh`q@tyw4CVp477!Tf1$Hm!5UB!prqbPDd0z8}B8J5>z=yHkhRW;Ys$u zp=NgQoo*GWGx0P&v5H>%#bg#h&*c3GDfh9*$FoWRPs_OL;#rQJ(pKe3?bb_Q;3ghA z^LkzvP`7RzC#N#An_H9lUA)oYS8RA0lajV&RpHKIx)Fzq>{$Q$Pt%hi&SYL-bn}fu zw*=ORJQ-OB=|K6S`Qm=-c`!)m;I}3WyGvNFmVnCS2-qABV^Gw7?|~5i3My440jAhT z>iv@el~kCgQ1_xI^AW(>6!qp22U2_v_e2o0kWk`^8u3MvNx65`wFb~m^e8IuS}g}isWo!H|F zqh`;iz3n0Ss8fN;U#$mwBI~F_SAOtOZkk2 z%OX`BD#ff=Hvo!S-Lt2|22-@1d0|Yu>{)q2?N!~M#t+kU>tP{ zY-DW~$pjn?dp?4*lwTOW3u53YXNMLL1^Vet4PaaP)ns(A-S=UDkZzQd8 zM=N93{X~Po9?;Af8%Xp5C%OS`)SZl*&g|-EhBz@1^Qo4@cIRq&%?4`5oezBd&rehb4y89(oM%)tzuWP)M z9uumNIft}hl)8;}1DijiG6ePeP66248Sh7PVQ3SS?iAuUPtKCL(A zX8d_Fnzt?nkd~9xH}1mE%yKpUt#hsEburJfjZJnxBoNq2A9VS=!f4 z7{_}b1Z6AdJ<%G!)R#PbreIB!Bz?&#ps`>_pP;>egeqL$$sCuu2}cXerf@T-bEhj# z{jO0G{15L}x?Oc(w_+N*0i(qns5N!t?I^r4_VNz8o6Y!eR0%{mWih{ZApL=ac@Uwn-O5woIgs$zV{%XTP-~d6UIt>Q?cq!Dl*@p&cTQAGaI)51IZ!sp@ z3Z=+&=%I4LlMWm7$mr4`q_M8MtV0lm2ru03N^n!BhpBw}x!9Agc|FmDZqW?udU-(20&0L;LU1dj9X2KB1D1L6;hZ* zUj)eVE;7My7hS+#I)js-w709^rYDBlAVprg{qS$WV2k0YxsOo!MNDPUki-0)^&+(v zWlxP3u9qqptU(BV7T|Fi5GhMv-dV|reLB?p&G#f>ds~g+g$nC*R$JGlK~V<{Hl%>p z$`V)=Qs7*_N?sOTxvBuWKJudBZqtTGtn zMSzeFbl~zCO9X>Y_YC7{-Tq87qm3l*?42qO^QHnMJ64I}!pEV*AMz4(lt$TeNhACo zp|Yc~LbBhDug*5LwQW27rFHecLv!?yKad?pRzKM^(G}ad?%zOG;JWY+K~1#2|2-l7 z%gX+{68~L^|L%u>@KXL4KgywLPN~UJI9&jUO$A8qGBbNY%L2AMHUR9`POk z*!0W)EO2}MRP>4`N9m$q#BWfQ#j`W6*0dK&JAQlg<$?e;g#$BS>719$hxX3t=amS8 zB2qz{+;@$YzeW}*!TO#?{R30aQB1WoT>Q=YR(hP3%i3+fz3l8?G18iC-?beGq0q>R zQp2SxfGSTXid09hfWo0X!2`}o*AcC6_calKCcDE1K*w##aX5rvyw;Jo>_^aQ|0JSQb5CpX^va7)^)9^&{+TrE0H>Ya{@J3Kkn-Y>sS;3d78u&PaY zPy^U?kI*=_k9w%`!txDOR*q!^syXj^c965-$R^-am%s5BjC#pDQ6Xa_6jiV`(yVtzz+b>2@)40shK6M} z8CLL@0MCU1T3?jmxLeZSit&a4x{TU!-(| z|7sLOZI75EoRDV;`3sA$_jc71bLISgcftT3(a? zuFL<=7Gkx`LHgs=-VEifozD8*t{3*|2M-M;IEBNkCIQR0Xa@*z!x0zxkC z`f#dNXYm&w+&Ke3b8}eQPYop2xuuh|yx5yx-ysePRCnW+{yJX;RpE|&Lh3l(-BWHH zX2QAHYl981`sESLd($x#1_TB}P|~-f$G)h)^~?2xEHblZ{_fHzwB1?SRt-|ipK$wA z_l=uX+uxu`d+EzNi@VfC{cGDAg#sh?Ciwo2#wG7_o3})z-(*+$1gJBp68LMx^0myD z&;8uAYinzb$%5;)Zw8qUEuR=5v$Yj?IJc&+Sh?!BrKKeq2OV*?;i+;@+qc8hH!-r@ z>_Q4>5&|#YJVMSoVL077d~#Ath58 zZ64b0FNLljG_|OFs$UKse{)w#kXAEQPVc0f$);_n)M4UUju{RSBOgpO1S)yx^^3(EEvBy5Ggic zKiP0Bq_pZ;VtSZsD+XVGC5`zyzT*|^@_a`DOI%R=){&6)zf&?ZOLvE0!e$+kSo55@ zOh&a23FJ3EO+`;f=Qo!x)V+oH2QeKN0tRzL;iip^f4~NA+jQ7O*9Iq0sX#0H6=?J4jPu;KZ zvh8iJ%N2wDS5AY3_v3V#*s99p0A%k<)~Jk_oq#GnGDC-dm=B9E^*SO35gBErBD} zW{sEg*IHQ~G|kU4R|LZx_m$$kdvmlZ81LBLPY-|>OQG#NeeeYkT&7h+51q=5h2?lo z=%3ZGe*lJyo?MZ{lQ1B{H_8R}WzIyE)MoE!tM_cPTl%sEvqko-{Hveu zKM@S*(Bw`@y@tQCkHi-1aO5t#J2XxsbQCLP-t~4xhJ@w87xL?x_FRG|tjV9?bos1O zdU5u$cO}+U{kNz}yC8D6`vuCXN?M)LXw|4T*naqISAmC4I5{7Pe(UPBsL3YFH{7X4 zWOl=S&w2m2VXv@!FfrbZ&*ecs3;OLn`z}D{sdUB5qbn`Ir;MMxaG_xAQ^5G*q(Yy> zWz)>TJBQtd6g|v++dDe8tJ0lpOVrQ5zxcH!DCF_(*^r&S4@!qqizO9OjLvTY^)y*bF0M0r+Kj&lU1s=PuFl{F|Xu1z6YJ~z3ePRpWvDyd`< z-jB3FvdI?J?y1SUoFd+W7~E`2v?)ZGsSQMHB9q-jXkGGaU1gH}^02U2Psg%{1=U#Y z`{wjj>j@Xno!dy}cUl9Z0bI85-meHwmp>{w=9KM#T%btj(^;eMF6!2goV1nV$Qxh{ zCsIsdizb<5B~(*8RW`J#yi|sT%jxTND2Kmw%NzAz65kRV_C4Fafk`IV1KKbAHgmvm zAl0EnYj$%dbJ#rpx6zjt@OU*^!!-pQSs>!dQ;u!YoEWtDnF@7D!}L`$v=Ml*aG^b; z7?Srudz2;6rBUkQL2>Zfs*WK+%o`uKsnFj4tSX=GD}?#lZ=0M--UPS!$k_olZUDwB zwV3CrY6|XcP-dZSp|1Z8*c5^U`9A@EzHQdv5Oy*4#B8LZg=e?u z4Ge}gcb8KzKX8BPmVdS$eV5zc4N3T-VkKYi)O$+%yHhaT3=D0#Pi zxLYyYw!X~jQ$nJc`K_(-C?(RIe^)RZrr_WQ^l5qA@Gnw)_Yzl>v6O&3Lh5J}WPrD0 z;zt)%7wU-mbPo-pH9Xm=>?Pjm5z-;gsXHEmwGdtwzdgV{)F%yPM*DPKeI**QOI=Dl zunlAShkA4!r|QeKRF3SMhAZZP7a-oqyVo3BFB(qpfFApjg$X$t;E9rslm8EUZvxb0 zwzZAo@hGRIm1B#bAYfx73IdHxGCQEOBA}umvx0yO2_i#)5Q5qW%A_KKi~<58Lzt%k zabQrH$1n#$2vZ324EfiN_POV~_0_HVZ~gzRdvBdoQ6-qXdEdR)Uh8?Dwf5Q}y_DMw zIC9>zF&yOAxsm#_oJwJb!@7*`n67)wlJ-`)-F}>VTE|>J*YL|&di0;7 z1O$wXKnxqTw?D_HM)%#~#~JpxK14sGBINPTnDT`V3pg0L_JXpmmH@PeN!~7QfoHVe zhfQS`!c07Ct<dlaf z>O)~f%6c$;r3KnGjF6oI3eU?Jkun8+%36louc?SH+SkYB-yMFduB6uzzFOzD@RGJa zcBiYBCRE40ZX5?5TzZaHBDdN=-o<|8SE;B5V*VN6?BdB5CoRIpb1bwy@jPSd)rGTy zMts(38e@6WWoy{Qpo_!}CglbBHxbME(XDHfruc~8Cz>JH)pi{KYAFU!5>erD{5uX# z?q;hm^R{4T&fOU=ohx?>2d2hvo-5)18MTqTT4=W zM%w;91~t(jnE%?8HbaJNr2-C`y)T6$wh1?-w%TA;q*C~RJ!HQjGH_BDd}v6kO??ph zfzi2Ma`#$UvE|te^YWD2Q&?z<`1+irmEcBIoKo3tZc-e5+m|Isa6zM@XNfkF0>s;q8m+>t8px zvLqBHt`O($i7YW7v)^JxPZQ9n7?5y9fNlxligRgDlf)cg(BG7K+nM(sD02WB zaF$#3Quaf;)(~ckyi;HI$~yy#NLe};LbFP>(NR&B?Qfw2l}R*?0;Yz*vyG&7YAX_B zPK0m`;WnNoM#uPHJglED=pG~b#!BU4w7Yl5&u3sgT(-~;i+1L#k zZ+h%Cv_EU$owInjxN_iSEplu6tt}j{z=cW)6X=ZZ^wI3zOPGDHQx%4uQpWQq%bMG+ z-ic>C@Tb(0v%pBfx!saXhn~txE)`F3Yk*tlvete2jjWALOombw`=2klCGXx1!M;@P z`t#r5Tlr!h(UDt$_7k-|LU3M1J^Sef>lGEByB*Bv%(#TYbRy%4)E#cFf-{y`At3fAYVqZ8BK(cXXithL}Fz#Clx)_X*nK%Z^GZJMb3 z%XB_lABI3JWB~59|K$zX?o&3)iAdH%ZegKAT2sQ;lFn5@UUiV6<5bgf0;Gv{Sm4O&Kw+;>`$)n79y87-oJ)g{ZTaQfXGR_i2Cyb+b3B~wYSPp83$ihQOa03^KQ_bZ zr7*mcPb{>BCFSPkHg>;lnz)8L*S9=#mXn~33EeN760za-LU?m}QxsDKUOH&MzCim- zk)gMNq2XDb`qBH;?l!OtgI6JpC(QN)41zDvkL2_HrQ9}KCLpzJy?8Z1jb-z(+Nn&_ znC}uq{C7EHHEDJAs5A8Imt0=0y@J>|DaOSerm7o(x8Vpv>VU*y?WY%#2S0FC@P{4N zoosdQei_A;_U26hdM7x*u|*l1x=0~_ZB40|r+OfJLg>cfS?IrG6dY_wg=YYZn7WS@ z;odfINiW-Xar&V_gL~osKMh(0G}5dRzAm(MDHFSL}cXsATT68^XYcYpRW{ly?kE{IP486zGc|` zkK`*dwheq2u>K$dpJ0`_kWJ|*ybqTVr`m0uI0z>e; zH$Dyg8arhn>*ea$Tb$!R1tLn5zn5}#NM|Rj66mpDE9z`TtMw_|44Hf= z)kj2xUDkr~IbPSStgL?f)x-h$n&VySw>uTL`fopyXBW!w!u#e1p8}=u6$mG_T6o9r zf%SPSn@IuQ{{H%810UrWt?VwC78z%;MmLnEY4Lv!q z3gcrYUhBo}BP@s;!m2OHDpC)brQW~Na5T)(;tjyMmj(Y-8g-KOJstJe z=Qwg-e($rv(V2-_4F5MGFGub_ew2HV!=s%_3Boz%;c!N;{ESqQ~XFBoh?wiVk0M7&Jj^RWv zAnXSK9TWAsQ&s5QIV1g}E{lsZ2;K!l6ER!Ko@;BxpwR8!&1l^8-Lf@tmbkgyYc7U7 zzH|g`tVtN*`=&#Hum-~o&EZ_{O?^2z)d<Z=A0ng=I zOKuglAlhyeBg68X{d^r1w+svt0q)meK>UD4+xy@+^atBMHb4$EskBr%B-ZO<(P{(6 zP10!gR={7-Hs?0S86I5{v&0h#@K7>$ zzio<|)q;9jS-LRc@`Bk(vVb9JSlaC};{7YXfJOaJq@tG_U5(GzgLu4B4vD1bs8oET zK-)Hs&OT&?*{$FhWk7Z06ZB;-5LhHAbNb@I&y2UIomxAUN2tyeRsfO?VApem>3Zm+ zsPGY>y_R1;tX+t@{5CD^=tv{wkVql-WbCG2BcEU!9^8`A*FM2^AS5sph{?@m39Ea1 zyeAw={DxnFz`~>2a5L=iqHYpoBFHi5o9c^tjqlP%r?xFsqHW)RjCO3_6}VvMu4+(o zXY_o$s*K3&l7K8IzCG(JXU}{R3@yLiycI!NUA5tFx({CZtvWk&tSf=Uq6wHVbz@o( ziO?VLObPyT*t{Pi_%VQ9D2!|0q+OY^s9|#U6N-`t)Wk)f;rNa*Zd+=mMSM|kO;B3!_#5%=hr+!$+I=@c1v^V<;`idJOHR|~ zWTQ_CwVM|P*xe2P9K>06qB<)uPm<>%?1^EowKSt_Hi z9=Iniz~bFZWcWw-F$B!qSC+iW$6^pf_~m+mR(PKN24T%YDaYIUuKqPeqbI`cGa#=GKHU z`>CVQPT-=%{NuK%+m7DyKJ!EA68{IzH-F*{ZZXI_X4%hfY zvk7O*)T>;aUe2V{y7Xc-VIOpiqgYY8?qQ96mE}T|JQCnFL3Fxx{3W)&?&_zI2f?6#?oyrhMQ{lX13&)`|Lc$xSRd0P zim$XkEB|W9^1<~FgRHBow(#u@zq~l=OvS8NV^ukj%J~~r$;X%y3h)UecIsL*v-2Mw zqX}BdeOYim2Fg+NyQNbz;OObMiEDfUbt;K}`0j&cPdMiFYC2RHGK#11v?C(_^2zT9 z#)RWN6>KhQBy7z6`4chvk(>ol+!Akbw=&?XQSD;41m3+F7-!;5Dqu+Prh@WGS~V(6 z$3W!K*H;=zn)>$+j=?JCH}q!CmT7or0J1c}v^PTx+qU&JJjr1_b@5$Fw@kfnj5a}k zLd8uwL#2c;3XGTK*iJwi`TCy;M9U6|#z4=0unQ*tOU!mcr*Z>Of34`MtLvB!W~Lmz?U7DSPOVP+1fO7>Z+c8;ZWA z$?zH-81dutmUnoPT3RX&!}V(xdGldxTtdndAV;=4zv+U^hCEVY-XQE|9kS+HDa_C3 zIWQRu;;s?H(8y>%kZmCF0>Czh1z)ZAF@-0ud{Pw2ZLk0;3bXxuarn%3ZfN6?w+KYD zq30pA+kV+TvH~XGLwe_U5>Ee~O1>12X^4@ogu(4$E;@awn;1~-`mz+g9qS`!)LY5@j9dfwvXRzv&&gh z$I}@W6_7L97CN`mNKi=EFoj%&1*_Z7dCZztPu_?(U>$YJgyhz?9(ju%z)WXBu)9A0 zj#;{36Aj|i_2TJ@vI)p4nckhzuYGVb|S&E=o z_=T#7O&{!4gH6fX>LB?8Ir;_hg#K`iPk4&T_Fk3N95qDegw-6cICf_Lx><@b!FPIt zX>1E^?g}NEn3!<+5am2)FZWNOvXa>E3TC#0F*HWsMy*Gg0**$*1|=0>flD50E8DLg z5&?=nC_#@gn{I!GN}?b@2^Sa_o|f%RX&p=4dW&7fb0uvNBL?&V@lg;`kSi*(FM+^V zy+V%_fHQL1&ik@G>gt(_(O@&3XL-i;kvOoW6S7Bz zOAGFk0*GaMz%C81{OW7z27A|^+Q8`DfB&Un^);AqGsCA>$(DFjs$@ADWnKD1dveOb zO9wu@&8s*bE@>vVKnN4k7HwX9Zec{x7=Htkz%w)8F%slcuw#t!Bi$y&j_gr-bolyzIy z(g1luF91aw$ftm0oTIjG3Kd_|B?w<8)=16wYHGsyr02_9vthuSLL&k0`eHpnN#ik9 zTzQkUBP$))IAk^YhVoW~R2Cu=>FeY$-5>#{RnPtfKbu427$+w$73M66gx3K@z2E;3 z>2_Z{4d<^5R-Nv`>^d>vdw+th#N8<;)WYjJhRp-dHIc<>n^NaIPRT8xro3WZs`_O#PZOXpKtt)ovNQspjc z1C>*+Om@`Yc&?i|*ASJLWO=Xo^j6a(&l22j793zv`fk3v2pn_%4QObuTQS|{bCFKlBd6-7@ylO@8LrWm53-oldr5MVgCmNbtsPfM?+m@Hy;GcbA`InFEmW;^KQACf z=a5EJ-L1lEGIQi%Dt_EQ8~XWNpIzYDu>*H?(j1frnK-k;43mP2@-fDnkhhTRmsOv> z_kpkuc3Zp}<#6bSLWiOtr^fPoA=dlJ{9k;Q1#;Bn%mI`-X|O|30B=*_jUyyVAcUWbo?#iC3& zvgA^E4OOa=->H|<}6QS4MOhvk@4Wt!S%;?!SKVDF$SrlO=W*m+b%{8hhqHryx zCZMuC3KD(ZE7~q(!9_uU2%mdG4wMkz8x_rMEaO1@pA)hLhNadQgxs9dcY*ST?M{BV zJE@DzwEaP)NstIZ9PI>sQu?fT__zTB!4L$c6J?L=Jr2l23PXgfQ!UvW0Da6HAAems zWIR%%AY(A91#>McRk0kc(yq7)u$*B)dqaMEE_nRu5C9nM{=zol(sPdS6sp7) z3$Jq^Wmc>NlWI^(+<}Fx5VU{yqX#KTS$__K3$>j6mNc7%2WDmz4=EM#WrDWhgK;z? zDzd1FG`S3CY-(~+;_VecAWv#R6m+gyk_r`z_ZBxd%}K2N+6Ml)h&B_rJhx471Qmzh-Yy7Q4F8Pt&5I*3GFJ9OsO{IohxF3*2j38K$EA=> z*{%TncV6XFrwQ>(&AZ0~EE97t#K1`WA;Ly+8J!2s({^mbl9STX(q5m+IQ_nB%hn6R z7a2M~aw9(|BwJT)sU?bg-f=?63X;#`Pq&O%A{@)~?H`wS=-yi9w|#C${WAEurWDEP z&JIN{0JaG8=iK@nCb|%eXfw*?EM!|5W*W`W)zxSlluaFWBB~0=;$d-#xh1Cd&J|uR zF%lGlz*omm^Z}yrMyixw7Sto8f@Cb=RZtB9 z|BuvGPl{F7Obx}af6C?4uR~rXdl5AuqMGtowU2iWWp~^=9x!4?N90w@D&%#w5@X@(GmQw0U1~S#R65Ln25nz0;?!$4Yo@#WNAs(H_j4 zafd>_qs2(0N?hZepen*09nk@d52L5e}lA$?11OV%exIJ2<=mhUFQdC@=5EKS+QQrTN>E z2&n?RPV7MiUE8STPVoeq*lGWXeaygQZhJSG>L#~-Mn`0 z-VGU;OVjw5I|Y z{(>nSg0|EiQ)QE*Jm9-^^);MdbA#I4(((&#W0rPz_1x~=h7D_UUE229#gw8sDQVtY z_24%iq7-U1thTu<)_kgNAZl~SEcN4J2BZ8Dr0Bkx#lwxU>%FAHFDzV$cYWvkfl&y{ z#B;6jdqq*XnK~RgA0**HK;Zy*{$lN(f50&`X74v1A9a`BoKMv_i@|LAI0L6Z)qqB_ zoT*pR_NlGDi&S7Nm&q@tzz|l+HcADef|eJkiVA_2)HS{x62#??YfI-h(1bb<>1Z?> z`LIaDFvTP0`m&`xG&iuIcT;r;ZMkaayc5O4=&s84&(NTalJx*Zg;-Jrd(aosf11^Oi6QKq3j=lI?Yih8%%>Y72|N2%H! z9Vd|Ds5;e~*Dx8bZkMxZ2&>UHLbBGl15_&%LGNpfVDdnFkFka*v(1(o98kQu8js3E zD?v_p`q{BhXo@g^+xDO|HZ3bl+@pd`FPPh54+Y#h-COYq6c#~7wq{!D;RpPOempa5 z&vbC7&GV88;DYT;=g*0??7F z_yz~6%-@p%w**x%zkyrX2Q^orl+7iJxrSFgDNPyr`TAaE-n zS=Dw0R>q(StwQcxsQT|P5s$f{96@v`7!GCIn3d9SY^hA#=qyw>Mv4bK0)*)TQanh$ zmB|qVMP>zZ9CHmUdw{%rR@H6QCQciosDQWZRGk7qY->SE>fVXGMyLQr6-THR6iRx& z%`<>7(;3VHKj;h){1_@03OJvKst#IhV`?1^JL*N+5t|-qnYZG;K)%<%!x)z$nGwU> z{}Lc5(x3+4L1qw@ltT1oIzrC}sB{P5#*gIvrARk70(B`XBV)3qTtF$;$z~s_l4h5L z`(mSE2_$Z{_Oo%rL@TA`abVLBCMLYgZc>^{gTkEDgw7D8nM0rbTm424k^W`T-;}I? zN}w0*HmYn~`Y!_%Yrp4!);mh{tIjta@?5(M!sy2Xe|WbAD%gR$r`qh=xpv+7rTxHO-ExFYMO#6m5Jm z+cnVueEvRNJOyJje0nW>weKBx_}Xp@NnQgi`C(O{wu%81o$pcKoKeptR?m-#_L1+F zpM}~4j@1X>8!M5sz!ghzYNL|bF^gsIpC{fM-VE8V-!rTXNT3pw3$k8!+7qxuluqUI z9(FJ^^>}Z%=6xd2tbhiTM+(XJ;6Ut~B~lOZ>Z~XMwC7YJ6JAh1l37U@HrDh_N@tvl9&Z zKw&QI>P-1w*cuEgqZ7IU&0pX7VEaylA{O`iD$7Fc=?SQJ)vOcIhw@Q&ktd8ScIP;% z0YDMC%SH(0=u&$wsIQc?GBuGg?fS(38Em(&q z!jV{tRy&j{2E$*wEySXJL+=1MSPkwE<^SOD8av$|2vcffR8Ns$xyK7cyLKR1<%L8X-U{gizxTC{It5l&dFuKTG46WqZxi_7kkZwQ*Mj-m9SVd& z_UVof_TClUw$T*@?$#V1OSL=4m4-}>G@|z z9nE6dqi(K-keNYJvq)j%^_cv7f2>A8R0~|0V+IVRbkv|Wee7+U!(%E-F@kI7;Gl^k zO2V|0**Y0^c2pAyMifliZ!rHY9Gk!M$;a$HW!(5|IA<-z_!OGul40tUSY$@4e`8c-I1R+oPNdoM(w$M7Gmbm{1g;RlTIOt?#flr}m%gX3V@U)}KD=^`B#RiuWgds?D$ zR1^u%+Ad~74A2i%Qr-oU@*60$#;w5TI368B1tsu08H%VO@-&cJI3xo5I-qyDf>Zx5 z;ID&z?Di_DMa2hysQ#!(;P9#k$eg!{Zos$Y|0rKTm-BV}SE&U2_T=~f{Dwy5KO^yd z6#joa5+{q;P0fGen9oKyANGlV`UuA#{P+8*KRrRc{y}>OsUMvK;Pnp5InXZ6laxf} z&rK}$CiqK$-lwpp!)F!mA!@bWC?<#?vt^X11+QH#`TXq8h0$&_!c;AWN_o8;Dz2yUO z9+1Rh*WjX3FtQwnG9*tPA+cSEFgb&EP-;4I$Nu|C11Bl4Cpde8{X7ScqURBE9vRCU zVx3PO9G}2G{wq}gdCt*h&T7!2E!lk(f7#-W2c;^9a3{Ckj~*{57BBblpeSr4fGho> z&aUu#rU|usqcVBAE4RF%x!X_#PCCd0O9oto7|$hWXE6E-h5-6y?HNRE)Zj!6G@0+P zFv2&euTa7M$zRY&soA6cY0#fm5Kb6uo#`+kqE?z!(49^&-P~LddafVEc{W+29$f6! zd#E)(=D#-ghvO1p?)qT6@J2BF__0UvJPH`|kKxP()W`yLZwd%N{gj8Lek!%Dx%Y** z-mS530I-x-(7!%&lKDM9d-9pu!l+XZ3v&*XH;zx=H&!>PzySWsEgZc0Kl|GzadApr z_e*KNFl%k1h4gEBpQ!Wuzws*B`}S@RQ2d9;OF^E3BTm21zY!g2K7OV?NyI)Sq2}2| zS^2Zg$99M^s{eVa?T_Pb^s74k8at*Y^%oZK%b zY}A<(veZxJ`0^2`WJa$`xBmTRUwuO3{@3f|(|_xn%Hb>WSDa=4|Jnc2_uw(R=J2l% z^6dXByrRDxIWD*Zu__LpJV}#r%ev0SR7t#h|BxSh-}vK(hr!X7>vp#C>Qe$>8J z!qX8z3$tqxknwdt?yyLL_9N~4Yf0-JRm};nf1_8_S{U!8cznyrx7H^WzwV;5Rw?lU z!`8_vrfVImtC2EuU#}N!HYzzB`4hfyFoZl67}ecfZN0I&7&0R!7{BClmD*M zVrz4b)!S<>*&_>YBWDI0kH08t$HS(f$9@j<*pCHy4)hW9I9!1dm3e`2dSxkU>rFR3 z%c~{KFAy`m3>N?B#jZJw7F?UKgque<9T0HBdpUcb^YXNT#Tl`Bf~IpBA6Tz3o1J!l zejJ{T9&djw-j$^pBR`WLvxe6@x9ZZ< zv;&@&Q|pmFYi5rMhVe;vSG3ay%ecgBkHyR5p|fW>gNYt0+Nmt19=nR^EIuk#J{Wa< z&g)urskcSmcvN?!$KuNV8g*R%{pz__`ktP3#cPEMT=%eDSNIfry&VQUAt`keCV;8e7CKsCDu(oxvy%XT|o!IoMJgO62}*Wme#9s!LO-`Bvvb9TiTL-CdB_UsF~N zx3%2Q?eqME#?D&)P5k1^J(h!ihMBnPV&JaAq$$eIW(l-FuxV?S&e{yhiM?278 zxG*NWm4vB2^pQ$mr)MQ|ywJ*$ph$NpyVW(DU(-VrXh3%Y6XMy}h#qLatT48DruWWV zW@a3EOiM&%IL;2>HI^?YX#bH1rf^~`&=xjoJL>7x#R}IDrt+(?$1}s3I$yRUgKi~y z7^c@CQ;%_*@^h}8S8;zc1#4=y^nBN=uQtOWK~#gAU5invg%xS5gU{v#o^R}$>RVni z$|JT9x;VoqS#?ZW466xOEvx^X!}s(ZMvMlvAuD!nrm}ZMvclbbeXzyjRs$(7%ZdL) zKgl7YnqsjQ8*QShfupI&zM$lblQ8in^QXyw&j3sB4nz4Mo_p}%lb5LBh50v!Qi67A z#(t?y0>ewt4ip<{2!%<~HtFatjq|p@S)F?{LNBBwlqC`uyX1jfR6bUAcTZ_{Vd0_6 zS8QWD+A3~!Jq{AE1oH{1@maZ{s=}P{n2g}Aj%+^E+ToOyghlicJs`$=%8rf-TK92~mw&t^l%%R=4jqCYZsuCVi6GX-a3XsV5{_zsR z$Tb*o_wPxKpY=1)sOhrRz1^M0a`YI$k!%Te zEc%;W_f2aQ0l?_|q|z^c)JaP-G;Q&i%aTVX78j|a!DtMzFTN+g`FcoTYlP%e*F5Jz zJBzyO9dPmeq}CAccDvNs*+}<|b{Y1g94oAZnbcmK# zdg{aI4=QlJNGX4fhmWBeCi4ylsF>1q01*fcr;uN)^ z`7qgraP$FTWz1?qXFx!N1woSGOsOarU~Nv3nbxX}bUjt6A*E*{StYw_ksHd?o?Z^C zuIjYXC{NOdA-cq)%u@=Uuv0(Q=L_H9z4%R4#<{9_=oj9s72KI{$2$k``)yqZdk5JUg!x0l=X(psW?^b`VEyvamM0)wB2RDNk1?@FRtokv)l@%IctVRm z!||^E+Vt0t{H%VM3N+Vf0`98UfTOE)$#S6+a$E_DQ0Jj%k6+i)%hZ z$o^%w7DZ;>1X^KG^=8-4i=@y@t$E)GCWvoU%Zp%z}hu|w@1G1-O4>tSJ?#G~`reE*h~ zmKmI7CVm`ywU@xGuHiy*eGyG}?!w)n0#*8F+kz@!#v2QHGb z(t^jJw|dSxIHcdO6qv4J>s7(vP&}BiSjZanv>p^8@B}%~%^o<=`&wa6mP3zH;of%i z9WECRyZ^4l5fH-^3evL-$~9X&0Ko8$EqFA^ozn?^agZYQRY3dXIkqh>5H%F7x|V#V zlTJreVUMIm@9si)!^%jzbPEO$T{>wEYgv=j2-`OX7bDD_b-Iyv$4*)fFKp$wp*Pa~ za&fSGB3`Ou{%xg0_j!8E)11j*4VEawIcti19?dW*hUh$43U>YJ`U{?ux~nXZl-^}! zo%s#{%)sTv5JvvY*A;c4O9sFNnzJkxiu1fTXpK5$EBb#pq<1iyHo;$ai&{j##-6)H zov+4fV=rOh<^rY5#fQ=_z-|XKqTAq#EHll|F7`o9yqpw6!n)iC?D8|%CB!~_7C;7P z1`ewWfN!ntr$?lTbW-Mdd~ed4*sKugJJhJ%v$YERe&)$p{01Nz6!yAvQv}s;*8oK~7igdDjz29SEzy&(p`W%+F3FwnT4|@Y2rW(YsE{_m z(uZD4$30!Jx!q#uZvfb{Z6X!W`&DxxKJSH?(|QnMdG=Bp7-sriyBN%FPwG`OeP|c$ zb*(P8=%7^}xc7j(!*MT)X#h1_g^087w$Ac9A#ge~IoGN_-#xHsl6sc)VC}alS%<}# z>YDOJQjk@3kobHXTzL}pAx1J9mDb&UysgFLg=bDHOl6x&)a+0@{%&0I1wF>`tD(>gnr(TtJwDnu|{ z!Bn$rqiJBpov8mH$E!aM$l6?;hd71l0-&l<*g<7BD@N|X^sQ>}mHv4SHlg;bqfxc%G+6Eo~iz-B&vX=#e$?YquqDDYFY>w5o`@Q{jpr;K2%+R=m)aMc=SVOW}R$5x{Q61$q zXe^#-9Fa5mFdBMCW-Y+t#6>Wk1HY5}+Fe2@cJFt&MNVM=@$+V-OhlJ=c*6-(`85Ct z>Pj}$WQBsr%-EMX-e{o@7fCgEhSUS5Wy}C7s(Kv$l4R8)Kfg%r1{QcrtO2S}PWB+i z>MPQM96V5+{OH?77b%B!fv--`?#c9qpz-3E?Qi-5GKlNjCs=0ehcVbLaluPz^OOs z>9r@SPm4=S1e>oJru6+;Hij>%0vxB^GnEj}I&MWv3ROPNm_c?aE#!F&(}h4o&Y4Rl zC&vCH>ZmA=G?@G|tU>7zMmGXh;n4Pp;+&ckwJeAZ%#8spyIL?e!67|mpVYR#{<-|5 zD4%w^7`N&{XEI>%+Up$-tS`kY)ebud(9$m3#O23>kz&yz-p`Cii1TBCVd3bRbZIH0 zICU*f+lOTcUGL=4i@7tatE3JO*msW&{S)*l(mw@MyulL&2wr<|9T@GBYVqw=y!av$GOQ zc&c^a#bqi0{5>!S}O_%w0gv%BL?x>Kt$-oRb2mom2VYe2xMt*bR3;Mh?Gh`?U^ZF_-BsoHRNiB-*2Bqj{ybqq@uKq52P}$zY?2)Xi{Dj0)T|+hWaTrG`O0Y8A4Lp0MRpvlzXy zh2usjz1x$1mvPZ0%7>%S);jbw96Sql{oR4JgU(d}Vd->4j>!bbNM%@qIKUJq90u+D zq>3*4KYO;ksY6x7A0mG}Y<-1OFxLc3v3m%noUimZ$fW^IREY;HrEfyG<2F2Q*dhdb z7_Sey1NPXv`UJWE^DJ9NcL9rJ+Zb1mJ>fD^cY={WSn@k3hwtLu<u4QK@X^U5ZZHmP)Db1bHyx^F`k!H zU@d?HgUK*iI(N*dP=r^Vd_+WK`#e(su{~`3vPT1H;^Nyh+WYd>hHwa7{P9KdQR)^g z%Z*ZA>Kz7|fyjP;J;SNh_+?6|k@`C7S;J>Xx`lcnu>ynwfW31|52KxQ*2cRSlvy|Y zdAhsy1m-hK|Jg{kJW_Z%_XAQ2LJ0nRBQ*~E2{nJGo1Q9tJ)gU_0VsUst}iObwa(8q z#k?*#_Cl(agIxMBehm@)1Z`0F-@bh-{`glD2?UbrZ>I}{zspJhR0Z1v`Gs*)WsO4A zW~hcS{!$=2`4Z5=^Tkeixpz~rZQWPxY|fwFDm-2yuq0kZI!>rxEB)9A(13%ik^l(K zH`al48s!`RdcFQH&GG;5tE~T?pZtGB*8R^H{xyaF%aro}4C#M{lsy9fGwGH87|s7{ zjHWfAY1^d}!0jIw(N-KBA#Vb~q|?f6EY}H|p;=uvFy_w-oxGE3Q+X_Y|D77gybKI@OI0}v^9tXJ3&kzuMN;mZLS*0EG*{K8mk=#L<~W%y zS1vSpHSTd(^`>@^>ikf1x~)+{?baJA4jmnpLGhU(C0rdW=E6X3r1KKD88#GeD|Xa;HTd&P6M?((Pkr4z)7y@mR+Eqz%5^gTWvSm&4>abQk@0bqwLp5{ z>4@zdla^8D+`2DSRej{={yZ^}U-7pJjxUscz%HvybeCDPk>?%Nj)``cP1vL1mB zgEE<&esZSV?&5k?R@|}wtU1u0)Fis)iGS8!e9h$knwlr~_U4q7R{#5#6acsi7L3k{ z_cD7PzG|=GYjz%97faT^9Fdj?l_wci0TnkJeFl?Lxk!CLBV%tpj{wv8g+sC_;TEa* z>)S(#`kr6@{gQ62OV~i@*cGe%D|qNfG`m&PMf( zfzkH7j3#LQze7-8aL~j;nE$W2Uc7a>f}DWvAc~qUA~HB9=92|)%-*UlfBTN6CX5(% zov6ZbQlZC!J8re)0WxQm8=V7EyT5utiV4b>^$vd{3)n9wvwo@x4!rt>n)|oOFBdLc zfNFqhsNQ&HErC9A=^NZgO{mI2Y(SPAS)tO?EMoR0Y0Nr4kynneLWRzr3w9743Sy{!X|L#SAr zG8w(XaeM1d;qK{$433j7@WtfGwFLI3M&~|bguDOxm@WEPdGRez*eaY6)}X^de(>zs zvucV{XK@;g0ms>luU^@6>fmz8keOhuFJw-fLXYgL7W#yVy^YwiHvIaE!}PlagW&#O zjtls|PL%)0lcTXjum2#bf1?QhdyEFfD%hGDHMdN%Y#(obG$FM%BI+l_jVQ{kju@(C z)i~}Ks!sWQ)9DCK_N0(^LS2WkHN07d`1O9J8{1sTq`m31N8r50vVIU5-@-hU4!^N< z+`jVD=?FBNTY~m?l{bm9uL6ZWVivvzP+T9VvF?1iJ2&ZEh`6f}{NLm)lW}N!qp>cE z>5|b}IKuuTIAtu_Ufl56G!z?voLtwcEIl~1%KH- zsT9gniV8h<41Db(@v{F3ZiyTER5k5@n)94nT`i|0Ao8A6>A6xQ&nw+`b@Vv<6259D z{ehijOBipqJpOU-$BbqmjQjSrKw`pUje!FK$hdMDJTrRj(EY?$sBdVDCv7rrXldXfR-K}t|lh5fG zWp3-_WM~h#gHVj|@h0~;Z{bj(J)fu$fE>c7>pp9lt%;z(+?^srY}-fdHi8@>=(@%p zN5)oCP1Th`_RHG)JJ5iHn|f8r=RiU}M*h-u7|nA!43?a_Zt{i1GFSR~ow1n-pPSo) zU2UnsWFNMsv|w{%^ioT%G_Kp2g>NHwHT*%de&+`?)K4ChuNXIPvDjox4bnwrNbox+xHiz^_# zfCM3#)i=AF69R~OXl6LF^Ol2^@%gJTrYr4+gPcV)UI*$m>%J`8&@aoAwjse*A)Bu0 z4<0QnEX<^eN>`Vy=vA%zaphvEd0N&7oIAhDEaq9w?b=5&Ef@D$Shm_1I8-e} zTP}u?N7j=0@baN^)uWfSs_t+ZvNkY-=%iTqkErZY-pE@ z&?C{>7l*O>0GnzEixxwpNg-G&hpW0vOy-CHb`{$_ltMc7jLt`SGFNsx-ShSdny|-h zn{yjGtqv%xXO9{gb&a@0Fw_*lt}}HjZ{&ZMW&mGPJe}QYkbHQcJOg=Qf$3h0-m0_fevYq(-E!$Gc=) z)^ZIDt6uz~a-+<9xMwD?W>dn-lhVIdx$Mj-r``{S{363cBJ-ilA##oFxut-9PWLa$ z{d@E>1IKWZ!S(H9!dC>3XN#Qsnf3jl{cS1yff3jXy(#*h;=256(lcF25=cg6aT>X&ddKX+soxNqAJ~OFT~%lM7GVUdqcSy^wi%+)obCH!(2)2qL6@WF8<=W zB~w8->>|o}JxV3WK#JOE^h%3r(Wqaf_e#4`QJ1Nipgt*{7fG9d2~D0yFk-lekS@Z( zBk`-n;`K&ppI@O~W+Q08{#n7q+d+R^|89-4xsZMGmbWfej1Ka;y)-kqwl_krM1+~s zk(K^a<^;N1gpVLb z!hsTiIYot;t`^=bK7yf)IQHfrP4QWaig^!pqvKKI#jJ#v&a5-CLs1f>keg>7j;PTw zlGFo=v5hIYe%_FJ9;%L}M7#NfjeJeFc-c01$xR1?QH2&Y)k&fCpB;h%0-~KFc?tNX z&$s~1`dE#fbMoLS4Fvf(PHI*HoU(-6Y|rZT$(3H3R zn6fn3lE&q-J^hdfFo~)@?lK;n#`Kko(aIvb1WGOXUHUuj);qVZIEtHmDk?3M%Bpa_ z+_9n)C)W8*Dg0@QaQj+a)SR)om8NE|I>vRL+2iRyl2J03db0vdmiBfiaX7_X zL(-<#%_O1rHmb9+*;>)l1D1f2Yc{OOip2FB5OBc9H(V*B5&ogl{G~m8@T~)>LBh4C z4VL@hd|pEURPWgoWs*ptJ;e2-6HEtsk$r{$%f)zo?JQq)wNA8g8FNuyjxXOa8~l2) z<@mMWP-YoD%YYG){z55~CEl?9#i)L}cXa7^ce>Sxp0MMBDo-?W2KtbOq^@I`U~0{Y zp4Y*;=w=>14Nw#nC>>pPX0GqqT-Mj(IOE?b?L%8Q@P&}Y-fL1QC;ZA$y67V7@D5unn9DJ4@Au@~ zgk!1IJG}3|*a?A$htFcgb!ccz!@T?L#@+Vg4h;@6n=~N@1<0v=JIXZ_$7xvTCG@x+ zE#o0wf*fh3uOF`23AdY9c=)i-0#0db#rcM4RVV)uvMAl`qqEvfTd6^)mJdpdLh8_B z^f*6Zcs+1W1WFoJLn>K0Ji;~uO@geAy6I>On}Bx1**o}{@N~nfstBF_1p1q-w2(O| zRZ7<@CdI<_X5hzgolG;sy|zBX8D*!dblNvF6?|6b{7ra%Gs|_fYisMMhKV!2y7tUk z+X{2-S_4Li#UKs&;|e#^A2W%!N3gr+3HI;T{|9^T8P#O=_6y_qS7%g2L@6RLijEZ+ zq!WsrQ3PyA4JZhRG$V#ybQ}ewsVFF25KyZ05{gQdUPB2GAoKtsv;ZOGTsu1FInO%h z(|JFhm$h^)1(G}a-uK?u^=rF;uAVgAtl~jUX4lw65;xZiR~53V)$etbN~qEfE#ZLdRG1cDaKr zC%m9a{rj-tzI5Ar@PGuc^Q-5!?orUa&QzDC40cFteI;p9bTrw9YPM3FZ*+q>ln`MC zk5nW{Wo|jVU?fPuNL2tPe=EAmp-*^ed{3N6xK9RFO+5o=DEy~`U{Y>|o@Nul*B7m% za9i`;Olq2tx+U%W{XQ(v+Yu=;-g?r&tC)d$3p%by;j-1rjkuOgAt&<;K#clS64(~# z3$XYj4>vUTi^ASP#G}P)Vf_y6$Z?54Utb{Od|l^bp08{ZxMORCy)F{W9;Ls%Uu*5x zwAlXliV~q?$ImSbh-JOvMWPXXE+wO)t?5Ml-kM@cwfghAQrCl7Z{v?mXOB z$>M1U04h(p=*EqlyOK`r&_4;sUkHG;4Pp)~qROAy?OmAY!%X8mJw1C}o3IAeE3KdS zhCUDa$YFT6-%m!rdE?iV{Qzo^(?fhg!U8dSWfei~AXv82=a4Ph1Pi5~c(5QZS|r`m z;lP0~dbAL%@r|zjT}#oc6HQ``tI@x3)r`!zTUA+s-KWUOnjFLDH~|6!a`;-VqV00C z3lf{k5t{el81+;8TaJeKXq2fj&lyZ?fzt`UKhGa>o+nnq8Yq+32Ovham?EQoM!xN^0R+QMTOW)wS!U9etQ zQCPry95$4#p?9J4d5Mv%04BMvRO~tI&2X?x4?fob@?9b}v)pJQOD<3fNVC!t462ow z3s204--|goiS9kqQQ{VQNUdq^jl4<*66 zCuU4l&!)4(ve>FBU@F?N|3F?zlBG(qGS&TgUQ6?Dj8wm;B{L?hxfG$gMqsI`$_CAY zSr2v%ncY`!h|e{4>T@tFMX#Pj#kbYBu(u1=1pVN<3zEJfLy3(Fo({*yzQ zympnBr`ORGO-(=So9w5_u&7XqgwvZ0&$i72drL8=zg zbD@ACEnd&i#8^=$C(R}oJ1srM)aHA$q0c#Gf;4xCe>8Hc#xnM3&04Kpq?olTS)Bvb z-kGp(Y*axLV<&Syi4T2#&rFcO*p7SQx|&%-v$vUtHveIzzHTZ_AMS5?e~_j5DzDwH zL1?(xvu8Y~`3}337XTyiMwMKh%WL-@jCDXWRb3&q)VO!DK5?)x@v^9BeDD1jr_^|@L|@f7n>~7 z-cPJ3c=c{Tn-=|zL%r6Hn@z?-leV0lrUui1d zKTO~Y05)|L9NV;;NVyd$Yi|n}9e-+K^qW5O+7b3zXc};#Vlfbb6)=rNtDuamNH62e zLigt-PHRVRb!M&yD(nhlOkI7)JKNX)QX{}OTJ@N4z6h)qk@tHGmTL{hD%98M!2#3W z^ctz`%}N=CbCapqt96$&DN9BUb%xc>P$snJ583P~gXx5DysOu)?SfVe%`AGG5Rbg* zb@?Zow+GCZdf0!sM*90Vjlg|-)T8LP;99NvtYD(})xA2C%u7-(wilbVdx63_986y6 zQ(%#_?NHfm2pMm^F$p0HP-F6yr@<@Z`oo$6PjB38nHm8oXnTi6|33{KysJUu4W36)EDaD z^dO}qEs>YG$M3mXAG2JWl`){cg)#&ciiVRgTPSbi-Od5*7I*f$i8S11de_;*vM?lQ zfjl3wS);l=ugOQu@kX%A)O$XF8<*zYpm5w3cIfF1@i?4h#N^w_o!6yQWizV?VlJO_ z=dav_p0f&S$=3z;HD8`r+_!-jA41cz=1cvK5l4a4UEfW>$(KZ(!9!{S7uZ`UswjzE;R87H(yZfHU z>bs{B;XY=wKI)bM^jY|`g<2}rez!VT@%bzE0a4#19Bo8aX);Dv zlp;*tB>3&`nQOffpd4q^=;=@}>07~SR4mjXdiHC-rC70-Es4`*YSvd8_hxk{(k4Q& z0s>E-dM|520pn?Rx6xAr8p@%$>KOfqrGi4qR*m5L_3&WD1&To@YxH|EzX%mptaGl` z4_@S(C!duWYYycPJU{SiRizd$` z!OXB!DzyH2AES*=_rFv+69tdmo&UTQ-q)3Kg6btquHC+pt{=rZ>jBpth~M6&fLqCK zDib1vzsXpWqZTzXAHsZ!_4?iyBOrN+XGdrIx(-pYwB(qkW1}d+8vn$7`z|E*{GA&s=SR#7vCqup0D=3C^ z+aDD{`VP$c?$6ZmYgb=7vQTY^`fgMYo_)5%zdvTGZ%R#TWb2^iwp97WOl`5cqM2YR z6)||6-Z-lk{#`7Bu`GM(Qr{9|uj_enRLb~8K`|gaks`0f5A^rG@R)b%em)|R{9bT) zQmxn6YN)J?$*35C)v>{SZB$u7W6fc_=-mKbP0e-Sv{GmOg6Af#7g`_=tpE{_IcSN5 z!?em+>B!AA-X-0!de}0*E!T_|Nu_xWB%lh+tiEOjxBQtEXBy)f-k0>rcz>_$PAoxJ+09`;q7YesD==StU$2m2~Uq$ItM zJIT%STe45xcZ~95FEQ8=1s+f)x(1Vt$3}hG+*sQ3$gPEyJ2PaVoCVLyAnKKCy96Sg zwGBTz|1=ID@5EC{sd7-s9eVZxDWF+AGi}eUIG9)${cR!#5K0xhzAx?0`nOHi zU)y2RLn}^j`7<&8J_5e3J5a&sh};48eOsf=zdLSnb?kN~PbtKbl1l+M3HOx9`c6#C zdpnX`m^;5SGtJ%MtWUo&_CAAJWLm0ISUxwQ&&}0*6VTqL$Z51!4E!lUK3?l}Kh#Bm z2V?+SK)Xbv9m1XXFHh?l?2Qi^=NIQ;xh#yYw?y{2WIw$oKF3p?XTfz+?}b8`B-1h@ zH*M1Bx-MzT=A~AieQxCmy@s?XAHY<4imG;$6c!D5#Zi4o)B5uh`dUnkW&KB&fh2WA zbID0YfNSh0Hkdw$Y&w@q*Cqo-Kw(8;Dqu~4pG03hQRJfRYNa_}UTMJC_@}d-u)4kM z=6Zw&R8Lq%!6D9X#h`HYBbCy})M@RjMm=Biav%4GI#KOrp;4M@r;z)rv{sxYdZIcy zMx39x(@IE5My;_bn43^xbW1`KIUy-mmp#SR1Cq-Cp{RA4b>x2H7G@Ry1#)jEe^@i~ z@5adK=HeI|r7A1ty#)?yt?8;jzm3+{p*(8;GD$nFEJ4K9NVvw%I=_GOanlv5w(9z{ zUDtvMAyBs-~^W1H5#i_-WOufz<)q!j!iS)N_y2Myoj_0yw`V9Fk@20f|k2<%dX)xBxECsn@KTTtJFPt-WqCXWP_2N1`pGt-t zXaiaiQKL9!PD{%tm5w2n0suqp*8W37$734zF7&;7Uh>2a0Ob#=k`?=2rdlzUe$xnj zTqZ~HE0ifosf>I~nrmwP}m1F%S`|QP~rch^KatGF#d2XpUl0S4X`9siqN$Q)9 z?^@C~WiuSXBDp}NBbP*?3GO1e$N%InE6Bl8yA4gnkkoys0Q_45NDn`LCCesy41IJi zyVEscs)o=t1qi!)pg(YQR%Li@Mm6;C0jNXQtM1hhi{LvPh>P{qPL=U<%(Gu{oL>@mlGzI2vls!bbjf|C&<6=zH-rCUUU8ami( z2J4BmzEJw&@Xkz$N6Jv%p%O1M!QQ4gFHt$#c=AR|##UiP&+&OlL}# z7aR^YIBS+ZlaB_3c6w#YOpHpIDy*%$#M#F24NQr=__KNu9Z_+;^W|zW!wf2RaPc<4}vmG6G}QZcgfpW1m{6O(9s zA3Vo;;}A}8S4OMMUgbdC*^3uFZ00&!L(^PKj)k5ux;p;g=mX%m4zhH9gf+%gJd7& z&$b7mCZpG*JSX*Z2RN*dqeMGdM?L$`jLaC*H4h@*XS>MF@lV#7&>_FXW7E?s2@y}2 z3k^jOi*IK?75XOhLeq({0Y07e(r%Umn;tyW#aC7J$tqG*p?9G#M;+F$zssNTaMaln z1lnVtZCHN7TPW=I$v-SBxZ65V0R1Ms_nV9l-Y;bI4tE?DD1F)4)wE7;NTu!&%QJnS zlvsHocs%sydvDm{+*o7}7>B%2e6j4C#eE)Y44a5Pb|}Uqpq#xpsOa@AUV5ZT{R8VZ zqczj~n4}^tGRFdX!Z=mS;+*G64>suH{XY0`@a_tU7?u{Nz=spoDQU#eCX46H(CaO?O@UIm}vhEQ5$^uY{1xpeoKlB#UsQwp(DQp`qc|z z&!6A*12o6C^_$Zvw9n$kRGrV`wJZxbtuHbuSEu1?Q;%>y7|(TVhlsV`7Knh~OF!F)7f@5TaB(^*)~BN-!(ZK1)e zsjk#9a-f2@LVCmltszBk!#Oh|McKO7&$a0*V*22y>r?LI;0*yJAq$JyCEo@5BcvlX zU&;1AS(Zjo&`}YJ>5?juvH78Jmtek&8MXUF+NfdHS4=bRTa7Aq*-Lcjd>>XD2}G+-!do)!ZooW6ardqWHBEa?Y%v>)#$}37#wl7{GC^7or@!_Ck zRIpxvN$0gxGnP4)8HJXfm z6cC2X&Hz&ruoo7tCH^HG1h-@IsA-fez znf>pCNNFzC`1eWn`3sMEruLx9T>KQFu!uubHF_%8x=Eosh3kv;ijoP{QYmzPcuZ#p zHyNliPB+#mVY=cJso-itR8B8_=(0cQa|ddlIXFaLcybS5OG$i~b~VPg2q6dwwcFpi zf8&xw7kzj#Q3Y;u7oc5Kl{?g5qijTpscGMTU6v2P*CZ9)IUyybRS7*_q+;XG;=;0h z83@!W;BvA>J7c}S4gsMe^+3bDU+w5m>yCit^|-e+|v#CsP$AFBdCI zjTeZkL`ytq`GVC-+%+;-?v>Tbo+oaW6)Uf=&ye{T*3BhG`4yRxMFTblXG0k3vNEE~ zh733k>Ouu77)-nt-{?p0Jk{T?S9bDKw|%*tHA|Rk)GMbLX58lycEV_HQVjk=kB17Y z{#%8ecpm=qG>LHEl-!jNcfdxY-`5xR8;^767N)KWsTMK*qTa*U)-;bpcTCSUfz>sD z)mwlV+I=S<>K7LdHn@xyT4hHI08~=-dH;6>KXvb+u`H)~&xK4S`hv_h^HXK(LQ!cU z9*f6A4!o)D>)y^4+lBn$$Q1}?uR@AWm1G8>QMjt_oPP%2lg@tLB3^W!{6vi(s&JbV z7k~ZT0Rf~D)AGHc#Pke1T<3uC1N=O;_~G?PS-PI}ytKohupjAyE%g>!`bVGjS_3o{ zl`HOWuHtPI3(m;{BH%)kXo5A?jt3ahh2#@j9%%wV;fV*cT|wZYiuTYo*K&e1`<+5o zv;Z{X6o7&crDGNyw^&S%Vi30n?;%~lE=6O2p81EDR~83nYyn5!{bBw`Cd&%l$ZhFJ z^*+{w%eWQK;b$@_Hp%AD2>L~tXBVzKvjzn7Re?Rg@zPKT>BB7;S_R_T1r6|BQ3}#Q zcA2wtv15}ZjGnw~ZCLq@aor;Zm_1_{G1wlYC+eO~)aI3-BBKlI><*%fN&p?L_d^t} zW~sDNBeQ44fKfN5asp(CG-G#9;v&FNlB}v>MBW`rcTglp$Up}ssIasgr53}*g((Mf zT=kTOw+Kigo!7p1jRb;%p_eh6TycLC5p;xGo@QfRw)Qdd%F3?~dY^8+eU{52r?rT^YjRf9R%8XyjBdF3H0eYp1Ph-isThoL7!*n>}^kX ze0gWV*WRxWpP!$2&*+g8^99_a*Wb&{hgQn!icHnUZo>P+SJ0nP#x% z^fta9zQpUnJYJX`^8Z5xk9ZZ$QZQi&}xEYR7Rt_*<^l(b)NrBP<{DTagEA;cUn|# z{6j}1F%6L_Do9J(!4<1px=tIb0^8wTkH{tk?uSQ?`k}rMkAL^Ru`!DAePxZeB{>Fz z0pA*u1*HUM3wjazu=vY(-)@k>3bN_F}7vT;Rb=zktn7ZFV#AjsGqX-$)6-?Bx z8BGTM$0o?JiR}JEg##&L6-P}K zrOjR2yhC0^m&uXW94UCc>1Nz&{w}mtb%g+8TYdECQ9s0Fq7?=0P7*sJ5b;_1fEX5U zWnp1r9tfQi@KULjj~fv(^2*_IqfKPgpm~&9-u38w_z{)+O`fQD$vtwAnQ;AZ#hA{T zaf-d@bSkCmWvo5aIm=%q^7C9oQT-AEHF>{6&NFVIy$JUApw`ySr)L+Zs=)gxA;JJ- zx8D0=BbQ-vajNVA!*!W+rOe!=m550|c989!{hya%I}W*5!ZsgR?lUdWMln0&dTxQtO0HJ8C9$eo4eMzEN@kzu`C3QFW1WPAcFW6F2r%5KQ<}JLxty? zD$UDQ@Wd|;Ko{enYOZe45D~#1GxQRV+uywMlVawyr;0a05RKr^<@VUG2q2%I=_f!R zRP~WpoUe0aYb8YG-kcWTh28Fve32Sxk#x0we~?wELgSvYuy2IElyLPB3T8ymB)V(YuI z#n>6CkI8^|OHcaRBe_$&O=Z7?Xv+TPLZrnC*+%wt-A4XvUkWIqpcCTv@krL(F5G*F zHdBIKVqHOtJPv#84Qp%_U;$m``Ex|p+yqxI(qlt^CCGTkG6(kA90=Njo3B&{Oo^%% zdFFilRzk9I!CC<$ODv)1Lt&anSA4cT)t9$iU3AORF&igQ#Qmuh4g^NY^Nb{XDzI9W zrmy)6Q-ZyF-U=QQ1sPB6C3V3Vp+0e%V}>bhlu&NjCf#w{r*S{$wswWA#lYEk>t)fZ z?(&4e`gd^LLYwyShb6;hD{Z>qjxV57SQ3 zW>TR=f^vdTRD7WV6g5t(%=|%{6o2VcT8ZX5T}$jG$Y+VC^6oxW-lW(6P-%AJ)V|+#>ivxyTy!Uq=YCj~g9DK%9#FDv*aM$^R*AZudq9os6bFvzJ-ob4 zjqb(G30&^6KkW7XR_s}iUdESl@QNrOH~?;aK?Sml7Hs!NANrxOAsph&jK`2y1g0Pg zW%jI3CAUJ~UVq5gLb!`jBAJ(?FCE#y^*I9LvCst)=A8&g|9igC4-!ju&LS|bi<9ab zy9bRJjl3|8`f32mc)dLaE1?w9eqZ;?(k+3@%7+ulIKB6E@DUX{zz{ud&@HYeGx||b z{^95QkYfPqX-+DPNHc&x0QI3H99d{XdeQ3vzXzlEdCRfm5_{%0bprYcLOepC(! zS+~*Y3fS2>PZG>7C-jic!LN(V3rPND%*lhEx+q_|NJb6$Pn9~n7a_FdM-Cx+WB7WZ zjS_i)SU}o3O?Q={=3tHL*QQs0Jc+U+JJ3YunvMgZW1_av>pWYa(*CvT{`Hv?_=X_) z+2OwxwsIa*cq}A;m^~gjNsr~*{BQpol@6~3gdcwqBqqVvBkTnvHm?MH4~>_3U8n?P z{Z;$=0@v1(Zcdw$7Ot|+pxpZe0!T<-@Q0XpP>Ewai>Jp*PG>f1W%hXOz|gXFpkqW% zFs?2MTL3@ga@l;myx8MQ_F>Y{2WQY}n3JO++xojE9A8~}14L{xb~GW)4kQ5mURIiG z3~#nL9LT_P?%cUCPvr|fyJA{6&FCXlkOUsoFLcvYnQg8|eFo94wyu@}8}dF4QQRGq zuWf-5@|7hFa#&_D>986vpSDsO9S4Zi+R#hWDj*8$yYhhPQyltSP61@0*Y@s%kx5;8 zO0`&>et(iP4K)x|;g(IV2N5>cBVXpZKwpZQ{R21>vlY$bLpqu!sOk{xkn6Cf>L}V9 zN6OAr$aDAGgJd@vG(av>3T2Ig8DEjQ_XF;5RnlheMrF36C|frb$X;CEJE+NN;n`7A zyu9%mB6|RUwaYeds^(Vk1Rj-c9_$ZQ`iF+wt%VrqqZAij47LDY97xKq06duVN}Z#*5!NuDz}F$94gnW`fJAj!C2m5EU)3%yI4#o`)uj3 z3=hmqbm2*a@gep5vCRx}em5?~hC1Z#e8SpeZD=lkS3Uu>IY_bA7K%I;sZ9g`=1;7Z zo0_d+HO(FI*dcjnAkD9^Jt_6qnY;1h&PcMGlx^woL|tbqrz< zjT|gQki05BF;8ByXn3eNC|4C6SXHh9(J|TSh}b}=u6^n4o*kPmh#x>IZ8~{f8NFA7 zJprVpF1UB+`+ID320rZr1HT>w{Q?aMhT- zqml=PKn_sjE()@@3+k+ua;1%3ylfSzy;U`bw}ZI2(iR}>f~jz;tY>!O2LMGALoyW5 zp7al189`_-Qs9gK)!PoeY8B31H?#^mNxt$V@JXNr#IjmTx%9&e8gZ3jUR9n#nb~4; zZ%L{1Mn@IEL~rHBauxrVl6~IhGZ3Byt5xTfOi9yy2yFtY@I27S%cCnXwZH0xg(YS+ zE^aaveVZ#h8(Ng6w6Ut;DPAwt?J%wvh!<;y5{E5$)c2Cce8TS7`Poiu91qPuuK(#+ zHL#a`k#%xjYDoPK+GS_}@ve5{8=%9?KHYN$J*$x?={d#JqM2t@bQd!0LXg0HofWrj z9^#4|KpBc&=x`A0)9G%^K~>PUxdzj_9K519q&#vlOq$!p;1o{@fpmai`TtWA`>OxF&f#qEZXVZ-t>&Bu~hV#={bcRL!nsc_iSI zg~zm_1$0z$CC&TZzU<>;fR@(5AcmGJSD2B_FS)AOxY(q|wj7!}&FV?dhMFI~roR8< zvHt+^rtXNy=Y~W8JCXc5ZR;u#KGD53Q|bWd5OYB8+(%(P@@ zD+6}b>RUHfu7p|)LQhr7mY-UV4Hj=}a+u=ky#{)Qz~hM~9g}~mK(ST}H(r{aQs&># zA;C0QD*iq{L?>t1(Or-g)-_~o`bvXonb}uu?w0=ff!gAtU#wKCV6Fi`9d*;R)R5UD zF9!>EnCht-?PYlzPWz3ln6`qYxRE(mD6y9vrzxrJ-U~*Xh4FrIB-(H+ zL#hu*wRHrk=A38>46D~kN`I6}yo7>#qqtHhveN`9t+r*Nez4U-lk#pXIxv@-_w=?A zpZ+=$^%jwTuGAM8_I<)Onrn|CsVwG1rws<7 z0{~|sO!GW!!9eDC`)>k$RlV0**)GOAug+HF^nm_mT4s&4Iu){9jOP44P~}zNQAcZO zV**cwpW{tzgvUNWUSx?JF$vd{;C@rzn!V?WK~Ey{%RA6p`ypTo_Q{}>^b%$B7_=m_ zml9+QcNg1@|G|Mu0H09;r!KgDk+B+Ca&VEGZHT3!@Y~icbbE3M+S3b$g-OceH*>R6 zrh~x8SGV^$oN@)wq;+){ka|VlLn&AVG(r^FJ;pjEb?EPdU4e+bm!Y$YpH=nG!2q-in zqN*J89cXF@p24kkQL>!w+K<~7vy2rWuGhXnnHZ)W^@6{L)hI^}f;oX6OTV#ls7Nlm zo5E6+={_*ljcJVbT>Xk8T6+qMF=!;EDlj6?xZbyD{M}Z*BNuOgfdeB>1ky~^lj%6n zPD+N$Dkc-z64IYBfZzgH3S`n{*tUIryP&NVWIF^O3Uci3e1^o{*+fMqUCy8r4t!OS z_|k*mG=R@4a7k+61*}(l90X*CnN#x)e@Kc!V{bPDxbor&e-qWZp%0lQyMnD?>rZuC z$!LM!^Yu-2RD?aKs`IuxT$Iq@AYgF;7Tu$^izyL&)xy$iluhB-9}ha~ZVDVZmnhHd zya}hx`=(M6q=lrdzIGd`kq-`Otu@P}l_uB6ybX;J-|mk%K;Cov;Nd}hi~+-_=lKhp zCpPKD%JyZ8d9WP_A&dRTm+|4;u`}Dfm(7>bubL|3&k%RcHSnFyL9-HF z6}1%E9Re(b73^yF7K_Q=x@D%<4+XC&hW#y-g2;(8I&etqum$)wQs0x{>23X`l!O6S zKkC>eM5Xi|EiFhbXd8Pi{WH85IhtURwNpD>Skw@tC@XWt&?Nlc)bG9R&pT*2uAUNf zRDy+>E{{AmU>pU?2DW^0GoxRf0b-MeVy?=9ROi%t5$hasrU-FD%5Rdq3my)}l^s+Y ztIIg#jcFY8nd;mj0}(**<-Qqs8_LL5W3_dXDS{QOu9;4I5CU}1)=%9dS;cyo1(Jhcd@HANQO1;Pp(ek+M{&EuK?TrFPn{COhX zZLwD!w}NoG3rSrd0}EZ0tLyMwnXrm(R9**`=i{Na9(!%p(09MDM3KqIuMrl}+1r-GZ>E(C~ReXLfoWf4bfM(NCUc;QKX{?Z0U5 zUTGDaZtJnOgkg&alUHfJf9+_#aBg37iVAhUq;RlLe)=^5aG!#C_3952MubxNe&AJG zEXX|Pt8dI@AvW{A3<9`I4Ba7-3sovf9xA3i*ZE zY1*iTi4239trH;HA~CIE2$T$xC@a7Iz}WhhT`%k>H#}MUqi4#2q04lL-I4+9qPfKV-$qO z2p%4^lHM-&^+kI(A_fuQ4I&f%67_D3GBl16nA#dQCf#c1?K9jx z*I)sC6%4-7R@FmFx}C)HSgdE&oVtTcesjX1z3txQ)Ql!@^q9~5y(oR>*7*Rzb-G}C zhT~M{V!3O{3^!7d0}L*7dlc*kyjD?5O$$rV_*hJlb~aCY-bxCAp(CQ$-Z@J)S?`l5q=rN>E@Eq%rOt~0N)h9(iz!Muf z5}e%=#2U&psUSZJ!}p=V&@gh{=V0$5W8Mv+e(wb5;O>H>5LQqKYBwl4h$F!;$mB`z zuQakW)Ye|6QuAnkM^Zh;TS`_k5&_M`V)MA}LPI#;!{)=P_fBS_hVlC*hl+3Kz5PHN zpS$Ynl!&m9%DVpN-N9_V6vEuiPNE(4mNA!AbkE^p3$fKdEKYqZq&=C9}gUiiw1 zLZHd+C$M0l4aBBJ4e^CY1q=?!edg*4lHiV1hgQyLVIMcr>130pN}Q=>>5f)v0;FP4 zVp^Rz4)!+q=zRI!_AQP=bTK4%rY|nm*6X)Hs}O5}bnN?_C*yH- zB2xSoU!0LK3Is-|CPoI;_Q^*M=MFlp+eraP{#G7;&VLD9#T@o9BrOL#mr!Ok(b^PA(I4{B6iAMBHYX2+IJHiF}4y$N)FunumfusyL z67sCS=ZQZuOzH;Y(@D4R?}!zrIlE8KW>HnS)pOY(&A^BcsYYRSENxE6@hg8eBjD)0 zvI1^jeCH_YOr>L(=Ti-gLJGW?pJ$f#7SZfsDHN~ilZ}noz1z!93vbA+CC9y)!%}i# zQ0(v{v)1galN>cAI1cFGEoj_& zFi>rDn#Bk#-Qsz|&ei?t-!N6p5cyiz0Bu^lMt*SB;UcdBTsP>|4cgxi1bx~ez1Vt| zGGEQoTpP+{-_uQl4(IgR)J&$(m=;Vw*kDfdB0a=kl=JEFgC?<}%GIhcCsqboJXrU#YV*)36QveJ<; z-ovvsHfKi;>GH_wpul*r*N1U@?Qgjk}r-}0#m;V6*NF`5~zZ6 z86GsPXi&K9FD1uUY&^djuA&k9KE&>odg-i(Nz+T6<)W`(?u@`=b+<()9m?8-kAY{C zm-i~1K%8r{0CmOPr1tikbKLr4ZW0mE8C9-SQG3A@Y4@wpsW?(2re@Z z2Ly5wA!7FKzYP!h259>(8~5-0T#t4zSNtQp&vS3eEU{rJ$EiJwPubmWNJw_&6|2*5 zn*b}J`OwUZ>9b|oR{LV))}p2t8<$qoyzq|25V)0N0XvxAhQ1H6v(Pc}J*h;hd(P)P zMdAab4q-VU$a zn+1Lrqka4mz^a28H>|+Ep{J7q*y?3t=cy$1wm6(qqHP~u`~ZSf(NQls;FC_~>pWDl zv@tu9hxeu2&c0hqks5p1YM`9VQ5Rd8^&xro!)l%2_K*IaZ_V7*-LF17?nh`5!X+K0WZAdd5P{y% z332s>;m4%304}R{Xk6(l#%C5PdomM6+=UNrdKv?I<)PjkLZGE|3P?hRBK$R0zTie-PwcThTW3+Am4Y$zK({#j}V~lC1*C zcB~>p)cXWNuB+zevgV*yDim+)NpyQi1JDA2->z%qvT0%}^_2-zN7UsC@d{m{tt@7- z#NfJ-Ff#uc=O2G~9%><9-zHJ)JA9vrx`8NQU^>Q#Pc3jCt>q7VbNonz#1$0wk(qdm zxj2W!PDUQ}4AtUe^nzDKb@E7BGo*%CR?Lh5CZ*Ne%W#b0}|C&k1KwlYPp<(sgN@!RjSo>BTn?Q&BvSjPy)Ikvo`?27)}JL~nImAK(f0S4DJxqbTT-zRhG3UV%;( zOh7HwqZRcrYHu@(fm$xbkAMQHV)VfC63V95s6HunHbLofgE(_ib8?VF(; zytvO1shvnMtCb>&P%d>mYsblO-{Oo1n$c1W=ybEXn$ z8NhMO@Vo0eD+;kZ2|(=oiRgcTS8VFv*wop~j z_~H78U6o>FjBe$_uL(VF*&LBV9a<4k_#?eeUVxW(KH5v%E)~T&a9-(7U%x|i>b(|~ z+&8VkUVqZ%P_#p1dL_CM$Ulz5=1CUwt~hr#>}hUTT;oQ%G33_G|FLHqHaZM+Q&^-5 z8460e&@Tln{a+c~0vPLyZanmx=_{%REqpbZ%yL&*$Od=wWgeA*FK5Jvl7}GD4dIYe zccDGGJ|)wnDy*^3$z`e&U*{G4F&_k zr7)tNUTf1!QDbHxS@3+%QjK^}y$!+aD)zca(q$D1q{ZOK(BOd?ny}&@-G18}!2|>< zc&`t;Eq%PaeE=CNQ7D8UbYAW4?Fa3%k(Rg$wWQ700furFcDBtx2@8Ck5)i?FqA^fq zx}Z944r%`m^1}BT{?L2m1QI{#QO7bQ$A`2J_IJT}=$Onj$gn3OVGL!Qw=WJjrdLD@ zC)gMbFkiK!vp&;lNNWp%u-#A&f+A7YW^CY78-L{jA+!qp!aWxGV;IxZ1?8@#9 zV6+&TYU2UB5E54U7fJH~Fj~IZif6^?GXCz;q6q%njCM&0&s4ju$dgrkfA{qt*<$Xg zoJIq=V~iC>!WBVPhgma5E%#-`k(@JPt&*QW*@fH_{L?Zp&~H6jDqE)cV(xal?bIs# zqZF+FH#>Vpk_aENQ3j~4MPn!VmgZ!@I2USm|f={gElCDh>UI+o&AyzHHV4qh1Mob$|FA1LNXLrOYXEb0qQ$Nl{vB5o)wEtc(= zvbh;B|D#(cYy+6q?lc+o(#DP>9j;IRjF<#{Z8~__lV+tx>~T# z>s^3BLXZ2+Ks|7?bBaB-SKXOp%$%Dyv{mwi2DB!RZ0ElKO6&g9WN5w+)dH+CCm6y1 z>@pfxq>>|C*0=+dW-?q+Tr4e$t@KB&=Jl40CN+t@Nhl~2dMp!)EO)3jFo{U~M9WkL zS9a5kC@IeKzyZnSxi7^}N(Hp`?nCh!kl*sws+XAc`Jp>9RicE=$`_AUJdf8eL3xi{ z;BXZeWW|P5WA)}cafRP{;h7uO{c-=Bl|@ja8G;&3h?_v{l_iwCLMMu!K~f)r5|8tO;=Y}BK_K@p`x#aB?#))u~HGt zC|$a@X8qkCsgL7}5{q!2pmU=;@HGYKaPRHydXEQ3K!aQA3PL+kYy{_LQQ#N~bm~p~ zL4gE`xf_FHEvds?#I@pOCbR{qROHRlbc&XM?iOW&A|7RJNifYJFH0{mq4Bu zQVOn6)(@>RrX%x;!FG+XY7Rr~A6Bvle2PYtBA^@nAOOW8pyP8B5J~=4G2Sxw?y(P} zP$FJl#61HrGZeDrwFZz9@-xUCh(vCRBOjg&A!7rCmD0u$yx&C(5D~FaZ260%P?4PtDi~2lZPgqC#x2Oj2XQe`s@_o8 z(1X3#%!6IkMcu#UvGbciv@+gP$tG=hbceh12@vk9_7sN(*>76HJNwJ@>$D zaR?WJh&={D^kaIOe^JruUkR6->L~FeJJ_8*;y(gSE8h<~zxXNn`J%0>tGlQ2MOw#q zfw_Z!VilkMx_n``b2SC~wJ{n(3U+O<<>FdWTzP2H2ikaEf(=(*vFx6!Z|iPxHl5g- z-42)^t-+5?=ri{9m(Tc;2C4mr&}Kl-hyzyI+#s+@8&BM5f6 zoI3qq0^UJ$)MK~awAL!bd!|%^Xp*7x4+8*Dgl}N0lsvk;fXH|s?9`M+Ph;EXck2C{ z)d*RV&l^8T#SkRc3PYgAJR^4R>Lf5*oMoA$pJVJ^qSi0Y#kCWyBcP^(F6+d?`x)GY zeXDdQch4X2l;PQ$u(<0t4U-8r8{WKreGas}XTnbJFcE`AO&ugKk`I5IItW`*oOx|V z6=$R#4dwon?r{Fe#ns9Y_Wk_Fsh9Mqf4F4PFa^q#J-kpgcjK7ucb&VT&>+58-d17|G$KO8Fie>0>5Y%3a>l9EjG zptD(aidL_P^0vvF_NBb^u-o>_`5lS@0q5J=C)z__?jZ83)NE+vt^Qhfmh!aqm$Zaq zt5*ZEtCYgT=S%uCTCr)@hw}(ZpX>GTwFoCY1L1f*5@Nu z(zkN41_q1*SJHnBwCllP_kFA5PsiZb*z1ElbJ#sxt6v>>Sn>xB96O#kOgP4M>amTg zXz#+4a1XsrkG$^Brm3I%hjJRd=_9^3FKD>&)H%@R(rZ(4uacWz&?dQ>HOQnj(BVbm zR;|$+UKub9-ePl4_yDQ-^^b3R4VfPJM*khX#uvX!H4McY*RJj3s|tqH@0hhVojLf7 ztFbd*9=oi*OM{nI5b%^GcWmI=+WWHliF*I4TWQM1Q({x*B9y7MVHy=El%SN0t12_ zy>rzCtWtshx}eK&LHPa;es(1aeK3%e&K}1)l?cw@TgEJ5bbfLlCc)mKKhQb*^Hs zRuzHvIM=MuB!cUas5Ojr>HMjBgV_(99}CoK$XpJJsS4L{()gdl9tFdGLZ(1Adbx2` z@YVN<;~B&%E2{=yJlR-GOG|R*m)&uCAjIrb=&bfGd|(2!GZEgR)b?V4i0U!2?nbU4 zX>VoGef#8cx#{1VJQYNZ*$-sp<;Dp2xITPzO;AJs_OP58{a=Uoce5*{$G?23-7XoJ zYcm689AiN_<~@qHr#W^l!y(n*QB6hi68ZXGZ)tk3haT_1KaA0J1&SY=#i}7%k!ou- zURR3bdtMFJ;yRTfq9(d#NTLtGhpI+@{=uC!zdsREIqay@;Jjypwu{4*H%8umJxuX6 zt4(Vq=;!GIhlbsMsX_0bLJP(c7!_DXzZklP%9!Z@-@DYKyB>Y})3c|28K!gf$CqLO zr_Y~XN#DVBKR^@18Ps#uP20YyOE0Z^u9wC9%_DX+dh>N=*{Y* z4JCvzMhW+tk9QcY@4l9i`t}uV@>IrlNr}L04X;1p!<=97&GLZM97Sy<(P0;g%L~fm z9}fjr(E)f_G)(mat$F@i+LAnB$p1D`nv0uhJ+CkV(}wHH>^`0c#rIV>CmwJ`{@VYN zwv}svm~A9KMnH1i;eTIi;2dN7cQ2ZMO25P}_(k!o1kRyBP((`%j z4=fMHVO$3Glc3)kBqVUN14dVkjrYC#ny`|t9XyqR0o8oD54Y;Qy61}0zhvZ-OAaLG z+u8}66n{P@nOq!OPS0NCZfS4d&5OHiulet&Mk@Z}@tCiFy=bGfXI11mSBuKm`bFc3 zl1VYfI9%g>=8M}sYXl4}T{b_bqBbKfh+9|zZ|LJuLsY1EMVbki#yE|>cFQ#wH%sY#1nGZT96<;?bma3r{J(Uo)U{h02m~cpxyud238rkT1 zFR9dC9Anb^Mn(nf02PV^Cz>B&rbOLJ_@3$5%6$5`E>l)szS2rEChRVAeKp_*Xvf8M ze+W(i9zV4hnkH>iWfkXf&Y(2qRkEe2ov~i-$MRcNYN`gThbORk#X9p{x$Zi~crD#Y z8=tK1kNsJ4Fqnab*;ZAN1~RJ&{j8S6?$u{+T~| zNl<%)1)Ky;>~bM87db1+aywv@ar1Ff_`=@Cg=+cMuCCX2$F6HD6@qvoG<11RkJdOb zp`b<0LpxsqEj?r5;|{oualfno*yiA#?PGEtm)%*V&vtG(V{U%EH+zmMZPVYeNkCF% zpE7Ih(Pj^l(R_1}%l+w9HHnPEgap%`oRmkjmwB`NF z4FzEACaz=bx|U1cs+ntNY%FEv2A(_Dt{^+SJuLW0-EUeMSjOG34P*3elcuYLsBNVMfK#z`JZRJ87%d{2~_ROW|&Ph#f zdxCcOh1N9~0uN3HTx;_g@e7QFTqNnG7k+QxOW0A=l4)v${g28NZtnpAc3-bO$UG@5 zAvH<`Tzbl<-|iGGIIwuPIN2V3YxvkO;}nxH!X5Plqp%Aocpgp>q@%?uzs{H4^s!ZB zUCYmCN1uJMzP&;0t4VshyJJkwHE%R8@?1^U8ZEb`n=PW}N1{i#A9)cpJAV80Rh5mE zIW9ZQili59Pt(3vo+68+4Z()@tAn?eGujt3T@L?}F0VP{>{FE;aY}ML6%XKK!b7rN z)!CbreKk$}lGA&m$?iI=ZF;-|6aoD%IE%%m2{Ky0MT(RjpB;darCP z?jk0CUZV`zwogM6Xw?c>Y(+lg$wE|amE-2{i%AU3oAHab&Xc)q2ElU_Vn{Rrc2uCJC@0M_VJ3$aCOVA{89LR*aK?>`gYny_+?t{( zp%#@j zN>Kyaz1{b>JD+MKe!l>5vv^n~XUNv5khc^&ZE!B&%H!;`>Bi8!-e*CWGKe89F4(7b z+`nK~hsCp-{#c&v(;sS0;+B1ulRIY-$2r-GLL%Bs-kO8#nOa)kM5=hK-KD&Bz1gD` zBN(0Dp*sdle{*vumd~-lZ%M%=g&c;yPhGjPHPhq6q+cGuEWC7^4yj z{BBg4I8;F;Og~6Pe1J=Ig7o1=)5b_2tg^)wnkoB*5CW@gYs)K7d{(nme9Lw*P2J{Q zI?Qp_{6PY$FM+CVGkRn59b@f;Y1}raKwIZQ9f-G`H?`Z@J=c1}0q}*UX#Pp$KMeXf zKztc1uE9RsQ0bhG&!(?qN3Wh=9P=DmMBpbqjckxtAG!geAz12>^&dIj1Pr+a|z+IZg zSwKoY8pTmqdvYk2j2{rZN(f)6{ONsO^{|IlnS&rv$);6?Z=7{fY5o zVcVRi3L6c$p%NIOeP*DbXo1Z477|LUF4#ZWjZtm{QFyACs36^s?c@@j_uQhQ<=gX4 z(R{m)kYv<8deV45Sb9Yxl&kz~dgj}}rX_myWHf$jgZd_ej{;g(>-K~IsnhY$-Ohs( zb;A^Vg0zQvgkX%IPU65u*|S8Y@5q2%MFxC?7Z$U8xwa){!!_o=*bBK3Zp2LkO<&H8kR#lxF z>bIH7IZHb*c#!+aImoEaUIqtW1LYa>&;Y0aOZzPiT(NC}iiY-xIWqdD1%%Wpfs)a= z=3$f2?0SIZ8@=lelP~AO>9^4Zz*X9$G#e26P1dA>>_Akb=1Yq~l`x+kv(qssb4!eHAa?N> z6g3?g6Nt7awm!tJ9$~>&8MdF=W!2gcGh+zR`@pnkE|vO>TL@49fZ@D+F}3ty5H@-5@H$BnC5-?)3XGu%#7by;MXqtKup zt!TyKOxzBO*}MJpYyW5YrwOLm0pd9boVLM$P^5>yH|7GSlyjVVI2 z0E>gxf=GAC(Yth$jo_9KUX9(zIR=F!N(O+VXwE;Tt$kk&-Jf5NY}{D!pU|=5@?Oye z20KF4tr+g|MK7;IH%U_q%_zfD(RpK9Y z1bEW><;fjV@al{1%smWodrQm8{9fmzUmXG5#*lfFb$$w$7RWrES)!u5GtD~TiER-i z>^$1=E^1~|IxGMJ_g8X%K!mS_yQgyCq`v3`7s(x;h^-!i#$NIkd=tEBtuc8!(F>kN z9=f!0zv8m09Dub}tq?O28f(>^ZYz@aS58p4vt*%#`Qe;meH+2h!|Hj*cm>LDhD(If zkdeWyjJYr2oTK8WJdhi9-zl7#6g@Htu(G#Q&$HR=;i-05*3QMG_OD$fD%HW9Og&-q z#wLSm?OJckU&15L{{``{szhl-NgH>bq~cQ=*gBSopr_xIn^Y& zMtc*Y-?))XQq&c6hwxg#6R(OoMAxsI@9-P_kl`E|Ue9@WGHyoF9oJQ0YhAtB=&#eK zA>>{^|ChlWc#5)KMT{A9RXjmZ7ZS`IPp7C6`7fACsE8{mVF$LR0-*l!Q1F7%AT8X>Bfj45x`vb%9M* z_}2XnB(`9V_>H!eb+(0gN052>GcR4!sexg>|1{itEr6ij1Z4){A#B(6ZcG_SDu{|h z5qOAq!l!jD0sC1EMc3$a@z32SUq8d>_31!WnIInQEXmksz(;7oMap5#S2^?}h9ezf z;wvPd)Hi1jd3^GJS}40b_yz1)a|z-T{|O&IK`MCaDfS(;d#C}R-c!&RTznIXoHPMk zKRaSN+M5!uw0-8&pM66!)2%II6WRvyjk2!!>4S=X#qtcxvZ-gmVQ{8U;`7cf(arcn<8x>> zd@}@E4;$~EB@t!R!+D>u^JQZ9jrTd-;cZcD{urygD)VHZ=I@4B!IwwcnPHUtWd%U( zoFey1AtVMG4BtMy>EX7Lbi24}yt}YHJlCUrD8$dMW(2_5imvYUzbut`H|lxR#-`07 z2xDarn5G5lx6n*mNK776q4zfJ2jFZz1r>^+Ptbos8F|RFYaujNV|8LZvv;#00KTL; z!A zJ5S*|feuk9yH3%p*`gx1aibII43-nfGCSSMGtqWi5dRTkcLn+Z zeqa=khhN=6-iECiY)wJ%Hhs=nhZ_Z|P05gZ(4 z(h<9$Et@wFpZxSzEojIyN#tqZWRHj=`T!3jzL*VV<~0nwn+*hDxniY@@aD(j!A3;b@4Rq?}*4;?8*`7 zZ{y{-YQ1{Z|Ed|yw18Y`^h2HLPqWw7j9K^ii;c_hgnP;j$c`nHAyl*n^CfrIoW?Z_ z&JGfgnTSIP0R8FS>*wcp{N&+-_k%9{+}_i#Md{_9u*|ttQ*j1i=W_?c0BZV4yAO_j z$!DG&myKA-#14H4#UI$TqN{d|0>LTtOroCqS<@#gXWz2l;>UF--u~;VC5p;8V-}o{ z;H~CN<`g#V#S49)@%}_qUUpVq-sUG#E_;IUgCM1^`Eeoo*B{(p3D5NKWB(XNEwl1I zwwwGvvXaa$jsMl7W^Xq9=YJKq|9`RT|0~n}yYprK&qDN)N&o@>`4f8`JzQXr75(nO2_1RdtF#aJJ+x+6|fpGmg5a>Rxsi6@Icp*GjI5wO=*&SFfOH?!?{J&6b}6sl>VKU8)YpZ?JC7;Lp6PBL^zI9 zymB)N)yZHU3r#JRgt6q6wx25#EhMv^4_?2;j)_zW-3d%=bTc4i8^8Bc6z{9CNR1oJ z_+#V4R#uU_w|n$9^? zRti5hDiPYP%G1jtdzR<{bk(EslTN{%Sk|Wnl`-&PF;E_IY>k$FKHqtxN^>wixN{Pi zn}1if`vd?s^t~e%`ziBG`Pg{UH6^k3G~+?+hddaFU%c7F1;2fm+GT07=e~-5tOSr{vgo@vR1N66nc%EJ3+cPm zI0O7t*S(bFZ$!9bfom9E>;<09CJbUvBdmja%G=c1Iqbe7%Gc~mKAC%4y*T1;N#CIh z*0?}J*K682cMvGXOd#b<-n2Kj7r!p!4nJTt`(FoeS|CY+?FlQ%e-#1w|E4B^KOEss zT-cn$3o`PX2I%$gw;daX+tjzKJNj=#rN#HOM>OAP7z6Sp&!b{2@%8ykP}@ExQ)vB7 z(Vab4*gFsLDgWMtOHpO(Nba_pvth&T(LIXKs#!{R`EThtbWb(Kn0WJYEg>n>g;{<#hwJ9MV!?HbsS`PJzjL zX8?odipx?2<__;fG&TY2sDS8@&xBBK1f87}rZ=Qni;u?*O6sb@0?_i6vz^917jvFu zC|CC(Iy==1*bWTO62cq;Z-+s>95tmK6FV?P`^=3?I!4Q(Sz>G$8j`#{uR__fkyGr( z(=2IQwtSEl=98KQl_6rpnIi!0K3?@~R>N(U_T+jTix&df(!GT6J!`CpRzJ7G!n>>Q z$dlDT>HcABs;i^b4rM12ESV#5DY!H{?nGy;^9`tPhHM~K)!+M1G(NhcZf^9H0O*FZ zqZsT*C~}^Ub<$!Wc$Fg=Zj;?<#TXy5Hd5Gc}vUT@;-h{GygRN-S_v6CqXQ!{H=8Vi%4hk*icd53fKG# z;hSu9dva?m?-$A*AZ377>ozGNNUOLz?=j*_L1rKHDf0JA|yloT}sHKhn8nADkNTU z3z@X(Ye??&pPPl)HV`ikPRvDQ&+<8yh_!=qg0pjV+vBjA(ZT2vl~!$c>`VSD+bBXi4)~nV`jkF|whwPGom~g?X zcBtv)(2txPVRG*|h^a?dy6sancfG4M;XJ8(;;qqP2O9?IRx`K+u3FZ!bL;BPhsCau z5dY*u*f~%d!AK{j)p#QHEqq3))m96b!++21r-T?YX&zK&Ac;8@8M7Nzyv3ORozAQGI!-=?O=q=_0 zzgKY0P+Wg5E^FBK9f^2-X_!muwrsuboHqY59mH^(&JQ;l`yh~C;fnY-8(oj?g-mIL zn9Ejk=BfW3CXlP=HvzZLls5pbWfMmPsx^<{}DE z?n$}m`{= zc3=xZ@WFg4($x24>QFo_aQXxgCnM|8Xu} zDlK_6c10Zmf^88nml3jI!FE9Iq`I-9qGlpyScq`~-F7`Vwh&N={?1LSHamQn^qtqY zvB?MW_Z`TNkXUc`Z$~Hm2bBnBwU3|(m&4ZI)N2CA1c^52$Dn?Ks9j}ceLtw7Z1lN; zvh{t6h@lYuMlk4Puk|vU!~&qU_BOviroh=Ntn(!+Z2Y7U@aH5?$WB@@VFI#Woom&< z0dWJ4m}H9aNVV&U7fMsD`Te{R5BTb*!_K`S4#Ad8gvgpY{W3m)eYN0E-!K#lQTRmR zIIZ^4y zHS0|ftV=14ZFS4hu{5u3m6KbX^CX@zZ!}gO+p~mtt(lw&yU`1bJt$eFCW+QwmzJrl zUHH03zv=&09w0XuPDWH-czFe>}?3}2;dNxRohr=#82Rk6T-8mDiHk|E_&mrA;w7fY{L^XAr6p-z@ z|9OQ#H>~I1e`7*9k3%N!)Lb10ZmT%-TRgHFD8EjACf)*dhNU-In?J4%q-8N|PV-P; zMpcX1rUh(UR1k2C;c`qz60Sxk(+vFnYvJa1ln{#lQtMXm)Bn9B@Y@lKLSS7sCMhI^kklVQnKRhQ2pU;eAOz=twGg_x*B z6#&t8tZH2D!f-n55~Qh|oqDrT3(BxZpdc;*fg5sc9-K~N!6%NoG~aB{S5!EtT>aa{ zhmarQgL$_A{?tqfX(`iwXBnVxumw0%#=!l1>;!eA$tE9L`jM@KJ|oLmT#_S(F?}_$ z1HweAup5H#BM>mv(wyFU1koDTXJZG489xnVso}u)0e{@$>hqO*RqPfw{de3$7oaGS zp#;R7+mjJDqTnew0kpyuite)xKv=|g(d%I!3#DDn%=P0 zh1iLiRo35PG!+XC6HUgh=AAj`-P(EWgwulG$ak}S0Fep2ohM$--iV_%-)tP4lh+j1bL_5;k3^P8mfTmD@@YiN{g0uA7SgR?r| zk4^3O(9Uh}LlFv~n_3iJJ>eE?mDTW`YDp70-kPLb${CgP=a+mSCQN?F)(adQ9|Pvy zpbW{_@U>R0s-RT)#MqDL)NCe*)M|_P8xnPSSA8V9K?z65>z@8zgjp1MThsT0Cj>K! zr8nqlEYUaPvAkd0>t9wAptgTG+|9R>kR7qeg8jJb{pEX*Kje#Ok8Fd1X@xn{ZO^Cv zWeH}cKg5R2xD!7bs3@;Mo|vwwK$M)kAb2FcX)kpH<5-B~mJ1ycG(2<~d>W%5>Kp%c zRopc1s9Q2qu%}b&DW1~bVsB(J^C32|hyNnUxkY-GIsa``Y}nr(P?g)c;7#Dxd{pqE zu0i*aIsZM?dk~q~W_8+y8P%|6{cmq9u8p&xPGu+hcG|_W<>5iWU9Yy8ZT~U}2v!_8 z)B=$h%EleUEd0|qU`NaXuhu!y^_J|JiPNcBq|23i{3|!BsZ|w1RoJOUtb1R0xnfs7s{|*bZM2SuuLcK->1T%CHk;+t5g8X!- z+FN_Utm~#wtui)|;9&ioHhtmjD|qMT28X;$EBNjEVh&C+j&V{M_w&FKu3X_lRfn(m|D>V142H!ohRZ zdj}1}De%2O7I_n~w7TghyTK?>^j0!ZC5NFL}+r(hi}o0z!EH_M_o1F8`V( zurQ*O3gr#Tvfr~#W;4H4p2%ELW$>3Gk`$3HsZ4T%MV$?sU&5X;!0d6UeGP0$J!hx~ z^8}EG`61qaCIVHI>P3$sAERSn;RrKkGwnTZ;^OA%6NDh6i86YA+Z5Cg@0Es}S|KhJOs5;_F+^Rv#Vh&bB zBL~Vnq!ociH$T)5B`t4S*uJxU0BtT&1B_dY(8jKgp*Hj-?=At$h2pX&HBZ8VvYY}4 zDVpJk1_awq&0GC?1*Q4nae)vWGIP>zfCEEl`BezFkSx)!qH=PBZ$!Pq^9vI@1eD@7 z-*I`{y;WR9v%rfvuPq+LqV4}Yc=u}bsmP;_Fi_Ro+W?+6eYgqVF9eWwbk|qpDp7l3 zgzN>7)IR+d0h{Vb;`l|1dvL=i*^gy$#!muDMc{nu%(FPD_94UFWQp1Ir{|Ckh{u}G z0d%Uc)a)~@?uDfP#;)%;Lzq9dn?m1% zR;@s1bopqAE}BoL6C2!;Sr2jA(p^bRG5GKM5>*T!g(2-9yvXp7+s>kKtb2W-9(Xkp zJCY|3Ggt7;xV0-#@ID8K++|CMG)z4Qqro|lmynqmZu|X5 zwG}Eer)oxkYz=B%z)dM>?@OSb9!H&M@bd9V3}z1%)1X=T<=)XAfGe z3~mbJ?ds1B_GqNG@4Y^q9)AxAsOBzyhC}fN)VksAlxQ4&CXKh-6+R#~8E(`k=F(bj z*z#EYa7BhF@G+J$o&;SZ&W94^-3iX=2tZ{z+}Eyw#Dm<0V0zg*^fQ-uhe^P+LkjPx zLQv%MWAE-r)7!85;NUBROUTNI-w^c)f2t!<68VscMJ# za^4Emk&-;IWP{iAgw*T-+};Kxa~BCi&)5U_07Wi7KHb-q_deB{eD@d%z>Y2Gd2kx7URr^%rGm~pKD+CBWJu>Cmrc-!uXx1Rl zdv8thnxG)s!GDV55^L|6;yNsrGv7mXat_pLt*-c3D~vEpjcEZ91FcLbb9G9X z1yhj}1{G23pRH^AM*}I1DcqHJ!k4&~rzi^{>)l=Z2lB~{_f^L};~8x%lGX?0jgn!4 z%HdW(M4a`T;u_lO{2Oqlpz{9k=)i4z93QYASfE2oC55I=o2n80Na3_c2yc|<4P~Eb z?)^s@0Tg5$@wQ?C=kEnaAaN>aWsC6}{DKU5CL$`#EA@oa8W0Z6srg(<5M z#$RxshB)jB8ha7I+(Si3tMNupI*(vcg=|W>%%^02PODvl$h5%QNXRPO#!svR>|jLc zzyWyh``lpNecu%X{V`X|<$X7S(NCY#0`Yj;fU9hU#IIc|bx_T+(BVyg+Ok?oP@@&s z3=5u&bY>+=QFtY=SL3nU2jlrYlVh>rb}~=DB8r_q_qb^(6uAavsDOm_A6`!ztXKa{ zSTA=%Djtfe;)wUt%C&tJDU|!_VxU)0ghNt3Dy9(%D~GM^mZ);ltT7Wu1C8Vdv6->T zMD`M}_ID{y4=1dJQX>i!Z&a=$!&FFC2kdHK@L$raJUu)r*J!{pAs56W0^y)15zziH zf7&^{n)1~DJVjVAX!9~i(gega>hs9nj?(GbQbEd{opC;*h?=m*2uDaEPkr%FCiP)j zvFy>8-YJ7X7L{{dq|qB$&2t zeHL=(l^`5=#s~wYZCvuZ6JSq3v_yJt5Y}smUVv=Oya$-MLHf}T4s01(LJDFw}n@8N8b0p%3pJ5Pgqw6u@K8y=mA^A0i2%i(HX zS_0KRC@oc+Guz)3+MQ`V34j4P{85{YKKSYcAtDts{yp>74h!dCr-y%daHD|?>U^J@ z+uwL@OIW>CRs8nomoeU)#^!2DKu~Xka!a^||6!H@<=P@Q1FD6fgoO=(9R9cBOcDdy+JJ%x$@ua!ur6yJ z2l{bE&kUEN;D4PC=zp!b;@!_nT5i3|M)$buoGcyo_c10iM9-265OWQ-^lck+9qPB4 z{S2tP&5aN6}J?KIpt~54b;U)Od*0Ig6UgjG?@NYr=R()sID1QD?N4gDj9Wi z#A4(>r=W%$D8F_Og@ZkR)hZZXFQnVUT~Um)`lsvtzhYi5i%HjC_JeM<4NDowK{)x{ z_?I`@$LPpImpasF=fbn$t<6SqipFD6v}xf0N+S1PI&C;We#M8bh=Il;w1;xUkz3;! z(KAp1e*}|Ivu2+vLkJJ0%&%lhleInfe|fC4zVAa7D4U6o=(L#>0`J>j3BWD)?t4o~ z-)0|ZTME9#=XDYA9lO_ps9{WY4TvL>fxPfeqV;p9$!JfbjcBfD+GGsaFG@dri1AG; z09H9}&>tE$Sx+v&iN{#uJeKZFOWKNd7y607#DELi4_<(NSl%(ooMCZG68G$%r06C! z*}|f|){Lh{K^z`VcFUGZ_k5t*U-{^lN+xx2y38bRq);v;Cj5d} zSP~;Wh(+}sT#8|an45zYCa$*X77(1sFb0w2q9;*0UFsVS&)PVk<@dn=MU_6e*c6x2 zZK%n_neaCaemCMfs6GpBcfC&@j4Lu2+Yp-h`CbiIFah8vOmU2xt8mdJ9k1h7oR5Rb zy%V%C-8S5umud*=M9_9LpD^O<$|R@ZI+Dwf`E%}pv z^;-7rd9@}dT-f8u;c>3Hw9!ET&F~}!1hu!jO!`~-e9A_on=PFNgouCTa4|QA-l#ik z=Tl_Q+TE*eHE*b-j%M?^XYJKXUUyA}`1-oLLvVO7?t=`g+4b*95lrHMOS?Fxw24N7 zR;|b2{yPEaD3q@1vfY`ufyLE~#|53QtCUh-7n(N9D(FZJx zfhz(ZP%m^tV=Y|FQ~21|KRrx+F4U`Wh*>i|;==Yl%a01n!hsRgpiD*d&Ytxo2h|;* z%_3I1+!Mn0&4GH;2kNx|#ULwOR6u=5aLD3$vmYzdFNb{U$Rd_H1k(Et7x|JM^s5%N zk6nq_ur@FJUtf{dGUa^Dc{32z=-OtR2iuZ_Ccv_!ybX8ukfovz0@3+}6{)+(DBwEE zVGz43u?PXn$V^0`b}5vJAcQ5p0eTV{=;i#1)u2p~i8YH|xY!o{Kxcyw@{_Vp=z9P` z3%fSKHW+GmtYI}6zB@r+zVk>!6*2tF=25LrnkB7Rlmp2F!I_)F+17}JSv~lvEttVr zNBugcqGL!1T%i>QWZz51!N)E69j=ZJ9>j3FXU)i3x}=#nv9m1$=)Q0}J)ibAmw0Wc zj3Ur#ob%9}##pzTC{*m$5=r72HoU3_sP*F#)ML`VA5Y z?Zvk}&C6X5E2fZeDi}?kWM9n)>YXxl2Dw}3J1P^Me?HMW))fbBpqKyKFsT4#Ww;$U zA1=-54e|Y+WEOhNO0yX0^^oTgmcqC~U(D#p`ncZ1dhk}}*6+~jJ(}6YuM~Y-Y_=Tv z7KlSIXWI(JdZHIYKH+7+qWi8?vZh}}C+e4n>s29ruYK+7hJAU>)dz}_ zHvcXiRK8KSG#mH+% zeu*S!mBIV5sD42Kd`B4>i>q)>WLcokg_2(xz3uGuzD0s^viw>JGBN?dAj)~iS$)2EH2Mj9nXt9r*`W1j0ZUc%_s|`~MU&9=6aEhOmKzQ7 zVHD)|O_j)yJ%^1`kwEFnbBthG#;19@t3Z zVH^iUR#o~{wTE!P!BH_RzBzW3H#4F#vv)Q!pBUo+sRVH>#dzRRxEZXTuhoJ}D=aV= z%*s7QC#~V}d1x7oPR@Hq_n?TG5c;}jXAH>5&|mz2%NR?4(M*tBKwb^gF+0K<&`c6x zoCsX-{T~xR^hL48YPOUqCaO{rcP!{3&sD?&UTYDLyL^<*Ot}Hd@huRWrgD}~Fs!8m zL@hX);db+I*}%yHHM$=SEb#+<|0DFyApMA%IbTMR2%{Q|{fs~5)u4eTiTa86Izq)#!|h~YWCu9ILdbbqx@N*~ubC;$ zqPmodRFa(w16H>6t(AneE0LsqwIE@r$K5p(=R-=oG~+q>c`rQQ zB{g8ic8D-|)OduYmMXd?kG^u95tykK)BxCEEK^RiHrcA1BRQU^H+p=I%cZ%x;r-;Z z1J#5zF`1iSP&R6C?n1*vNm4YxApmfxOp=4 zo6&tvzuU21Z%|c4w%-6+zM~rp05EGT<LZe+KrXB;gB{!{@}Fsh=5tAlDJ{I|Q`(2F?9udQP%b8T#u%)A4~Q z2wLijbt&{`h*dLJ9In6)wGz>Yor&FP1^RF?>pQL4JQ*1!cb1DjU*c*by$l_n%Vh8) z!@~O?(|MXPzoMxwNBbE$-)5#pR*Pq1KVgGp`mcT=Ti+NnQ~9wbyJ1ZxdZlP%ibls~ z=NWw!;ia{jL!V$?(xti0IAi^(D{*}=^=RU16E9%)EnCoAHo8EQ+mFA58?fh3xB<9< z9&quFCSzylv+p$WZ8HxR6k5A_{+*f>-h7Z(GdqcC#=Xws$4sD=B}JYSJe(UfbM7v) z$^L%=R6iK3dAj4*DdkAcpn|IE*)Ol|p|CH43ooAaeW5Wk>VLxor}Z^!9|H{fD%&ne zt}CLU1G1~bgS_9Y^ULfvxE(4yh#?rM&FfBpLwga@6dLn1?*(+VqA5^|9(C=$)D@2+ zkNRoYqKY(#JYz@xcXAg>k?7C=*%JQ0v&nwkCHU|E8*_-X5u5eB>N{~sgUE*E&fIQ0CT(8+C}XJrx|*$Re)npLx2gCD=BzYH}9#}`Q_n*W`ghW>%R z8Om;FM)8!(nA3kO%rddIKboU$s<-m})R~a{V4%@FWSw%GMCKY`5NF33Ssev%QY=)V zwd;n17q0@Q>Wn#U*Y7}FeFy{>16Qzyz;Ex~jk+wM`?X0Ixr!094X*?EfF9dC@u4Y2 zYREouNx2o+K4ey~iTG&(7$~>}b3nijVwj|Xks^m3sD}be3gS3k`CNntFN09)C-@{Q zsG45qg6wgH)2Wq>TMGjgSt%#Nh1gt5~HX$$QgNo8nUhcljgD{8V{7+}pTRYNv}Tqe@LU;>5|I_dfpuTLkQA9Kbe=_yp7_3LvB{@M4P&SagNF%w-B-Y|WQei)+7*jD(!ztQB9MVRNlp?`|!aj)Zs#u}b zoe&&LBGT-C+BNzldYMGFdp7$PmyrJvdQymq{$rPBF1#$n6Ovp8E)v|ow65=J=qM|p z(2z~Kaw-v_+w`s=enR8DphL`s{un6qSKL954a$j^-Ouuo{*I=uIpZZROlV?}+9yih z3qQUVQndHu`zczl`jzm`P11R^xOhS7D$SWrk_~&r#~}BJsjy+SfQtQS-}6{~^RSej zpOmfS4NVXxs4tP^#oUwC5(6LCIfQu9u8 zXoCjnnmn{F4KgEQ2W*SL7-*n|t?#9QF)G)(05Lr;s~!@aZ^lHmydE8yft5vsmA6yi z^*wb`?ZylvwGtNwMNbPaj%jco_nCRi%>;`$vfpz&r+%IK*AX&HemR3ngefnJtS7TH zl-Za#+lZZDf?Dj+XbSuy73fbm+til1Vo+HQP0InGjU;`T7{H%OZ9qM-(4}rmuvPV^ zrTBo|BVw@_RI9U}!QBpUD~x58trt_U1$e!Sap2yH1;;kUgZg$~Dq;5K;x^Y@}b{%GqlH5a=xRbbT{1=ZB!PqQ1oBOS{2$tC z2A+|DaUTNkptFtz;bn(DG@%OF<(d;pX6^K0{hz}G-x>ULUhN(69VFWze?G8L0~Zx@ zC+J$As$g!EwlQ*j^Jha}hZJi~;t!Wt(9-rHxBi#c(@0Q?IDrwTPygOykj!fQF|? zd=6Ax>%*4RHd9H1viR>=i5=4EYEF2VsGc2>4{_9{+st5LXdMiKY#F=P3{wrbM}AR` zD_9rNC=j+)SVYYH^^-)J*LvUo$=&R zT4B!KzbgEA7~AFbK|OEf+i&K!i}yrw{mwfcw=yuH`gXZYSbi!Y8G3XH(q`h-{IAD_ z%hm-pP~Fi-kxm=s+3jxg+G{%Gj|L~6(zT&FaKqoSAJ2(}Q6lYKO$Hj_5uS?1YNqq?V5S~r=TM(jx$aYxMo^(| zB;svL-M9JVWDj;(J+g%^@qrZQO;bViI2x6Q%y}iOWDq@U_nOKa{RFCUUNGyq1b5HY z1r3)$G`~mx`fMrcFM#YY{wilLv}GZCCP|QB=vW*3VLP|k4-SxcU}u+nexv!nKSz3I2>!(}6flU^AV($Gfw#9ctvoQv-OD4k z0z17k;}5fXF0@359AMPw`SS`T?1*bQZ+qhWYUdf1c(DcCk;iWn!rGR}<`4`_r{3~D zn#d0I`-ib%Ht9Ae>UQtR57wf#_H3pT_F-!RKXnF7Rvo~^AA$Z1CMccN;EMsezMCxi z>h{x3HI=hJ9wqV=&D04y6VrgH(&vASUmi!frzyh2uDs|q#PSITMmvWxIxbm0a;v{Iz5tT2NrV})A%gvCxT*N( z1Sd4uPM?AH`WrsWjG+4!P0*u@QxW&xaqp}M4Pp2(KhRky{AO6k^0h2a*<(v#@owmc zpKFA6q35;YDPUMx*{_dhB6F5d*b8jPgryAWd8m-Cb)dY1`%+Sp0!%#9lhQgvh1rbh z+*a1_j_B1l0PToqb2?@mz!bYXsmc-L$dKTw-b8RyKqHr#<{${qD-kqA&1h9VCtccS zq@rrZ>(Q{#nQ9#95_1gb0vP3HBF$z%_%;TkVM8;BD$ALlQH!WtBq1N?ACYka$eDEU zBeVxCf>eNLjh(-Tn&|t)QMy_$yhF`ok+6sqI8_wlE2;VhJI#t7HhfJFTK&Y{UhG!# zic|<`HieUqPleO1!y;~YE3#-aXBtY6v3p=m2sb_D zO_e;ATUlK_UhL4P)?8LSclc=0W`yINx5rSyAfN+z@*dB_+`N46%pi7P?MrV}xcsO2Z32Y??%9!zL-Ec$!+j{NVCwY|hN3 zCgU!V5%`2b$i5iO7DM0*3 z`;{=Yd1!Zz@3WQpM7%t-s!$Cu>#;I@)bIknjoEhvUx9x_pW{nOyO0Dum#TS$uL+WP zz!qoCUI9;u=8d5tiD>L%XA6wEP(bvxb0e9SocQ}4YDy^p7<8@Z2aSE`ot?s>L6ZdE7Q9^pY7BgnRNOd; zDd=H}+1ApKT?{!@X@l)_bPf=k?Xj!sy-)R5V;vw6mkS%=UX@H&GwO%;w+)xL^)#=b z*;6DM5tDZ;R*VmD;3=iODgqCCcd$W81Y%}8jLu8A>&T9;5yYc;j-2WL=ylxB`%%4+ z2bA9hqQVtH7uYW>@Hc-1^%l#i1B^ z2lR=gwvZ!dOWOZ2t4n(f@(oYif})WTK|ofBbom!0oQ^EmA66}D+qW%|xt0TZ?DM)6-gamPjJZ7; zb_FpIHK&5Mk~U1c!_$3?f6Y_JLL3bLcaD50}p|AZD* zPbSak7^B*gg2Dkhg(k@w(6-lNg>WuRO8OlQ_kpGYG;tYO&uPZ?Law}z?A!NM_giN75bs7fF?0#O3`F>G?_GA%+{nNhDB`Het-m+7x_hyoxVtu z9bu;9LW%M3RDY4&iMquAYu3=(W5*#jqfwja|2bGk$R^AQ?!4!G?nABHjF}7B4tb?^ zG<(OHfw$(feI%3B9t2}am$T{T4C*>;!dq|>6l13_Cdt+g?@El=w-j`}IV5)0M(|H+ zx4HB!MpN3Bvf5#ej=vy&nCxkPmJy4a!OVywY9ePQ68Jz}9=J(^uZlVWQ7bTN*gr%jI+dw*-P zB09dsxt$K|Sd*U11;7$XQHzg%@bT(a%}RR`LNY?*+s0bH>dck3yjL;fo01va;C|V@ znVuB-^_i*9QBjj@vmEXS-b0Ca!q3m9#uxC8lHj~C$P{teZ}kt|n3-st?zYVhUi}E) zOQa3Irl^Emw2S05qLJgDtM94WIw+#FWMVdh2^U8b23|X}Cu=m%!Yv@PPFmEsp-Ud6 z*WA>4ZWRkX8taN?hV+sI+e!s_l)Q;v+R%HWy#z$hpmBL@m_zxW`Nj6D1tb=UccI{b=fPYE~}W2yzCRvDz|Nbch_{ z3loySNLxHIT`sJNF$>#_yoGjE$R>FuvzL#P)X!aMcRIq}QFBW+>UKj8n$S39)<{U) zAj?$!cR4K#2Mh{v&Xaf0&}ai!)Sr>tl-CFiGWd2Y!@-T)xgXr2QKhKa5w+x@-WGGs zi>N~^lIa4w8BMPv4n0)JS@W81F>e=PEi||OVh%NPd**jH!lA`$Ymou(B(N+tGX?Gn z5RNtJ0FZjylNBI`LOHdP{ zXgw~-%Co9K!c)@hHGQwT{eT^Ej=ZtgHoaFLfiNfxpZy+dX+Uig$GOCTo3IkA(}~*I zJN3CBwUTwL;Ci!Q(6h1Ro||n7BY-!?^q(8OP?ow1b#7&&4w(RFqc_Xu@e_2AM6EME zGW8D{%@HCJ=>3BH1U5t@LxWwoC)@>mtLa?Q8$HwqZutjmCD_L(qUuA7)v$eJte)$&#g(uz!vTWl>KEbnKh48W49^2oJjSE&YA0 z=byWni-(q)F8jQajtapBcf_i;`E$!3NnMf;loo-o_(^x?zO51F$q2zI0XVIUo4!@28UTZQfAz7TMI?3HzfX~Wo(1X1-28<%NkMPM@=J-=`M9~ga*<^n7T&pKk<_gplktbb`?C&5Qcm0X;E#L~ zs=E`OsdtX7FNG28>CN)<#9OqB3z%wr7psA57f|=rlRp-H1H$MW^suC?*i#H{k6DjB zwvkal7{Ykw<~icNjvsQ+r)hRnqb6qPSO~-KR#%m1{o^kG3mESYcR00JL8sl4-gxa%BlK7X3Y%^V6XRIvbrHW(39cdTF~;7R}xD3=Kw3lyUz#x}>yS z5r-OwtB?l|SqIBQfJ|ZGrbF?2$Fy2u+8^I=N25wdE%FexT<~3Z`N3?1F`7I;fB*cR z70!;yQ&AC- zB2~JIfPnNKN^FRT5ETLGDkajT_e4dcNevy6NbiIe0trd}^Sb-{&iVEn&e;P7CD7D^fV4Hi|(zQHZ0Ih#dWBZtNU#}=12|G#~ zx_@#WdbW2Owe2@|dpXExRu{zeth%-c;(iyAyO&?wvXD7rES~?Ii@g%H^j(f6o6Ka{ zh9P=0mNYh6u+<4dj3Sp-Yn;2!?#8qohkBF?l?u63&|+SUz0R6T@5_T(>jt;EK;g^ISf$ zNISvE-Ott#NdZKlZTr}>7rwZn<0YHL22pYv)bWltxfqQb1e`|9^XJk!YT}gnE+a_? z4OobwZMguil(}VkWMoxw0mL*2LWsXdBnejaIXOaRKoU>$k6R$xNOZ+~TG#_JOWH)? zq2Jo@;6>$rQB9#|Joqp7l3rB~LR_&>coow1%iK?LAwPLw?@VG9%m)!Qt}9thQ-T&) zs@om#t{X7SSgk=oCYdJrP6?N=piiwcn?A*Sl{)+;Gv!(!3p0-ORskvWP+%>Hpf(d| zBJ)8lxTq89Vb>AY-DL|~aUxWb*)V-N7yFMr!_7mtb)%TlU5Z60iPlA!zY%C(F&CVd zMCK#A5)5$fQTS`D!W zn%bktac02)akLitcq6n0cLddgi$XBbZ*R8V?(2Q9;iv3*?}KAvlA`l&mB%dIP3von zy?70E3>3y!xnnnadF;L9iQrX3YsPg{%SNrdtOr&=$RLG{kL3I~87bW3z*I8pk&|N` zjg-OXWKtGOUJD=WoGwN$rF0HX*SA$COIJfiuQYc>@#x4)0+}%EG;YrwIc?DPqUj9w z6pPx`g^Bg0)5$^0?D@r#Uh=vnCRT&;|0%}SpsM>XAZ-CadJLcDoi#7>8=dy zj64;v|L669H~UwS9Tw$?BQ5Ol46gs!BcO!L)F2cDumG8($S#Qa#M%F(F%KexAqQQd zAIv#Fy(YMNx+UUFeI-I3D)>qviqfn1h&QK7nbgP6#}v|_eJ@t7vzXmNhm99Hn|4Mp z-4~}jz@_FOm#1-=lzbH^oDHc@l%j_=^_jK0Gm9njN_#GkWATEXlEQ-Mm5#!Ls;ibY zW>@Wx3^l{lYwFl3c3mdI-RWC1Hu4JvpScpec*j&$RwH$%bgIfn_fA0NMMJl`d{!=V z92`Q@n&VJ^p`$gUSE&2LUge_P(RveK9@fI?KdK=a7^QEFR+VhR$@}(`h)E_Us*2;x zBwVnue@1QZOz=vmJa>K-PY&Y#S7njXL>2RSZt5_StXkwaxx(YQ3D|2R@8oJf@(f{s z1y%&J{!RlxjA+g`&K`R`sJ141bRR?3Q*yybOurW1Bk3_Y~{c8lUUV&=@ZaC>;rPw z3#}1~mP|Wj2ZbGbvTbcv5t;Y(R|(z8!NI8sZsIzGi+2FVfUt9yj7HcDlq7d!|C85WRIwUB0}k)IZsuEC@I;H9Q`>75RAWEf)B3nNNg+4tf|os2i*U2; z2P}U&w{f6mmYH5e`>f|}JV`38{qVp5*(xSp9RO>-ysBS%Rta-cvhyxA6F0=%y%3rK z5(kA-L&zc+eAQUgwtY=AKM`@P69#JTHi^*839yv;{6U3LCW|O9`L0HhLX}ky56Ukbf zYmuA?ymf=GG%Sy@r0p>|=~7({g>x%CxlouT3?Lx`w5~sqY`Tky$ijv(qtB&Q`9`rW zD1c-E<2&Ih2K&y3sdf6M5K`vRG~-S?cDwiHdUR2 zR$PMY(15|_@49@65Kv;rTR+&rXd5h*IKR(r6Lh}1u~t8JEy>N!m!VU7$7#maDfdjQ z$yJ_G$m*d4s%pUFj69`OHPCi((Xjjt8DnG(cmQSB`#;KbVQsEja zoe_IAWXfbwec9NyS+XVI?r$IWxi9AaC{4!B!nIXx&80lMr+jkymI2()0uQ5wD+S2T zAY`!UF?TzuX!+54FPWKlJK)u0QalWyU4A|S;{^3c>>EEEkdN>>u4Fi!S%cfZZtWIm zuRzvtP4Y=&2S2m}=nJ|PX$GFkFzeR7zjqnr@l@gC8=-U>V zA{%f_T?d{Ck!zdGh=pN-T_T3>a$T>@%bXi=yKM`iDCs(|82EuO^1N!`My3@pmyE1XRsCTy#2LK@s2fB*X@w4BiNK;<(?x1y-bUdbO)eEn@tZRH73iG= z6SO~sG(@cprYl1BpPkZ{bO5yPUfW_lcN26+Mv$!vfAN8%>AoV$5xEOdR|5bIQcrTF zV_NWrelK+5#MPVSzd{7bTrdefFWsO^WFmkEm5ycHsHZPMF8?de)-vT3^01M6L(M3G z0|Y0ZHDZAFMV54wMDy`g9ZLD;F-Gh0X1}VA+aRHpOIw$ib>EjV$C26Z70Z&;qvAgU z#inMpuiM8Yb)+rXNM$XWvR993tdmXOMYLglnEjCCh;#0_;_S zR=SX2X*7u^tZgu2WaGSi zP7ufXeWbtVcYlGl2GrFRCbr^E6((=bh#Vh{F7^~BW-<__6c9P`h4hZw%+D|mq}0HW zI#kM2A_WPk%g*dRYU`gj&b=}Vc%g=fxn;^Z|D6@Nl^z0pIOy5Nh%=|)Ls`9ENhtBV z(|wb86=_*O$Pc2mS@q{E0yDi>;6WdHeL^TUq(Csz%D%LM5k|3t1V044?eFzo~lwnL0%V77&g8De6QA7e<(>k@)q(nKNf1Ax-zV z^cI$o#7B1bER?O-M3& zz?MpF40;cyv+oI;azzSby8XSLCCtn!!=U{5(|kk>u5Z1s_8cp*AdEcps1$aI!DSxP zevbq}P>d2R+LCRfU}y@r&R*%-AjP3Oc7FVpnw7sN+OpNnOfvMh+}QsF?$1E^N(Rvi zIDJNQk5;!_8eY<&=FH9;=i2RBU=R^e@2I~GRdrpiKRh-M0dYr)0d*!#t{NW=-Qa8* zvLj?drD}d75sNBuWG9rsDjbMAGtgfIScc7HRaGggt%4wgLTycrDGusWBNq1Ghg+3+ zH<{H)<98)x4OW0;v`U&&=&(WO6z>shL}i_{&!$cF1j5Apgw*(+K>S^b24z_28VYs_mDZ)KkLhbWtG`WAkSDiXcyw(x$OBPb+~t8(er++KoFD${>g4(i8YoTf31AD?qfB zz`b4j5E>ZZaVO&y>cI7Y;}gQH_IHXd10kuTnbW_jpc;n^MB@@p9GU;9tHJjfrfJq> zPlGJTypP{_Lt8JvfSns#^*$qnUE=!&M6Urh{H0o9BDaddu)FiF7l*SuBOj%Jc-0K9 z2Zj_APB+`%&Z>!wJkt3s&7uCXph6GEWI)*DZBmCnmK$2hkx|qc))dr(PPUF;zPt&$ z{RUNSeV;r4?!>!m@bLj%hRtG6@(5-(3u$wL6lKBSVVw+3&LaGRj@605iX!#qAR zRjFA_uImnRA(zuqOr~bJ_7YW0+F8|?bkRjfS%PRC4;fRa?ioi4P_Z>6g^9UVnl$`m zYB7=ZKYg&Jgdix>3MOa!Q%mIZn+a>%bQGt*SCIY$b*JBoU4O^lZ~zt2r7naDStnKE zBM|!V+#QR0-$GVlhAND{CL5+OC*@L=fD7{##QFSSkfSIkX9^+*D3@m&y&<|n(r7uA z<2x$1h9{1%BE2rqIl2ryP0RfU+y+?gHa1dE zS7x&FeJfh)i`MGB=4h{Kzmq!l2iiZYO$kJH3xM% zL7V9k2qICZnmQHRPoIaETz54A!Ay!K0E$C}OyK<#YQgl52Nxj#NtH^bxATna%ysr+c@@_Lve;C`N)D!;cesb0(6K z%NR`UdNF1I^>74=p8D=xEL4H&)TCU0nvL|0{MPg1<3O4c2BI`HhOOm|ktuIr=f|Jw zVAgD2VSFb8{ce2xJ?&VNiWC~5_p1Z%UyfDzgU9q%kPG zi`yRhARBB7bW!J1{M>qL8a2xQTjryWOUNKwz~EzIP!u%96hQp=&gzFwxP~|vU?;*x zN8rZm{6xQZX}>;L#`;l)I{?$_4QGCLEcm4kY_o)M*{B1C5zpme<;E7J1-1D3E?il| z+Qk81JR|M9{S3_W_Cen*Ij0mEPL_&HoP3K{lq`_Mk7wF@^uWH&69~)GDh~l``#oX7 z&NS-Z=%Z^RRS-MshoYK(>o<{85g}(lcnV};hsnPYSbM82b;EKK__;Uhf|gYa9p7Dz z8O%svN{)`1KgXLQNjCNB1R^#o4D+6Iu-bg``4q@-Ydj;`(h)LJDmd zE5G1YirIjw9I@xc6zG!Ux1P$glSZe_W75KetaoOI!Ju9BEZf zJs(5bj7`!(iV5xmi&6U40sv7IYN7aXT2fS->v~U@?02LMc3530_}&fAW?`G!3~TRo zI`qE^<*s}!<5#jIb-1;@Gf)h49-WAPCp|jHZB3>we_dZYz}_F##x&RyYI9W(SEWRr zjkasnwZeo;+@RcfUqW&~+RKxk1v)|VYYApP;@mOaNTg}jXMh`R>3-F*fd++_Wb#cu zWSqzQQ2(F^;1qraR!MbnA zYz-oY_1*83e!g1bEo4$2S)P?z`4c+Be}L|-uhS4B-6MaI+)*4QDxve%+gE|}gt(YF z2pS^!iN)p;28N~({lkJthb=D<>cao~{mb8h7U1-`QysL9^ur)+Nap|{yTj0TJyN%` z_=LIkOcE9d?x(_}_Z!bGwunxFStu>V$8n}EeJ!%O-241sX0}!5QEuzQ1M9>wn;)?C zj}Mrw!SQ2P%sD%t6TT}~!yaLc*KfeG-S-^ZU^og4I92EYi(?x|Jw_C zy#>NMgJXemXkr-b?EF@Uh%Vq@mK`A{AV56_^!556mf2#l?{y#SD=YtE2p4qZWF0ck zgdlfh7cZ;|3m&AHW4G1dnCl0^mJr<`7=7O{<~M%71Zf)^3DjaFi|v5wPUOv_+^(ER z9X4?F8|HfzIY=LAtU;uL(j>6u>$>$Ka2M{vX0f6n#OZ3Dsu68Xl2=7aJfLh=e}Ec~ zTB>IRF+Uv3RjEsvl9L4}Ytu%u-emVztt@_;sbaJ9Q#6||Lke{Ek3TNy>lt&Ne6IJ$ z-517t>wXMuJ-XF5#A8(d<*sd8E?T@Yf1U8y_mRlfTv;BiI^!2&S59AQ!u@&h&!bNd z{yhFiOIpi1lX>w}%TERSQZeaAlcSa85)+=KZ5u!1j!!1`R%4E|OI}%^h!uUFi!w;* z9>wSw((;m`(bX67nKQj7o2k;pGGi36fWX9YO3u$m%+-7}ODjCK+F`w_wo0+m5RES? zlwu@U*)PiJgxH7QU|TgEZf`5>^w3_w$No%q91oiRHs_14!W=_;|J{}QhJfqYY9Q*gWA);Mdu3|x(w>2_%% zx6npYF*8HJoPQu=a<%7Em~eb6yU04HciEa~XI4|%M9n>^%Sf;lhMyY0%v}n4U@%y3 zZX9b_Ir6a6wfriv>fn!E41=j_A!dXdu> z1|dZvpU!F2zKDF$nfBM{4N2Y}?7M>rLJ8+dZa3v!dxZ!yFG%IDLbZ(an=iX~#htA( zb+uzYYspyYuKp_Jv-)RkTbv26nCzx<)|svph1nk0o$Hg?rl%|I4i?vQ5qHbY=C1XM zix#1;Mc=CyP3t<0y-@DzvaM-u!uIs&SpN0+ zKg^90i6!ly^C7so{WG)dI{R9!qIZ#&hsUEsxpCrY!E(iw2eBg~oe3$JW0mn~2 zn9b9kT}2wcnw_a^re2NVDXxjcLGo{78xH?#qkh*Wt5s^3=k}xRDRt75KV_JgJX_jx zx+!AT__AR6sh(GK;;S57&fz(-78WNz-{E3(&xZc?a0I_xN2)m6@8X4Cd^LXRfFO_U zy2Uy};F^8|7}!I}$mLxg#w$hBgJ`^ISsZ-i-^(9jPhJnGK?y4-K%_T7gNhJ1C3E(xdpxKxc#99 zslludrSro}HiPwL{8=UtlyZGt@|WE5Im_f|**t+e20yn;qGe9IDLkJvxy zaaNu_Cp74j++olboU=caa2EZTn2|y{FtF`?<*)2`kFeTI+iY$};*Y2S-_N%~u2=`$ zK+nA1oih5WYT_1E3JiK)=~j-ZB>Ju*H?D`a{#`)4EQiNV=vno`R82RQf>?1Rt! zShZd-&kwC~Dt}t{z3;rkxAN^|7l;1wmDTqSy1Lx!oAN-nFQ@OCoiF(H_265Xe?H-J zvAT_aHB&>~mbk4-bW2b3woBc_X!8rN(hq_jK*emLa&(6_EUhZqdjS3S!nM#IKg>yVaI|ZJ&_{eO%;}s>&_-3?(vAnmIcBBEr z$314HG97y&-gqop;+bS_jHJ7~N-5uSJ}hmQBWs{Z@^m)gHa1_Rw6pHL_xqC(TDzRC zj2<*E?j-CNS1-^qIlSmsLw&+fjuBKU%)TR3X2MJ96b<*?)`hA6K`TBSx?pLxK4E9`_t}TX@X* zCP_}2?F%cTjk>C%E7MySJ{un2CiP@vP;m$o=X!s~9^oZFVwtaGvwu-2F*8z7p9+_I zD?QQHcCFVSN?4YeAlE*sS$QyKM3d*wST~~=dt$8)sYr%l<*sO4abPujo_CzPoI7UsYzYc33gzlvci*$9w2R|g z!_DA}>K+*ziKp0Jcgm~&;#il12$JJP>AXbnL@~JDPVl`wga6`d`)alLa!Z0Ov!M_k zG;d2wm*nJwqMUF1pWu-i>fa?Sa#;w*t*vW|h-Vk?PTqcZt|5=htfTKbo|LOj!rY{J zcQzVKGM_atBh=U0xm73QmhQQ#EU%u#*B45*X;0f6x~DPPnX7dwCBFr%%n6>b;>HHw zGY8p(77E?0wEAQk&MNSDZ&D>V!-2RmbMrFFS&OOGrn!7vO+cnKnG~oc9HDP)hsN|i z3ba}fLWK^Gpx)2MzDyac+~3f4z?$j!e15y*&riej;rHex>@Rl&l>;ve$Xagd8&5n8 zp6Q%c^qw7Pw6BYa5zk*@km6eo>@K1e1oXC|*hC{mYMsOQ{6MNUdN6BqbXya=tc#^$ zCIpwRJWGM*huJN5(yN&h>&AxiPE1nwobNxQ_j<6m%gUr(%QO;+cBW- zq07Osm8Xe#>-^Kl%bwUa*tim z&T-d_3$)x1KVUvFNGP9agqtIk6dB`JGPng*KVoEEI-Y5pD}OVXRv~odySh?qDq_hq zZ)7l7_<-FMEywGHsG)XCIonl3?5xsN!*%Wm^V5@N=g!HhB&|GGwZM0H1+jXBXC}|T z8vj{pPxU<0P7XjWkOK8DVvLNQa31+nXQvcAHG?2kVPU>W&!i8m)~tDjvVNcWC3lp4 z-TtIA%Ck#KqBZvME>olvM}zH6#Tk8Rp{r2=l(qFFo}X<$==3DpZfo?Y;UL*$f#%!m z(ndC&j~b*~ zSx+kaW}417LD_w#JuW%s(;?lpHGx8lvZXXzxBgyty<4j#g~EYqQ{%@oDT1yErMhj? z^KwsTv++1(Ssa#p+etkWkPd{F{p}31qBpME5T^dB{8;35?w69CU+lw)T=?-eJSlZZ zrvCB3Rft%V-<_q3G3O1<_;A}o) zp+l*N12-S_UFOg)4`V&O_tzSF^c(qN)6>3ZnAL3R%=dX4yXLF%yItwYVL8jp)F%aQ z7p|%6`zW*0nBZ-0rzFm5!9C2%I@#xpqIc}3Hs0r1_>jf-WP7Q>)watme(nJmY}9=2 znJ`zAXaBYdPZTzV<1)nO;~rOLHujK@E<|-)r?A%~YL*7ow}iL>Qq2-3#UD2GQ&XJT zsRD=NQdy9mjP`~@3V_G1IokQwUn}9^rMV9yyP%ukJq|*zM7W+LheNG3X_qhG-eFb& z({*@ZE626{av@tdBDRB#1+HRL;9E0~Qa2yBJ#?j01!a61oGbN)yFy;PaP#HkSaf|I z^^TMOmRA?g47{NrDYHzlCn5vi6|#%nxv`9PWDk43N%L`zpB_ld?plr)?LN|NmHjF+ zz&uh4WGBU!`i#qTTDry7sw!u)D4z`K|G-BF)KIG`N)RCJWfnehE~dHUzR-bAk2hkm z;TWg=dMWenvMnjn7XW5?PZ9FH)|y?taGNQL-We8_c~xd_x^g`|>*{5~KDp(WsUFHL zW)X!mi*xkrCUG78lK9tN=Y|IJoTc#>O#>3OF%gtZO?I@g3iq30@A^a3`R=UNA0KqD ziZUI>Ej`{Ulz8^m-KO$|@EzCeJ!zTVH70NAKkoZ@Cs|by7y1LUAmowa7_b{jN-m@m zGLP+`J*^VhH=SjtT-oD1n}L5?_3z@a^ZZ53Zo0IV6l;CnR$6 z>%9~oNOg39ctI@{4@k^yE#t8X=RU*1FX(-xfyZbzYrhlnbV=zUmA;u#-60_Xn^F4{ zo>dEzM$g{AU4@#?R%tc1pDnHLPcs{m&WK0$u{<15T$^UO`5w7@0&B&$AaVKW@`|mZ zXVyPM|1NTU-y!cca<9DVAUPIPj>U&a>va!>Q}Nn7sq{h!{u%5pLlqKi2n>0^na8v&O=7u^;~fx z68gi_ZAMEiy&?^2A9qbQzwMhbE4lU{{heq+mgRMgI@#>H(c_M%)vPn3>M>a(3HelN zh)0sV&n?S<)i-Ic%8Y_>H`qB|`~&$fA`A{Bw0N;}_skh*%QiapXGMJc_%Se#Jnk_) z7iR#U>_XiYdoNqUtrAV0ZW(d&UX;a9(Cf63kJ5DdC4cV|ea6mjx+}Fkl3oTlcOSOF z7G2WdUO3lkV3m>oB3yYOhs0S#H5cUbR^gVRrFShwUvCXErP$1~UM4Y?b8s7#7UKKO z&!nqkAJ$_({6RO(@=`2u?e)=QZ$K@rO55v0bq*>yH-U9mfmbG|EG)M*_+Oo+wd4KqLX@_p2;dIC@$JlqDobny=vhUvQI#ZE5S76e;QQC@TMyv^|eQ-H5-<>lZ zeZ;jH5KC(lntx!_CP5@a^FXtz1&@uv5nUT(B z+1evAB|>~fOH+gMBdL}K=BLh-cxRjC;`2Z(Jgaz9jYjm8b2*okzi1Iw@03mZCvjA7 z#qW%Scz6GPjT%e|%|1tq;WHD{srqXv(^ZCCCW%wFS0IOdD-z0Y#<9%YwIg&Vsb$ti z60t7?Z8HcDv~jaEPo6n@Ar6ThAgg%Zv40Pa{Lf(H4B1#ZrCMR=x9wYaSV+WjQ^@IR zxRqzEi6VJ-Ami@w`cCcGORUpgszxM7kv>JDN`<0%(EkLq~_ zln91i#~Ps7gVrV91Zy!2&CcD2T1Dk1z9L~;x8gT6_DuV_imh%OT9LcT)kwB)9QSb8 z81aAR-x)wN=@qJ3{qeCPxNJD+Wg5Jr9j8?HEm$Jb6b}`PAgrJ^$|{=FET?=)T|Juo zXxd)t)H{Ct=PrXRbrdU?oH-U)ac6*@RHb5d>U?;2+FaH7PrVUaav6|b$mFQM$-o>k zt>M<#*cSA*%-l(pRdokV$ftbz8idLub6IjRV$k%Lb`s`y6s}J9`gXl6pzRP`>=xLwTT{)4ta1m9t|7~bD(pfFwg$iuZt}!W+1lM5d8;5Zd(aGk+X6Id>!DH@SEZEaq z**U!<+r(kMN>m&>=}gW6d*OOS*y1+9>jAG{BnOBdGl0}8*vqFi|$)Ovn(ZIRV`LZ+i3U+*&bY#z7yFp^DFJ;C#v71 zuu2*`E_&1#C&$~;{fMY^-f?s6)z(!m#8$Tkx5202_~l^$X3$5F*5=GGUe@Iku^-Nl zZWlAoSCvxzm4)Srjz-<78_am5EP7(HS;n(}XD+eySQU`MD{CJA$sPywGE1#~MSml@%xwTy$?E9vF^*#`@@@H5k(F<=y6;hVXy~_*! z^gdiVr}ExEDwwxRe#~X)BR~_m_s(KLY)LaZ%lfU#XY1VhL9+WK?@aKOLh*n->$^7= zRO2b08{a%n0O+}`K%AM>43*8Tt(oQ6EBpHb8}=fun;)yU6(^G<={c1dR!a?c@QfZP zC~EWfbs79y&sJA-VR8Oxqx9Ci8K<7m3tN*r>%J^3*R8Dn`hLm~4plVv=N`Ws!7JIZ ztBVG08;Nb!Mz^hzN1Gz3a8SSd*R>TR_t#d*3JPiNeZ?*jJ9W`Q?ed0-NhoHw>BVh7 ziO8>BvB&+X;3shZORu;q!_IrYL-ei)9mSwus^FgM&(xmnd0HikJpa9~0Rd}UEut@O z3v?1uu+F<#G~u}LovokZg3B*k&oUNxUagC9yw-Xi-+enbaA{lt9`6CY_RbntOf_9p zJZ0@E7(aDtv+kngq}Fi7e&$@di8?=KD8k%O@0OS59~TA3`OZ7`XdQ`ACjlPy|H|I> zH!IJh;{0qxH1cL0l}KL?`z|oeSd&zK>9NwiSI;lsGSc!fUs)mt>5$QzPi-4|Zw7xY zRw?n;zh?Af%P)QRE9FYad*|SgP{VI@YpK>ow~WkPrx2}k2aYo=+SosvZl)6)6qwl~ zAI;!nA;u4oc3X|bMb^HWDo0Jd<}tAQ>sSIs-d_o>4TRak#08Ivt=#Lf;YH>yHsF>% zaNB&pwb@iuA`}|pnX**zU}Hn7#V^p}+7|0FxG_wZ`k%dgO(kO`3Ku153t1;TDlCrB z%2biiiw>NOaP#Fv-r&)m964k9?q4U)hZ_L|PPZm^Orkt^S!N-V*Tt}W$)Yz~zgdSK zUd(lvDCAKsvmL*8^ELRPeO*!;*j?kN@$dPJs|CH%N5R@@3T;v0F@F@df}v;3`=$!?=KoDCdGWH z-}29-RFew>ft&Nx?{uiCR=ScD&K`$}cXo zf7{t-E>)`icMmAZ;d@`G%0S+Wh!wvY&%u+J0;&x6zurvSe^5?f!aFo8-{tlFN(t*D^YFQ{ z+8z#eRcaT)8+K^ca4H#3-E(774Xk1rBeZN@Qkg-pV4Qj=jj|I4(SsCGektVJKJY8lIks5T@u1};jhGZZnz9Sox$|?q6k-isB_grS zQmgNxJJbnRF^iwf%x;=y+F*5NW7)K44yRYz27S$pg@VB0#W1fqC#BV+<8x)hdK-iS zjHo^_IQ9oyj*Z4ETARjA)Y91odm*Nf(wL7XU+r6*pGjPcP@k@Ip2G+5VZDia1v0=IV_5Cy$Eae`uH{VtJj{;k6(i3nD)DZe^Yc*}vDLx=0Fj zA0V(}uhh+Pvf)%}HPH-iu=Zq|n&l_;Q#xiY^*36WT6kP4o!D-a8*iN}J&Eg#so4?u zY!1!n)xMKCVZIWibkolmBBHY*c_@sXzNRUzGElmpcE3{BxZ4ktq$t>a(73`P!|1J= zihq+=oRFY_K;RI^KX3H@+ZTEMlccI#eqd*ocHzPy)z)^>cRPdL;@uI+uWTxZLY;oP zif!9Jc4NNJw4g;|`vseXx-%s<_S!%Fjp1;mEhWHFjti^&-5G4%dqTTt5}&E`jH|bX zrj~Zblh@2REZUHWK$@D*ZqW+|Wi5Pn3*;dwb=%H*#8L;WroGIJWn6ged(|;I+1#a- z^`pbsWI_!MBWF30s>Z-=7zy7^sGFYQ4XdoY^6_rXe+|>UC4^21+6 z+l;uPgVfT_2?VzNsj}JS$$xfAC*5Z*XUmg?RsWY-ovpb7>I245O<8pAK52@7Cu_2c zY?dA3n%nK z@+Xy}Gh{PZuy2ZmxX^MxhtPQkqTYi~%)xH>^Zu#7y^Ri~kfwx`C7kjM;LmsL$vOJ-cDmh+}TXshVdYs?L1h5izI`VhTUM2i;Sok5H^(kjJ>hL$``VYrqy1ggi;G5m22)RYm z@AAnNgIgtWZ3*R;7J&s1kB7Tba9}Hlaf<18qQ7b4|HF8TWu2-^C0#lhI7P z-N{N(O_nPTDnHg%?~{{q_LydzTt-}J!nS{o5l@AxmL&FMuXwA*mhi8TS>93gv&a>_ zI0A7e=8@s_$gSSpMp$d|P18dvZ{ek-{&5}0nKR6F!l4Mdt*p z1#6c-?v(AIyxxO0syxG=sJe@wO|v;Kp~v5Ojq0m6)*- zzwKBJv15|kNnisR|g%C-O0A^#jIaW)9lQ3G;`EiJ0iS-1D@e;}&2_`!VHNRD|& zoTM05CcJ1zapYU>Br)v9PWSjFlOsLX^=C_C2>au$Z4`2WOw!ofnNyfQ7)LloO$d?P znA51Z@{7j-kHt}?hI zG~wMMZv7QCRK8gAleetd(;Xf3p&^r70}1(GR`ZUfIM6%CRbwT^*`m5zG^H~hAQD!=#65>=-clrIMK_?@V73Xj6 z8=RYb63{=0N*CC=bgGTl-*BUZzR}tDV;9E^=Mih<{d2UxkMDcHuX04gRV;qimjpzq zpW%CFUV9U~Ii+2jjPJIWY4Z>^y2i=1lcE0#eHr|G7vmOCX)PY{`48EP`j<+GZz^I`C5ctiJgvnFiKs^+G`KWSB$ecaMSo{ZKQ97OP03 z1|MKrnuPlD=_UP)Ijd5G!X;EI_+X(U);_r*h;kMB@C4B(`=pQvh z8&aW2_S?33_hoR=;)&=KIegrRU}cUIUu*0(WeRxLOC%l3Ki=#!&3obej#li1u?n7C z@(vVK^C|!OlMYe zlt(jcbLP+K!Cb9!UqOzeTmL)Ty}WQ*Qz0SQm$<)9KyIpFaI$H9wBz~fe@Y)+O8PVZ z0;cw)tczmz_OHIk+gr?qw}-OtkyOunSON8whcN0sB6hdkioklIL-IkJ&r_-+TJ{7VRUACI;6_=n%{1^ZC#)R^v5)Rj{#xAR zQTBFCDBpD^u*O5qW$C_>f9bW%T=Qu{6n1wru!daam*hDtX87Bzfx)@ZUt53eO{vQ* zfrUJHi+`>}G1#I*mY<@t&5t{Nd9Q?IzL4@GtUO%A`?S{F;Mz*KQcmlTe2S!%Iuctw zDz0Ot-#cdM*oTZ4k9BEbUwj9~Kr*4-A@GuCvqM3FySFSiaN;2WCY<_^Jm_fM57sWP z+98<;BGeolug|>6Z#*ues|TT%YsNbjc$y?-^E9HyN{KCH3;qKcc$qBVq2I)x`lC1( zl6-+HArOn59*o!|Y*+S)Ev!5o8uQnh8>Q8@StuYHP<$2yY7GViT%#11B(C9E2(Zs+ zM6uJ=Kwavamvv?k1Gos)qU^0-#$O^zz7cv*_(ZB%9M8{^@f#s%VVl#HGpZ%XigMW%?5y)ZOzC^n^DP+wh5&{u&yX{C=YGKf{$0W zV(iSflDFnvSde}9X*=rpC)#?i;mmrI>$`FQoh;Lh6pjU>0{1Q32v58}4AE;aW!6NF zV*3yoX9rRs|r24EwKn^R2vQ3!-;K2-E za9x^+aCmmTx(X9~5`IGPuZi0hQU+vUmFu((YHp}&gYHI=;<)_s|G6Q(07+Hajq)lz zS8&tUA;~nD5Hl1FcgyqbCY3_-P@bzRsq|rK6~c{UOSGmBy0KSEU0uq*E)Q2qIAqLg z?4j&}Mi@i?3QD^NioybpXHc$r+@o7lI55P|H>j{~vQQ#cOIQ1F9`yfY(8(}TrxEYR zzRk27*4je*P=0z@h)+OsW~K&NeDm-JO8W;q7Vr_#!8IG+k-ECc%YMXEo2+ps`p@d#&r|pzsEs zB@1L8wC-fn$*&R}W|UaxBI0@0sWBJ2#+P_Be_kpB;;Lk2@%6)r=$FHYjXDAn2P|U5 z+V-%SCmO)l{mULE;s*N7&7(J)Ah2{AO?%B;@{fxYR?2}|lZfI5`g+@q7cU==n1Yi7Sk99le{T4i!lVmlu6& z@Wj=ADg5&x-R@DTz~%Ef8~H@Ye2VAzkM?bt}P0|s>wO) zPPaFXdLKy8rjgXF@Mx!kJERp`ZgFd6-90*i-@Gir83eD0&h8W%6-w^BJqqQ=oYt*% zWn4A7=Bo4y;2)kjAO(Ujg|_(ZK5&E%t#89JW)G_zIrZRT<9Fj;Gabg*CLT zlL>?i!Tr|qz+vsUqIR#J@Gqv()`^alL%^%_LOBXm3<49nH`H-&RtI(&tajz}?jE(A z_Ht}=@)V;roA@#HJ8|k*#mt9E?L+i)$+IMOQ0lG?#J@g_{;z~^aG;kmlsnW<5PO1g zu{)xo39U#gnwm(5imF{2QWa8Uk8NXPp-f=Mub1mlvD)>~HYVuZ+n`tnMoI5^*S@c- zN5HFm$TgXqMF8gc3-nEMJYm^E_uVrd(SODUmb?OZ zJWIVH@sc_&xb;CPXm0n{PWRb-vT#$Wcbj{ zp-Q(MCQA4+rm{zlR@@M>or2FZunT`a_p)eXv7-}U$#9`2=tj!DC)sQ zy8mNzRIDrt2m$hoTJPzU=vGB@6G&STqRngI^^<^Nw*_g%pse?(O{fUzq=>XTNxln> zH!smNZcF&)TRRO-E{1@BGx%f3iw7B5qyekDC88GY>%@(?`-Wr+ zJQvRX>9h|Z*7jWlA!DGyplnQq@`sPhU|O$rl5Hi|^hiPPce`xDIWI*~X!-O`pEfEe zu?|af8q5Gv8xjwtGUqJStBx$O-w)tN1~6D}+kb9a%-BoTWI?K#B~?Mv2T~#$7J204 z4SvveQa1w3?pf-u4OJF4#Uwhz&S2AK$?+j{W(~K(*@{?)(?mWQz!`}oV!|w)q6Wf0g zQws)WufVOw{K}yoa3ks@Y7Q45R<_cvwNe7bPvA5VrZ#tQX-`VMmAc8x`It`J*HR;4 zDc2j>-O3B^KdX_f&%)sfBK{TryYM9J#S48TFBtGO;m-hi{XN8ttI#d$kli!SlAd4H zgzw$1Yz6+?%mpbF-w@rQ)tTY#lqH&5>KOH)f)!s#L7yH{za^^TI)n8!5kJTEMo6I? zTX{F%D19*gs&2!%+|5C~HwO7D3N?1r;Z+h-MMTbi_0W573Y)a5;=3a#{Yzx{zEPoq z!=dJAKg@IiFfJsvy|K;(#(-F>m1MouKlOn&J>TsJfXc$?swKkEJ~S}( zLlyUi=v+*qq{&*B=^Kgdrvj{KH8T#US`%e*Kc2DNO+uMmj%FFO_;%$~WHgTlp^8xf z#?h8FDl*HsS8M$@gFjs4j5h}Q?mr`2J$o}y8EtEOS5t^56(O!C z`fhgTBi9j*_B>bDb|r2o5lX4Bj!!R`)2CY0*xkh#h-=!}W(k`2d9yb#tm;P^w>bm0Yr1vNd+)Pi0CHlqMgUAdQY?z ziew-6)^1*`5~rlpz+^a$6+L>1mDXZs>0Y*NO7UW~hDv}8OY;2Wyw$(L6A3b^w z+|UdmcR2YU+Y_%GbB*m@){yOGeqE^lYGOMHWRD81sH23`HW@5i`Bi-cKo?hKO(pNO z^-{pzDFkN7L#RBMZm-_*%j3%bf(tA$IE|zX!~piXS?(k_hWK|YIb_E|2qBjMHLVIv zt(GM5@#ZZbns{(SU&-hH*n9JEDEs$sT&qg76N(mE2xTp6rEbZVY}u3SdyH)i+Nh){ zg=`_&x2$7VDP(6b7!29hv5m2f`JI>U`~KYb_xbaAj^A?}&+(am(iqoV*ZY0G&+~O| zue1GSXz131-s1BciPQ^g5|KPel1JX#7zuoK&Xaa~96>@S5R#vED+6eXq+m2s_g-y= zyx_`@+(HWL40v>b_4T;57;O_rWNj?ZeT@6K(1D>^LR>jd4=dF-kr_Vu{y5HRIrm8U zLsfpz%Z`#|)ZvWFVgsR;!TT|O{puI_ zav$dc7zueo)#dNprWz7Mw3qQ5dyAE83Bk(F=*zSVl)Cx!%soDSWt)mVPO*J**A6-t;l3eOka~FLgnc5`jYb80_tbR&D~+ru1glz#Q$x0jE z&hKy-=cd}?EhB)0tcQY@HwDUrtRnfeN1hHDzk|xGGqZ$q4H;-ZtOi6FmzlE&e)X&x z4im2qLO2V_Hgx4qtAVu$sOZ@!drERcVumkO309QhbUhj_9kR+?O%IT|3J9tvJFmKW zZz;1!qz?kTo@58&LJg6;y}N46^pAGFZcaf9r6c3hrbSYg7>$t$3K&u*%Sq&nt{KX6 zNPnITkV-24orK~Y9qJi4zpz@0WS=SGEQ=kfq7E&MxoeFRc?-1ir6{>r-82aC#@BJ; zH_KLWqqFh zt|eBZ$@I?eqOEY%`MeCUzHIBInP1P1JSf!Ed(hyb=XG^H=36W{;$ur$6?}q{aUVsD zZv_u<~nuKvL^<=7A~}9h}=#vr-UpFSp1fogdc|rl7%PzL zJE$%RwNOhP><)z+IH_n&EPEWJhYK8y4m&`mQ}bjSVBi&l?=vk7)_XDc*)E%#w*iGh z{Z0}TL?ynxew|N%t`~HWo2c1yyDL=}&)+5xk4axO1S<@6Fp!vRZKpBHB>XT&1sCNy zu(h~CjX~<_WIdq>yx>Dpb&Uf09Rpr|(oBuP@AIpw#I@i!ZK9U`hQaDGi(*w*gCNf+ zmPC2_7|JeH4`GsXcv+50JKc6tiQuEHuvou!4;#yJ%`we;q2?Wqn#q?O8#S z+h-`1`w5DwNa=WaCLDp3P{INAw;sQ=RJ88qAWL#*Dc++dyTn z%*x$%;|Kx1!6`y0hA{vIg{djjiSIv!^BmkRY;D5n@aL5b-$a$ITVGc&?02?}R1J-l zk#QJ*BHvb65vK_ zyk{_YD}A|w%oRG)$n%I7t@&%l>&VnfeY6hU2l^8@P{yaDW81=03`o}PwbAZr70UG# z`z7Mp`pDv*^J?1k{P5+$dX)69ugJ4A`$)wfDwnfeku4;aR89HgPDN~YaGT~6?jdEJ zEVizj5e%VSTo}^{f1UI7!OE~)&%`{&6E$m(rlT?#leE{aZ1#dtdkV!abiNwSC^O^S(TQD6L*keXkNvN117Ft@>U0C|;d^A}{4U z!!bwLw?9&#bFWuFA`^OCcDaYGJrWLFS@!p|zZv~nJ`DPu_|Ik~honm;#q+Z)Mfr@q z*wTfLxifFx^GjlM;*7I(WdJc4mv|pz)-ocl=Iw2iJT)MYsJ|^!>QmQ*^A6;muNAZeIeQMXnR49M zhtTaAI2VrOIWt~rIw2U0QmMmfuygf4OP1`Pmuh*9r{F6-@HYga)SMotHRs$#=0DKw`Hh^D7K8}Me9z}kzadPh;g-r=B(hv($n<4GQKUy zq&sRaGjlq0dUxFt>zMjJGF-bj(NpwL0U3RI`BNd(AFyte_FtCqaQt;ZvjeGFV>q@u z>5JzWWG&vAzdCwo+8zMp&Z}jYPipk1&FNmJ(n!6$=x(2@nX4?Ukp0aM+kS(-rv9*U zH+g^SqD4cG5seP=J-))?n&-focpy4-tb+Np%Hu?P{dI3WsL2=UNrVoDa}dH(YSqBr z*YwxnkJRcar7I07lTIjZdiuD+q=I$6rf|&adAOW~uLALXzv19-(NlT+RY`|y;ZA}> zrgnm9DZS|u{xvzv%b&D9JazUq?ACP!_J9(fIc!cs?ZRmLHxf*q{O9X1eQV!m$D|rW$f!}gy#>JRj7FsuGcSI#LO#R%{qhD&@ylI;P#qXlf zY!Lh*#Imri{iua95w}Lm9TG+~h{Oadw$J*dfF6|WTQzsrt*{SD_MLGK_ro5T6fd2r zqpCpMzjLAWBzppWZt>tx^~GokYNET_MK<-qdNq%ut#%%DaAZU!lvGMq!Y{hoMYN5a z|CjFiVixdPsOcFm-4Vpqo;XwV zi|Q{B8ukHC%*{@7x$s5W^*-U2e~!^nS-pFg>UFJ-rm4b1U3V4{lS#xubC%|`gWo7@ zYpz#Y9gfgC#6`n5_~rMk1~A*fc^YDS$KHj9#yJw_KbMx_^3>~w z%^drTzc#N+;1&zhw1x=KJpLs~bef&%Z}b+q1!>J-Q^=EPG7X-oWyprJTZc3Dsy z_|cK|dk-4Thk8{=HXbxO-?QGwox7)^`rqp|^blD$FV8xYOX*rITC$;b&C->*rQiEC zgC~f-tFOb4Sg=c?iraX+#hi*p50w}cC3yh&Y(={Ne8tz+0)C9k+c8p zW2Ss>rHVwpsGS_;dSTTB&A)+tc#N1D%rkI!1lFbZK+bqs5X$k$zgFfJ8p)&heu-0t+^g<~wXyzU+*}+<@~6YpgRYV0Z3u-QvZF{gX&%H`f$;&q2=isrsW$VobZx z{!i*d8plcE1{J`r_>WiU)9P~%cYbMo{??3=-MjYa3xNoYDyX6!EF^a*x?8K2|tnzUXnb;ban zfK}6?lWaW{iv)5Ew_s2G<9i|DYXr0W0-wqBYOe-gJ8XQV_oFlENijOl9Ghdc9=R#} z177OWkkS!VP(L5n4#X$mk(IMT&lc`d96oMMHY<7jZF!qnileq-kgAtG7lTA9!$xlK z?=MM``n|uDvFTe6wCv2FBPgo&JzR^mCN3gH@b-w0C?- zsj)S_D)CWD%-U#cy{E<4-5(v&KDPQ#?Wo+6iO~4eRa9|jTL(HU!rnF+^rdvyt0Ae@ zQY9t~2=g7;ul8?crdyaI|FxZ$d5#Y<8Jy4)#j3ls4(Ub~fA8Pz#Yiv3JGJtD4y2|PwipJL zq>Yux6d9iU*LPf|yT(Yp*u;K(ohe{W0?q?9omw=rKMYwFn3R*@2MNT+b;BR6Dsy6Pf~FG-Bd% zjJ@OF;ErwpT(-n8wcbg$PMfzG!gg4!&AqPP<8Ag`Wy3;He588xfbvKwEJcl>b6uR> ztivMvFwz74GSK+XZlgbM{ub$m^`^iZV3yui##;fb@U!wjzsR+1yHb03@+BLN9~4`= z!+Lj|jMydBS|1cTYH|MCQ=e^OKJ*BEh)VZN^{x(q*==o<6J?h6kBF;Fo@69RM0SB{C(;oS z=PPOXF*DV2sBCpmD2DUKtpfR!(&K)6{{5|JomTHd3>5>p^J%looSKp!59%!QHgYi#BC(tY6<}eu-&QA^^!{^Mv;pU>9sRbt6FS`Am%m4IRM?V=8k zdRXUVMc;v(zXPRqyi~z{+BdVD_A=`_zl=ch15bEFl5a-&UPoxz=DgebukT|Bx~BL? z%swU9&9+){>my^b`gFLd_XzTZz{a2h4dAbFokK;b6R2oJH&K1ip{~<>EniO>TUCT; z@Ht&fO@s%ro_r=a?nP_ccXS4&1fT@DR;*fiOG_~0h8r@Jy@G2?;&!`jy}IO!Ys+-A zsio`zaVxK_>i8H8XA%4x{FqEpp5@XeARYbB%eVF*B7y<0imoDki%3uU`tz1?-(kRZ zt>9fMvZWTB;_0}LWh(h-3*hK|KI84567wPA)qQ+Y4B(DHv`jT(B^pjmGYj$y=1K{V`HYPp0`dDh3sytTW8BNLsZv8Jhl@ z#iW#C^X+@AGs`6Mgrit3w+$~h?s<(LgP*I>f6wLJxcekd#QDAf&pTd|2{0+Ot;kwq zNYM819r?^`)Ti9Ul>`jUqpoML|y_s%ZMwx36NI1GcvfE-L)n zML7m~bng}L!!Jb%MlF>nvk9VPxi`usInmVYmIt=p4g;(t~T`Kk31_yht4^2yAx zg8T!2goBqZ1`HcxxuuLR#C#eU@K)d)Qfe;|&k=~}&hz7RHl(d_^}_K9?n~^$NqJ@eYc@|D(R_xR5-z#_cf1Z;m@5lbTApco`|4PWeK@wD({XctTgs$v#MHfKXU()GWC#T=FWp|<8?Mr;X{1p(1 zV-K8EwlfGCLJWPc{Sz;tD>7)8P@Y54tEajRH!-wsQV7dgp=N`bzetttIkg{A75x8@WS_!!)lV zfO2Ipdq@iGvVa|J@Fh<&=0P!X9foTcsmpzkSs#2*_-*6YQ>0S){UeXzX)k|DEmJ6W z3P>q9wS^(D7jhoF@c5VS9~(sPylEYl1Y9jaE~6S;&AS)^bs@@Cy1aN?vBgez%SNhg zZ@=}2J*PTNz_a|oW*cBzUhI8taNe7EnjE7jEfVz#4q6>ozvOea=LY}*ub0U`4OZ$6Yq zCH%Z9)YSA(181@2*6xwVz#h=tU>C8mC_9jnP|kqio4>q(a2%4eDdc7!Pt6I}wUgaY z=KCZDQaezIbA00w7~v7#$;hhW69;bunK$%g*hxW+suF|mv4gCb^k~|>Ql7Iw6(h{J z$mA9Zt9(*@-fJfCx-;r-V}qmHPKLctp!{oLuI^{hMQ#KfavXrgs@1lHUUKB_j_rWv z%!Qm#qhR1AVR-ZOHJR7;RyzaGsqrb+gq2;-Jl7i!p~8aVZ45K9z|;Xw`a56(1LztI zoL%!qULg)UHZ%Cfv`TXuK&PZPy?p|TRw{Y~7<~6Yd6ani>RY679=bSw*N}-`<)V(V zt?Ol$&@v$N^!tNQbO(KQ*E=;Vptb!H;XcE)$Wcc(2FgU1;-0gDg4#-?J4kc@aZ)N@ zO{PHTtvy)BTVcbNUuP(o)Vi3Oi>8NqqABVCOH%9`fru#alw93UKwvu*>*v3!Otf1< zr5gv-5kP~!g1G)m$oC$e7To;BEVE^-HA{_Z1EeN+DC|igUi!UELavCtJ86B3*(d62 z8CgL@iWoxoLSFJ%_U|KN5RP2)O&}Z!4yLPRp<&a8QmMe=)RvhFIYzc-&4S zHE47o)d49z#v>jPcmRqkn~q)N{vQ8i{))k_-vT7K$KdVP_$M~P1qU7B{6fP%RhSLx zfj@1Nd&%jQTGa@ZY_zj`!q7=C7GdJi8A-Sx^8q_6T2Qf>XjdcrSnLm?u8_}|xs zgWwK|iy@{TFdgz||9G9){b%X;f8IOx?XQ-k|Gf9V^Ra9G#s9|ZP4Cn7%N2nokYZVD zn5V2LaG;_`jDhlL#`aCNKQ`Gr>Mm|V&F9X(Y5V^D|HFh`e(d-zWUP&j>1pZ(1~cXw zjy|{Lu^i($cUoMre-f;_EP({9US@lWMY@Yw4nP^4xNY~xbfVknwXcdp+(NH;;;G6S ziu*QPdIDitHN704uMjC*vr`B9ftpGp4@oGiK{5wCkxIn@ft zDr;_#j{{*`<4|en6tOr|;aQV4?TOdPMkpdic-cVD?8eWzP9?K=CQy~}2qmB7U^v1h zdhU*`WV+zhGFUs-HS6aObTThQaaCW?L3lQ5aCr-X!4%=`+I4(tj8fv6=iGZ9ka}oF z?Nxb_XENWrVtBl9zN7i1sCnzmYij4Xh5WI!-dZ&**SvcV zY!sFN+>xTr4F=e|nbeC2m?{~(c{g& zNZdj=JKah1ST@Vnj^_#F=*+$pT)abCYxYp;)#>^5E&lRG?KQm^Mj0cw$=7-`9|fy0 zxVAO>3}txYqC>UQ)j^1r{^xwaVVi7s=s4ndj3rP9IAKOc8YH4Z?&A2r+C#_-o@!gU z-u@Z!kW4SB7zU>^7$38*6C7gp_tFXFUPHu$*m^LU2tB9iXM0-0YaGs=c8a;s!u|3U zb>r?ft5Tq~z6xgQ3@9TLz2_CNL;NK1+(u9D4^}>~>MGdB$gP)c@waZH7Y+7M#R>T# zF0&We#oAX6Um!|QS6nB%%`A;)QhBdn@v#3DGSW#Wk)ZRRuf91jsI4HGGF{uvn0L70 zHs0TNZ#c40uk1e1^gZ;5%i*j6%6}3v6!OU&MLiH4tl%ezd{KJgW|O-n!sM^=cJ^f*uEAz zQq9G!(}PnVIOSP4-&`p$-kay#WO_Hjyj-4L!(rN+8{t|;NC5H;-*1tnVV{$$DZ zo0;~XkVsJ@zZ@rky!5|U`b^C88oL@ju;4n4=9;)N@K=2qLufq6F)-fl2KG27U|U@_ z>|H&PL49C8f$OwwV>7ae`}i%nC0iI^tjXt^U1M`^&oH+ z(Bbq;OaN&6B0NcQWU5#zH4XmGbh(OG@#t-h&5{Y$_z{P)WJJfrC-6+v@SARyeI-A1 zM&K=eY`0Y;5YCaEG|uwpln>RPrDhFc(oW|or)kl9qK`Y|86pf*3^9vf{w#Y|RQ6Gy z-l7(I_h!f-T!!Ot>WffQ2U`p8_jjI{l5^~cOCRBj;#ml$9QzEUe4(5j)$t@=NsZ4IRJ+)fVPGK$(!(qmu}Wt zV*+Qh6bDe$CMnU0WsfUQy`qAH{k#*Y$|43i6Hf9H71_Ne?LBBJ=dj0G^K-7%5Fr1| zic@fZlD~x+jg~StvbRjF&#V09Z0u12gp3Y_nL!I*s#m378B)tU8jEeHvRHyY3-^Vz ziNY|AwO1Oj9w|wV?Gs{GMR#BQO-R8|a1Ly<#^THcKSWK3)90MML#altKuL?`+-E+4 z;G8gx=pBWPAk_%vv8)3JP*h))E2Vz;Y*-=y9W?OdHU*bn7hCIoktkg}dhwH73AJp@ zI|0ZfzX)K&tiNMW982j*j^j@~GTHOl>(Ikz1n{Z!)gs`lx6n&I>3mr(u#A2h#k#OQ zMJ7>ngHOTfhQZ(J7Qrgx){(L@o^=B#oM6=M`BrMydTXw3f`mRa66sr*ZrJc024Fm@ z@}21Y%67g_`Ww|I5?Y>a@+kB3qqR(8kvUK*z!X>aIpY_WJ9kbvu zHC-yk?f!CL;p`~aDn&n68mZxVBz!tWdF2Z5+8#5cF3V36jR>n%HhY_|(n&y1z*1wC>q34MQIGm@X z9M5$3oWGOr6%43iR{zlbF;4zqu_c8oT75}OaQ2OKXNnNK(Uwnz-P;L63Z^YULIp6U z@u$FMq!?sK!Yo*@mIH4Fu6m|@jEKusj;mB{1--@$d4DjV?_mA@2#h+TV5Pf5f9A;J zw6Ah=s~tZ}4AG+f{YPLo`0iCg*3WzWY1IVKtjZWMqSGQ|IGRII6B1$Df7e*|&;OdV zkTwdvcto#kZpzV3Dahjsp*R!1NHl#vM-qFEadydvZ0`zlJ7Sqe5OQEbMslaUTiYo6 z9aIxF5J$Ve*7;XrQT^5qlIc8I5y`oK5mG`LEjw& zBGA&*TWFtC-F59g2{X0@{yR~=NLLv0>Kh8hkF*k*-`pC^Sbs~poQP9!PH2}@=12lc zP>#g`Mr&F}1*{c2*H=qkfK+;n7_b51va7s9%3&rPbVjPadn|X>_ zSl_BW2OHR)PCB^g^kANaoNwukqR?T74=!Y_=RAMAMV94Ae5{txMxwt}D5Bnc#}aJ0 z*<(ZE=U)@jVq(07l3u#M9NnKNJlk%&igG%=zGY;BMGrzD`P%>v+A

{$u!<7oM$^^7{3~|l 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

ZtvViW2+f-Lh=9-9BEnMf8p@@0C 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 Date: Tue, 20 Aug 2024 15:46:54 +0000 Subject: [PATCH 183/206] Fix incorrect removal of last_query_response from database client --- .../simulator/system/applications/database_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index 0a626c00..3f80c745 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -75,6 +75,8 @@ class DatabaseClient(Application, identifier="DatabaseClient"): server_password: Optional[str] = None _query_success_tracker: Dict[str, bool] = {} """Keep track of connections that were established or verified during this step. Used for rewards.""" + last_query_response: Optional[Dict] = None + """Keep track of the latest query response. Used to determine rewards.""" _server_connection_id: Optional[str] = None """Connection ID to the Database Server.""" client_connections: Dict[str, DatabaseClientConnection] = {} @@ -381,6 +383,9 @@ class DatabaseClient(Application, identifier="DatabaseClient"): if not self.native_connection: return False + # reset last query response + self.last_query_response = None + uuid = str(uuid4()) self._query_success_tracker[uuid] = False return self.native_connection.query(sql) @@ -404,6 +409,7 @@ class DatabaseClient(Application, identifier="DatabaseClient"): connection_id=connection_id, connection_request_id=payload["connection_request_id"] ) elif payload["type"] == "sql": + self.last_query_response = payload query_id = payload.get("uuid") status_code = payload.get("status_code") self._query_success_tracker[query_id] = status_code == 200 From 4a7a4fd571c53977fae721da00ff4ef2f73699f3 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 22 Aug 2024 09:53:27 +0100 Subject: [PATCH 184/206] #2686 - typo changes in jupyter notebook --- .../Privilege-Escalation-and Data-Loss-Example.ipynb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb index c28d8bd1..f4ea2062 100644 --- a/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb +++ b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb @@ -121,7 +121,7 @@ "some_tech_db_srv: Server = game.simulation.network.get_node_by_hostname(\"some_tech_db_srv\")\n", "some_tech_db_service: DatabaseService = some_tech_db_srv.software_manager.software[\"DatabaseService\"]\n", "some_tech_storage_srv: Server = game.simulation.network.get_node_by_hostname(\"some_tech_storage_srv\")\n", - "some_tech_web_srv: Server = game.simulation.network.get_node_by_hostname(\"\")" + "some_tech_web_srv: Server = game.simulation.network.get_node_by_hostname(\"some_tech_web_srv\")" ] }, { @@ -193,7 +193,7 @@ "source": [ "## Check That the Junior Engineer Cannot SSH into the Storage Server\n", "\n", - "This step verifies that the junior engineer is currently restricted from SSH access to the storage server. By attempting to establish an SSH connection from the junior engineer’s workstation to the storage server, this action confirms that the current ACL rules on the core router correctly prevent unauthorised access. It sets up the necessary conditions to later validate the effectiveness of the privilege escalation by demonstrating the initial access restrictions.\n" + "This step verifies that the junior engineer is currently restricted from SSH access to the storage server. By attempting to establish an SSH connection from the junior engineer’s workstation to the storage server, this action confirms that the current ACL rules on the core router correctly prevents unauthorised access. It sets up the necessary conditions to later validate the effectiveness of the privilege escalation by demonstrating the initial access restrictions.\n" ] }, { @@ -240,9 +240,9 @@ "source": [ "## Exploit Core Router to Add ACL for SSH Access\n", "\n", - "At this point, the junior engineer exploits a vulnerability in the core router by obtaining the login credentials through social engineering. With SSH access to the core router, the engineer modifies the ACL rules to permit SSH connections from theimachinePC to the storage server. This action is crucial as it will enable the engineer to remotely access the storage server and execute further malicious activities.\n", + "At this point, the junior engineer exploits a vulnerability in the core router by obtaining the login credentials through social engineering. With SSH access to the core router, the engineer modifies the ACL rules to permit SSH connections from their machine to the storage server. This action is crucial as it will enable the engineer to remotely access the storage server and execute further malicious activities.\n", "\n", - "Interestingly, if we inspect the `active_remote_sessions` on the SomeTech core routers `UserSessionManager`, we'll see an active session appear. This active sessoin would pop up in the obersation space." + "Interestingly, if we inspect the `active_remote_sessions` on the SomeTech core routers `UserSessionManager`, we'll see an active session appear. This active session would pop up in the observation space." ] }, { From fbbaf65aab91a922551ecf37e0e5c7164ede08ef Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 22 Aug 2024 18:12:37 +0100 Subject: [PATCH 185/206] create doc page on rewards --- docs/index.rst | 1 + docs/source/rewards.rst | 116 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/source/rewards.rst diff --git a/docs/index.rst b/docs/index.rst index 93da9b88..ff97f60d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ What is PrimAITE? source/game_layer source/simulation source/config + source/rewards source/customising_scenarios source/varying_config_files source/environment diff --git a/docs/source/rewards.rst b/docs/source/rewards.rst new file mode 100644 index 00000000..e8888f14 --- /dev/null +++ b/docs/source/rewards.rst @@ -0,0 +1,116 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +Rewards +####### + +Rewards in PrimAITE are based on a system of individual components that react to events in the simulation. An agent's reward function is calculated as the weighted sum of several reward components. + +Components +********** +The following API pages describe the use of each reward component and the possible configuration options. An example of configuring each via yaml is also provided. + +:py:class:`DummyReward` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: DUMMY + weight: 1.0 + + +:py:class:`primaite.game.agent.rewards.DatabaseFileIntegrity` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 1.0 + options: + node_hostname: server_1 + folder_name: database + file_name: database.db + + +:py:class:`WebServer404Penalty` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: WEB_SERVER_404_PENALTY + node_hostname: web_server + weight: 1.0 + options: + service_name: WebService + sticky: false + + +:py:class:`WebpageUnavailablePenalty` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + node_hostname: computer_1 + weight: 1.0 + options: + sticky: false + + +:py:class:`GreenAdminDatabaseUnreachablePenalty` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 1.0 + options: + node_hostname: admin_pc_1 + sticky: false + + +:py:class:`SharedReward` + +.. code-block:: yaml + agents: + - ref: scripted_agent + # ... + - ref: agent_name + # ... + reward_function: + reward_components: + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: scripted_agent + + +:py:class:`ActionPenalty` + +.. code-block:: yaml + agents: + - ref: agent_name + # ... + reward_function: + reward_components: + - type: ACTION_PENALTY + weight: 1.0 + options: + action_penalty: -0.3 + do_nothing_penalty: 0.0 From 9a6b1d374a9c12c759ed76f22f3eded30d2654a3 Mon Sep 17 00:00:00 2001 From: Archer Bowen Date: Fri, 23 Aug 2024 12:22:56 +0100 Subject: [PATCH 186/206] Fixed incorrect formatting on .rst and new priv esc notebook --- docs/source/rewards.rst | 19 +++++++++++++------ ...ege-Escalation-and Data-Loss-Example.ipynb | 15 +++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/source/rewards.rst b/docs/source/rewards.rst index e8888f14..921544e8 100644 --- a/docs/source/rewards.rst +++ b/docs/source/rewards.rst @@ -11,9 +11,10 @@ Components ********** The following API pages describe the use of each reward component and the possible configuration options. An example of configuring each via yaml is also provided. -:py:class:`DummyReward` +:py:class:`primaite.game.agent.rewards.DummyReward` .. code-block:: yaml + agents: - ref: agent_name # ... @@ -26,6 +27,7 @@ The following API pages describe the use of each reward component and the possib :py:class:`primaite.game.agent.rewards.DatabaseFileIntegrity` .. code-block:: yaml + agents: - ref: agent_name # ... @@ -39,9 +41,10 @@ The following API pages describe the use of each reward component and the possib file_name: database.db -:py:class:`WebServer404Penalty` +:py:class:`primaite.game.agent.rewards.WebServer404Penalty` .. code-block:: yaml + agents: - ref: agent_name # ... @@ -55,9 +58,10 @@ The following API pages describe the use of each reward component and the possib sticky: false -:py:class:`WebpageUnavailablePenalty` +:py:class:`primaite.game.agent.rewards.WebpageUnavailablePenalty` .. code-block:: yaml + agents: - ref: agent_name # ... @@ -70,9 +74,10 @@ The following API pages describe the use of each reward component and the possib sticky: false -:py:class:`GreenAdminDatabaseUnreachablePenalty` +:py:class:`primaite.game.agent.rewards.GreenAdminDatabaseUnreachablePenalty` .. code-block:: yaml + agents: - ref: agent_name # ... @@ -85,9 +90,10 @@ The following API pages describe the use of each reward component and the possib sticky: false -:py:class:`SharedReward` +:py:class:`primaite.game.agent.rewards.SharedReward` .. code-block:: yaml + agents: - ref: scripted_agent # ... @@ -101,9 +107,10 @@ The following API pages describe the use of each reward component and the possib agent_name: scripted_agent -:py:class:`ActionPenalty` +:py:class:`primaite.game.agent.rewards.ActionPenalty` .. code-block:: yaml + agents: - ref: agent_name # ... diff --git a/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb index f4ea2062..c751edfd 100644 --- a/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb +++ b/src/primaite/notebooks/Privilege-Escalation-and Data-Loss-Example.ipynb @@ -1,18 +1,13 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK" - ] - }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Simulating Privilege Escalation and Data Loss Using SSH and ACLs Manipulation\n", "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", "## Overview\n", "\n", "This Jupyter notebook demonstrates a cyber scenario focusing on internal privilege escalation and data loss through the manipulation of SSH access and Access Control Lists (ACLs). The scenario is designed to model and visualise how a disgruntled junior engineer might exploit internal network vulnerabilities and social engineering of account credentials to escalate privileges and cause significant data loss and disruption to services.\n", @@ -53,7 +48,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# The Scenario" + "## The Scenario" ] }, { @@ -558,7 +553,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# End of Scenario Summary\n", + "## End of Scenario Summary\n", "\n", "In this simulation, we modelled a cyber attack scenario where a disgruntled junior engineer exploits internal network vulnerabilities to escalate privileges, causing significant data loss and disruption of services. The following key actions were performed:\n", "\n", @@ -604,7 +599,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.10.12" } }, "nbformat": 4, From a1553fb1b45410e215030a5cb7b05275ac3a798f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 28 Aug 2024 10:20:32 +0100 Subject: [PATCH 187/206] Backport core changes from internal --- src/primaite/game/agent/rewards.py | 13 +++++++++- src/primaite/simulator/network/airspace.py | 10 +++---- .../simulator/network/hardware/base.py | 20 +++++++------- .../red_applications/c2/abstract_c2.py | 1 + .../system/services/terminal/terminal.py | 26 ++++++++++--------- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index b97b7c5a..1de34b40 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -190,8 +190,12 @@ class WebServer404Penalty(AbstractReward): def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: """Calculate the reward for the current state. - :param state: The current state of the simulation. + :param state: Current simulation state :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float """ web_service_state = access_from_nested_dict(state, self.location_in_state) @@ -263,6 +267,12 @@ class WebpageUnavailablePenalty(AbstractReward): When the green agent requests to execute the browser application, and that request fails, this reward component will keep track of that information. In that case, it doesn't matter whether the last webpage had a 200 status code, because there has been an unsuccessful request since. + :param state: Current simulation state + :type state: Dict + :param last_action_response: Current agent history state + :type last_action_response: AgentHistoryItem state + :return: Reward value + :rtype: float """ web_browser_state = access_from_nested_dict(state, self.location_in_state) @@ -519,6 +529,7 @@ class RewardFunction: weight = comp_and_weight[1] total += weight * comp.calculate(state=state, last_action_response=last_action_response) self.current_reward = total + return self.current_reward @classmethod diff --git a/src/primaite/simulator/network/airspace.py b/src/primaite/simulator/network/airspace.py index 9c736383..cdb01514 100644 --- a/src/primaite/simulator/network/airspace.py +++ b/src/primaite/simulator/network/airspace.py @@ -60,13 +60,13 @@ class AirSpaceFrequency(Enum): @property def maximum_data_rate_bps(self) -> float: """ - Retrieves the maximum data transmission rate in bits per second (bps) for the frequency. + Retrieves the maximum data transmission rate in bits per second (bps). - The maximum rates are predefined for known frequencies: - - For WIFI_2_4, it returns 100,000,000 bps (100 Mbps). - - For WIFI_5, it returns 500,000,000 bps (500 Mbps). + The maximum rates are predefined for frequencies.: + - WIFI 2.4 supports 100,000,000 bps + - WIFI 5 supports 500,000,000 bps - :return: The maximum data rate in bits per second. If the frequency is not recognized, returns 0.0. + :return: The maximum data rate in bits per second. """ if self == AirSpaceFrequency.WIFI_2_4: return 100_000_000.0 # 100 Megabits per second diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 4f73ad7b..ef2d47c3 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1149,7 +1149,7 @@ class UserSessionManager(Service): local_session_timeout_steps: int = 30 """The number of steps before a local session times out due to inactivity.""" - remote_session_timeout_steps: int = 5 + remote_session_timeout_steps: int = 30 """The number of steps before a remote session times out due to inactivity.""" max_remote_sessions: int = 3 @@ -1179,15 +1179,14 @@ class UserSessionManager(Service): """ rm = super()._init_request_manager() - # todo add doc about request schemas - rm.add_request( - "remote_login", - RequestType( - func=lambda request, context: RequestResponse.from_bool( - self.remote_login(username=request[0], password=request[1], remote_ip_address=request[2]) - ) - ), - ) + def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse: + """Request should take the form [username, password, remote_ip_address].""" + username, password, remote_ip_address = request + response = RequestResponse.from_bool(self.remote_login(username, password, remote_ip_address)) + response.data = {"remote_hostname": self.parent.hostname, "username": username} + return response + + rm.add_request("remote_login", RequestType(func=_remote_login)) rm.add_request( "remote_logout", @@ -1422,6 +1421,7 @@ class UserSessionManager(Service): self.local_session = None if not local and remote_session_id: + self.parent.terminal._disconnect(remote_session_id) session = self.remote_sessions.pop(remote_session_id) if session: self.historic_sessions.append(session) diff --git a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py index 7316dd63..5d4cc8e0 100644 --- a/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py +++ b/src/primaite/simulator/system/applications/red_applications/c2/abstract_c2.py @@ -446,6 +446,7 @@ class AbstractC2(Application, identifier="AbstractC2"): if ( self.operating_state is ApplicationOperatingState.RUNNING and self.health_state_actual is SoftwareHealthState.GOOD + and self.c2_connection_active is True ): self.keep_alive_inactivity += 1 self._confirm_remote_connection(timestep) diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index 406facd1..e98e8555 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -171,7 +171,8 @@ class Terminal(Service): return RequestResponse( status="success", data={ - "ip_address": login.ip_address, + "ip_address": str(login.ip_address), + "username": request[0], }, ) else: @@ -189,15 +190,9 @@ class Terminal(Service): if remote_connection: outcome = self._disconnect(remote_connection.connection_uuid) if outcome: - return RequestResponse( - status="success", - data={}, - ) - else: - return RequestResponse( - status="failure", - data={"reason": "No remote connection held."}, - ) + return RequestResponse(status="success", data={}) + + return RequestResponse(status="failure", data={}) rm.add_request("remote_logoff", request_type=RequestType(func=_remote_logoff)) @@ -464,6 +459,10 @@ class Terminal(Service): command = payload.ssh_command valid_connection = self._check_client_connection(payload.connection_uuid) if valid_connection: + remote_session = self.software_manager.node.user_session_manager.remote_sessions.get( + payload.connection_uuid + ) + remote_session.last_active_step = self.software_manager.node.user_session_manager.current_timestep self.execute(command) return True else: @@ -484,7 +483,7 @@ class Terminal(Service): if payload["type"] == "user_timeout": connection_id = payload["connection_id"] - valid_id = self._check_client_connection(connection_id) + valid_id = connection_id in self._connections if valid_id: connection = self._connections.pop(connection_id) connection.is_active = False @@ -500,11 +499,14 @@ class Terminal(Service): :param connection_uuid: Connection ID that we want to disconnect. :return True if successful, False otherwise. """ + # TODO: Handle the possibility of attempting to disconnect if not self._connections: self.sys_log.warning(f"{self.name}: No remote connection present") return False - connection = self._connections.pop(connection_uuid) + connection = self._connections.pop(connection_uuid, None) + if not connection: + return False connection.is_active = False if isinstance(connection, RemoteTerminalConnection): From d3200f70e10ae6342206ff00da72f96c244a0825 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 30 Aug 2024 10:23:34 +0100 Subject: [PATCH 188/206] #2844: Added evaluation stage to Ray notebooks. --- .../Training-an-RLLIB-MARL-System.ipynb | 16 ++++++++++++++++ .../notebooks/Training-an-RLLib-Agent.ipynb | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb index 28f08edd..49801a2c 100644 --- a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb +++ b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb @@ -82,6 +82,22 @@ "algo = config.build()\n", "results = algo.train()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Evaluate the results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "eval = algo.evaluate()" + ] } ], "metadata": { diff --git a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb index 9d870192..2c35048d 100644 --- a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb +++ b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb @@ -74,6 +74,22 @@ "algo = config.build()\n", "results = algo.train()\n" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Evaluate the results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "eval = algo.evaluate()" + ] } ], "metadata": { From 049f7b76478630267841d0913fef97d2cd162d8c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 30 Aug 2024 15:22:05 +0100 Subject: [PATCH 189/206] Update action masking to inlcude new actions --- docs/source/action_masking.rst | 118 ++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/docs/source/action_masking.rst b/docs/source/action_masking.rst index 30b1376d..2b17075b 100644 --- a/docs/source/action_masking.rst +++ b/docs/source/action_masking.rst @@ -9,6 +9,8 @@ about which actions are invalid based on the current environment state. For inst software on a node that is turned off. Therefore, if an agent has a NODE_SOFTWARE_INSTALL in it's action map for that node, the action mask will show `0` in the corresponding entry. +*Note: just because an action is available in the action mask does not mean it will be successful when executed. It just means it's possible to try to execute the action at this time.* + Configuration ============= Action masking is supported for agents that use the `ProxyAgent` class (the class used for connecting to RL algorithms). @@ -23,95 +25,121 @@ The following logic is applied: +==========================================+=====================================================================+ | **DONOTHING** | Always Possible. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_SCAN** | Node is on. Service is running. | +| **NODE_SERVICE_SCAN** | Node is on. Service is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_STOP** | Node is on. Service is running. | +| **NODE_SERVICE_STOP** | Node is on. Service is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_START** | Node is on. Service is stopped. | +| **NODE_SERVICE_START** | Node is on. Service is stopped. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_PAUSE** | Node is on. Service is running. | +| **NODE_SERVICE_PAUSE** | Node is on. Service is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_RESUME** | Node is on. Service is paused. | +| **NODE_SERVICE_RESUME** | Node is on. Service is paused. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_RESTART** | Node is on. Service is running. | +| **NODE_SERVICE_RESTART** | Node is on. Service is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_DISABLE** | Node is on. | +| **NODE_SERVICE_DISABLE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_ENABLE** | Node is on. Service is disabled. | +| **NODE_SERVICE_ENABLE** | Node is on. Service is disabled. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SERVICE_FIX** | Node is on. Service is running. | +| **NODE_SERVICE_FIX** | Node is on. Service is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_EXECUTE** | Node is on. | +| **NODE_APPLICATION_EXECUTE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_SCAN** | Node is on. Application is running. | +| **NODE_APPLICATION_SCAN** | Node is on. Application is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_CLOSE** | Node is on. Application is running. | +| **NODE_APPLICATION_CLOSE** | Node is on. Application is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_FIX** | Node is on. Application is running. | +| **NODE_APPLICATION_FIX** | Node is on. Application is running. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_INSTALL** | Node is on. | +| **NODE_APPLICATION_INSTALL** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_APPLICATION_REMOVE** | Node is on. | +| **NODE_APPLICATION_REMOVE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_SCAN** | Node is on. File exists. File not deleted. | +| **NODE_FILE_SCAN** | Node is on. File exists. File not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_CREATE** | Node is on. | +| **NODE_FILE_CREATE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_CHECKHASH** | Node is on. File exists. File not deleted. | +| **NODE_FILE_CHECKHASH** | Node is on. File exists. File not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_DELETE** | Node is on. File exists. | +| **NODE_FILE_DELETE** | Node is on. File exists. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_REPAIR** | Node is on. File exists. File not deleted. | +| **NODE_FILE_REPAIR** | Node is on. File exists. File not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_RESTORE** | Node is on. File exists. File is deleted. | +| **NODE_FILE_RESTORE** | Node is on. File exists. File is deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_CORRUPT** | Node is on. File exists. File not deleted. | +| **NODE_FILE_CORRUPT** | Node is on. File exists. File not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FILE_ACCESS** | Node is on. File exists. File not deleted. | +| **NODE_FILE_ACCESS** | Node is on. File exists. File not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FOLDER_CREATE** | Node is on. | +| **NODE_FOLDER_CREATE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FOLDER_SCAN** | Node is on. Folder exists. Folder not deleted. | +| **NODE_FOLDER_SCAN** | Node is on. Folder exists. Folder not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FOLDER_CHECKHASH** | Node is on. Folder exists. Folder not deleted. | +| **NODE_FOLDER_CHECKHASH** | Node is on. Folder exists. Folder not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FOLDER_REPAIR** | Node is on. Folder exists. Folder not deleted. | +| **NODE_FOLDER_REPAIR** | Node is on. Folder exists. Folder not deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_FOLDER_RESTORE** | Node is on. Folder exists. Folder is deleted. | +| **NODE_FOLDER_RESTORE** | Node is on. Folder exists. Folder is deleted. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_OS_SCAN** | Node is on. | +| **NODE_OS_SCAN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_NIC_ENABLE** | NIC is disabled. Node is on. | +| **HOST_NIC_ENABLE** | NIC is disabled. Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_NIC_DISABLE** | NIC is enabled. Node is on. | +| **HOST_NIC_DISABLE** | NIC is enabled. Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_SHUTDOWN** | Node is on. | +| **NODE_SHUTDOWN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_STARTUP** | Node is off. | +| **NODE_STARTUP** | Node is off. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_RESET** | Node is on. | +| **NODE_RESET** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_NMAP_PING_SCAN** | Node is on. | +| **NODE_NMAP_PING_SCAN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_NMAP_PORT_SCAN** | Node is on. | +| **NODE_NMAP_PORT_SCAN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_HOST_NMAP_NETWORK_SERVICE_RECON** | Node is on. | +| **NODE_NMAP_NETWORK_SERVICE_RECON** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_ROUTER_PORT_ENABLE** | Router is on. | +| **NETWORK_PORT_ENABLE** | Node is on. Router is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_ROUTER_PORT_DISABLE** | Router is on. | +| **NETWORK_PORT_DISABLE** | Router is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_ROUTER_ACL_ADDRULE** | Router is on. | +| **ROUTER_ACL_ADDRULE** | Router is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_ROUTER_ACL_REMOVERULE** | Router is on. | +| **ROUTER_ACL_REMOVERULE** | Router is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_FIREWALL_PORT_ENABLE** | Firewall is on. | +| **FIREWALL_ACL_ADDRULE** | Firewall is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_FIREWALL_PORT_DISABLE** | Firewall is on. | +| **FIREWALL_ACL_REMOVERULE** | Firewall is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_FIREWALL_ACL_ADDRULE** | Firewall is on. | +| NODE_NMAP_PING_SCAN | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| **NODE_FIREWALL_ACL_REMOVERULE** | Firewall is on. | +| NODE_NMAP_PORT_SCAN | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| NODE_NMAP_NETWORK_SERVICE_RECON | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| CONFIGURE_DATABASE_CLIENT | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| CONFIGURE_RANSOMWARE_SCRIPT | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| CONFIGURE_DOSBOT | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| CONFIGURE_C2_BEACON | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| C2_SERVER_RANSOMWARE_LAUNCH | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| C2_SERVER_RANSOMWARE_CONFIGURE | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| C2_SERVER_TERMINAL_COMMAND | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| C2_SERVER_DATA_EXFILTRATE | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| NODE_ACCOUNTS_CHANGE_PASSWORD | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| SSH_TO_REMOTE | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| SESSIONS_REMOTE_LOGOFF | Node is on. | ++------------------------------------------+---------------------------------------------------------------------+ +| NODE_SEND_REMOTE_COMMAND | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ From 3a6e10b772f81b8eb8cae62af566a752f105bec7 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 2 Sep 2024 07:46:03 +0000 Subject: [PATCH 190/206] Updated VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 6d0e8e51..15a27998 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.3.0-dev0 +3.3.0 From a6dd9b850bd1c65d3f28570f9ea7fee1afe26437 Mon Sep 17 00:00:00 2001 From: Defence Science and Technology Laboratory UK Date: Mon, 2 Sep 2024 09:53:06 +0000 Subject: [PATCH 191/206] Automated benchmark output commit for version 3.3.0 [skip ci] --- ...nd Bugfix Releases for Major Version 3.png | Bin 0 -> 82270 bytes ...nd Bugfix Releases for Major Version 3.png | Bin 0 -> 47362 bytes .../PrimAITE v3.3.0 Benchmark Report.md | 38 + .../PrimAITE v3.3.0 Benchmark Report.pdf | Bin 0 -> 210119 bytes .../PrimAITE v3.3.0 Learning Benchmark.png | Bin 0 -> 159498 bytes .../results/v3/v3.3.0/session_metadata/1.json | 1009 +++ .../results/v3/v3.3.0/session_metadata/2.json | 1009 +++ .../results/v3/v3.3.0/session_metadata/3.json | 1009 +++ .../results/v3/v3.3.0/session_metadata/4.json | 1009 +++ .../results/v3/v3.3.0/session_metadata/5.json | 1009 +++ .../v3/v3.3.0/v3.3.0_benchmark_metadata.json | 7445 +++++++++++++++++ 11 files changed, 12528 insertions(+) create mode 100644 benchmark/results/v3/v3.3.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png create mode 100644 benchmark/results/v3/v3.3.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png create mode 100644 benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.md create mode 100644 benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.pdf create mode 100644 benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Learning Benchmark.png create mode 100644 benchmark/results/v3/v3.3.0/session_metadata/1.json create mode 100644 benchmark/results/v3/v3.3.0/session_metadata/2.json create mode 100644 benchmark/results/v3/v3.3.0/session_metadata/3.json create mode 100644 benchmark/results/v3/v3.3.0/session_metadata/4.json create mode 100644 benchmark/results/v3/v3.3.0/session_metadata/5.json create mode 100644 benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json diff --git a/benchmark/results/v3/v3.3.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png b/benchmark/results/v3/v3.3.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png new file mode 100644 index 0000000000000000000000000000000000000000..123c97744a7bf3c8ad99e3f0b7cbeba70fc8f2ad GIT binary patch literal 82270 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 literal 0 HcmV?d00001 diff --git a/benchmark/results/v3/v3.3.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png b/benchmark/results/v3/v3.3.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png new file mode 100644 index 0000000000000000000000000000000000000000..43f661ea894cdb0e74d0e149ab1dad63d5be14a7 GIT binary patch literal 47362 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 T>%MXT2R1k7y-tO@j$i)=2K&VJ literal 0 HcmV?d00001 diff --git a/benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.md b/benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.md new file mode 100644 index 00000000..da71ede3 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.md @@ -0,0 +1,38 @@ +# PrimAITE v3.3.0 Learning Benchmark +## PrimAITE Dev Team +### 2024-09-02 + +--- +## 1 Introduction +PrimAITE v3.3.0 was benchmarked automatically upon release. Learning rate metrics were captured to be referenced during system-level testing and user acceptance testing (UAT). +The benchmarking process consists of running 5 training session using the same config file. Each session trains an agent for 1000 episodes, with each episode consisting of 128 steps. +The total reward per episode from each session is captured. This is then used to calculate an caverage total reward per episode from the 5 individual sessions for smoothing. Finally, a 25-widow rolling average of the average total reward per session is calculated for further smoothing. +## 2 System Information +### 2.1 Python +**Version:** 3.10.14 (main, Apr 6 2024, 18:45:05) [GCC 9.4.0] +### 2.2 System +- **OS:** Linux +- **OS Version:** #76~20.04.1-Ubuntu SMP Thu Jun 13 18:00:23 UTC 2024 +- **Machine:** x86_64 +- **Processor:** x86_64 +### 2.3 CPU +- **Physical Cores:** 2 +- **Total Cores:** 4 +- **Max Frequency:** 0.00Mhz +### 2.4 Memory +- **Total:** 15.62GB +- **Swap Total:** 0.00B +## 3 Stats +- **Total Sessions:** 5 +- **Total Episodes:** 5005 +- **Total Steps:** 640000 +- **Av Session Duration (s):** 1458.2831 +- **Av Step Duration (s):** 0.0456 +- **Av Duration per 100 Steps per 10 Nodes (s):** 4.5571 +## 4 Graphs +### 4.1 v3.3.0 Learning Benchmark Plot +![PrimAITE 3.3.0 Learning Benchmark Plot](PrimAITE v3.3.0 Learning Benchmark.png) +### 4.2 Learning Benchmark of Minor and Bugfix Releases for Major Version 3 +![Learning Benchmark of Minor and Bugfix Releases for Major Version 3](PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png) +### 4.3 Performance of Minor and Bugfix Releases for Major Version 3 +![Performance of Minor and Bugfix Releases for Major Version 3](PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png) diff --git a/benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.pdf b/benchmark/results/v3/v3.3.0/PrimAITE v3.3.0 Benchmark Report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..10a55a10d4f228093c0b3f47bd77b61ad47fbd5a GIT binary patch literal 210119 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 z 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

q--=WiTXm*z)gc=z1ox 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

D|usiINId8`rD>ISjA_Sxm8)1wOfB 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%0AoWuT#4y=m(c%zR6QJ_tj41nkBQjzp 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

VC3=1L^th7hy&SVqQ!hdA9A*B6Rqbsj=~Z;n-% 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 a<0r6pj_1*-ycjJA{K?5EO6N%F`~E-KEeDAJ literal 0 HcmV?d00001 diff --git a/benchmark/results/v3/v3.3.0/session_metadata/1.json b/benchmark/results/v3/v3.3.0/session_metadata/1.json new file mode 100644 index 00000000..836363b8 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/session_metadata/1.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1507.500278, + "s_per_step": 0.0471093836875, + "s_per_100_steps_10_nodes": 4.71093836875, + "total_reward_per_episode": { + "1": -22.899999999999963, + "2": -11.84999999999998, + "3": -45.15000000000006, + "4": -11.449999999999983, + "5": -22.449999999999953, + "6": -14.549999999999981, + "7": -69.80000000000005, + "8": -23.149999999999963, + "9": -92.95, + "10": -1.6499999999999995, + "11": -41.85000000000005, + "12": -20.199999999999953, + "13": -2.049999999999983, + "14": -23.34999999999995, + "15": -66.40000000000009, + "16": -60.350000000000094, + "17": -12.69999999999998, + "18": -19.599999999999987, + "19": -13.349999999999982, + "20": -23.24999999999995, + "21": -14.399999999999986, + "22": -45.600000000000065, + "23": -49.10000000000007, + "24": -21.649999999999956, + "25": -95.7000000000001, + "26": -45.55000000000019, + "27": -21.749999999999957, + "28": -39.05, + "29": -42.900000000000105, + "30": -19.849999999999966, + "31": 7.00000000000002, + "32": -52.75000000000008, + "33": -28.799999999999976, + "34": -4.099999999999985, + "35": -34.749999999999986, + "36": -21.09999999999996, + "37": -37.00000000000011, + "38": -16.24999999999998, + "39": -15.299999999999986, + "40": -12.499999999999995, + "41": -83.54999999999981, + "42": -22.2, + "43": -84.75000000000009, + "44": -16.89999999999997, + "45": -25.49999999999999, + "46": -18.89999999999997, + "47": -11.349999999999987, + "48": -21.049999999999958, + "49": -22.99999999999995, + "50": -22.499999999999954, + "51": -70.44999999999999, + "52": -62.300000000000104, + "53": 3.049999999999968, + "54": -7.399999999999997, + "55": -16.799999999999972, + "56": -73.75, + "57": -33.30000000000002, + "58": -3.0000000000000067, + "59": -16.74999999999997, + "60": -21.699999999999957, + "61": -69.05000000000005, + "62": -98.54999999999998, + "63": -7.099999999999993, + "64": -3.749999999999984, + "65": -98.19999999999999, + "66": -60.90000000000017, + "67": -97.2, + "68": -22.199999999999953, + "69": -14.549999999999965, + "70": -20.999999999999957, + "71": -20.399999999999963, + "72": -5.599999999999977, + "73": -13.300000000000004, + "74": -14.649999999999979, + "75": -11.399999999999993, + "76": -6.699999999999988, + "77": -43.300000000000125, + "78": -30.449999999999992, + "79": -23.29999999999995, + "80": -75.85, + "81": 11.55, + "82": -37.24999999999999, + "83": -94.24999999999997, + "84": -18.74999999999999, + "85": -89.3, + "86": -27.350000000000026, + "87": -103.15000000000006, + "88": -73.15000000000002, + "89": -16.999999999999975, + "90": -31.54999999999993, + "91": -16.699999999999974, + "92": -22.699999999999953, + "93": -91.19999999999999, + "94": -18.949999999999967, + "95": -87.8, + "96": -17.89999999999997, + "97": -65.3, + "98": -16.24999999999998, + "99": -12.749999999999995, + "100": -2.199999999999976, + "101": -30.199999999999978, + "102": -69.74999999999997, + "103": -75.4, + "104": -63.35000000000011, + "105": -21.749999999999957, + "106": -15.04999999999998, + "107": -11.149999999999993, + "108": -95.4, + "109": -9.299999999999994, + "110": -7.399999999999994, + "111": -67.90000000000006, + "112": -66.00000000000001, + "113": -88.4, + "114": -14.949999999999976, + "115": 0.20000000000001994, + "116": -7.80000000000002, + "117": -10.3, + "118": 4.550000000000024, + "119": -42.250000000000114, + "120": -23.89999999999997, + "121": 8.499999999999986, + "122": -73.14999999999999, + "123": -18.749999999999968, + "124": -18.14999999999997, + "125": -2.8999999999999737, + "126": -6.199999999999982, + "127": -13.09999999999999, + "128": -11.849999999999987, + "129": 14.849999999999994, + "130": -14.749999999999977, + "131": -50.60000000000007, + "132": -39.65000000000005, + "133": 14.300000000000018, + "134": -9.399999999999993, + "135": -21.949999999999953, + "136": -16.69999999999997, + "137": 29.599999999999888, + "138": -19.99999999999996, + "139": 0.7500000000000469, + "140": 25.200000000000024, + "141": -18.399999999999967, + "142": -97.19999999999999, + "143": -90.15, + "144": 20.800000000000008, + "145": -7.900000000000008, + "146": -56.750000000000014, + "147": -81.70000000000005, + "148": -91.45, + "149": -31.10000000000001, + "150": -64.35, + "151": -59.49999999999999, + "152": -15.89999999999998, + "153": 8.65000000000002, + "154": -80.35000000000001, + "155": -84.64999999999996, + "156": -20.79999999999996, + "157": 1.900000000000028, + "158": -53.599999999999994, + "159": -86.80000000000001, + "160": -93.6, + "161": -92.14999999999995, + "162": -66.75000000000001, + "163": -78.65000000000002, + "164": -8.049999999999995, + "165": -87.99999999999997, + "166": 27.249999999999893, + "167": -35.30000000000001, + "168": -0.7999999999999727, + "169": -96.44999999999999, + "170": -53.09999999999996, + "171": -7.750000000000002, + "172": -1.2499999999999776, + "173": -63.39999999999997, + "174": -36.79999999999995, + "175": -10.09999999999999, + "176": -9.699999999999998, + "177": -48.2, + "178": -76.7, + "179": -73.59999999999995, + "180": -76.2, + "181": -88.39999999999999, + "182": -15.649999999999977, + "183": -91.14999999999998, + "184": -11.499999999999995, + "185": -21.949999999999953, + "186": -30.85000000000002, + "187": 40.64999999999981, + "188": 8.850000000000062, + "189": -77.00000000000004, + "190": -75.45, + "191": -0.8999999999999571, + "192": -47.25, + "193": -61.69999999999993, + "194": 7.100000000000066, + "195": -7.099999999999988, + "196": -4.050000000000007, + "197": -6.499999999999984, + "198": -82.9, + "199": 1.300000000000014, + "200": 9.849999999999971, + "201": -3.7499999999999805, + "202": 84.9000000000002, + "203": 8.45000000000005, + "204": -32.749999999999964, + "205": -36.44999999999997, + "206": -90.1, + "207": -84.05, + "208": -12.199999999999989, + "209": 13.94999999999997, + "210": -18.849999999999994, + "211": 16.80000000000005, + "212": 26.599999999999895, + "213": -22.84999999999995, + "214": -74.05, + "215": -8.149999999999993, + "216": -28.949999999999967, + "217": -61.29999999999995, + "218": -3.8000000000000043, + "219": -56.799999999999976, + "220": 25.85000000000001, + "221": -87.0, + "222": -64.14999999999999, + "223": -40.10000000000003, + "224": 5.250000000000007, + "225": -11.449999999999992, + "226": -0.39999999999999414, + "227": -65.19999999999999, + "228": -34.400000000000006, + "229": -5.9499999999999895, + "230": -19.349999999999966, + "231": 32.99999999999977, + "232": 6.500000000000082, + "233": -1.399999999999994, + "234": -46.099999999999966, + "235": 51.249999999999815, + "236": -68.25000000000001, + "237": -74.30000000000001, + "238": -4.049999999999976, + "239": -82.25, + "240": -28.799999999999937, + "241": 5.90000000000005, + "242": -1.949999999999961, + "243": -80.85, + "244": -12.649999999999988, + "245": -1.5999999999999868, + "246": -53.999999999999986, + "247": -65.85, + "248": -25.799999999999994, + "249": 0.8000000000000482, + "250": 8.250000000000032, + "251": 8.55000000000004, + "252": 7.000000000000038, + "253": -30.549999999999972, + "254": -49.400000000000034, + "255": 2.2000000000000446, + "256": 2.550000000000025, + "257": -17.399999999999984, + "258": -71.35, + "259": 13.550000000000004, + "260": -80.0, + "261": -10.74999999999999, + "262": 27.84999999999992, + "263": -10.95, + "264": -57.65000000000002, + "265": 25.99999999999989, + "266": 31.899999999999963, + "267": 2.4000000000000163, + "268": -71.5, + "269": -63.45000000000001, + "270": 78.64999999999993, + "271": -78.9, + "272": -13.149999999999956, + "273": -17.599999999999973, + "274": -14.24999999999999, + "275": -0.19999999999996576, + "276": -34.44999999999999, + "277": -1.999999999999969, + "278": -16.700000000000017, + "279": -55.699999999999996, + "280": -63.64999999999999, + "281": -0.04999999999998295, + "282": -35.45, + "283": -31.89999999999997, + "284": -69.44999999999997, + "285": -78.5, + "286": -1.1000000000000014, + "287": -74.20000000000002, + "288": -78.35000000000002, + "289": -81.80000000000001, + "290": -32.50000000000001, + "291": 8.750000000000028, + "292": -22.49999999999997, + "293": 6.8500000000000005, + "294": -91.6, + "295": 36.099999999999795, + "296": -81.25, + "297": 5.149999999999975, + "298": 7.249999999999992, + "299": -10.149999999999983, + "300": -68.54999999999993, + "301": -61.24999999999994, + "302": -13.749999999999988, + "303": -66.64999999999995, + "304": -72.1, + "305": -53.400000000000034, + "306": -41.95000000000002, + "307": 22.650000000000034, + "308": -78.69999999999999, + "309": -62.0, + "310": -72.04999999999997, + "311": -60.74999999999993, + "312": -77.45, + "313": -51.69999999999997, + "314": -78.50000000000001, + "315": -44.65000000000001, + "316": 15.80000000000004, + "317": 39.44999999999979, + "318": -43.999999999999964, + "319": -48.29999999999999, + "320": 42.99999999999991, + "321": -23.049999999999983, + "322": -4.899999999999984, + "323": 34.099999999999795, + "324": -62.24999999999992, + "325": -76.95, + "326": 7.3000000000000504, + "327": -101.30000000000013, + "328": -16.95000000000004, + "329": -50.199999999999996, + "330": -41.8, + "331": -60.84999999999993, + "332": 13.500000000000007, + "333": -53.04999999999998, + "334": 0.7500000000000511, + "335": 60.79999999999987, + "336": 6.50000000000005, + "337": 8.10000000000003, + "338": -63.7, + "339": -22.79999999999995, + "340": -82.69999999999999, + "341": -39.10000000000001, + "342": 39.599999999999795, + "343": -32.35000000000003, + "344": -65.24999999999994, + "345": 85.15000000000003, + "346": 18.34999999999998, + "347": -86.14999999999999, + "348": 30.99999999999976, + "349": -79.75, + "350": 43.44999999999984, + "351": -78.65000000000003, + "352": 34.799999999999834, + "353": -4.249999999999974, + "354": -39.35, + "355": -75.14999999999999, + "356": -67.94999999999999, + "357": -64.94999999999996, + "358": -54.19999999999996, + "359": -68.19999999999996, + "360": -38.10000000000001, + "361": 10.249999999999986, + "362": -2.0999999999999925, + "363": -10.299999999999955, + "364": -70.75, + "365": -59.25000000000002, + "366": -46.25000000000003, + "367": -61.64999999999998, + "368": 5.250000000000063, + "369": -24.54999999999994, + "370": -32.00000000000002, + "371": 25.10000000000001, + "372": -92.89999999999998, + "373": 26.450000000000102, + "374": -49.60000000000004, + "375": 13.300000000000011, + "376": -17.49999999999998, + "377": 7.600000000000042, + "378": -66.69999999999993, + "379": -25.049999999999994, + "380": -64.74999999999997, + "381": -64.34999999999998, + "382": -38.20000000000001, + "383": 59.04999999999991, + "384": 0.6000000000000636, + "385": 21.85000000000011, + "386": 14.049999999999986, + "387": -28.49999999999998, + "388": -65.89999999999996, + "389": 31.79999999999977, + "390": -54.74999999999997, + "391": -58.699999999999946, + "392": -73.99999999999999, + "393": 7.249999999999879, + "394": -62.55000000000001, + "395": -64.75000000000003, + "396": -64.69999999999992, + "397": -72.95, + "398": -57.300000000000026, + "399": 17.350000000000023, + "400": -77.60000000000005, + "401": 49.599999999999916, + "402": -78.75000000000009, + "403": -32.750000000000036, + "404": -13.849999999999985, + "405": -57.54999999999998, + "406": -67.64999999999996, + "407": -14.549999999999986, + "408": -38.69999999999999, + "409": -42.34999999999999, + "410": -75.05000000000001, + "411": -73.25000000000001, + "412": 36.849999999999795, + "413": -43.14999999999998, + "414": 50.84999999999989, + "415": -64.3999999999999, + "416": -17.599999999999984, + "417": -3.6999999999999673, + "418": -65.64999999999998, + "419": -11.450000000000015, + "420": -57.24999999999999, + "421": -65.54999999999995, + "422": -59.34999999999998, + "423": -64.79999999999997, + "424": -8.000000000000071, + "425": -12.900000000000041, + "426": -18.499999999999975, + "427": -24.499999999999975, + "428": -55.39999999999993, + "429": -30.89999999999997, + "430": -28.44999999999996, + "431": -12.949999999999976, + "432": -65.84999999999995, + "433": -50.99999999999996, + "434": -19.099999999999973, + "435": -68.4, + "436": -60.800000000000004, + "437": -3.9499999999999735, + "438": -10.999999999999922, + "439": -62.49999999999996, + "440": -57.299999999999976, + "441": -61.749999999999936, + "442": -46.04999999999999, + "443": -67.99999999999994, + "444": -62.64999999999992, + "445": 25.599999999999856, + "446": -55.09999999999995, + "447": -68.19999999999993, + "448": 3.499999999999991, + "449": -34.300000000000004, + "450": -29.700000000000006, + "451": 34.25000000000014, + "452": -12.100000000000064, + "453": -42.04999999999998, + "454": -29.10000000000001, + "455": -23.09999999999998, + "456": -26.79999999999995, + "457": -20.24999999999999, + "458": -52.2, + "459": -72.34999999999995, + "460": -65.34999999999991, + "461": -55.79999999999996, + "462": -41.65000000000001, + "463": -33.04999999999998, + "464": 22.450000000000095, + "465": 95.45000000000006, + "466": 41.64999999999985, + "467": -50.09999999999998, + "468": 6.800000000000042, + "469": 37.55, + "470": -13.75000000000001, + "471": 10.600000000000062, + "472": -62.24999999999993, + "473": -16.699999999999964, + "474": -56.79999999999995, + "475": -27.550000000000004, + "476": 84.70000000000005, + "477": -59.54999999999993, + "478": -74.04999999999998, + "479": -74.99999999999987, + "480": 28.04999999999995, + "481": -25.19999999999999, + "482": -96.60000000000001, + "483": -20.700000000000014, + "484": 77.85000000000011, + "485": -50.849999999999945, + "486": 28.700000000000042, + "487": -58.649999999999935, + "488": -35.949999999999974, + "489": -24.349999999999934, + "490": -54.0, + "491": -14.149999999999975, + "492": -44.8499999999999, + "493": -66.24999999999991, + "494": -10.149999999999965, + "495": 1.499999999999983, + "496": 26.350000000000072, + "497": -57.949999999999974, + "498": -54.799999999999955, + "499": 33.00000000000012, + "500": 7.250000000000083, + "501": 74.15000000000002, + "502": 21.699999999999974, + "503": -73.0999999999999, + "504": -15.34999999999998, + "505": 70.99999999999991, + "506": -66.84999999999991, + "507": -61.649999999999935, + "508": -58.94999999999998, + "509": -41.09999999999997, + "510": -26.349999999999994, + "511": -65.6999999999999, + "512": 44.44999999999985, + "513": -70.19999999999989, + "514": 65.74999999999986, + "515": -50.59999999999994, + "516": -66.84999999999997, + "517": -64.04999999999998, + "518": -25.099999999999998, + "519": -59.99999999999991, + "520": -67.19999999999997, + "521": -21.99999999999999, + "522": -49.89999999999998, + "523": -27.19999999999997, + "524": -51.04999999999998, + "525": -30.54999999999997, + "526": -38.25, + "527": 67.24999999999982, + "528": -63.899999999999935, + "529": -62.44999999999999, + "530": -36.05000000000003, + "531": -67.59999999999995, + "532": -67.89999999999992, + "533": -64.14999999999999, + "534": -22.099999999999984, + "535": -62.64999999999992, + "536": -52.49999999999997, + "537": -76.15000000000006, + "538": -15.750000000000046, + "539": -59.89999999999995, + "540": -1.1999999999999718, + "541": -63.65000000000005, + "542": -65.94999999999992, + "543": -5.55000000000003, + "544": -59.99999999999997, + "545": -66.7499999999999, + "546": 15.80000000000001, + "547": 87.35000000000007, + "548": -72.19999999999989, + "549": -64.09999999999992, + "550": -52.69999999999996, + "551": -9.40000000000001, + "552": -19.750000000000057, + "553": -62.94999999999993, + "554": -60.09999999999995, + "555": -39.7, + "556": 27.00000000000004, + "557": -52.04999999999994, + "558": -30.59999999999998, + "559": -86.75, + "560": -51.39999999999996, + "561": -61.20000000000003, + "562": 0.5499999999999772, + "563": -37.59999999999999, + "564": -18.650000000000027, + "565": -58.349999999999945, + "566": 55.750000000000014, + "567": -15.649999999999968, + "568": -27.250000000000007, + "569": -47.499999999999986, + "570": 100.40000000000032, + "571": -43.05, + "572": -62.24999999999993, + "573": 28.100000000000087, + "574": -65.99999999999996, + "575": 28.39999999999995, + "576": -2.0499999999999177, + "577": -58.399999999999935, + "578": -57.19999999999993, + "579": -24.6, + "580": -63.69999999999992, + "581": -4.249999999999938, + "582": 13.300000000000011, + "583": -51.749999999999964, + "584": -49.64999999999997, + "585": 50.100000000000136, + "586": 82.85000000000016, + "587": -34.00000000000001, + "588": -26.950000000000024, + "589": 102.25000000000016, + "590": -33.900000000000034, + "591": -1.549999999999984, + "592": -61.99999999999995, + "593": -56.95, + "594": 14.499999999999964, + "595": -66.7499999999999, + "596": 52.29999999999995, + "597": -50.99999999999997, + "598": 88.75, + "599": -23.750000000000014, + "600": 68.1499999999999, + "601": -47.39999999999999, + "602": -68.29999999999997, + "603": 62.750000000000156, + "604": -65.84999999999991, + "605": -3.4000000000000314, + "606": -23.75000000000003, + "607": 3.1499999999999764, + "608": -52.29999999999997, + "609": -13.599999999999982, + "610": -51.59999999999997, + "611": -37.8, + "612": -19.049999999999997, + "613": -55.84999999999996, + "614": -7.299999999999946, + "615": -79.05000000000001, + "616": 29.05000000000002, + "617": 6.500000000000016, + "618": -26.70000000000005, + "619": 79.24999999999993, + "620": -34.80000000000003, + "621": 47.85000000000002, + "622": 32.150000000000006, + "623": 88.59999999999998, + "624": -19.449999999999946, + "625": 49.79999999999995, + "626": 15.09999999999998, + "627": 38.949999999999996, + "628": 19.950000000000063, + "629": -12.799999999999974, + "630": 10.050000000000054, + "631": 67.65000000000006, + "632": -1.949999999999986, + "633": 21.60000000000012, + "634": 92.8000000000001, + "635": 25.64999999999996, + "636": 73.35000000000002, + "637": 3.9999999999999902, + "638": 42.89999999999986, + "639": -49.499999999999964, + "640": -18.200000000000045, + "641": 63.14999999999993, + "642": -16.550000000000015, + "643": 64.95000000000006, + "644": -41.099999999999966, + "645": 24.10000000000002, + "646": 34.84999999999997, + "647": 42.84999999999999, + "648": 62.49999999999998, + "649": 45.649999999999906, + "650": 72.89999999999999, + "651": 32.30000000000004, + "652": -26.04999999999997, + "653": 68.10000000000008, + "654": 70.24999999999977, + "655": 90.7000000000001, + "656": 88.55000000000003, + "657": 24.04999999999996, + "658": 57.899999999999956, + "659": 33.700000000000045, + "660": 59.29999999999987, + "661": 101.15000000000005, + "662": 41.899999999999935, + "663": 36.14999999999995, + "664": -51.19999999999997, + "665": 81.8500000000001, + "666": 88.4000000000001, + "667": 61.69999999999994, + "668": 19.299999999999983, + "669": 70.64999999999995, + "670": 46.49999999999997, + "671": 40.69999999999995, + "672": -27.34999999999998, + "673": 107.95, + "674": 44.99999999999982, + "675": 11.40000000000002, + "676": 90.50000000000016, + "677": 38.84999999999977, + "678": 56.59999999999999, + "679": 93.05000000000005, + "680": 57.399999999999956, + "681": 41.05000000000001, + "682": 90.94999999999996, + "683": 64.69999999999997, + "684": -54.09999999999994, + "685": 101.75000000000003, + "686": 53.74999999999992, + "687": 100.40000000000002, + "688": 35.8999999999998, + "689": 47.50000000000003, + "690": 32.59999999999986, + "691": 42.99999999999987, + "692": 76.00000000000003, + "693": -5.800000000000042, + "694": 3.199999999999882, + "695": 24.200000000000006, + "696": 43.40000000000005, + "697": 91.05000000000003, + "698": 84.25000000000014, + "699": 37.04999999999994, + "700": 30.149999999999984, + "701": 94.55000000000007, + "702": 94.60000000000008, + "703": 24.45, + "704": 30.49999999999995, + "705": -24.300000000000033, + "706": 82.0, + "707": 55.3499999999999, + "708": 76.55000000000014, + "709": 40.09999999999989, + "710": -10.999999999999964, + "711": 75.35000000000007, + "712": 62.09999999999993, + "713": 82.65000000000018, + "714": 8.700000000000028, + "715": 87.75000000000017, + "716": 84.55000000000001, + "717": 12.949999999999957, + "718": 73.14999999999998, + "719": 50.79999999999997, + "720": 60.599999999999994, + "721": 91.55000000000008, + "722": 93.15000000000006, + "723": 42.74999999999994, + "724": 77.49999999999997, + "725": 86.10000000000015, + "726": 69.45000000000003, + "727": 63.299999999999926, + "728": 86.40000000000002, + "729": 78.8000000000001, + "730": 92.50000000000004, + "731": 75.10000000000008, + "732": 50.99999999999997, + "733": 91.25000000000016, + "734": 85.25000000000003, + "735": 93.4500000000001, + "736": 65.05, + "737": 76.20000000000003, + "738": 57.95000000000003, + "739": 48.85, + "740": 66.79999999999995, + "741": 66.65000000000003, + "742": 76.25000000000011, + "743": 73.75000000000004, + "744": 76.15000000000006, + "745": 5.349999999999966, + "746": 45.9500000000001, + "747": 72.94999999999999, + "748": 104.60000000000005, + "749": 78.95000000000002, + "750": 67.34999999999998, + "751": 32.549999999999926, + "752": 48.449999999999825, + "753": 84.25000000000004, + "754": 53.54999999999998, + "755": 79.40000000000008, + "756": 103.10000000000002, + "757": 83.95, + "758": 92.45000000000007, + "759": 100.00000000000006, + "760": 85.30000000000001, + "761": -24.09999999999999, + "762": 53.000000000000014, + "763": 42.849999999999916, + "764": 85.10000000000004, + "765": 72.24999999999999, + "766": -34.849999999999994, + "767": 61.199999999999974, + "768": 90.8000000000001, + "769": 61.14999999999984, + "770": 81.09999999999995, + "771": 53.55000000000009, + "772": 60.849999999999895, + "773": 63.05000000000005, + "774": 53.400000000000055, + "775": 77.84999999999997, + "776": 94.4, + "777": 66.94999999999996, + "778": 67.34999999999995, + "779": 52.44999999999994, + "780": 101.69999999999999, + "781": 78.05000000000007, + "782": 46.29999999999982, + "783": 100.85000000000001, + "784": 73.85000000000004, + "785": 53.25, + "786": 84.30000000000004, + "787": 76.89999999999998, + "788": 77.30000000000008, + "789": 68.04999999999997, + "790": 80.60000000000011, + "791": 86.50000000000018, + "792": 52.39999999999994, + "793": 95.65000000000006, + "794": 88.14999999999999, + "795": 87.40000000000009, + "796": 56.29999999999998, + "797": 93.30000000000011, + "798": 85.0500000000001, + "799": 85.30000000000011, + "800": 72.05000000000001, + "801": 69.79999999999998, + "802": 76.30000000000007, + "803": 56.150000000000006, + "804": 65.74999999999997, + "805": 73.30000000000004, + "806": 76.89999999999998, + "807": 86.79999999999986, + "808": 84.99999999999997, + "809": 76.80000000000005, + "810": 86.0, + "811": 62.39999999999998, + "812": 88.30000000000003, + "813": 91.55000000000001, + "814": 75.59999999999994, + "815": 76.5, + "816": 65.29999999999993, + "817": 29.899999999999984, + "818": 77.55000000000007, + "819": 90.95000000000009, + "820": 73.30000000000004, + "821": 58.69999999999999, + "822": 87.59999999999997, + "823": 89.94999999999996, + "824": 68.29999999999997, + "825": 91.89999999999998, + "826": 74.89999999999995, + "827": 71.24999999999996, + "828": 70.69999999999999, + "829": 93.04999999999998, + "830": 88.30000000000003, + "831": 102.65000000000009, + "832": 23.799999999999955, + "833": 96.55000000000001, + "834": 89.35000000000002, + "835": 74.05000000000005, + "836": 90.3000000000001, + "837": 75.65, + "838": 81.5, + "839": 29.04999999999999, + "840": 78.9, + "841": 61.69999999999996, + "842": 46.19999999999999, + "843": 65.54999999999998, + "844": 60.95000000000003, + "845": 100.65000000000019, + "846": 73.50000000000003, + "847": 96.75000000000001, + "848": 57.24999999999997, + "849": 64.3, + "850": 59.04999999999996, + "851": 103.14999999999996, + "852": 86.50000000000007, + "853": 63.150000000000034, + "854": 67.30000000000008, + "855": 69.74999999999997, + "856": 89.69999999999999, + "857": 73.50000000000014, + "858": 58.80000000000003, + "859": 93.35000000000008, + "860": 98.75000000000001, + "861": 80.49999999999999, + "862": 78.50000000000006, + "863": 68.25000000000003, + "864": 102.9000000000001, + "865": 94.05000000000001, + "866": 46.65000000000003, + "867": 96.39999999999999, + "868": 100.6000000000001, + "869": 48.44999999999997, + "870": 88.05, + "871": 68.70000000000006, + "872": 75.75000000000001, + "873": 100.00000000000003, + "874": 102.50000000000006, + "875": 85.50000000000001, + "876": 21.64999999999999, + "877": 59.69999999999999, + "878": 70.19999999999999, + "879": 85.15000000000003, + "880": 88.3, + "881": 70.00000000000009, + "882": 92.64999999999999, + "883": 96.00000000000004, + "884": 86.60000000000002, + "885": 70.70000000000005, + "886": 53.69999999999994, + "887": 104.5, + "888": 63.85000000000002, + "889": 86.85000000000004, + "890": 81.45000000000003, + "891": 73.30000000000003, + "892": 94.95000000000003, + "893": 42.05, + "894": 93.99999999999999, + "895": 94.80000000000018, + "896": 91.7, + "897": 62.349999999999945, + "898": 66.35000000000001, + "899": 86.85000000000002, + "900": 37.30000000000004, + "901": 74.94999999999997, + "902": 92.05000000000008, + "903": 92.34999999999998, + "904": 61.80000000000008, + "905": 85.14999999999999, + "906": 84.49999999999993, + "907": 66.30000000000004, + "908": 88.05000000000001, + "909": 81.55000000000001, + "910": 99.35000000000001, + "911": 71.79999999999994, + "912": 87.15, + "913": 82.39999999999998, + "914": 38.949999999999974, + "915": 91.35000000000002, + "916": 69.45000000000007, + "917": 73.04999999999998, + "918": 72.00000000000003, + "919": 62.45000000000006, + "920": 46.89999999999998, + "921": 66.95000000000003, + "922": 77.94999999999997, + "923": 84.45, + "924": 75.39999999999998, + "925": 91.70000000000013, + "926": 80.74999999999997, + "927": 77.20000000000005, + "928": 79.20000000000005, + "929": 59.05, + "930": 66.14999999999992, + "931": 47.899999999999935, + "932": 89.64999999999996, + "933": 78.3499999999999, + "934": 91.60000000000005, + "935": 70.89999999999996, + "936": 85.45, + "937": 84.65000000000003, + "938": 82.84999999999997, + "939": 102.19999999999999, + "940": 53.80000000000001, + "941": 50.199999999999974, + "942": 72.7, + "943": 63.90000000000002, + "944": 80.15000000000003, + "945": 92.15, + "946": 13.999999999999988, + "947": 62.400000000000034, + "948": 73.60000000000005, + "949": 56.29999999999998, + "950": 84.25000000000003, + "951": 80.85000000000001, + "952": 84.45000000000005, + "953": 86.70000000000002, + "954": 87.04999999999995, + "955": 30.700000000000024, + "956": 82.05, + "957": 78.55000000000007, + "958": 83.95000000000006, + "959": 57.44999999999996, + "960": 83.45000000000006, + "961": 72.25000000000001, + "962": 73.05000000000001, + "963": 79.30000000000001, + "964": 81.55, + "965": 69.99999999999997, + "966": 67.20000000000003, + "967": 92.80000000000003, + "968": 72.10000000000002, + "969": 48.64999999999985, + "970": 71.94999999999997, + "971": 15.949999999999934, + "972": 61.44999999999984, + "973": 90.85000000000004, + "974": 96.55000000000003, + "975": 78.15000000000003, + "976": 84.40000000000009, + "977": 84.75000000000003, + "978": 52.95000000000003, + "979": 84.84999999999995, + "980": 52.20000000000008, + "981": 67.1, + "982": 84.00000000000001, + "983": 87.8500000000001, + "984": 76.8000000000001, + "985": 91.4499999999999, + "986": 80.74999999999999, + "987": 83.09999999999998, + "988": 92.9000000000001, + "989": 63.34999999999997, + "990": 66.49999999999997, + "991": 96.65000000000002, + "992": 101.85000000000002, + "993": 84.79999999999993, + "994": 91.65000000000003, + "995": 77.25000000000009, + "996": 64.0, + "997": 59.04999999999998, + "998": 72.10000000000002, + "999": 85.40000000000005, + "1000": 38.94999999999991 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.3.0/session_metadata/2.json b/benchmark/results/v3/v3.3.0/session_metadata/2.json new file mode 100644 index 00000000..62f351cb --- /dev/null +++ b/benchmark/results/v3/v3.3.0/session_metadata/2.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1437.777365, + "s_per_step": 0.044930542656249996, + "s_per_100_steps_10_nodes": 4.493054265625, + "total_reward_per_episode": { + "1": -11.099999999999989, + "2": -32.05000000000004, + "3": -58.200000000000095, + "4": -8.599999999999987, + "5": -81.89999999999999, + "6": -63.89999999999999, + "7": 2.050000000000006, + "8": -25.199999999999996, + "9": -21.249999999999957, + "10": -31.65000000000001, + "11": -65.34999999999998, + "12": -19.39999999999996, + "13": -81.10000000000001, + "14": -12.099999999999989, + "15": -27.799999999999933, + "16": -97.5, + "17": -13.399999999999984, + "18": -66.80000000000008, + "19": -18.29999999999997, + "20": -11.59999999999998, + "21": -71.05000000000005, + "22": -15.149999999999983, + "23": -18.54999999999997, + "24": -51.90000000000001, + "25": -19.54999999999996, + "26": -64.8500000000001, + "27": -78.94999999999996, + "28": -24.649999999999935, + "29": -63.800000000000104, + "30": -15.949999999999978, + "31": -0.24999999999996536, + "32": -7.449999999999997, + "33": -13.29999999999999, + "34": -18.64999999999997, + "35": -22.499999999999954, + "36": -36.20000000000002, + "37": -15.999999999999979, + "38": -23.04999999999995, + "39": -20.099999999999966, + "40": -14.149999999999977, + "41": -13.849999999999989, + "42": -20.69999999999996, + "43": -15.349999999999977, + "44": -11.650000000000004, + "45": -19.74999999999996, + "46": -11.849999999999993, + "47": -10.049999999999992, + "48": -78.7999999999999, + "49": -23.29999999999995, + "50": -16.199999999999967, + "51": -22.399999999999952, + "52": -12.14999999999999, + "53": -20.849999999999962, + "54": -46.05000000000007, + "55": -19.199999999999964, + "56": -17.249999999999975, + "57": -19.649999999999963, + "58": -10.7, + "59": -41.35000000000013, + "60": -35.35000000000003, + "61": -19.899999999999963, + "62": -18.949999999999964, + "63": -1.7999999999999938, + "64": -18.19999999999997, + "65": -7.05000000000001, + "66": -5.949999999999997, + "67": -2.649999999999964, + "68": -14.55, + "69": -33.54999999999995, + "70": -104.4, + "71": -62.25000000000008, + "72": -14.099999999999989, + "73": -8.1, + "74": -15.299999999999986, + "75": -7.699999999999998, + "76": -14.649999999999984, + "77": 0.20000000000002705, + "78": -4.3999999999999995, + "79": -8.850000000000009, + "80": -1.399999999999962, + "81": -94.55, + "82": -26.200000000000006, + "83": -5.899999999999989, + "84": -10.299999999999997, + "85": 14.200000000000006, + "86": -67.09999999999997, + "87": -23.849999999999948, + "88": -19.74999999999996, + "89": -19.899999999999963, + "90": 2.4999999999999614, + "91": -15.899999999999983, + "92": -21.899999999999956, + "93": 8.400000000000063, + "94": -47.25000000000005, + "95": -11.949999999999987, + "96": -3.649999999999981, + "97": 3.550000000000037, + "98": -10.849999999999996, + "99": -17.74999999999997, + "100": -17.89999999999997, + "101": -6.999999999999993, + "102": -14.49999999999999, + "103": -31.800000000000008, + "104": -21.199999999999957, + "105": -14.39999999999998, + "106": -5.749999999999986, + "107": -2.4499999999999744, + "108": -25.14999999999999, + "109": 2.0000000000000373, + "110": -28.29999999999995, + "111": -14.14999999999999, + "112": -83.15, + "113": -1.4000000000000008, + "114": -2.14999999999997, + "115": -49.30000000000006, + "116": -9.449999999999987, + "117": 9.500000000000043, + "118": 13.65000000000003, + "119": -5.350000000000008, + "120": -10.849999999999982, + "121": -6.64999999999998, + "122": -18.74999999999999, + "123": 0.9500000000000433, + "124": -7.499999999999983, + "125": -18.09999999999997, + "126": -22.499999999999975, + "127": 7.350000000000016, + "128": 6.75000000000004, + "129": 14.700000000000049, + "130": 16.899999999999995, + "131": -52.54999999999996, + "132": -89.35, + "133": -17.34999999999997, + "134": 33.99999999999979, + "135": 8.900000000000048, + "136": 0.5499999999999989, + "137": -11.349999999999998, + "138": 14.650000000000023, + "139": -5.5000000000000036, + "140": -9.500000000000004, + "141": -40.30000000000011, + "142": 20.849999999999973, + "143": -2.049999999999981, + "144": 39.899999999999736, + "145": -87.6, + "146": 37.74999999999996, + "147": 14.400000000000034, + "148": 9.50000000000004, + "149": 10.95000000000001, + "150": -2.549999999999983, + "151": -0.8499999999999777, + "152": -8.100000000000007, + "153": 23.649999999999913, + "154": -4.599999999999987, + "155": -70.4, + "156": 39.649999999999956, + "157": -80.55000000000001, + "158": 8.550000000000013, + "159": 41.49999999999975, + "160": -28.099999999999977, + "161": -17.599999999999973, + "162": 42.6499999999998, + "163": -36.35, + "164": -41.80000000000015, + "165": -4.450000000000005, + "166": 6.150000000000029, + "167": 23.65000000000003, + "168": 36.250000000000014, + "169": 16.650000000000045, + "170": 15.200000000000077, + "171": 33.14999999999989, + "172": 63.14999999999998, + "173": -10.550000000000002, + "174": -23.29999999999995, + "175": 16.749999999999893, + "176": -74.75000000000003, + "177": 31.75000000000006, + "178": 14.049999999999985, + "179": 61.94999999999975, + "180": 20.499999999999943, + "181": 39.69999999999988, + "182": 6.100000000000064, + "183": -7.250000000000073, + "184": -6.950000000000007, + "185": -69.10000000000001, + "186": 29.150000000000073, + "187": -76.60000000000002, + "188": 45.299999999999905, + "189": -50.74999999999996, + "190": -48.10000000000014, + "191": 43.74999999999974, + "192": 10.300000000000058, + "193": 54.9499999999999, + "194": 38.9, + "195": 8.150000000000055, + "196": 7.00000000000001, + "197": 44.59999999999982, + "198": 72.34999999999984, + "199": -47.9, + "200": -49.45000000000003, + "201": 46.29999999999976, + "202": -46.099999999999994, + "203": -2.8499999999999783, + "204": -9.450000000000045, + "205": 2.600000000000059, + "206": 31.550000000000026, + "207": -65.50000000000004, + "208": 55.29999999999975, + "209": -39.000000000000014, + "210": 72.29999999999986, + "211": 64.34999999999982, + "212": 42.44999999999975, + "213": 13.750000000000027, + "214": -21.300000000000033, + "215": 21.99999999999996, + "216": -12.299999999999985, + "217": 12.149999999999999, + "218": -67.85000000000007, + "219": 9.400000000000013, + "220": 50.24999999999981, + "221": -7.300000000000061, + "222": 99.84999999999995, + "223": 53.84999999999977, + "224": 27.100000000000044, + "225": -50.69999999999999, + "226": 17.0, + "227": 62.94999999999987, + "228": 61.14999999999988, + "229": -33.30000000000001, + "230": -56.850000000000016, + "231": 7.650000000000029, + "232": 75.10000000000004, + "233": 10.900000000000059, + "234": 41.2, + "235": 20.80000000000005, + "236": 65.34999999999977, + "237": 92.25000000000003, + "238": -34.149999999999984, + "239": 55.24999999999974, + "240": 24.999999999999925, + "241": 50.74999999999988, + "242": 59.09999999999977, + "243": -67.4, + "244": 66.49999999999979, + "245": 10.300000000000068, + "246": 69.09999999999977, + "247": 98.94999999999973, + "248": -65.04999999999994, + "249": -79.70000000000003, + "250": 104.89999999999986, + "251": 71.6999999999999, + "252": 87.69999999999986, + "253": -50.74999999999998, + "254": -56.29999999999996, + "255": 91.54999999999977, + "256": 48.89999999999978, + "257": 34.55000000000007, + "258": 20.249999999999936, + "259": 68.99999999999976, + "260": 46.94999999999985, + "261": 68.79999999999974, + "262": 38.44999999999996, + "263": 84.59999999999978, + "264": -74.45000000000006, + "265": 44.349999999999866, + "266": 70.54999999999973, + "267": 29.450000000000028, + "268": 83.2499999999998, + "269": 3.2499999999999343, + "270": -70.75, + "271": -6.750000000000014, + "272": 66.94999999999982, + "273": 59.899999999999835, + "274": 83.99999999999979, + "275": -65.85000000000002, + "276": 40.94999999999991, + "277": 81.09999999999972, + "278": 7.450000000000036, + "279": 28.399999999999917, + "280": -59.750000000000064, + "281": 58.849999999999795, + "282": 68.55, + "283": 22.59999999999995, + "284": 0.4000000000000341, + "285": -93.34999999999998, + "286": -1.4999999999999711, + "287": 22.900000000000023, + "288": 11.349999999999937, + "289": 79.09999999999978, + "290": 91.54999999999995, + "291": 58.19999999999978, + "292": 36.849999999999994, + "293": 78.14999999999985, + "294": 15.799999999999962, + "295": 58.599999999999774, + "296": 7.149999999999958, + "297": 39.99999999999988, + "298": 34.80000000000001, + "299": 86.79999999999978, + "300": 54.09999999999981, + "301": 91.29999999999973, + "302": 61.09999999999973, + "303": -11.449999999999982, + "304": 75.79999999999986, + "305": 33.04999999999986, + "306": -17.800000000000043, + "307": 89.59999999999977, + "308": 68.39999999999988, + "309": -55.85000000000005, + "310": 69.39999999999975, + "311": 88.19999999999987, + "312": 57.09999999999975, + "313": 23.20000000000005, + "314": 94.19999999999975, + "315": 91.14999999999979, + "316": 33.54999999999974, + "317": 94.79999999999976, + "318": 98.44999999999976, + "319": 53.449999999999726, + "320": 81.09999999999974, + "321": -12.450000000000038, + "322": 95.29999999999973, + "323": -6.65000000000002, + "324": 88.59999999999977, + "325": 101.29999999999987, + "326": 107.70000000000005, + "327": 101.29999999999995, + "328": 102.39999999999979, + "329": 15.799999999999955, + "330": 38.05000000000004, + "331": 67.99999999999989, + "332": 74.34999999999978, + "333": -19.399999999999967, + "334": 96.14999999999979, + "335": -5.7500000000000036, + "336": -9.849999999999985, + "337": 87.19999999999975, + "338": 97.5499999999998, + "339": 27.20000000000004, + "340": 43.799999999999976, + "341": 92.39999999999976, + "342": 92.79999999999976, + "343": 90.64999999999978, + "344": 90.10000000000004, + "345": 20.650000000000023, + "346": 96.4999999999999, + "347": -85.49999999999997, + "348": 38.299999999999955, + "349": 99.84999999999987, + "350": 93.09999999999981, + "351": 59.24999999999997, + "352": 66.74999999999984, + "353": 89.54999999999976, + "354": 60.39999999999989, + "355": 13.699999999999973, + "356": 99.69999999999985, + "357": 25.949999999999886, + "358": 79.24999999999976, + "359": -9.149999999999986, + "360": 94.29999999999974, + "361": 103.09999999999992, + "362": 99.3499999999999, + "363": 95.34999999999974, + "364": -43.89999999999998, + "365": 103.39999999999976, + "366": 102.34999999999978, + "367": 106.49999999999972, + "368": 101.34999999999975, + "369": 103.99999999999991, + "370": -72.85000000000001, + "371": 86.74999999999973, + "372": -9.499999999999982, + "373": 97.89999999999976, + "374": 100.44999999999976, + "375": -84.24999999999997, + "376": 101.34999999999975, + "377": 78.59999999999984, + "378": 100.59999999999978, + "379": -28.049999999999972, + "380": 9.80000000000001, + "381": 104.94999999999978, + "382": 102.79999999999977, + "383": 93.59999999999994, + "384": 64.25000000000009, + "385": 77.09999999999992, + "386": 92.24999999999979, + "387": 98.04999999999973, + "388": -76.55, + "389": 94.49999999999979, + "390": 89.34999999999977, + "391": 26.050000000000026, + "392": 27.999999999999975, + "393": -24.05000000000006, + "394": 106.04999999999976, + "395": 105.99999999999974, + "396": 102.39999999999975, + "397": 59.6499999999999, + "398": 97.99999999999977, + "399": 101.49999999999973, + "400": -18.7, + "401": 105.6000000000001, + "402": 95.99999999999977, + "403": 103.69999999999982, + "404": 90.14999999999976, + "405": 96.34999999999987, + "406": 87.99999999999982, + "407": 93.29999999999971, + "408": 98.89999999999986, + "409": 104.39999999999972, + "410": 97.2499999999999, + "411": 100.04999999999974, + "412": 86.5499999999998, + "413": -60.74999999999996, + "414": 99.79999999999977, + "415": 82.84999999999978, + "416": 101.19999999999975, + "417": 1.5000000000000357, + "418": 102.49999999999976, + "419": 65.89999999999989, + "420": 103.89999999999974, + "421": 96.84999999999974, + "422": 101.39999999999976, + "423": 102.14999999999976, + "424": 101.99999999999972, + "425": 101.74999999999976, + "426": 102.39999999999976, + "427": 106.99999999999977, + "428": -73.45, + "429": 104.14999999999974, + "430": 100.09999999999984, + "431": 102.49999999999979, + "432": 101.19999999999975, + "433": 101.24999999999973, + "434": 102.44999999999976, + "435": 102.59999999999977, + "436": 98.84999999999977, + "437": 85.04999999999976, + "438": -77.7, + "439": -89.75, + "440": 33.79999999999973, + "441": 94.74999999999979, + "442": 99.84999999999975, + "443": 99.64999999999978, + "444": 103.6, + "445": 101.64999999999976, + "446": 52.39999999999978, + "447": 100.19999999999976, + "448": 80.09999999999977, + "449": 103.1999999999998, + "450": 97.54999999999977, + "451": 87.94999999999976, + "452": 103.49999999999974, + "453": 75.04999999999977, + "454": 94.59999999999977, + "455": 84.64999999999982, + "456": 99.49999999999977, + "457": -0.9500000000000004, + "458": 82.89999999999972, + "459": 103.79999999999977, + "460": 102.39999999999974, + "461": 106.64999999999976, + "462": 95.24999999999979, + "463": 97.79999999999977, + "464": 84.49999999999982, + "465": -11.800000000000033, + "466": 101.04999999999978, + "467": 106.29999999999974, + "468": 18.449999999999903, + "469": 105.19999999999975, + "470": 105.59999999999972, + "471": 82.29999999999977, + "472": 103.44999999999976, + "473": 104.19999999999978, + "474": 104.94999999999975, + "475": 106.19999999999972, + "476": 101.19999999999976, + "477": 106.44999999999973, + "478": -66.74999999999999, + "479": 98.14999999999978, + "480": 102.29999999999976, + "481": 102.44999999999976, + "482": 85.79999999999973, + "483": -77.75, + "484": 95.94999999999976, + "485": 101.19999999999976, + "486": 97.69999999999975, + "487": 104.59999999999975, + "488": 102.24999999999977, + "489": 103.8499999999998, + "490": 103.74999999999977, + "491": 104.39999999999971, + "492": 100.64999999999974, + "493": 105.04999999999976, + "494": -41.75000000000008, + "495": 105.84999999999972, + "496": 106.59999999999972, + "497": 99.04999999999977, + "498": 86.34999999999974, + "499": 104.54999999999976, + "500": 102.44999999999978, + "501": 104.69999999999979, + "502": 104.39999999999976, + "503": 107.34999999999974, + "504": 94.84999999999981, + "505": 104.34999999999977, + "506": 100.34999999999975, + "507": 104.14999999999976, + "508": 81.09999999999985, + "509": 97.69999999999976, + "510": -71.14999999999999, + "511": 101.94999999999973, + "512": 98.79999999999978, + "513": 104.79999999999976, + "514": 103.84999999999977, + "515": 103.94999999999973, + "516": 100.04999999999976, + "517": 104.74999999999974, + "518": 101.99999999999977, + "519": 105.14999999999975, + "520": 106.69999999999973, + "521": -84.45, + "522": 101.99999999999974, + "523": 60.84999999999993, + "524": 68.99999999999977, + "525": 84.59999999999974, + "526": 97.84999999999977, + "527": 104.34999999999977, + "528": 104.64999999999975, + "529": 104.89999999999975, + "530": 104.09999999999977, + "531": 103.44999999999976, + "532": 105.94999999999986, + "533": 104.24999999999977, + "534": 73.69999999999986, + "535": 106.49999999999973, + "536": 107.44999999999973, + "537": 85.34999999999978, + "538": 97.04999999999977, + "539": 103.79999999999977, + "540": 97.24999999999976, + "541": 42.84999999999979, + "542": 54.34999999999977, + "543": 67.29999999999974, + "544": 80.19999999999973, + "545": 19.350000000000026, + "546": 90.84999999999975, + "547": 49.69999999999974, + "548": 68.64999999999976, + "549": 82.89999999999972, + "550": 105.49999999999973, + "551": 4.50000000000003, + "552": 103.19999999999978, + "553": 98.44999999999976, + "554": 48.04999999999981, + "555": 58.39999999999973, + "556": 105.39999999999974, + "557": 84.39999999999976, + "558": 55.44999999999979, + "559": -74.25, + "560": 7.249999999999989, + "561": 103.24999999999976, + "562": 101.64999999999986, + "563": 105.40000000000003, + "564": -83.85000000000001, + "565": 20.699999999999964, + "566": 61.54999999999973, + "567": 108.19999999999976, + "568": 70.19999999999975, + "569": 40.29999999999995, + "570": 69.89999999999972, + "571": 35.2, + "572": 72.19999999999983, + "573": 112.84999999999992, + "574": 100.04999999999973, + "575": 30.000000000000053, + "576": 102.49999999999977, + "577": 103.89999999999976, + "578": 51.749999999999716, + "579": 74.24999999999977, + "580": 49.34999999999983, + "581": 1.2000000000000497, + "582": 103.49999999999976, + "583": 79.14999999999988, + "584": 36.14999999999989, + "585": 104.04999999999973, + "586": -4.849999999999975, + "587": 106.94999999999973, + "588": 48.39999999999986, + "589": 73.79999999999973, + "590": 71.19999999999976, + "591": 106.14999999999974, + "592": 90.19999999999982, + "593": 102.44999999999975, + "594": -26.200000000000017, + "595": 104.79999999999973, + "596": 54.59999999999989, + "597": 75.1999999999998, + "598": 88.89999999999976, + "599": 100.14999999999976, + "600": 99.24999999999977, + "601": 55.299999999999756, + "602": 68.29999999999973, + "603": 102.04999999999977, + "604": 101.69999999999978, + "605": 76.04999999999977, + "606": 105.39999999999972, + "607": 102.49999999999977, + "608": 102.59999999999977, + "609": 102.14999999999976, + "610": 105.94999999999972, + "611": 65.59999999999975, + "612": 104.44999999999975, + "613": 107.2499999999998, + "614": -82.45, + "615": -5.00000000000005, + "616": 80.24999999999972, + "617": 98.04999999999977, + "618": 67.89999999999984, + "619": 99.74999999999976, + "620": 103.09999999999977, + "621": 103.19999999999973, + "622": -49.15000000000008, + "623": 98.69999999999976, + "624": 56.09999999999976, + "625": 107.44999999999975, + "626": 103.59999999999977, + "627": 38.049999999999756, + "628": -41.949999999999996, + "629": -88.2, + "630": 104.7999999999998, + "631": 107.59999999999972, + "632": -9.949999999999976, + "633": 86.69999999999972, + "634": 104.49999999999977, + "635": 86.89999999999984, + "636": 61.24999999999979, + "637": 73.19999999999973, + "638": -31.000000000000007, + "639": 76.99999999999979, + "640": 80.19999999999976, + "641": 74.8999999999998, + "642": 103.79999999999976, + "643": 97.39999999999974, + "644": 107.44999999999976, + "645": 97.94999999999979, + "646": 104.74999999999976, + "647": 102.64999999999972, + "648": 104.89999999999972, + "649": 104.59999999999974, + "650": 102.74999999999976, + "651": 102.89999999999976, + "652": 105.34999999999972, + "653": 105.59999999999975, + "654": 101.99999999999977, + "655": 101.19999999999978, + "656": 106.59999999999972, + "657": 105.04999999999974, + "658": 76.14999999999978, + "659": 104.69999999999976, + "660": 103.04999999999977, + "661": -3.800000000000068, + "662": 103.94999999999976, + "663": 103.99999999999976, + "664": 101.04999999999976, + "665": 103.44999999999976, + "666": 98.09999999999977, + "667": 90.65, + "668": 69.89999999999976, + "669": 103.59999999999977, + "670": 105.09999999999975, + "671": 104.19999999999976, + "672": 104.79999999999974, + "673": 66.19999999999978, + "674": -6.449999999999992, + "675": 104.34999999999977, + "676": 79.89999999999976, + "677": 97.49999999999977, + "678": 81.64999999999975, + "679": 48.19999999999988, + "680": 89.09999999999975, + "681": 108.29999999999974, + "682": 105.74999999999973, + "683": 102.54999999999977, + "684": 38.79999999999977, + "685": 103.69999999999976, + "686": 80.2999999999998, + "687": 103.49999999999977, + "688": 107.7999999999998, + "689": 104.84999999999975, + "690": 100.39999999999976, + "691": -71.64999999999999, + "692": 104.24999999999976, + "693": 102.64999999999976, + "694": 104.09999999999977, + "695": 105.09999999999978, + "696": 93.24999999999976, + "697": 70.94999999999972, + "698": 104.84999999999975, + "699": 65.79999999999981, + "700": 108.39999999999974, + "701": 100.54999999999971, + "702": 104.79999999999976, + "703": 102.84999999999977, + "704": 103.49999999999977, + "705": 104.89999999999974, + "706": 101.14999999999975, + "707": 104.89999999999972, + "708": 103.94999999999975, + "709": 102.49999999999977, + "710": 62.44999999999977, + "711": 102.39999999999976, + "712": 105.99999999999973, + "713": 104.49999999999974, + "714": 105.34999999999974, + "715": 106.69999999999972, + "716": 38.44999999999985, + "717": 103.64999999999976, + "718": 103.09999999999977, + "719": 102.89999999999978, + "720": 35.2499999999998, + "721": 103.99999999999976, + "722": 105.04999999999974, + "723": 103.39999999999976, + "724": 104.54999999999977, + "725": -81.7, + "726": 104.54999999999976, + "727": 100.89999999999975, + "728": 105.44999999999975, + "729": 111.64999999999979, + "730": 104.69999999999976, + "731": 99.89999999999978, + "732": 2.8999999999999346, + "733": 104.39999999999976, + "734": 103.14999999999976, + "735": 102.99999999999977, + "736": 103.39999999999976, + "737": 105.69999999999975, + "738": -85.25, + "739": 41.99999999999972, + "740": 103.99999999999976, + "741": 17.600000000000016, + "742": 65.44999999999976, + "743": 102.24999999999977, + "744": 102.49999999999977, + "745": 105.89999999999972, + "746": 102.64999999999978, + "747": 104.69999999999976, + "748": 102.79999999999977, + "749": 102.19999999999978, + "750": 104.49999999999973, + "751": 102.64999999999978, + "752": 104.74999999999977, + "753": 104.54999999999976, + "754": 99.79999999999978, + "755": 103.94999999999976, + "756": 66.09999999999991, + "757": 103.99999999999976, + "758": -85.1, + "759": 103.29999999999977, + "760": 106.04999999999974, + "761": 99.29999999999973, + "762": 104.89999999999975, + "763": 104.34999999999977, + "764": 103.69999999999976, + "765": 102.14999999999978, + "766": 104.84999999999974, + "767": 103.09999999999977, + "768": 104.04999999999974, + "769": 104.69999999999975, + "770": 104.49999999999976, + "771": 108.84999999999974, + "772": 101.49999999999976, + "773": 103.69999999999976, + "774": 60.79999999999977, + "775": 103.29999999999977, + "776": 104.84999999999975, + "777": 104.29999999999976, + "778": 102.84999999999977, + "779": 103.89999999999976, + "780": 104.54999999999977, + "781": 103.79999999999976, + "782": 105.59999999999974, + "783": 102.84999999999977, + "784": 104.69999999999976, + "785": 101.59999999999977, + "786": 96.09999999999974, + "787": 105.99999999999972, + "788": 104.34999999999977, + "789": 103.79999999999977, + "790": 103.24999999999977, + "791": 102.89999999999976, + "792": 96.19999999999976, + "793": 105.09999999999978, + "794": 52.8499999999999, + "795": 105.24999999999974, + "796": 107.49999999999972, + "797": 111.64999999999986, + "798": 104.59999999999975, + "799": 73.74999999999973, + "800": 104.69999999999975, + "801": 105.49999999999974, + "802": -69.5, + "803": 105.69999999999975, + "804": 103.89999999999976, + "805": 105.19999999999973, + "806": 103.89999999999976, + "807": 107.24999999999974, + "808": 105.39999999999974, + "809": 106.69999999999972, + "810": 104.59999999999975, + "811": 81.64999999999984, + "812": 103.84999999999977, + "813": -63.90000000000002, + "814": 106.59999999999972, + "815": -68.5, + "816": 103.59999999999977, + "817": 104.99999999999974, + "818": 104.29999999999977, + "819": -9.799999999999994, + "820": 104.19999999999976, + "821": 109.09999999999977, + "822": 103.04999999999977, + "823": 108.49999999999974, + "824": 105.24999999999972, + "825": 103.94999999999976, + "826": 107.69999999999972, + "827": 103.49999999999977, + "828": 59.39999999999974, + "829": -74.35000000000001, + "830": 103.84999999999977, + "831": 91.04999999999978, + "832": 103.54999999999977, + "833": 105.19999999999979, + "834": 102.84999999999974, + "835": 106.09999999999972, + "836": 104.04999999999976, + "837": 104.59999999999977, + "838": 109.09999999999977, + "839": 103.29999999999977, + "840": 104.14999999999976, + "841": 103.34999999999977, + "842": 106.19999999999972, + "843": 103.59999999999977, + "844": 100.54999999999977, + "845": 103.84999999999977, + "846": 104.44999999999976, + "847": 103.89999999999978, + "848": 105.84999999999974, + "849": -61.300000000000004, + "850": 103.79999999999976, + "851": 105.59999999999974, + "852": 103.64999999999976, + "853": 105.74999999999974, + "854": 106.0999999999998, + "855": 109.14999999999975, + "856": 106.79999999999971, + "857": 105.39999999999975, + "858": 101.14999999999976, + "859": 104.24999999999973, + "860": 104.19999999999976, + "861": 106.94999999999973, + "862": 102.94999999999978, + "863": 104.84999999999975, + "864": 103.49999999999976, + "865": 103.04999999999977, + "866": 105.94999999999973, + "867": 102.34999999999978, + "868": 107.29999999999974, + "869": 104.14999999999976, + "870": 103.34999999999977, + "871": 103.89999999999974, + "872": -77.69999999999999, + "873": -86.14999999999999, + "874": 103.19999999999976, + "875": 108.6499999999998, + "876": 105.44999999999975, + "877": 105.89999999999974, + "878": 105.14999999999972, + "879": 103.64999999999976, + "880": 70.64999999999974, + "881": 103.09999999999977, + "882": 105.24999999999984, + "883": 103.69999999999976, + "884": 107.29999999999973, + "885": 103.59999999999975, + "886": 105.59999999999974, + "887": 104.69999999999978, + "888": 102.34999999999978, + "889": 102.99999999999977, + "890": 107.69999999999972, + "891": 104.24999999999976, + "892": 100.89999999999978, + "893": 103.69999999999978, + "894": 106.34999999999972, + "895": 107.39999999999974, + "896": 103.44999999999978, + "897": 103.54999999999977, + "898": 101.14999999999975, + "899": 104.14999999999976, + "900": 105.84999999999972, + "901": 69.94999999999983, + "902": -37.80000000000014, + "903": 9.05000000000001, + "904": -22.399999999999988, + "905": 49.749999999999915, + "906": 22.449999999999854, + "907": 66.74999999999989, + "908": 69.29999999999987, + "909": 30.299999999999937, + "910": 83.39999999999984, + "911": 23.899999999999878, + "912": -70.00000000000003, + "913": 43.499999999999865, + "914": 51.34999999999983, + "915": 40.50000000000001, + "916": 55.599999999999795, + "917": -5.299999999999992, + "918": 99.69999999999979, + "919": 24.89999999999985, + "920": 32.19999999999985, + "921": 94.69999999999978, + "922": 18.75000000000001, + "923": -1.2500000000000309, + "924": 49.799999999999876, + "925": 6.649999999999977, + "926": 92.04999999999981, + "927": 38.100000000000016, + "928": 52.099999999999895, + "929": 61.399999999999984, + "930": 53.399999999999935, + "931": 79.69999999999986, + "932": 66.04999999999977, + "933": -14.500000000000021, + "934": 73.94999999999979, + "935": 71.44999999999979, + "936": 104.69999999999982, + "937": 93.74999999999982, + "938": 21.799999999999986, + "939": 107.49999999999973, + "940": 83.3499999999999, + "941": 77.09999999999995, + "942": 92.24999999999986, + "943": 103.34999999999977, + "944": 104.64999999999979, + "945": 103.59999999999975, + "946": -20.79999999999999, + "947": 64.84999999999978, + "948": 104.14999999999976, + "949": 55.599999999999916, + "950": 97.54999999999976, + "951": 103.24999999999976, + "952": 107.04999999999974, + "953": 104.79999999999976, + "954": 103.04999999999977, + "955": 89.69999999999976, + "956": 86.39999999999988, + "957": 104.04999999999977, + "958": 103.89999999999976, + "959": 87.29999999999978, + "960": 95.09999999999981, + "961": 104.09999999999975, + "962": 103.49999999999977, + "963": 87.14999999999976, + "964": 101.9999999999998, + "965": 103.49999999999977, + "966": 82.94999999999978, + "967": 108.14999999999974, + "968": 77.59999999999977, + "969": 103.89999999999976, + "970": 109.74999999999976, + "971": 108.89999999999976, + "972": 103.59999999999977, + "973": 108.09999999999974, + "974": 103.24999999999977, + "975": 105.39999999999975, + "976": 105.04999999999976, + "977": 107.74999999999977, + "978": 103.74999999999977, + "979": 103.49999999999976, + "980": -77.8, + "981": 108.69999999999973, + "982": 105.54999999999973, + "983": 103.49999999999976, + "984": 106.99999999999973, + "985": 103.89999999999976, + "986": -63.9, + "987": 102.89999999999978, + "988": 109.24999999999977, + "989": 111.94999999999995, + "990": 106.79999999999974, + "991": -64.75000000000001, + "992": 107.59999999999972, + "993": 98.29999999999976, + "994": 103.39999999999976, + "995": 104.49999999999976, + "996": 88.94999999999982, + "997": 103.24999999999977, + "998": -62.95, + "999": -70.9, + "1000": 103.34999999999977 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.3.0/session_metadata/3.json b/benchmark/results/v3/v3.3.0/session_metadata/3.json new file mode 100644 index 00000000..7b4fd0a2 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/session_metadata/3.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1478.051265, + "s_per_step": 0.04618910203125, + "s_per_100_steps_10_nodes": 4.618910203125, + "total_reward_per_episode": { + "1": -64.2500000000001, + "2": -10.899999999999991, + "3": -30.800000000000004, + "4": -14.649999999999977, + "5": -75.69999999999999, + "6": -60.350000000000094, + "7": -21.8, + "8": -93.69999999999996, + "9": -19.499999999999964, + "10": -34.64999999999998, + "11": -17.999999999999968, + "12": -38.15000000000004, + "13": -15.749999999999979, + "14": -15.34999999999998, + "15": -21.599999999999955, + "16": -55.05000000000011, + "17": -10.049999999999995, + "18": -20.949999999999957, + "19": -53.30000000000008, + "20": -13.199999999999989, + "21": -19.29999999999997, + "22": -10.65000000000001, + "23": -9.000000000000002, + "24": -103.89999999999996, + "25": -12.64999999999999, + "26": -1.8999999999999888, + "27": -26.54999999999997, + "28": -34.600000000000044, + "29": -29.650000000000013, + "30": -64.30000000000015, + "31": -43.50000000000005, + "32": -29.600000000000023, + "33": -18.999999999999993, + "34": -101.0, + "35": -21.499999999999957, + "36": -21.499999999999957, + "37": -8.699999999999983, + "38": -6.550000000000001, + "39": -20.74999999999996, + "40": -17.999999999999968, + "41": -64.0500000000001, + "42": -17.349999999999977, + "43": -17.099999999999973, + "44": -14.899999999999965, + "45": -10.499999999999995, + "46": -13.849999999999985, + "47": -54.05000000000008, + "48": -16.79999999999998, + "49": -16.849999999999973, + "50": -61.650000000000155, + "51": -15.699999999999987, + "52": -47.80000000000007, + "53": -75.5, + "54": -25.049999999999944, + "55": -95.6, + "56": -7.65, + "57": -3.150000000000033, + "58": -16.649999999999977, + "59": -15.199999999999985, + "60": -17.099999999999977, + "61": -1.149999999999972, + "62": -93.65, + "63": -20.349999999999962, + "64": -7.749999999999991, + "65": -21.049999999999958, + "66": -23.19999999999995, + "67": -40.60000000000015, + "68": -18.699999999999967, + "69": -76.6999999999999, + "70": 5.15000000000003, + "71": -14.299999999999981, + "72": -8.399999999999997, + "73": -23.29999999999995, + "74": -21.550000000000004, + "75": -11.699999999999982, + "76": -66.05000000000005, + "77": -93.85, + "78": -15.749999999999982, + "79": -101.05000000000001, + "80": -11.600000000000007, + "81": -85.0000000000001, + "82": -6.999999999999995, + "83": 22.04999999999996, + "84": -47.15000000000007, + "85": -3.4999999999999805, + "86": -18.049999999999972, + "87": -97.4, + "88": -77.79999999999995, + "89": 9.00000000000001, + "90": -15.049999999999983, + "91": -4.350000000000004, + "92": -21.499999999999954, + "93": -3.6999999999999797, + "94": -39.69999999999998, + "95": -57.45000000000009, + "96": -17.349999999999973, + "97": -7.249999999999995, + "98": -14.199999999999978, + "99": -11.699999999999987, + "100": 2.5000000000000444, + "101": -12.649999999999984, + "102": -3.750000000000001, + "103": -20.349999999999962, + "104": -90.05, + "105": -18.299999999999972, + "106": -0.9000000000000015, + "107": -57.05, + "108": -2.399999999999965, + "109": -49.15000000000007, + "110": -20.49999999999996, + "111": -8.749999999999996, + "112": -79.10000000000001, + "113": -17.14999999999997, + "114": -3.0499999999999785, + "115": -64.35, + "116": -47.39999999999996, + "117": -10.999999999999996, + "118": -12.199999999999989, + "119": -16.89999999999998, + "120": -64.85000000000001, + "121": -6.749999999999996, + "122": 7.750000000000069, + "123": 13.75, + "124": -3.0999999999999863, + "125": -27.09999999999994, + "126": -16.649999999999977, + "127": 19.349999999999955, + "128": -49.350000000000044, + "129": -21.2, + "130": -39.49999999999999, + "131": -74.44999999999999, + "132": -5.449999999999989, + "133": -0.3499999999999986, + "134": -14.499999999999979, + "135": -21.699999999999953, + "136": -1.7499999999999736, + "137": -12.149999999999993, + "138": 23.949999999999946, + "139": -48.3, + "140": -11.49999999999999, + "141": -43.150000000000006, + "142": -11.04999999999999, + "143": -18.09999999999997, + "144": -11.1, + "145": 19.249999999999986, + "146": -90.4, + "147": 35.69999999999979, + "148": -78.85, + "149": -39.95000000000009, + "150": -6.799999999999987, + "151": -10.35, + "152": 16.45000000000007, + "153": 8.500000000000078, + "154": -18.199999999999967, + "155": -1.0999999999999819, + "156": 12.350000000000023, + "157": -36.69999999999998, + "158": 14.750000000000039, + "159": -13.999999999999991, + "160": -2.399999999999996, + "161": 14.250000000000043, + "162": -80.15000000000002, + "163": -19.499999999999964, + "164": 18.00000000000006, + "165": -41.499999999999964, + "166": 6.550000000000042, + "167": 5.700000000000033, + "168": -15.95000000000001, + "169": -10.549999999999978, + "170": -89.14999999999996, + "171": -0.2999999999999714, + "172": -17.70000000000004, + "173": -9.450000000000001, + "174": 14.849999999999959, + "175": -90.44999999999999, + "176": -11.799999999999978, + "177": -56.5, + "178": -13.249999999999984, + "179": -55.35, + "180": -17.699999999999974, + "181": -17.85000000000002, + "182": -7.799999999999989, + "183": -49.900000000000006, + "184": 27.400000000000055, + "185": 31.449999999999942, + "186": -49.59999999999996, + "187": 16.20000000000001, + "188": 2.5500000000000336, + "189": 27.44999999999993, + "190": -3.049999999999991, + "191": -60.84999999999995, + "192": 5.850000000000024, + "193": -7.199999999999984, + "194": -48.800000000000004, + "195": -69.60000000000004, + "196": 25.200000000000067, + "197": -37.649999999999956, + "198": -64.1500000000001, + "199": -48.59999999999997, + "200": -71.94999999999993, + "201": -18.249999999999968, + "202": -14.450000000000003, + "203": 4.750000000000047, + "204": 30.049999999999923, + "205": -5.549999999999984, + "206": -32.64999999999997, + "207": 16.450000000000045, + "208": -46.25, + "209": 11.549999999999937, + "210": 15.100000000000076, + "211": 23.450000000000063, + "212": -6.50000000000001, + "213": -35.00000000000002, + "214": 16.199999999999992, + "215": 46.099999999999845, + "216": -11.49999999999999, + "217": 5.550000000000029, + "218": 23.749999999999908, + "219": -56.75000000000003, + "220": 2.400000000000005, + "221": -9.299999999999951, + "222": 83.19999999999989, + "223": 46.249999999999915, + "224": 16.449999999999932, + "225": 34.49999999999993, + "226": -86.15, + "227": -8.049999999999992, + "228": -39.1, + "229": 15.749999999999899, + "230": -53.80000000000006, + "231": -24.649999999999956, + "232": 6.149999999999947, + "233": -27.50000000000003, + "234": 10.249999999999982, + "235": -9.850000000000056, + "236": -49.05, + "237": -25.099999999999987, + "238": 1.4500000000000328, + "239": 44.749999999999794, + "240": -23.800000000000022, + "241": 49.34999999999976, + "242": 26.250000000000018, + "243": 12.250000000000032, + "244": -5.773159728050814e-15, + "245": -15.94999999999996, + "246": 5.600000000000033, + "247": -12.049999999999978, + "248": 36.699999999999775, + "249": 27.94999999999998, + "250": -0.34999999999997033, + "251": -46.449999999999996, + "252": -21.749999999999957, + "253": 35.649999999999984, + "254": 47.79999999999981, + "255": 2.3000000000000114, + "256": 49.75000000000003, + "257": 48.54999999999982, + "258": 18.55000000000003, + "259": 25.85000000000007, + "260": -0.9500000000000135, + "261": 35.8999999999999, + "262": 62.64999999999988, + "263": -6.200000000000021, + "264": 41.94999999999994, + "265": 49.94999999999991, + "266": 49.49999999999995, + "267": -13.349999999999987, + "268": 67.94999999999983, + "269": 41.39999999999988, + "270": 15.000000000000068, + "271": -47.39999999999999, + "272": -82.35, + "273": 13.600000000000065, + "274": 43.84999999999982, + "275": 36.19999999999991, + "276": 39.64999999999994, + "277": 40.99999999999974, + "278": 11.800000000000047, + "279": 32.94999999999998, + "280": 81.80000000000007, + "281": 58.499999999999936, + "282": -15.399999999999983, + "283": 8.40000000000001, + "284": 30.95, + "285": 14.400000000000006, + "286": -10.149999999999995, + "287": 44.84999999999989, + "288": 49.5999999999999, + "289": 69.59999999999988, + "290": 63.049999999999784, + "291": 86.50000000000001, + "292": 47.64999999999981, + "293": 71.1499999999998, + "294": -7.049999999999991, + "295": 47.34999999999976, + "296": 102.65000000000008, + "297": 66.04999999999983, + "298": 63.899999999999935, + "299": 1.5000000000000955, + "300": 24.95000000000004, + "301": 54.74999999999998, + "302": -13.150000000000004, + "303": 52.64999999999996, + "304": 40.2999999999998, + "305": 83.10000000000001, + "306": -0.29999999999999305, + "307": -7.599999999999988, + "308": 58.74999999999989, + "309": 47.24999999999983, + "310": 70.59999999999985, + "311": 26.299999999999976, + "312": 38.4999999999999, + "313": 0.2999999999999834, + "314": 41.84999999999984, + "315": 91.40000000000015, + "316": -50.15000000000002, + "317": 56.24999999999983, + "318": 38.24999999999996, + "319": 5.849999999999965, + "320": 32.30000000000001, + "321": 47.35000000000001, + "322": 58.45000000000001, + "323": 11.04999999999999, + "324": -0.04999999999999771, + "325": 53.2999999999998, + "326": 84.10000000000014, + "327": 18.20000000000004, + "328": 68.14999999999982, + "329": 96.35000000000022, + "330": 64.09999999999994, + "331": 56.850000000000044, + "332": 95.80000000000014, + "333": 64.24999999999976, + "334": 13.299999999999962, + "335": 78.4499999999999, + "336": 55.099999999999945, + "337": 93.25, + "338": -9.449999999999983, + "339": 46.64999999999997, + "340": 82.04999999999998, + "341": 41.94999999999985, + "342": 94.45000000000003, + "343": 28.599999999999973, + "344": -11.29999999999999, + "345": 83.59999999999975, + "346": 12.250000000000037, + "347": 43.54999999999998, + "348": 85.2000000000001, + "349": 46.2999999999999, + "350": 48.49999999999998, + "351": 75.39999999999988, + "352": -18.69999999999997, + "353": 55.399999999999935, + "354": 97.35000000000015, + "355": 29.99999999999998, + "356": 87.15000000000005, + "357": 103.3500000000002, + "358": 16.800000000000047, + "359": 88.55000000000007, + "360": 36.15, + "361": 11.800000000000036, + "362": 58.44999999999989, + "363": 69.04999999999995, + "364": 20.099999999999994, + "365": 44.09999999999997, + "366": 82.40000000000019, + "367": 99.25000000000024, + "368": 74.4, + "369": 71.5000000000001, + "370": 105.35000000000024, + "371": 99.60000000000014, + "372": 66.64999999999998, + "373": 84.60000000000004, + "374": 81.05000000000011, + "375": 80.60000000000004, + "376": 59.999999999999915, + "377": 48.59999999999975, + "378": 63.6999999999998, + "379": 83.6, + "380": 82.09999999999994, + "381": -15.900000000000022, + "382": 59.14999999999999, + "383": 38.09999999999995, + "384": 98.75000000000016, + "385": 15.499999999999963, + "386": 102.85000000000022, + "387": 55.10000000000002, + "388": 68.54999999999988, + "389": 38.649999999999864, + "390": 105.55000000000022, + "391": 58.999999999999915, + "392": 67.6999999999999, + "393": 57.949999999999946, + "394": 94.20000000000017, + "395": 104.05000000000017, + "396": 96.35000000000018, + "397": 51.54999999999997, + "398": 105.35000000000015, + "399": 1.1499999999999722, + "400": 79.45000000000003, + "401": 101.05000000000004, + "402": 100.25000000000023, + "403": 76.55000000000001, + "404": 109.10000000000022, + "405": 65.04999999999995, + "406": 23.99999999999995, + "407": 52.29999999999998, + "408": 63.89999999999994, + "409": 97.10000000000015, + "410": 96.30000000000014, + "411": 54.949999999999825, + "412": 102.79999999999993, + "413": 60.199999999999996, + "414": 112.80000000000018, + "415": 41.849999999999945, + "416": 101.45, + "417": 61.75000000000006, + "418": 95.55000000000017, + "419": 98.70000000000019, + "420": 27.899999999999984, + "421": 86.70000000000005, + "422": 54.19999999999976, + "423": 95.19999999999978, + "424": 104.40000000000023, + "425": 102.59999999999984, + "426": 31.499999999999872, + "427": 12.299999999999995, + "428": 86.5000000000001, + "429": 22.599999999999987, + "430": 80.25000000000006, + "431": 101.30000000000011, + "432": 90.4999999999998, + "433": 98.25000000000006, + "434": 36.59999999999995, + "435": 56.649999999999906, + "436": 3.4499999999999584, + "437": -12.400000000000013, + "438": 80.20000000000003, + "439": 78.24999999999987, + "440": 85.50000000000007, + "441": 69.74999999999997, + "442": 108.8000000000003, + "443": 97.45000000000016, + "444": 90.2000000000002, + "445": 92.35000000000018, + "446": 50.95000000000005, + "447": 91.25000000000017, + "448": 82.90000000000016, + "449": 102.20000000000023, + "450": 59.39999999999996, + "451": 95.95000000000019, + "452": 29.04999999999998, + "453": 90.85000000000004, + "454": 78.85000000000001, + "455": 65.79999999999993, + "456": 52.50000000000005, + "457": 105.95000000000009, + "458": 91.10000000000024, + "459": 72.14999999999995, + "460": 97.20000000000013, + "461": 95.50000000000003, + "462": 102.59999999999997, + "463": 84.1499999999998, + "464": 35.199999999999925, + "465": 92.90000000000018, + "466": 60.79999999999991, + "467": 55.84999999999985, + "468": 81.05000000000011, + "469": 70.69999999999986, + "470": 100.45000000000017, + "471": 74.04999999999991, + "472": 104.45000000000017, + "473": 62.149999999999984, + "474": 54.949999999999996, + "475": 93.70000000000016, + "476": 100.90000000000025, + "477": 17.750000000000092, + "478": 59.64999999999989, + "479": 73.59999999999997, + "480": 65.99999999999987, + "481": 38.74999999999993, + "482": 102.6500000000002, + "483": 46.249999999999886, + "484": 63.749999999999936, + "485": 55.399999999999956, + "486": 109.40000000000022, + "487": 29.250000000000014, + "488": 58.700000000000045, + "489": 104.35000000000022, + "490": 59.49999999999989, + "491": 101.25000000000007, + "492": 53.249999999999915, + "493": 53.24999999999991, + "494": 79.4000000000001, + "495": 88.95000000000007, + "496": 20.14999999999995, + "497": 88.15000000000012, + "498": 66.89999999999999, + "499": 97.7000000000002, + "500": 94.10000000000011, + "501": 105.90000000000026, + "502": 41.849999999999945, + "503": 51.449999999999925, + "504": 50.84999999999991, + "505": 105.50000000000021, + "506": 67.54999999999993, + "507": 103.3500000000002, + "508": 97.4500000000002, + "509": 61.2000000000001, + "510": 69.74999999999993, + "511": 70.64999999999992, + "512": 96.05000000000014, + "513": 53.84999999999992, + "514": 63.29999999999977, + "515": -5.150000000000013, + "516": 102.50000000000018, + "517": 73.14999999999993, + "518": 74.95000000000002, + "519": 101.80000000000008, + "520": 102.05000000000004, + "521": 32.34999999999997, + "522": 47.699999999999946, + "523": 47.24999999999991, + "524": 59.1999999999999, + "525": 51.69999999999993, + "526": 38.35000000000001, + "527": 58.29999999999991, + "528": 28.94999999999998, + "529": 42.69999999999996, + "530": 34.19999999999997, + "531": 66.7999999999999, + "532": 105.80000000000021, + "533": 112.15000000000025, + "534": 103.3000000000002, + "535": 108.55000000000024, + "536": 80.05000000000005, + "537": 72.74999999999999, + "538": 54.64999999999994, + "539": 96.65000000000012, + "540": 63.64999999999992, + "541": 44.54999999999993, + "542": 104.45000000000023, + "543": 28.499999999999957, + "544": 82.9, + "545": 75.54999999999997, + "546": 96.45000000000013, + "547": 93.70000000000014, + "548": 97.45000000000013, + "549": 34.199999999999996, + "550": 112.20000000000017, + "551": 50.54999999999987, + "552": 108.80000000000024, + "553": 91.20000000000006, + "554": 4.450000000000015, + "555": 101.75000000000018, + "556": 53.84999999999991, + "557": 75.0, + "558": 100.8000000000002, + "559": 23.44999999999999, + "560": 12.899999999999986, + "561": 40.34999999999995, + "562": 95.30000000000011, + "563": 84.00000000000014, + "564": 96.35000000000014, + "565": 97.75000000000014, + "566": 97.79999999999995, + "567": 81.44999999999987, + "568": 94.65, + "569": 59.7499999999999, + "570": 71.39999999999993, + "571": 22.75, + "572": -5.800000000000004, + "573": 36.89999999999998, + "574": 63.29999999999989, + "575": 22.34999999999997, + "576": 70.19999999999992, + "577": 81.7499999999999, + "578": 61.94999999999994, + "579": 106.95000000000016, + "580": 29.20000000000001, + "581": 55.499999999999936, + "582": 79.25000000000004, + "583": 36.849999999999916, + "584": 32.69999999999993, + "585": 4.449999999999999, + "586": 38.7499999999999, + "587": 51.69999999999991, + "588": -0.6000000000000152, + "589": 60.84999999999991, + "590": 51.749999999999886, + "591": 66.49999999999993, + "592": 94.25000000000007, + "593": 91.25000000000014, + "594": 96.19999999999993, + "595": 64.29999999999988, + "596": 104.9000000000002, + "597": 59.299999999999955, + "598": 80.25, + "599": 68.84999999999981, + "600": 101.30000000000014, + "601": 98.95000000000012, + "602": 101.20000000000014, + "603": 93.45000000000005, + "604": 43.89999999999987, + "605": 52.349999999999945, + "606": 101.3500000000002, + "607": 98.20000000000019, + "608": 99.45000000000022, + "609": 91.50000000000013, + "610": 22.7, + "611": 103.40000000000013, + "612": 101.95000000000016, + "613": 84.0500000000001, + "614": 92.95000000000012, + "615": 38.39999999999995, + "616": -16.799999999999983, + "617": 80.15000000000002, + "618": 93.05000000000014, + "619": 98.45000000000016, + "620": 100.30000000000017, + "621": 95.14999999999998, + "622": 69.69999999999993, + "623": 60.04999999999989, + "624": 98.75000000000018, + "625": 108.40000000000026, + "626": 103.45000000000017, + "627": 89.34999999999988, + "628": 92.7000000000001, + "629": 19.199999999999925, + "630": 100.40000000000019, + "631": 89.89999999999999, + "632": 87.00000000000013, + "633": 102.20000000000017, + "634": 54.24999999999986, + "635": 41.19999999999994, + "636": 25.700000000000006, + "637": 79.35000000000005, + "638": 109.90000000000006, + "639": 103.00000000000016, + "640": 83.40000000000008, + "641": 110.35000000000025, + "642": 8.400000000000011, + "643": 88.65000000000009, + "644": 108.50000000000023, + "645": 109.2500000000002, + "646": 28.749999999999982, + "647": 71.59999999999991, + "648": 103.80000000000018, + "649": 115.35000000000026, + "650": 91.30000000000011, + "651": 62.799999999999926, + "652": 102.5000000000002, + "653": 100.7500000000001, + "654": 24.099999999999888, + "655": 107.95000000000019, + "656": 61.89999999999989, + "657": 105.65000000000018, + "658": 106.8000000000002, + "659": 104.90000000000023, + "660": 92.70000000000013, + "661": 101.45000000000024, + "662": 70.69999999999997, + "663": 24.700000000000003, + "664": 88.95000000000003, + "665": 91.95000000000012, + "666": 15.100000000000007, + "667": 52.64999999999994, + "668": 103.85000000000014, + "669": 56.999999999999936, + "670": 37.09999999999996, + "671": 101.85000000000021, + "672": 100.30000000000014, + "673": 100.15000000000013, + "674": 71.89999999999995, + "675": 77.95000000000006, + "676": 103.20000000000019, + "677": -4.400000000000001, + "678": 73.95, + "679": 68.2999999999999, + "680": 102.7000000000002, + "681": 106.95000000000019, + "682": 109.4500000000001, + "683": 74.64999999999996, + "684": 113.95000000000024, + "685": 102.95000000000017, + "686": 100.69999999999996, + "687": 90.15000000000008, + "688": 102.20000000000019, + "689": 78.3, + "690": 51.59999999999995, + "691": 76.60000000000001, + "692": 105.4000000000001, + "693": 77.90000000000003, + "694": 54.34999999999986, + "695": 108.60000000000022, + "696": 65.49999999999993, + "697": 45.899999999999956, + "698": 101.00000000000016, + "699": 105.85000000000022, + "700": 108.15000000000025, + "701": 35.54999999999997, + "702": 13.60000000000002, + "703": 112.25000000000024, + "704": 88.1, + "705": 102.3500000000002, + "706": 107.4500000000002, + "707": 36.5999999999999, + "708": 35.34999999999992, + "709": 63.49999999999995, + "710": 50.199999999999946, + "711": 108.15000000000015, + "712": 49.24999999999996, + "713": 47.39999999999993, + "714": 87.30000000000008, + "715": 101.80000000000017, + "716": 104.0000000000002, + "717": 97.80000000000007, + "718": 105.10000000000024, + "719": 109.20000000000024, + "720": 99.25000000000023, + "721": 98.30000000000001, + "722": 57.199999999999946, + "723": -32.70000000000001, + "724": 23.69999999999999, + "725": 101.44999999999997, + "726": 70.14999999999998, + "727": 102.90000000000022, + "728": 102.10000000000011, + "729": 104.35000000000022, + "730": 36.10000000000001, + "731": 91.05000000000008, + "732": 79.79999999999995, + "733": 92.29999999999995, + "734": 30.250000000000036, + "735": 89.5499999999999, + "736": 65.69999999999995, + "737": 102.5000000000002, + "738": 76.39999999999999, + "739": 63.44999999999994, + "740": 46.549999999999955, + "741": 37.09999999999995, + "742": 106.40000000000013, + "743": 64.94999999999993, + "744": 83.25000000000004, + "745": 99.8000000000001, + "746": 56.99999999999983, + "747": 94.70000000000017, + "748": 50.199999999999946, + "749": 100.70000000000024, + "750": 105.55000000000021, + "751": 50.64999999999995, + "752": 18.3, + "753": 36.55, + "754": 78.45000000000003, + "755": 18.149999999999995, + "756": 60.099999999999966, + "757": 82.99999999999997, + "758": 98.75000000000007, + "759": 51.099999999999945, + "760": 98.75000000000017, + "761": 42.049999999999955, + "762": 110.5500000000003, + "763": 105.80000000000021, + "764": 92.55000000000001, + "765": 101.35000000000015, + "766": 60.899999999999935, + "767": 10.500000000000057, + "768": 8.450000000000008, + "769": 110.70000000000024, + "770": 60.09999999999993, + "771": 51.69999999999993, + "772": 28.699999999999974, + "773": 110.35000000000022, + "774": 107.55000000000024, + "775": 62.44999999999991, + "776": 94.85000000000012, + "777": 73.54999999999998, + "778": 17.700000000000067, + "779": 109.95000000000007, + "780": 19.799999999999972, + "781": 35.35000000000003, + "782": 105.30000000000024, + "783": 75.94999999999999, + "784": 34.59999999999997, + "785": 88.55000000000004, + "786": 57.64999999999992, + "787": 34.850000000000115, + "788": 59.749999999999794, + "789": 67.45, + "790": 66.25, + "791": 55.449999999999925, + "792": 99.69999999999993, + "793": 80.75000000000009, + "794": 36.849999999999945, + "795": 62.74999999999996, + "796": 82.40000000000008, + "797": 88.10000000000005, + "798": 67.79999999999998, + "799": 58.04999999999991, + "800": 96.19999999999999, + "801": 49.09999999999993, + "802": 58.85000000000005, + "803": 101.30000000000005, + "804": 83.24999999999996, + "805": 58.04999999999985, + "806": 97.40000000000013, + "807": 60.15000000000003, + "808": -56.99999999999997, + "809": 99.75000000000014, + "810": 94.50000000000017, + "811": 95.45000000000016, + "812": 99.60000000000007, + "813": 100.25000000000016, + "814": 93.60000000000012, + "815": 93.95000000000016, + "816": 62.64999999999991, + "817": 58.64999999999985, + "818": 28.59999999999999, + "819": 82.50000000000004, + "820": 84.30000000000005, + "821": 75.60000000000001, + "822": 90.90000000000005, + "823": 95.15000000000015, + "824": 92.60000000000016, + "825": 78.80000000000008, + "826": 30.89999999999997, + "827": 93.09999999999997, + "828": 87.70000000000007, + "829": 105.30000000000027, + "830": 107.85000000000022, + "831": 94.24999999999984, + "832": 76.39999999999998, + "833": 96.20000000000017, + "834": 10.149999999999993, + "835": 94.25000000000001, + "836": 94.1500000000001, + "837": 69.84999999999997, + "838": 37.799999999999955, + "839": 101.1000000000002, + "840": 17.549999999999983, + "841": 78.10000000000001, + "842": 83.4999999999998, + "843": 79.54999999999986, + "844": 32.15, + "845": 51.249999999999915, + "846": 78.75000000000003, + "847": 91.60000000000011, + "848": 80.65000000000008, + "849": 88.9000000000001, + "850": 73.89999999999996, + "851": 109.00000000000018, + "852": 91.94999999999992, + "853": 107.10000000000028, + "854": 90.10000000000014, + "855": 88.9500000000001, + "856": 62.399999999999935, + "857": 61.299999999999905, + "858": 58.099999999999824, + "859": 99.55000000000021, + "860": 98.74999999999979, + "861": 106.45000000000005, + "862": 28.99999999999993, + "863": 59.599999999999866, + "864": -28.099999999999998, + "865": 73.35000000000002, + "866": 94.64999999999974, + "867": 52.94999999999982, + "868": 73.04999999999986, + "869": 82.5, + "870": 88.90000000000008, + "871": 104.40000000000013, + "872": 84.2, + "873": 7.800000000000001, + "874": 55.79999999999995, + "875": 88.45000000000012, + "876": -12.30000000000002, + "877": 48.399999999999885, + "878": 81.39999999999979, + "879": 102.65000000000012, + "880": 72.39999999999986, + "881": 92.04999999999976, + "882": 29.199999999999967, + "883": 98.70000000000014, + "884": 23.949999999999985, + "885": 10.499999999999943, + "886": 73.0, + "887": 67.35000000000001, + "888": 63.29999999999993, + "889": 106.95000000000012, + "890": 47.49999999999989, + "891": 66.39999999999988, + "892": 78.29999999999978, + "893": 96.19999999999997, + "894": 98.85000000000016, + "895": 44.44999999999992, + "896": 103.10000000000024, + "897": 97.55000000000008, + "898": 30.54999999999996, + "899": 88.09999999999991, + "900": 77.29999999999995, + "901": 94.39999999999988, + "902": 6.749999999999973, + "903": 64.29999999999995, + "904": 91.94999999999978, + "905": 10.450000000000053, + "906": 72.39999999999985, + "907": 96.35000000000005, + "908": 97.50000000000006, + "909": 60.69999999999993, + "910": -19.55, + "911": 80.24999999999983, + "912": 43.44999999999997, + "913": 82.79999999999981, + "914": 46.99999999999979, + "915": 92.09999999999978, + "916": 77.7999999999999, + "917": 98.40000000000003, + "918": 98.70000000000009, + "919": 62.74999999999975, + "920": 49.949999999999754, + "921": 21.599999999999998, + "922": 80.09999999999981, + "923": 70.69999999999979, + "924": 86.4000000000001, + "925": 95.00000000000009, + "926": 68.14999999999975, + "927": 63.04999999999991, + "928": 102.95000000000005, + "929": 78.94999999999983, + "930": 36.20000000000001, + "931": 73.89999999999999, + "932": 49.39999999999978, + "933": 77.75000000000007, + "934": 80.99999999999997, + "935": 77.85000000000004, + "936": 101.80000000000021, + "937": 69.29999999999987, + "938": 67.29999999999994, + "939": 90.7999999999999, + "940": 99.30000000000014, + "941": 40.79999999999979, + "942": 63.2499999999999, + "943": 96.79999999999977, + "944": 99.85000000000015, + "945": 62.499999999999886, + "946": 98.1000000000001, + "947": 87.44999999999999, + "948": 101.54999999999977, + "949": 81.39999999999984, + "950": 53.09999999999992, + "951": 80.09999999999975, + "952": 94.94999999999978, + "953": 92.14999999999978, + "954": 97.79999999999974, + "955": 87.79999999999983, + "956": 96.94999999999972, + "957": 94.20000000000003, + "958": 70.79999999999978, + "959": 65.5499999999998, + "960": 100.24999999999979, + "961": 102.64999999999993, + "962": 89.14999999999975, + "963": 21.099999999999984, + "964": 69.24999999999987, + "965": 93.15000000000018, + "966": 22.149999999999956, + "967": 87.44999999999975, + "968": 69.29999999999981, + "969": 91.24999999999977, + "970": 51.74999999999979, + "971": 78.74999999999993, + "972": 30.599999999999845, + "973": 94.5999999999999, + "974": 46.84999999999975, + "975": 64.99999999999977, + "976": 53.299999999999756, + "977": 87.60000000000004, + "978": 92.59999999999972, + "979": 93.34999999999977, + "980": 48.94999999999978, + "981": 97.70000000000009, + "982": 97.14999999999975, + "983": 48.74999999999975, + "984": 91.74999999999982, + "985": 101.14999999999986, + "986": 81.0499999999998, + "987": 63.1999999999999, + "988": 106.24999999999982, + "989": 66.89999999999992, + "990": 74.19999999999976, + "991": 92.29999999999974, + "992": 91.79999999999977, + "993": 81.50000000000001, + "994": 88.85000000000001, + "995": 99.09999999999972, + "996": 108.34999999999977, + "997": 54.049999999999926, + "998": 44.44999999999992, + "999": 101.74999999999973, + "1000": -39.899999999999984 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.3.0/session_metadata/4.json b/benchmark/results/v3/v3.3.0/session_metadata/4.json new file mode 100644 index 00000000..cd4acfc0 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/session_metadata/4.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1435.848728, + "s_per_step": 0.044870272749999995, + "s_per_100_steps_10_nodes": 4.487027275, + "total_reward_per_episode": { + "1": -53.10000000000009, + "2": -17.299999999999972, + "3": -51.25000000000008, + "4": -48.30000000000006, + "5": -29.899999999999956, + "6": -23.449999999999964, + "7": -16.149999999999984, + "8": -38.750000000000036, + "9": -22.449999999999953, + "10": -48.40000000000015, + "11": -27.99999999999999, + "12": -7.199999999999988, + "13": -31.100000000000016, + "14": -25.95000000000002, + "15": -12.349999999999994, + "16": -17.799999999999976, + "17": -98.6, + "18": -43.65000000000011, + "19": -21.449999999999957, + "20": -52.95000000000008, + "21": -66.30000000000008, + "22": -39.55000000000012, + "23": -42.600000000000044, + "24": -81.64999999999998, + "25": -21.999999999999954, + "26": -15.499999999999979, + "27": -63.50000000000011, + "28": -20.249999999999982, + "29": -20.799999999999958, + "30": -13.249999999999982, + "31": -18.34999999999997, + "32": -53.20000000000015, + "33": -7.799999999999997, + "34": 5.850000000000034, + "35": -0.6999999999999571, + "36": -6.050000000000013, + "37": -20.19999999999996, + "38": -20.54999999999996, + "39": -13.349999999999985, + "40": -7.3499999999999925, + "41": -66.85000000000004, + "42": 8.750000000000043, + "43": -27.30000000000002, + "44": -12.34999999999999, + "45": -18.499999999999964, + "46": -33.24999999999999, + "47": -86.95, + "48": -16.8, + "49": -64.25000000000006, + "50": 3.5000000000000275, + "51": -7.499999999999999, + "52": -15.299999999999978, + "53": -23.94999999999995, + "54": -34.59999999999999, + "55": -11.35000000000001, + "56": -10.599999999999987, + "57": -31.75000000000003, + "58": -107.1, + "59": -30.550000000000022, + "60": -50.90000000000005, + "61": -103.75, + "62": -27.749999999999936, + "63": -13.699999999999983, + "64": 4.0500000000000576, + "65": -80.45000000000002, + "66": -18.549999999999965, + "67": -55.40000000000009, + "68": 21.0, + "69": 0.10000000000001108, + "70": -85.60000000000002, + "71": -67.35000000000008, + "72": -48.90000000000001, + "73": -13.649999999999986, + "74": -47.75000000000005, + "75": -15.749999999999979, + "76": -45.75000000000005, + "77": -63.7000000000001, + "78": -12.949999999999987, + "79": 21.599999999999916, + "80": -100.49999999999999, + "81": -56.700000000000095, + "82": -4.249999999999967, + "83": -19.099999999999966, + "84": -46.19999999999993, + "85": -21.299999999999965, + "86": 10.850000000000058, + "87": -17.94999999999997, + "88": -93.25, + "89": 11.400000000000013, + "90": -90.45, + "91": -19.299999999999965, + "92": -23.29999999999995, + "93": -60.950000000000095, + "94": -19.999999999999964, + "95": -16.699999999999974, + "96": -16.49999999999998, + "97": -18.24999999999997, + "98": -61.20000000000009, + "99": -86.45000000000002, + "100": -14.000000000000007, + "101": -7.049999999999984, + "102": -18.199999999999967, + "103": -28.550000000000004, + "104": -10.149999999999991, + "105": 5.500000000000013, + "106": -36.200000000000045, + "107": 31.4499999999998, + "108": -14.249999999999984, + "109": -69.05000000000005, + "110": 13.29999999999998, + "111": -81.05, + "112": 5.85000000000002, + "113": 2.7500000000000577, + "114": 2.05000000000003, + "115": 4.0500000000000504, + "116": -10.849999999999996, + "117": -90.05000000000001, + "118": -17.00000000000003, + "119": 4.550000000000038, + "120": -9.449999999999985, + "121": -11.75000000000004, + "122": -10.299999999999981, + "123": -18.949999999999967, + "124": 13.150000000000013, + "125": -63.050000000000104, + "126": -14.649999999999984, + "127": -22.59999999999995, + "128": 5.7499999999999005, + "129": 27.45000000000002, + "130": 5.699999999999998, + "131": -14.199999999999964, + "132": 25.299999999999972, + "133": -45.70000000000005, + "134": -5.649999999999976, + "135": -18.100000000000044, + "136": -13.150000000000006, + "137": 4.9499999999999655, + "138": 35.549999999999876, + "139": -3.3000000000001, + "140": 14.70000000000002, + "141": -9.150000000000004, + "142": -44.44999999999999, + "143": -14.649999999999977, + "144": -67.49999999999997, + "145": -67.69999999999997, + "146": -81.55000000000001, + "147": -34.65000000000004, + "148": 12.049999999999867, + "149": -7.5999999999999845, + "150": -8.249999999999984, + "151": 24.850000000000065, + "152": -15.649999999999979, + "153": 3.350000000000044, + "154": 40.74999999999999, + "155": -46.250000000000014, + "156": -6.149999999999986, + "157": 37.29999999999989, + "158": -15.699999999999973, + "159": -10.100000000000007, + "160": 7.750000000000041, + "161": 7.600000000000026, + "162": -13.49999999999997, + "163": -26.54999999999995, + "164": 30.449999999999932, + "165": -87.79999999999998, + "166": -68.70000000000002, + "167": -13.14999999999999, + "168": -7.299999999999983, + "169": 38.149999999999814, + "170": -79.34999999999998, + "171": -17.149999999999956, + "172": -16.649999999999974, + "173": 19.750000000000025, + "174": -63.00000000000011, + "175": 62.44999999999998, + "176": -0.44999999999996, + "177": -22.899999999999984, + "178": -7.2999999999999865, + "179": -94.5, + "180": -33.549999999999955, + "181": -94.2, + "182": -2.7499999999999885, + "183": -8.149999999999988, + "184": 5.450000000000016, + "185": 5.150000000000009, + "186": -4.550000000000033, + "187": 26.00000000000004, + "188": -38.09999999999999, + "189": -71.90000000000003, + "190": -73.1, + "191": 21.55000000000002, + "192": -31.149999999999963, + "193": -81.7, + "194": 50.449999999999854, + "195": -13.750000000000012, + "196": -41.70000000000003, + "197": -56.850000000000094, + "198": -5.399999999999981, + "199": 57.49999999999982, + "200": -52.40000000000001, + "201": -37.000000000000064, + "202": -73.34999999999994, + "203": 16.79999999999994, + "204": 44.499999999999744, + "205": 14.450000000000077, + "206": 48.34999999999985, + "207": 71.29999999999991, + "208": 23.250000000000014, + "209": -21.499999999999957, + "210": -21.34999999999996, + "211": 22.599999999999966, + "212": 34.799999999999805, + "213": 67.64999999999989, + "214": 73.19999999999978, + "215": -70.24999999999999, + "216": -48.74999999999998, + "217": -37.49999999999997, + "218": 82.90000000000016, + "219": -75.79999999999998, + "220": -89.75, + "221": 61.34999999999975, + "222": 30.749999999999762, + "223": -65.69999999999993, + "224": 20.500000000000025, + "225": 19.40000000000006, + "226": 14.349999999999914, + "227": 25.150000000000055, + "228": -50.04999999999994, + "229": 28.75000000000007, + "230": 51.749999999999815, + "231": 71.89999999999988, + "232": 73.29999999999981, + "233": -10.599999999999962, + "234": 69.34999999999987, + "235": -10.749999999999964, + "236": 44.24999999999973, + "237": 31.649999999999945, + "238": -16.09999999999998, + "239": -20.59999999999999, + "240": 21.25000000000007, + "241": 0.600000000000027, + "242": -2.7999999999999785, + "243": 38.84999999999985, + "244": 6.450000000000021, + "245": -63.199999999999974, + "246": 58.8999999999998, + "247": 38.30000000000006, + "248": 92.59999999999974, + "249": -9.850000000000032, + "250": -57.20000000000011, + "251": 57.74999999999977, + "252": 83.04999999999974, + "253": 64.39999999999992, + "254": -10.049999999999992, + "255": -57.05000000000007, + "256": 30.000000000000007, + "257": -0.8999999999999915, + "258": 21.20000000000008, + "259": 55.5999999999998, + "260": 83.64999999999976, + "261": 21.499999999999982, + "262": 32.65000000000008, + "263": -84.10000000000001, + "264": 98.24999999999974, + "265": 48.29999999999984, + "266": 60.7999999999999, + "267": 87.09999999999975, + "268": 33.10000000000005, + "269": -48.59999999999994, + "270": 78.24999999999987, + "271": 60.6499999999999, + "272": 34.199999999999896, + "273": 77.79999999999973, + "274": 26.800000000000033, + "275": -12.200000000000014, + "276": 88.24999999999977, + "277": 9.700000000000067, + "278": 101.29999999999971, + "279": 64.9999999999998, + "280": 17.199999999999896, + "281": 95.39999999999976, + "282": 84.74999999999977, + "283": 71.14999999999989, + "284": 34.29999999999986, + "285": 36.90000000000001, + "286": 61.74999999999984, + "287": 99.34999999999972, + "288": 86.09999999999981, + "289": 74.09999999999977, + "290": 74.9499999999998, + "291": 63.29999999999985, + "292": 80.34999999999977, + "293": 24.899999999999956, + "294": 82.44999999999978, + "295": -32.3, + "296": 77.89999999999985, + "297": -12.899999999999988, + "298": 54.99999999999979, + "299": 50.74999999999978, + "300": 77.74999999999984, + "301": 82.19999999999979, + "302": 98.29999999999978, + "303": 38.049999999999976, + "304": 78.04999999999977, + "305": -23.89999999999995, + "306": 85.59999999999984, + "307": 9.450000000000026, + "308": 39.24999999999997, + "309": 61.19999999999976, + "310": 49.149999999999764, + "311": 89.84999999999982, + "312": 11.500000000000043, + "313": 85.79999999999976, + "314": 54.1499999999998, + "315": 93.39999999999976, + "316": 91.59999999999977, + "317": 99.94999999999978, + "318": 80.19999999999978, + "319": 99.44999999999976, + "320": -71.65000000000002, + "321": 87.69999999999978, + "322": 94.69999999999979, + "323": 92.19999999999979, + "324": 77.49999999999987, + "325": 68.59999999999977, + "326": 99.74999999999976, + "327": 82.59999999999982, + "328": 94.94999999999973, + "329": 88.49999999999979, + "330": 85.94999999999976, + "331": 28.299999999999844, + "332": 85.24999999999977, + "333": 89.9499999999998, + "334": -50.50000000000008, + "335": 85.99999999999984, + "336": -1.0999999999999823, + "337": 92.64999999999978, + "338": 99.99999999999976, + "339": 29.99999999999981, + "340": 87.29999999999976, + "341": 81.7499999999998, + "342": 95.59999999999975, + "343": 91.99999999999974, + "344": 97.39999999999978, + "345": 96.09999999999978, + "346": 98.94999999999975, + "347": 99.6999999999998, + "348": 98.89999999999975, + "349": 84.34999999999975, + "350": 91.24999999999977, + "351": 83.09999999999978, + "352": 80.39999999999984, + "353": -86.1, + "354": 69.79999999999976, + "355": 82.64999999999972, + "356": 100.24999999999979, + "357": 91.74999999999977, + "358": 100.14999999999978, + "359": 88.5999999999998, + "360": 102.74999999999976, + "361": 21.94999999999991, + "362": 89.49999999999979, + "363": 95.79999999999976, + "364": 83.89999999999979, + "365": 83.2999999999998, + "366": 100.69999999999978, + "367": -84.44999999999999, + "368": 10.150000000000038, + "369": -11.899999999999991, + "370": 100.19999999999976, + "371": 102.44999999999973, + "372": 76.44999999999979, + "373": 101.89999999999976, + "374": 99.94999999999979, + "375": 8.650000000000025, + "376": 32.750000000000014, + "377": 82.79999999999984, + "378": 73.24999999999972, + "379": 103.49999999999976, + "380": 92.99999999999977, + "381": 103.09999999999977, + "382": 103.84999999999975, + "383": 96.04999999999974, + "384": 103.09999999999977, + "385": 95.09999999999977, + "386": 83.4999999999998, + "387": 103.39999999999976, + "388": -80.0, + "389": 90.1499999999998, + "390": 97.59999999999972, + "391": 83.09999999999978, + "392": 94.44999999999976, + "393": 100.39999999999976, + "394": 97.99999999999979, + "395": 90.99999999999982, + "396": 82.79999999999977, + "397": 99.09999999999977, + "398": 105.24999999999974, + "399": 65.94999999999976, + "400": 98.39999999999976, + "401": 103.09999999999977, + "402": 100.69999999999976, + "403": 98.74999999999976, + "404": -44.05000000000007, + "405": 91.44999999999976, + "406": 27.000000000000046, + "407": 98.64999999999976, + "408": 96.44999999999978, + "409": 104.99999999999983, + "410": 90.99999999999979, + "411": 101.9999999999998, + "412": 103.34999999999988, + "413": 98.29999999999974, + "414": 78.59999999999987, + "415": 99.34999999999987, + "416": 101.69999999999978, + "417": 77.8999999999998, + "418": 101.14999999999978, + "419": 103.09999999999977, + "420": 103.49999999999976, + "421": 100.69999999999978, + "422": 91.44999999999978, + "423": 100.54999999999977, + "424": -23.149999999999952, + "425": 99.79999999999976, + "426": -9.349999999999985, + "427": 106.89999999999979, + "428": 101.99999999999977, + "429": 84.2499999999998, + "430": 93.34999999999981, + "431": 104.69999999999982, + "432": 83.09999999999977, + "433": 59.49999999999984, + "434": 101.59999999999975, + "435": 98.99999999999977, + "436": 96.89999999999976, + "437": 105.84999999999984, + "438": 97.19999999999976, + "439": -11.799999999999985, + "440": 102.49999999999976, + "441": 98.44999999999979, + "442": 108.44999999999986, + "443": 103.54999999999977, + "444": 101.14999999999976, + "445": 104.74999999999976, + "446": 100.69999999999976, + "447": 87.79999999999976, + "448": 96.5499999999998, + "449": 75.94999999999978, + "450": 103.49999999999977, + "451": 84.84999999999974, + "452": 101.14999999999976, + "453": 23.049999999999898, + "454": 98.69999999999978, + "455": 102.39999999999978, + "456": 62.19999999999973, + "457": 102.74999999999976, + "458": 105.44999999999975, + "459": 65.6499999999999, + "460": -38.89999999999997, + "461": 62.44999999999979, + "462": 97.44999999999978, + "463": 102.69999999999976, + "464": 107.89999999999979, + "465": 103.59999999999977, + "466": 104.99999999999974, + "467": 103.04999999999977, + "468": 102.84999999999977, + "469": 104.79999999999974, + "470": 100.19999999999978, + "471": 104.59999999999977, + "472": 102.79999999999977, + "473": 104.94999999999975, + "474": -80.69999999999999, + "475": 102.34999999999977, + "476": -84.00000000000003, + "477": 96.09999999999975, + "478": 80.79999999999973, + "479": 102.24999999999977, + "480": 98.30000000000008, + "481": 103.39999999999975, + "482": 56.09999999999979, + "483": 103.54999999999977, + "484": 103.74999999999972, + "485": 67.74999999999976, + "486": 62.94999999999975, + "487": 101.99999999999977, + "488": 103.24999999999974, + "489": 104.49999999999974, + "490": 75.29999999999983, + "491": 72.84999999999977, + "492": 77.44999999999978, + "493": 102.69999999999978, + "494": 96.14999999999976, + "495": 94.8499999999998, + "496": 106.64999999999972, + "497": 80.89999999999989, + "498": 84.44999999999976, + "499": 101.94999999999976, + "500": 99.89999999999978, + "501": 105.89999999999974, + "502": -35.199999999999996, + "503": 106.69999999999973, + "504": 94.59999999999981, + "505": 101.49999999999977, + "506": 103.19999999999976, + "507": 103.99999999999972, + "508": 96.74999999999982, + "509": 97.8499999999998, + "510": 104.59999999999974, + "511": 102.74999999999977, + "512": 103.64999999999976, + "513": 100.39999999999975, + "514": 99.19999999999978, + "515": 71.64999999999976, + "516": 104.09999999999974, + "517": 104.34999999999975, + "518": 102.94999999999978, + "519": 97.54999999999977, + "520": 106.24999999999973, + "521": -75.3, + "522": 4.75, + "523": 100.34999999999975, + "524": 106.69999999999975, + "525": 56.699999999999854, + "526": 16.30000000000001, + "527": 101.29999999999977, + "528": 93.09999999999977, + "529": 103.54999999999977, + "530": 66.2999999999999, + "531": 102.59999999999977, + "532": 102.74999999999977, + "533": 102.84999999999977, + "534": 102.84999999999977, + "535": 104.59999999999977, + "536": 107.89999999999974, + "537": 104.04999999999974, + "538": 75.74999999999977, + "539": 102.29999999999977, + "540": 81.34999999999978, + "541": 102.09999999999977, + "542": 105.89999999999974, + "543": 99.54999999999974, + "544": 102.84999999999975, + "545": 105.84999999999972, + "546": 105.59999999999972, + "547": 103.54999999999977, + "548": 98.64999999999978, + "549": 103.39999999999976, + "550": 106.34999999999975, + "551": 84.14999999999976, + "552": 108.59999999999974, + "553": 104.34999999999975, + "554": -78.9, + "555": 91.19999999999978, + "556": 101.54999999999977, + "557": 103.49999999999977, + "558": 104.79999999999974, + "559": 48.14999999999982, + "560": 40.99999999999998, + "561": 93.69999999999982, + "562": 104.44999999999976, + "563": 105.49999999999974, + "564": 102.24999999999977, + "565": 93.59999999999978, + "566": 105.74999999999974, + "567": 99.74999999999976, + "568": 62.39999999999994, + "569": 100.64999999999974, + "570": 104.39999999999972, + "571": 103.89999999999976, + "572": 103.34999999999977, + "573": 85.79999999999977, + "574": 3.1499999999999915, + "575": 102.04999999999977, + "576": 104.64999999999978, + "577": 59.09999999999975, + "578": -81.00000000000001, + "579": 103.59999999999977, + "580": 105.69999999999972, + "581": 53.79999999999983, + "582": 104.79999999999974, + "583": 102.84999999999977, + "584": 104.44999999999978, + "585": 104.39999999999975, + "586": 104.54999999999976, + "587": 103.79999999999977, + "588": 105.59999999999972, + "589": 102.54999999999976, + "590": 103.54999999999977, + "591": 83.54999999999977, + "592": -75.8, + "593": 105.89999999999972, + "594": 102.09999999999977, + "595": 105.74999999999973, + "596": 103.19999999999976, + "597": 102.94999999999978, + "598": 107.04999999999974, + "599": 103.89999999999976, + "600": 104.39999999999976, + "601": 100.99999999999976, + "602": 106.09999999999974, + "603": 105.34999999999975, + "604": 105.09999999999974, + "605": 103.74999999999977, + "606": 102.89999999999976, + "607": -78.65, + "608": 102.89999999999978, + "609": 107.24999999999973, + "610": 102.64999999999976, + "611": 106.94999999999973, + "612": -82.80000000000001, + "613": 104.09999999999977, + "614": 104.39999999999976, + "615": 104.14999999999976, + "616": 43.74999999999976, + "617": 104.49999999999976, + "618": 60.74999999999977, + "619": 105.39999999999975, + "620": 103.29999999999977, + "621": 106.49999999999993, + "622": 107.54999999999974, + "623": 107.99999999999974, + "624": 76.69999999999978, + "625": 108.29999999999974, + "626": 102.99999999999977, + "627": 104.54999999999976, + "628": 103.74999999999977, + "629": 105.54999999999973, + "630": 104.64999999999975, + "631": 102.89999999999976, + "632": 105.39999999999974, + "633": 104.14999999999976, + "634": 104.59999999999975, + "635": 104.29999999999977, + "636": 103.94999999999976, + "637": 97.84999999999977, + "638": -79.04999999999998, + "639": 103.04999999999977, + "640": 100.74999999999979, + "641": 102.74999999999977, + "642": 104.09999999999977, + "643": 106.04999999999971, + "644": 106.64999999999989, + "645": 104.09999999999977, + "646": 103.24999999999976, + "647": 103.04999999999977, + "648": 103.99999999999976, + "649": 81.19999999999976, + "650": 102.79999999999977, + "651": 102.99999999999977, + "652": 101.94999999999978, + "653": 39.1, + "654": 105.84999999999972, + "655": 60.34999999999975, + "656": 96.39999999999979, + "657": 62.69999999999998, + "658": 105.24999999999974, + "659": 92.44999999999975, + "660": 103.69999999999976, + "661": 101.39999999999978, + "662": 103.09999999999977, + "663": 103.24999999999976, + "664": 94.4499999999998, + "665": 89.79999999999987, + "666": 103.54999999999976, + "667": 103.99999999999976, + "668": 73.64999999999976, + "669": 103.69999999999976, + "670": -14.500000000000007, + "671": 105.04999999999976, + "672": 104.94999999999975, + "673": 103.34999999999977, + "674": 90.89999999999982, + "675": 100.99999999999977, + "676": 105.09999999999981, + "677": 103.64999999999976, + "678": 71.59999999999977, + "679": 107.94999999999973, + "680": 73.14999999999976, + "681": 103.24999999999977, + "682": 103.29999999999977, + "683": 54.29999999999975, + "684": 98.69999999999975, + "685": 104.54999999999973, + "686": 105.6499999999998, + "687": 103.09999999999977, + "688": 103.84999999999977, + "689": 104.34999999999974, + "690": 83.54999999999977, + "691": 84.8499999999998, + "692": 105.44999999999982, + "693": 106.54999999999973, + "694": 106.24999999999983, + "695": 103.94999999999976, + "696": 105.94999999999973, + "697": 12.799999999999969, + "698": 103.29999999999984, + "699": 109.09999999999975, + "700": 101.99999999999974, + "701": 104.79999999999977, + "702": 103.79999999999976, + "703": 102.64999999999976, + "704": 103.29999999999977, + "705": 106.94999999999973, + "706": 104.69999999999976, + "707": 103.09999999999977, + "708": 103.74999999999976, + "709": 103.14999999999978, + "710": 102.79999999999974, + "711": 99.24999999999977, + "712": 103.04999999999977, + "713": 102.69999999999978, + "714": 103.74999999999976, + "715": 102.74999999999976, + "716": 83.99999999999983, + "717": 104.39999999999975, + "718": 104.84999999999975, + "719": 103.59999999999977, + "720": 103.39999999999976, + "721": 102.74999999999977, + "722": 104.84999999999974, + "723": 104.49999999999976, + "724": 105.64999999999974, + "725": 92.49999999999977, + "726": 102.49999999999976, + "727": 104.34999999999988, + "728": 104.39999999999975, + "729": 103.44999999999976, + "730": 106.94999999999979, + "731": 103.14999999999978, + "732": 103.69999999999976, + "733": 111.44999999999993, + "734": 102.94999999999978, + "735": 100.39999999999976, + "736": 99.54999999999978, + "737": 104.89999999999975, + "738": 104.19999999999976, + "739": 95.89999999999995, + "740": 105.29999999999994, + "741": 105.59999999999972, + "742": 104.19999999999976, + "743": 105.44999999999972, + "744": 105.84999999999974, + "745": 106.94999999999973, + "746": 107.84999999999972, + "747": 94.49999999999974, + "748": 104.84999999999975, + "749": 107.29999999999973, + "750": 104.04999999999976, + "751": 103.99999999999976, + "752": 62.34999999999977, + "753": 107.54999999999973, + "754": -84.6, + "755": 106.64999999999972, + "756": 85.69999999999983, + "757": 103.04999999999977, + "758": 57.54999999999978, + "759": 104.79999999999976, + "760": 96.04999999999976, + "761": 3.80000000000006, + "762": 102.79999999999977, + "763": -65.80000000000001, + "764": 106.64999999999974, + "765": 31.64999999999985, + "766": -68.35000000000001, + "767": 103.54999999999976, + "768": 104.84999999999977, + "769": 58.199999999999754, + "770": 103.89999999999976, + "771": 49.699999999999775, + "772": 109.94999999999985, + "773": 104.74999999999976, + "774": 104.59999999999975, + "775": 105.79999999999981, + "776": 31.39999999999987, + "777": 103.64999999999976, + "778": 103.34999999999977, + "779": 105.04999999999974, + "780": -68.1, + "781": 107.39999999999975, + "782": 109.50000000000018, + "783": -19.25, + "784": 108.39999999999989, + "785": 107.59999999999982, + "786": 102.39999999999978, + "787": 104.29999999999977, + "788": -84.00000000000003, + "789": 112.49999999999994, + "790": -77.4, + "791": 104.19999999999975, + "792": 92.74999999999983, + "793": 104.19999999999976, + "794": 104.29999999999976, + "795": -84.54999999999998, + "796": 103.89999999999979, + "797": 109.0499999999998, + "798": 105.29999999999977, + "799": 105.89999999999972, + "800": 103.14999999999976, + "801": 101.99999999999977, + "802": 105.19999999999973, + "803": 105.04999999999974, + "804": 105.74999999999972, + "805": 104.89999999999993, + "806": 105.34999999999974, + "807": 104.39999999999976, + "808": 103.64999999999976, + "809": 105.74999999999973, + "810": 49.74999999999981, + "811": 108.69999999999995, + "812": 106.39999999999971, + "813": -36.95000000000001, + "814": 105.59999999999975, + "815": 105.94999999999973, + "816": 103.34999999999977, + "817": 103.39999999999976, + "818": -76.6, + "819": 111.94999999999992, + "820": 103.34999999999977, + "821": 104.14999999999976, + "822": 106.59999999999972, + "823": 104.29999999999976, + "824": -3.8000000000000043, + "825": 103.29999999999977, + "826": 65.14999999999979, + "827": 103.99999999999976, + "828": 100.14999999999978, + "829": 104.44999999999976, + "830": 104.99999999999973, + "831": 78.94999999999978, + "832": -75.35, + "833": 102.49999999999977, + "834": -86.45000000000013, + "835": 116.35000000000032, + "836": 103.14999999999976, + "837": 105.34999999999972, + "838": 105.79999999999974, + "839": 108.69999999999975, + "840": 105.44999999999973, + "841": -88.44999999999999, + "842": 104.59999999999975, + "843": 104.24999999999976, + "844": 105.24999999999973, + "845": 113.80000000000021, + "846": 104.79999999999974, + "847": 104.94999999999982, + "848": 104.59999999999975, + "849": 103.39999999999976, + "850": 107.94999999999979, + "851": 105.69999999999972, + "852": 109.09999999999977, + "853": 106.29999999999971, + "854": 82.74999999999974, + "855": 71.29999999999978, + "856": -68.34999999999998, + "857": 106.49999999999996, + "858": 107.69999999999975, + "859": 105.39999999999972, + "860": 103.34999999999977, + "861": 107.74999999999974, + "862": 103.74999999999976, + "863": 100.79999999999973, + "864": 106.19999999999973, + "865": 100.79999999999976, + "866": -81.0, + "867": 105.69999999999986, + "868": 103.09999999999977, + "869": 104.09999999999977, + "870": 102.69999999999978, + "871": 103.94999999999976, + "872": 105.09999999999975, + "873": 103.94999999999973, + "874": 46.699999999999896, + "875": 94.54999999999978, + "876": 103.79999999999977, + "877": 106.24999999999973, + "878": 104.14999999999975, + "879": -73.69999999999997, + "880": 104.59999999999982, + "881": -77.44999999999999, + "882": -15.000000000000014, + "883": 104.84999999999975, + "884": -81.95, + "885": 105.14999999999975, + "886": 109.24999999999979, + "887": -77.85, + "888": 104.19999999999976, + "889": 113.75, + "890": -38.10000000000001, + "891": 104.69999999999976, + "892": -72.05000000000001, + "893": -73.80000000000001, + "894": 113.64999999999988, + "895": 104.19999999999976, + "896": 107.64999999999974, + "897": 109.29999999999978, + "898": 109.04999999999981, + "899": 109.24999999999976, + "900": 104.84999999999977, + "901": 104.24999999999976, + "902": 105.79999999999974, + "903": 104.04999999999977, + "904": 104.54999999999974, + "905": 104.94999999999975, + "906": 105.09999999999975, + "907": 101.49999999999972, + "908": -79.94999999999999, + "909": 103.29999999999977, + "910": 105.89999999999972, + "911": 102.64999999999976, + "912": 85.34999999999981, + "913": 104.69999999999976, + "914": 106.59999999999972, + "915": 106.44999999999972, + "916": 106.59999999999974, + "917": 107.64999999999974, + "918": 116.70000000000027, + "919": 59.150000000000034, + "920": 102.74999999999977, + "921": 104.89999999999972, + "922": 104.89999999999974, + "923": 107.19999999999972, + "924": 106.19999999999975, + "925": 104.79999999999976, + "926": 111.64999999999999, + "927": 109.04999999999976, + "928": 104.39999999999975, + "929": 105.34999999999975, + "930": 115.10000000000018, + "931": 108.39999999999975, + "932": 60.249999999999766, + "933": 40.69999999999997, + "934": 97.94999999999975, + "935": 105.39999999999974, + "936": 108.44999999999976, + "937": 105.89999999999974, + "938": 106.14999999999972, + "939": 106.09999999999972, + "940": 105.29999999999973, + "941": 104.44999999999978, + "942": 108.59999999999977, + "943": 105.79999999999973, + "944": 71.04999999999976, + "945": 106.94999999999973, + "946": 75.59999999999977, + "947": 103.14999999999978, + "948": 102.74999999999977, + "949": 106.99999999999973, + "950": 103.24999999999976, + "951": 110.54999999999983, + "952": 110.44999999999989, + "953": 104.49999999999974, + "954": 39.849999999999824, + "955": 104.94999999999975, + "956": -63.350000000000016, + "957": 104.04999999999977, + "958": -88.25, + "959": 103.24999999999977, + "960": 102.44999999999976, + "961": 83.34999999999975, + "962": -69.80000000000001, + "963": 108.59999999999974, + "964": 103.94999999999976, + "965": 105.39999999999974, + "966": 107.39999999999974, + "967": -45.80000000000007, + "968": 105.10000000000008, + "969": 103.69999999999973, + "970": 105.59999999999985, + "971": -79.0, + "972": 102.84999999999977, + "973": 103.44999999999976, + "974": 104.74999999999973, + "975": 103.29999999999977, + "976": -82.45000000000002, + "977": 105.59999999999974, + "978": 104.49999999999983, + "979": -83.35, + "980": 106.89999999999974, + "981": -83.85, + "982": -81.3, + "983": 103.49999999999972, + "984": 56.149999999999764, + "985": 106.19999999999978, + "986": 110.19999999999976, + "987": 108.69999999999976, + "988": 108.39999999999975, + "989": -41.05000000000001, + "990": 107.40000000000003, + "991": 46.79999999999997, + "992": 110.34999999999987, + "993": 106.39999999999972, + "994": 104.39999999999975, + "995": 106.09999999999972, + "996": 104.19999999999976, + "997": 107.14999999999974, + "998": 105.64999999999972, + "999": 103.14999999999976, + "1000": 107.24999999999979 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.3.0/session_metadata/5.json b/benchmark/results/v3/v3.3.0/session_metadata/5.json new file mode 100644 index 00000000..d6fc6124 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/session_metadata/5.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1432.237888, + "s_per_step": 0.044757434000000006, + "s_per_100_steps_10_nodes": 4.475743400000001, + "total_reward_per_episode": { + "1": -4.399999999999995, + "2": -48.50000000000004, + "3": -109.5, + "4": -54.500000000000085, + "5": -15.949999999999978, + "6": -80.89999999999992, + "7": -15.349999999999982, + "8": -23.29999999999995, + "9": -34.350000000000016, + "10": -49.800000000000054, + "11": -46.95000000000006, + "12": -22.699999999999953, + "13": -32.35000000000003, + "14": -24.199999999999942, + "15": -51.150000000000176, + "16": -52.20000000000008, + "17": -68.60000000000007, + "18": -30.400000000000006, + "19": -19.99999999999996, + "20": -73.15000000000002, + "21": -17.949999999999974, + "22": -12.949999999999987, + "23": -61.25, + "24": -35.19999999999998, + "25": -70.70000000000005, + "26": -96.6, + "27": -48.550000000000146, + "28": -4.599999999999975, + "29": -7.4, + "30": -44.050000000000175, + "31": -4.29999999999998, + "32": -21.999999999999957, + "33": -78.30000000000004, + "34": -15.099999999999985, + "35": -55.60000000000003, + "36": -51.800000000000075, + "37": -20.39999999999996, + "38": -22.499999999999954, + "39": -104.3, + "40": -45.75000000000005, + "41": 2.100000000000044, + "42": -21.099999999999987, + "43": -14.99999999999998, + "44": -94.15, + "45": -45.70000000000012, + "46": -17.399999999999974, + "47": -29.099999999999948, + "48": -13.749999999999986, + "49": -87.25, + "50": -47.04999999999999, + "51": -23.89999999999995, + "52": -47.75000000000007, + "53": -13.699999999999976, + "54": -17.74999999999997, + "55": -23.799999999999972, + "56": -16.49999999999998, + "57": -21.299999999999958, + "58": -13.099999999999985, + "59": -96.69999999999997, + "60": -23.44999999999995, + "61": -8.399999999999995, + "62": -37.65000000000005, + "63": -20.349999999999962, + "64": -19.049999999999958, + "65": -17.24999999999998, + "66": -8.550000000000006, + "67": -18.14999999999997, + "68": -69.45000000000005, + "69": -16.999999999999975, + "70": -72.19999999999999, + "71": -29.599999999999994, + "72": -19.049999999999965, + "73": -7.249999999999993, + "74": -16.049999999999983, + "75": -17.49999999999997, + "76": -18.29999999999997, + "77": -15.799999999999976, + "78": -6.299999999999986, + "79": -17.24999999999997, + "80": -20.999999999999957, + "81": -12.84999999999996, + "82": -77.1, + "83": -18.34999999999997, + "84": -16.24999999999998, + "85": -51.75000000000008, + "86": -19.649999999999963, + "87": -0.5999999999999621, + "88": 4.700000000000018, + "89": -39.45000000000005, + "90": -19.79999999999996, + "91": -15.999999999999979, + "92": -45.39999999999999, + "93": 9.800000000000018, + "94": -81.9, + "95": 0.5499999999999933, + "96": -3.149999999999971, + "97": -16.899999999999974, + "98": -3.899999999999987, + "99": -36.55000000000004, + "100": -63.0000000000001, + "101": 13.300000000000004, + "102": -51.25000000000008, + "103": -1.849999999999997, + "104": -42.54999999999998, + "105": -84.10000000000001, + "106": -97.94999999999999, + "107": -17.65, + "108": -18.44999999999997, + "109": -15.399999999999975, + "110": 24.44999999999998, + "111": -40.20000000000011, + "112": -6.250000000000002, + "113": -22.74999999999996, + "114": -5.699999999999991, + "115": -18.64999999999996, + "116": -1.3500000000000008, + "117": 14.250000000000053, + "118": -53.49999999999998, + "119": -71.89999999999999, + "120": -29.299999999999994, + "121": 24.949999999999925, + "122": 17.000000000000025, + "123": -26.649999999999945, + "124": -50.449999999999974, + "125": 27.949999999999967, + "126": -71.39999999999999, + "127": -21.999999999999954, + "128": -15.499999999999979, + "129": -19.799999999999965, + "130": 10.100000000000012, + "131": -56.80000000000011, + "132": 19.60000000000007, + "133": -1.2499999999999811, + "134": -16.149999999999945, + "135": 1.1000000000000354, + "136": -8.749999999999972, + "137": -9.65, + "138": -16.649999999999977, + "139": -14.499999999999984, + "140": -9.949999999999998, + "141": 3.2500000000000187, + "142": 32.10000000000004, + "143": -3.199999999999998, + "144": 9.300000000000034, + "145": -26.29999999999997, + "146": 11.149999999999995, + "147": -3.199999999999984, + "148": -26.599999999999973, + "149": -12.699999999999967, + "150": -0.19999999999997642, + "151": -18.649999999999967, + "152": -42.80000000000001, + "153": 14.649999999999956, + "154": 5.300000000000017, + "155": -9.89999999999999, + "156": -0.4499999999999653, + "157": -40.65000000000006, + "158": 0.2000000000000146, + "159": -2.250000000000001, + "160": -85.30000000000001, + "161": 2.050000000000021, + "162": 3.450000000000025, + "163": -85.69999999999999, + "164": 2.5000000000000036, + "165": -0.5999999999999849, + "166": -10.249999999999996, + "167": -24.849999999999977, + "168": -8.5, + "169": -25.899999999999984, + "170": 18.2, + "171": -94.3, + "172": 5.500000000000007, + "173": 17.050000000000065, + "174": -19.39999999999999, + "175": -8.04999999999999, + "176": -9.949999999999987, + "177": -42.550000000000054, + "178": 27.35000000000007, + "179": -0.19999999999995866, + "180": 23.549999999999894, + "181": 44.899999999999885, + "182": 32.14999999999996, + "183": -15.999999999999975, + "184": 57.5999999999998, + "185": 16.999999999999996, + "186": -10.549999999999995, + "187": 10.550000000000061, + "188": -90.0, + "189": -9.900000000000002, + "190": 20.500000000000007, + "191": 19.15000000000004, + "192": -2.2999999999999963, + "193": -1.799999999999986, + "194": 22.149999999999963, + "195": -14.949999999999976, + "196": 17.100000000000044, + "197": -13.999999999999964, + "198": -0.6499999999999884, + "199": 46.599999999999746, + "200": 43.94999999999977, + "201": 27.95000000000003, + "202": 20.10000000000007, + "203": 51.59999999999976, + "204": 5.600000000000055, + "205": 40.349999999999866, + "206": -56.4, + "207": -7.200000000000007, + "208": 10.650000000000082, + "209": 53.599999999999795, + "210": -49.849999999999994, + "211": 46.29999999999992, + "212": -30.24999999999998, + "213": 52.39999999999978, + "214": 87.45000000000012, + "215": -84.95, + "216": 67.34999999999988, + "217": 46.09999999999985, + "218": 77.1499999999999, + "219": 1.9499999999999933, + "220": 46.24999999999981, + "221": 21.699999999999953, + "222": 34.3499999999999, + "223": -7.899999999999994, + "224": 41.84999999999974, + "225": 7.3500000000000085, + "226": 66.79999999999977, + "227": -2.6999999999999664, + "228": 11.500000000000039, + "229": 0.800000000000008, + "230": -7.500000000000008, + "231": -87.85, + "232": 62.39999999999993, + "233": -1.3500000000000272, + "234": 36.59999999999988, + "235": 91.64999999999998, + "236": 8.9, + "237": -81.39999999999996, + "238": 47.749999999999886, + "239": -9.55000000000004, + "240": 28.299999999999844, + "241": 83.0500000000001, + "242": 40.69999999999978, + "243": 28.649999999999892, + "244": -62.500000000000014, + "245": 72.35000000000011, + "246": -23.900000000000006, + "247": 81.30000000000008, + "248": 63.649999999999764, + "249": 25.249999999999947, + "250": 12.100000000000067, + "251": 58.84999999999993, + "252": 14.199999999999969, + "253": 86.15000000000022, + "254": -10.150000000000007, + "255": 69.09999999999984, + "256": 42.04999999999995, + "257": 36.2, + "258": 56.79999999999995, + "259": 64.35000000000014, + "260": 68.44999999999978, + "261": 81.19999999999982, + "262": 35.15000000000003, + "263": -4.699999999999989, + "264": 106.55000000000028, + "265": 48.44999999999992, + "266": 24.25000000000001, + "267": -54.64999999999994, + "268": 59.049999999999926, + "269": 46.24999999999976, + "270": 32.99999999999999, + "271": 89.09999999999981, + "272": 65.14999999999978, + "273": 89.64999999999995, + "274": 43.44999999999994, + "275": -33.30000000000004, + "276": 103.50000000000017, + "277": -56.74999999999996, + "278": 42.0999999999999, + "279": 92.80000000000008, + "280": -25.349999999999973, + "281": -38.799999999999976, + "282": -83.99999999999997, + "283": 85.84999999999978, + "284": 25.099999999999923, + "285": 42.24999999999992, + "286": 19.150000000000002, + "287": 72.99999999999984, + "288": -71.55000000000001, + "289": 26.99999999999999, + "290": 41.49999999999989, + "291": 31.899999999999864, + "292": -70.84999999999997, + "293": 94.0500000000001, + "294": 36.04999999999999, + "295": -25.900000000000027, + "296": 107.00000000000024, + "297": 59.099999999999866, + "298": 106.05000000000018, + "299": -21.799999999999986, + "300": 31.29999999999999, + "301": 47.9499999999999, + "302": 67.9499999999999, + "303": -39.30000000000008, + "304": 87.04999999999998, + "305": -16.950000000000028, + "306": 57.3499999999999, + "307": 106.35000000000026, + "308": 62.04999999999991, + "309": -21.999999999999982, + "310": 60.59999999999983, + "311": -3.1500000000000057, + "312": 94.70000000000009, + "313": 102.45000000000014, + "314": 92.20000000000016, + "315": -74.55, + "316": 89.00000000000003, + "317": 9.649999999999999, + "318": -70.24999999999997, + "319": -43.899999999999984, + "320": -54.94999999999995, + "321": -13.600000000000005, + "322": 26.799999999999976, + "323": 66.69999999999987, + "324": -12.59999999999998, + "325": -16.349999999999984, + "326": -64.74999999999986, + "327": 61.29999999999982, + "328": 29.900000000000013, + "329": 59.699999999999875, + "330": 67.79999999999981, + "331": -45.15000000000005, + "332": -68.54999999999997, + "333": 21.650000000000002, + "334": 1.5999999999999868, + "335": 48.249999999999915, + "336": 84.09999999999981, + "337": 7.899999999999989, + "338": 78.59999999999984, + "339": -9.949999999999996, + "340": 75.14999999999996, + "341": -44.34999999999996, + "342": 91.85000000000001, + "343": 94.60000000000016, + "344": 73.64999999999999, + "345": 33.3, + "346": 13.299999999999997, + "347": 107.25000000000024, + "348": 40.049999999999976, + "349": -63.89999999999992, + "350": 102.9500000000002, + "351": 51.54999999999981, + "352": 77.19999999999999, + "353": 95.70000000000012, + "354": 47.54999999999994, + "355": 28.650000000000055, + "356": 6.55, + "357": 22.949999999999967, + "358": 103.40000000000018, + "359": 51.34999999999995, + "360": 93.05000000000014, + "361": 95.5000000000001, + "362": 31.199999999999985, + "363": 98.0500000000002, + "364": 52.69999999999979, + "365": -7.450000000000001, + "366": 37.69999999999999, + "367": 64.69999999999985, + "368": 66.89999999999988, + "369": 89.24999999999979, + "370": -78.4, + "371": 93.25000000000013, + "372": 94.99999999999991, + "373": -2.849999999999964, + "374": 75.34999999999977, + "375": 85.70000000000003, + "376": 98.55000000000013, + "377": 90.14999999999976, + "378": -72.09999999999992, + "379": 97.10000000000015, + "380": 24.199999999999978, + "381": 57.94999999999993, + "382": 72.89999999999978, + "383": 49.799999999999926, + "384": 86.60000000000001, + "385": 78.34999999999981, + "386": 86.70000000000007, + "387": 31.84999999999996, + "388": 51.24999999999995, + "389": 50.29999999999987, + "390": 65.79999999999991, + "391": 83.45, + "392": 61.59999999999988, + "393": 89.7500000000001, + "394": 81.09999999999984, + "395": 95.04999999999974, + "396": 70.14999999999976, + "397": 67.84999999999984, + "398": 0.1999999999999731, + "399": 66.84999999999991, + "400": 55.949999999999775, + "401": 91.90000000000013, + "402": 92.0000000000001, + "403": 81.7999999999998, + "404": 96.0999999999998, + "405": 35.499999999999794, + "406": 48.799999999999955, + "407": 40.050000000000004, + "408": 92.00000000000006, + "409": 104.35, + "410": 86.29999999999978, + "411": -5.849999999999988, + "412": 51.099999999999746, + "413": 64.79999999999974, + "414": 15.150000000000034, + "415": 77.00000000000006, + "416": 68.79999999999991, + "417": 59.64999999999974, + "418": 75.04999999999976, + "419": 38.39999999999998, + "420": 84.29999999999978, + "421": 51.8499999999998, + "422": 37.84999999999993, + "423": 92.69999999999979, + "424": 71.39999999999985, + "425": 75.04999999999986, + "426": 77.64999999999975, + "427": 15.799999999999992, + "428": 25.150000000000013, + "429": 96.44999999999975, + "430": 85.69999999999978, + "431": 78.09999999999994, + "432": 82.39999999999974, + "433": 103.0000000000002, + "434": 95.99999999999973, + "435": 15.200000000000014, + "436": 80.79999999999978, + "437": 63.09999999999979, + "438": 90.84999999999981, + "439": 58.799999999999876, + "440": 75.24999999999987, + "441": 99.05000000000011, + "442": 72.39999999999982, + "443": 94.69999999999992, + "444": 62.14999999999993, + "445": 8.450000000000015, + "446": 93.79999999999976, + "447": 75.60000000000014, + "448": 96.74999999999976, + "449": 44.54999999999987, + "450": -3.7999999999999967, + "451": -43.19999999999999, + "452": 97.99999999999974, + "453": 75.25000000000003, + "454": 90.29999999999978, + "455": 86.69999999999976, + "456": 94.09999999999978, + "457": 79.04999999999977, + "458": 66.74999999999987, + "459": 76.2499999999999, + "460": 101.99999999999974, + "461": 104.69999999999983, + "462": 85.34999999999978, + "463": 97.39999999999975, + "464": 32.35, + "465": 98.29999999999976, + "466": 79.89999999999982, + "467": 101.00000000000016, + "468": 84.14999999999975, + "469": 92.69999999999979, + "470": 93.59999999999977, + "471": 36.899999999999956, + "472": 86.34999999999977, + "473": 105.54999999999991, + "474": 66.39999999999993, + "475": 101.09999999999975, + "476": -5.149999999999967, + "477": 98.49999999999977, + "478": 104.34999999999987, + "479": 104.29999999999991, + "480": 99.94999999999979, + "481": 99.59999999999974, + "482": 95.54999999999986, + "483": 97.19999999999982, + "484": 93.89999999999976, + "485": 75.14999999999996, + "486": 98.19999999999982, + "487": 45.299999999999784, + "488": 92.29999999999977, + "489": 103.09999999999977, + "490": 94.74999999999977, + "491": 101.34999999999977, + "492": 99.19999999999976, + "493": 91.89999999999979, + "494": 86.89999999999976, + "495": 103.54999999999976, + "496": 41.999999999999964, + "497": 100.49999999999977, + "498": 95.24999999999977, + "499": 103.89999999999974, + "500": 96.69999999999976, + "501": 96.59999999999977, + "502": 69.49999999999974, + "503": 81.34999999999978, + "504": 101.54999999999977, + "505": 82.44999999999978, + "506": 97.84999999999975, + "507": 93.29999999999977, + "508": 61.34999999999976, + "509": 94.99999999999979, + "510": 68.74999999999973, + "511": 104.84999999999975, + "512": 102.64999999999974, + "513": 102.84999999999975, + "514": -2.3000000000000753, + "515": 102.99999999999974, + "516": 99.1999999999998, + "517": 103.54999999999978, + "518": 98.59999999999978, + "519": 104.39999999999974, + "520": -76.1, + "521": 50.99999999999976, + "522": 103.04999999999976, + "523": -25.400000000000066, + "524": 93.29999999999983, + "525": 71.04999999999976, + "526": 94.49999999999976, + "527": 98.24999999999976, + "528": 103.99999999999986, + "529": 99.59999999999977, + "530": 99.89999999999976, + "531": 106.84999999999974, + "532": 102.74999999999976, + "533": 103.14999999999976, + "534": 97.79999999999974, + "535": 98.84999999999975, + "536": 102.79999999999977, + "537": 26.35000000000006, + "538": 102.39999999999985, + "539": 96.04999999999976, + "540": 83.19999999999983, + "541": 105.34999999999974, + "542": 102.94999999999975, + "543": 98.44999999999975, + "544": 92.8999999999998, + "545": 101.44999999999975, + "546": 107.34999999999982, + "547": 103.99999999999973, + "548": 103.54999999999974, + "549": 22.50000000000004, + "550": 103.94999999999972, + "551": -46.300000000000004, + "552": 101.94999999999975, + "553": 108.94999999999999, + "554": 108.19999999999978, + "555": 105.94999999999972, + "556": 95.14999999999975, + "557": 102.74999999999973, + "558": 103.89999999999972, + "559": 101.54999999999978, + "560": 100.49999999999977, + "561": 87.5499999999998, + "562": 94.84999999999977, + "563": -74.69999999999999, + "564": 105.59999999999975, + "565": 102.74999999999976, + "566": 94.29999999999977, + "567": 96.29999999999978, + "568": 97.69999999999978, + "569": 99.24999999999977, + "570": 103.24999999999973, + "571": 100.19999999999973, + "572": 97.44999999999979, + "573": 102.44999999999972, + "574": 93.74999999999999, + "575": 90.54999999999976, + "576": 101.24999999999977, + "577": 95.69999999999978, + "578": 101.24999999999974, + "579": 78.54999999999987, + "580": 101.94999999999972, + "581": 103.34999999999975, + "582": 109.10000000000002, + "583": 103.49999999999976, + "584": 104.04999999999971, + "585": 106.39999999999972, + "586": 104.74999999999973, + "587": 103.04999999999977, + "588": 13.099999999999978, + "589": 105.45000000000007, + "590": 14.149999999999906, + "591": 104.39999999999975, + "592": 104.3999999999998, + "593": 111.14999999999989, + "594": 102.44999999999976, + "595": 104.99999999999976, + "596": 76.74999999999986, + "597": 102.49999999999976, + "598": 76.04999999999984, + "599": 99.79999999999977, + "600": 49.79999999999981, + "601": 102.39999999999976, + "602": 101.24999999999977, + "603": 78.44999999999993, + "604": 39.39999999999976, + "605": 45.299999999999955, + "606": -85.6, + "607": 103.74999999999976, + "608": 104.19999999999995, + "609": 98.99999999999976, + "610": 95.99999999999974, + "611": 102.59999999999985, + "612": 97.64999999999979, + "613": -56.74999999999997, + "614": 27.650000000000027, + "615": 63.849999999999724, + "616": 103.99999999999976, + "617": 105.04999999999991, + "618": 101.79999999999977, + "619": 101.84999999999977, + "620": 82.54999999999974, + "621": 99.34999999999972, + "622": -76.30000000000007, + "623": 104.09999999999975, + "624": 103.39999999999975, + "625": 101.04999999999976, + "626": 96.29999999999986, + "627": -87.0, + "628": 101.74999999999977, + "629": 104.04999999999977, + "630": 106.84999999999974, + "631": 86.64999999999978, + "632": 102.49999999999974, + "633": 100.34999999999974, + "634": 55.89999999999977, + "635": 102.14999999999975, + "636": 104.34999999999974, + "637": -74.10000000000002, + "638": 105.44999999999972, + "639": 104.09999999999977, + "640": 105.1499999999998, + "641": 82.19999999999985, + "642": -68.75, + "643": 87.99999999999983, + "644": 104.09999999999977, + "645": 105.24999999999974, + "646": 100.54999999999974, + "647": 105.39999999999974, + "648": 103.19999999999976, + "649": 102.29999999999977, + "650": 102.94999999999976, + "651": 103.59999999999977, + "652": 102.04999999999976, + "653": 102.44999999999976, + "654": 99.94999999999976, + "655": 105.44999999999973, + "656": 42.85000000000001, + "657": 103.99999999999976, + "658": 104.19999999999976, + "659": 103.74999999999974, + "660": 53.79999999999975, + "661": 104.19999999999976, + "662": 109.49999999999976, + "663": 87.64999999999975, + "664": 102.49999999999973, + "665": -44.65000000000006, + "666": 104.14999999999976, + "667": 49.89999999999995, + "668": 105.79999999999973, + "669": 105.14999999999974, + "670": 73.8999999999998, + "671": 85.89999999999984, + "672": 97.04999999999978, + "673": 104.59999999999975, + "674": 103.99999999999976, + "675": 101.59999999999977, + "676": 106.14999999999982, + "677": 98.69999999999978, + "678": 106.69999999999975, + "679": 99.94999999999978, + "680": 99.24999999999976, + "681": 104.19999999999976, + "682": 104.14999999999976, + "683": 81.3999999999999, + "684": 98.69999999999976, + "685": 101.99999999999972, + "686": 105.24999999999974, + "687": 99.84999999999977, + "688": 103.49999999999976, + "689": 103.69999999999975, + "690": 104.09999999999972, + "691": 101.89999999999976, + "692": 106.94999999999976, + "693": 103.84999999999974, + "694": 104.44999999999973, + "695": 104.74999999999973, + "696": 87.49999999999972, + "697": 102.79999999999974, + "698": 103.69999999999976, + "699": 79.29999999999987, + "700": 108.04999999999973, + "701": 57.749999999999766, + "702": 106.29999999999973, + "703": 103.79999999999977, + "704": 107.54999999999983, + "705": -0.600000000000033, + "706": -80.14999999999999, + "707": 99.44999999999975, + "708": 107.99999999999976, + "709": 97.29999999999976, + "710": 89.29999999999977, + "711": 102.24999999999973, + "712": -38.75000000000003, + "713": -34.04999999999999, + "714": 103.69999999999973, + "715": 102.09999999999974, + "716": -43.19999999999999, + "717": 104.39999999999975, + "718": 0.5999999999999659, + "719": 99.69999999999976, + "720": 105.79999999999973, + "721": 103.09999999999977, + "722": 105.39999999999976, + "723": 105.14999999999976, + "724": 104.34999999999977, + "725": 104.04999999999976, + "726": -65.4, + "727": -42.550000000000054, + "728": 104.29999999999984, + "729": 103.44999999999976, + "730": -78.75, + "731": 103.84999999999975, + "732": 105.54999999999973, + "733": 91.7499999999998, + "734": 109.29999999999976, + "735": 110.54999999999991, + "736": 103.84999999999975, + "737": 108.04999999999973, + "738": -72.50000000000001, + "739": 109.09999999999977, + "740": 89.24999999999977, + "741": 103.29999999999976, + "742": 109.34999999999975, + "743": 102.84999999999977, + "744": 108.84999999999975, + "745": 105.59999999999974, + "746": 81.19999999999978, + "747": 100.99999999999977, + "748": 105.19999999999972, + "749": 58.29999999999976, + "750": 46.79999999999987, + "751": 67.84999999999987, + "752": 103.54999999999974, + "753": 88.74999999999976, + "754": 105.14999999999974, + "755": 109.55, + "756": 70.69999999999975, + "757": 103.94999999999975, + "758": 101.74999999999972, + "759": 105.14999999999972, + "760": 103.99999999999974, + "761": 102.69999999999978, + "762": 104.19999999999975, + "763": 104.39999999999975, + "764": -77.95, + "765": 25.599999999999895, + "766": 108.89999999999975, + "767": 106.34999999999977, + "768": 96.54999999999981, + "769": 104.24999999999976, + "770": 106.89999999999972, + "771": 105.19999999999973, + "772": 103.24999999999977, + "773": 103.14999999999976, + "774": 97.49999999999976, + "775": 104.24999999999977, + "776": 105.34999999999978, + "777": 84.54999999999984, + "778": 104.84999999999975, + "779": 104.04999999999973, + "780": 103.84999999999977, + "781": 106.94999999999973, + "782": 100.54999999999976, + "783": 80.19999999999978, + "784": 105.39999999999972, + "785": 103.74999999999977, + "786": 104.79999999999976, + "787": 107.49999999999973, + "788": 106.54999999999973, + "789": -39.34999999999994, + "790": 107.29999999999974, + "791": -74.0, + "792": 107.14999999999974, + "793": 102.84999999999977, + "794": 93.24999999999972, + "795": 108.14999999999974, + "796": -67.60000000000002, + "797": 103.89999999999976, + "798": 105.39999999999972, + "799": 104.99999999999973, + "800": 102.39999999999975, + "801": 106.74999999999977, + "802": 103.09999999999972, + "803": 105.39999999999974, + "804": 100.54999999999977, + "805": 109.39999999999976, + "806": 111.59999999999977, + "807": 104.84999999999974, + "808": 104.09999999999977, + "809": -102.30000000000001, + "810": 104.74999999999974, + "811": 106.19999999999973, + "812": 104.89999999999974, + "813": -72.50000000000001, + "814": 104.94999999999975, + "815": 103.84999999999977, + "816": 103.99999999999974, + "817": -64.64999999999999, + "818": 105.09999999999974, + "819": 105.99999999999972, + "820": 24.24999999999989, + "821": 102.74999999999977, + "822": 100.09999999999977, + "823": 104.19999999999976, + "824": 109.59999999999981, + "825": 105.09999999999972, + "826": 102.59999999999977, + "827": 102.94999999999976, + "828": -76.85000000000002, + "829": 106.19999999999975, + "830": 90.54999999999974, + "831": 41.94999999999977, + "832": -87.19999999999999, + "833": 106.49999999999974, + "834": 103.29999999999977, + "835": 106.24999999999972, + "836": 106.24999999999973, + "837": 104.84999999999975, + "838": 105.29999999999974, + "839": 103.59999999999977, + "840": 91.04999999999981, + "841": 103.59999999999975, + "842": 103.99999999999976, + "843": 106.24999999999973, + "844": 74.99999999999987, + "845": 103.29999999999977, + "846": 104.04999999999976, + "847": 106.99999999999973, + "848": -83.75000000000001, + "849": 105.79999999999973, + "850": -76.30000000000001, + "851": 105.24999999999972, + "852": 105.79999999999973, + "853": 100.84999999999977, + "854": 104.99999999999976, + "855": 105.09999999999972, + "856": 83.89999999999976, + "857": 107.24999999999983, + "858": 103.54999999999977, + "859": -72.05, + "860": 104.09999999999972, + "861": 103.59999999999977, + "862": 104.84999999999975, + "863": -74.89999999999999, + "864": 103.04999999999977, + "865": 104.29999999999977, + "866": 99.69999999999975, + "867": 104.24999999999974, + "868": 95.74999999999976, + "869": 104.59999999999975, + "870": 100.24999999999977, + "871": 104.04999999999976, + "872": 102.64999999999976, + "873": 104.59999999999975, + "874": 102.74999999999977, + "875": 104.39999999999975, + "876": 102.89999999999978, + "877": 104.54999999999977, + "878": 103.74999999999977, + "879": -79.10000000000001, + "880": 104.24999999999976, + "881": 103.49999999999977, + "882": -86.35000000000002, + "883": 103.39999999999976, + "884": 105.39999999999975, + "885": 100.3499999999998, + "886": 107.29999999999973, + "887": 104.09999999999975, + "888": 102.69999999999978, + "889": 101.84999999999977, + "890": 105.29999999999974, + "891": 103.54999999999977, + "892": 102.24999999999976, + "893": 105.29999999999973, + "894": 102.24999999999977, + "895": 97.74999999999973, + "896": 105.94999999999972, + "897": 103.84999999999977, + "898": 68.49999999999977, + "899": 97.24999999999977, + "900": -85.44999999999999, + "901": 103.79999999999977, + "902": 101.29999999999978, + "903": 101.94999999999976, + "904": 105.84999999999981, + "905": 105.44999999999982, + "906": 104.09999999999975, + "907": 109.04999999999977, + "908": 105.44999999999975, + "909": 103.19999999999978, + "910": 105.14999999999979, + "911": -85.65, + "912": 101.89999999999976, + "913": 108.25000000000006, + "914": 107.04999999999971, + "915": 107.29999999999977, + "916": 104.89999999999978, + "917": 104.24999999999976, + "918": 104.69999999999975, + "919": 105.4499999999998, + "920": 108.49999999999986, + "921": 108.34999999999987, + "922": 99.99999999999974, + "923": 2.0499999999999616, + "924": 103.64999999999975, + "925": 104.49999999999974, + "926": 103.84999999999977, + "927": 107.3, + "928": 104.59999999999977, + "929": 103.84999999999977, + "930": 10.249999999999922, + "931": 103.24999999999976, + "932": 105.59999999999974, + "933": 105.59999999999977, + "934": 97.09999999999977, + "935": 105.44999999999973, + "936": 104.09999999999972, + "937": 103.69999999999976, + "938": 105.29999999999974, + "939": 25.800000000000054, + "940": 105.59999999999975, + "941": 105.49999999999974, + "942": -74.49999999999999, + "943": 105.54999999999974, + "944": 104.14999999999976, + "945": 103.39999999999976, + "946": 104.79999999999976, + "947": 103.09999999999977, + "948": 54.35, + "949": 87.79999999999978, + "950": 104.19999999999976, + "951": 105.64999999999974, + "952": 104.49999999999974, + "953": 103.44999999999976, + "954": 61.849999999999945, + "955": 104.64999999999974, + "956": 103.54999999999977, + "957": 104.39999999999976, + "958": 102.69999999999978, + "959": 103.19999999999976, + "960": 103.04999999999977, + "961": 104.39999999999976, + "962": 82.99999999999973, + "963": 105.14999999999974, + "964": 104.04999999999974, + "965": 105.29999999999981, + "966": 105.04999999999974, + "967": -91.4, + "968": 105.09999999999974, + "969": 106.94999999999995, + "970": -50.55, + "971": 104.24999999999976, + "972": 104.09999999999981, + "973": -86.0, + "974": -32.00000000000002, + "975": 108.34999999999977, + "976": 106.34999999999984, + "977": -85.35, + "978": -45.9, + "979": 110.29999999999991, + "980": 108.49999999999976, + "981": 105.74999999999972, + "982": 104.59999999999977, + "983": 106.44999999999972, + "984": 105.59999999999974, + "985": -87.0, + "986": 106.69999999999978, + "987": 104.34999999999975, + "988": -53.199999999999974, + "989": 112.05000000000018, + "990": 104.34999999999972, + "991": 102.64999999999976, + "992": -84.9, + "993": -39.350000000000044, + "994": 103.94999999999976, + "995": 102.04999999999977, + "996": 103.64999999999976, + "997": 100.3499999999998, + "998": 84.7999999999998, + "999": 105.09999999999974, + "1000": 106.89999999999974 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json b/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json new file mode 100644 index 00000000..b87c59c4 --- /dev/null +++ b/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json @@ -0,0 +1,7445 @@ +{ + "start_timestamp": "2024-09-02T07:51:23.135859", + "end_datetime": "2024-09-02T09:52:55.690035", + "primaite_version": "3.3.0", + "system_info": { + "System": { + "OS": "Linux", + "OS Version": "#76~20.04.1-Ubuntu SMP Thu Jun 13 18:00:23 UTC 2024", + "Machine": "x86_64", + "Processor": "x86_64" + }, + "CPU": { + "Physical Cores": 2, + "Total Cores": 4, + "Max Frequency": "0.00Mhz" + }, + "Memory": { + "Total": "15.62GB", + "Swap Total": "0.00B" + }, + "GPU": [] + }, + "total_sessions": 5, + "total_episodes": 5005, + "total_time_steps": 640000, + "av_s_per_session": 1458.2831048, + "av_s_per_step": 0.045571347025, + "av_s_per_100_steps_10_nodes": 4.557134702499999, + "combined_total_reward_per_episode": { + "1": -31.150000000000027, + "2": -24.120000000000005, + "3": -58.980000000000054, + "4": -27.500000000000018, + "5": -45.17999999999997, + "6": -48.62999999999999, + "7": -24.21, + "8": -40.81999999999998, + "9": -38.09999999999998, + "10": -33.23000000000004, + "11": -40.03000000000001, + "12": -21.52999999999998, + "13": -32.470000000000006, + "14": -20.189999999999976, + "15": -35.86000000000003, + "16": -56.580000000000055, + "17": -40.67, + "18": -36.28000000000003, + "19": -25.27999999999999, + "20": -34.830000000000005, + "21": -37.80000000000001, + "22": -24.780000000000037, + "23": -36.100000000000016, + "24": -58.85999999999998, + "25": -44.12000000000001, + "26": -44.88000000000005, + "27": -47.86000000000003, + "28": -24.62999999999999, + "29": -32.91000000000004, + "30": -31.480000000000054, + "31": -11.87999999999999, + "32": -33.00000000000004, + "33": -29.439999999999998, + "34": -26.599999999999984, + "35": -27.00999999999998, + "36": -27.330000000000002, + "37": -20.46, + "38": -17.77999999999997, + "39": -34.75999999999998, + "40": -19.549999999999994, + "41": -45.23999999999998, + "42": -14.519999999999976, + "43": -31.900000000000006, + "44": -29.989999999999988, + "45": -23.990000000000006, + "46": -19.049999999999983, + "47": -38.3, + "48": -29.439999999999962, + "49": -42.929999999999986, + "50": -28.78000000000001, + "51": -27.989999999999974, + "52": -37.06000000000004, + "53": -26.189999999999987, + "54": -26.169999999999995, + "55": -33.34999999999999, + "56": -25.149999999999988, + "57": -21.830000000000002, + "58": -30.109999999999996, + "59": -40.110000000000014, + "60": -29.699999999999996, + "61": -40.45, + "62": -55.30999999999999, + "63": -12.659999999999979, + "64": -8.93999999999997, + "65": -44.79999999999999, + "66": -23.430000000000017, + "67": -42.80000000000003, + "68": -20.779999999999994, + "69": -28.339999999999957, + "70": -55.60999999999999, + "71": -38.780000000000015, + "72": -19.209999999999987, + "73": -13.119999999999987, + "74": -23.060000000000002, + "75": -12.809999999999985, + "76": -30.29000000000001, + "77": -43.290000000000035, + "78": -13.96999999999999, + "79": -25.77000000000001, + "80": -42.06999999999998, + "81": -47.51000000000003, + "82": -30.359999999999992, + "83": -23.109999999999985, + "84": -27.729999999999997, + "85": -30.330000000000005, + "86": -24.25999999999997, + "87": -48.58999999999999, + "88": -51.84999999999998, + "89": -11.189999999999992, + "90": -30.869999999999983, + "91": -14.449999999999983, + "92": -26.95999999999996, + "93": -27.529999999999994, + "94": -41.559999999999995, + "95": -34.67000000000001, + "96": -11.709999999999974, + "97": -20.82999999999998, + "98": -21.28000000000001, + "99": -33.040000000000006, + "100": -18.92, + "101": -8.719999999999988, + "102": -31.490000000000002, + "103": -31.589999999999996, + "104": -45.46000000000001, + "105": -26.609999999999978, + "106": -31.169999999999998, + "107": -11.370000000000031, + "108": -31.129999999999978, + "109": -28.180000000000014, + "110": -3.689999999999988, + "111": -42.41000000000003, + "112": -45.730000000000004, + "113": -25.38999999999998, + "114": -4.759999999999978, + "115": -25.609999999999992, + "116": -15.369999999999994, + "117": -17.519999999999982, + "118": -12.899999999999988, + "119": -26.37000000000001, + "120": -27.669999999999987, + "121": 1.6599999999999788, + "122": -15.489999999999972, + "123": -9.929999999999968, + "124": -13.20999999999998, + "125": -16.640000000000004, + "126": -26.279999999999983, + "127": -6.199999999999985, + "128": -12.840000000000014, + "129": 3.2000000000000206, + "130": -4.3099999999999925, + "131": -49.72000000000001, + "132": -17.910000000000004, + "133": -10.069999999999997, + "134": -2.340000000000021, + "135": -10.349999999999973, + "136": -7.959999999999985, + "137": 0.2799999999999724, + "138": 7.499999999999981, + "139": -14.170000000000007, + "140": 1.790000000000011, + "141": -21.550000000000015, + "142": -19.949999999999992, + "143": -25.629999999999985, + "144": -1.7200000000000384, + "145": -34.04999999999999, + "146": -35.960000000000015, + "147": -13.890000000000049, + "148": -35.07000000000001, + "149": -16.080000000000005, + "150": -16.429999999999986, + "151": -12.899999999999974, + "152": -13.199999999999983, + "153": 11.760000000000002, + "154": -11.419999999999987, + "155": -42.459999999999994, + "156": 4.920000000000014, + "157": -23.740000000000027, + "158": -9.159999999999979, + "159": -14.330000000000052, + "160": -40.329999999999984, + "161": -17.169999999999966, + "162": -22.860000000000035, + "163": -49.34999999999999, + "164": 0.21999999999996903, + "165": -44.469999999999985, + "166": -7.800000000000011, + "167": -8.789999999999983, + "168": 0.7400000000000091, + "169": -15.620000000000019, + "170": -37.639999999999965, + "171": -17.270000000000007, + "172": 6.609999999999999, + "173": -9.319999999999975, + "174": -25.53000000000001, + "175": -5.8800000000000185, + "176": -21.32999999999999, + "177": -27.679999999999996, + "178": -11.169999999999984, + "179": -32.34000000000003, + "180": -16.680000000000017, + "181": -23.170000000000044, + "182": 2.4100000000000144, + "183": -34.49, + "184": 14.399999999999974, + "185": -7.49, + "186": -13.279999999999987, + "187": 3.359999999999979, + "188": -14.279999999999998, + "189": -36.42000000000002, + "190": -35.84000000000003, + "191": 4.539999999999978, + "192": -12.909999999999977, + "193": -19.490000000000002, + "194": 13.959999999999974, + "195": -19.449999999999992, + "196": 0.7100000000000165, + "197": -14.080000000000036, + "198": -16.15000000000005, + "199": 1.7799999999999216, + "200": -24.000000000000046, + "201": 3.0499999999999567, + "202": -5.779999999999932, + "203": 15.749999999999963, + "204": 7.589999999999941, + "205": 3.0800000000000103, + "206": -19.850000000000016, + "207": -13.800000000000017, + "208": 6.149999999999972, + "209": 3.7199999999999465, + "210": -0.5300000000000026, + "211": 34.69999999999997, + "212": 13.419999999999892, + "213": 15.189999999999946, + "214": 16.299999999999972, + "215": -19.050000000000033, + "216": -6.830000000000007, + "217": -7.000000000000009, + "218": 22.429999999999986, + "219": -35.6, + "220": 6.999999999999927, + "221": -4.110000000000063, + "222": 36.799999999999905, + "223": -2.720000000000055, + "224": 22.22999999999995, + "225": -0.17999999999999616, + "226": 2.3199999999999377, + "227": 2.4299999999999953, + "228": -10.180000000000007, + "229": 1.209999999999996, + "230": -17.150000000000052, + "231": 0.009999999999945431, + "232": 44.68999999999996, + "233": -5.98999999999999, + "234": 22.259999999999955, + "235": 28.619999999999965, + "236": 0.23999999999989718, + "237": -11.379999999999999, + "238": -1.0200000000000045, + "239": -2.4800000000001, + "240": 4.389999999999977, + "241": 37.929999999999964, + "242": 24.259999999999923, + "243": -13.700000000000045, + "244": -0.44000000000004036, + "245": 0.3800000000000523, + "246": 11.13999999999992, + "247": 28.129999999999985, + "248": 20.419999999999867, + "249": -7.110000000000016, + "250": 13.539999999999978, + "251": 30.079999999999934, + "252": 34.03999999999993, + "253": 20.980000000000036, + "254": -15.620000000000037, + "255": 21.61999999999992, + "256": 34.64999999999996, + "257": 20.19999999999998, + "258": 9.09, + "259": 45.66999999999995, + "260": 23.619999999999873, + "261": 39.32999999999989, + "262": 39.34999999999998, + "263": -4.2700000000000475, + "264": 22.929999999999975, + "265": 43.40999999999989, + "266": 47.399999999999906, + "267": 10.189999999999973, + "268": 34.369999999999926, + "269": -4.230000000000075, + "270": 26.829999999999973, + "271": 3.339999999999938, + "272": 14.159999999999908, + "273": 44.669999999999916, + "274": 36.76999999999992, + "275": -15.070000000000025, + "276": 47.57999999999996, + "277": 14.60999999999992, + "278": 29.189999999999934, + "279": 32.689999999999955, + "280": -9.950000000000014, + "281": 34.77999999999991, + "282": 3.6899999999999635, + "283": 31.219999999999935, + "284": 4.25999999999997, + "285": -15.660000000000007, + "286": 13.629999999999976, + "287": 33.17999999999989, + "288": -0.5700000000000784, + "289": 33.59999999999989, + "290": 47.70999999999989, + "291": 49.729999999999905, + "292": 14.299999999999926, + "293": 55.01999999999994, + "294": 7.129999999999946, + "295": 16.76999999999986, + "296": 42.690000000000026, + "297": 31.47999999999991, + "298": 53.39999999999998, + "299": 21.41999999999994, + "300": 23.90999999999995, + "301": 42.98999999999989, + "302": 40.08999999999988, + "303": -5.340000000000016, + "304": 41.81999999999988, + "305": 4.379999999999972, + "306": 16.579999999999938, + "307": 44.09000000000002, + "308": 29.949999999999932, + "309": -6.280000000000088, + "310": 35.53999999999984, + "311": 28.089999999999947, + "312": 24.869999999999955, + "313": 32.00999999999999, + "314": 40.7799999999999, + "315": 31.34999999999993, + "316": 35.95999999999991, + "317": 60.019999999999825, + "318": 20.52999999999991, + "319": 13.309999999999894, + "320": 5.959999999999939, + "321": 17.189999999999948, + "322": 54.0699999999999, + "323": 39.47999999999989, + "324": 18.23999999999995, + "325": 25.979999999999887, + "326": 46.820000000000036, + "327": 32.4199999999999, + "328": 55.68999999999986, + "329": 42.029999999999966, + "330": 42.81999999999991, + "331": 9.429999999999959, + "332": 40.06999999999995, + "333": 20.679999999999925, + "334": 12.259999999999945, + "335": 53.54999999999991, + "336": 26.949999999999967, + "337": 57.819999999999915, + "338": 40.59999999999989, + "339": 14.219999999999976, + "340": 41.11999999999993, + "341": 26.529999999999887, + "342": 82.85999999999987, + "343": 54.69999999999993, + "344": 36.91999999999997, + "345": 63.75999999999992, + "346": 47.86999999999993, + "347": 15.77000000000001, + "348": 58.689999999999905, + "349": 17.36999999999992, + "350": 75.84999999999992, + "351": 38.12999999999988, + "352": 48.089999999999904, + "353": 30.059999999999967, + "354": 47.14999999999994, + "355": 15.969999999999947, + "356": 45.139999999999944, + "357": 35.809999999999974, + "358": 49.07999999999996, + "359": 30.229999999999972, + "360": 57.629999999999924, + "361": 48.519999999999996, + "362": 55.279999999999916, + "363": 69.58999999999995, + "364": 8.40999999999992, + "365": 32.8199999999999, + "366": 55.37999999999994, + "367": 24.869999999999965, + "368": 51.60999999999994, + "369": 45.65999999999997, + "370": 4.459999999999994, + "371": 81.42999999999995, + "372": 27.13999999999994, + "373": 61.599999999999945, + "374": 61.43999999999987, + "375": 20.80000000000003, + "376": 55.02999999999996, + "377": 61.54999999999984, + "378": 19.74999999999989, + "379": 46.219999999999985, + "380": 28.869999999999948, + "381": 37.1499999999999, + "382": 60.099999999999866, + "383": 67.3199999999999, + "384": 70.66000000000001, + "385": 57.57999999999991, + "386": 75.86999999999996, + "387": 51.979999999999905, + "388": -20.53000000000003, + "389": 61.079999999999814, + "390": 60.70999999999994, + "391": 38.579999999999956, + "392": 35.54999999999991, + "393": 46.25999999999992, + "394": 63.359999999999914, + "395": 66.2699999999999, + "396": 57.399999999999906, + "397": 41.03999999999989, + "398": 50.29999999999992, + "399": 50.559999999999874, + "400": 27.499999999999904, + "401": 90.24999999999999, + "402": 62.03999999999995, + "403": 65.60999999999987, + "404": 47.489999999999945, + "405": 46.15999999999988, + "406": 24.029999999999962, + "407": 53.949999999999896, + "408": 62.509999999999934, + "409": 73.69999999999996, + "410": 59.159999999999926, + "411": 35.57999999999987, + "412": 76.12999999999982, + "413": 23.87999999999991, + "414": 71.43999999999994, + "415": 47.32999999999995, + "416": 71.10999999999989, + "417": 39.41999999999994, + "418": 61.71999999999989, + "419": 58.929999999999964, + "420": 52.46999999999986, + "421": 54.10999999999988, + "422": 45.10999999999985, + "423": 65.15999999999983, + "424": 49.32999999999995, + "425": 73.25999999999983, + "426": 36.73999999999988, + "427": 43.499999999999915, + "428": 16.95999999999999, + "429": 55.30999999999987, + "430": 66.1899999999999, + "431": 74.72999999999993, + "432": 58.269999999999825, + "433": 62.199999999999974, + "434": 63.50999999999984, + "435": 41.00999999999989, + "436": 43.839999999999854, + "437": 47.52999999999988, + "438": 35.90999999999993, + "439": -5.400000000000036, + "440": 47.94999999999989, + "441": 60.04999999999994, + "442": 68.68999999999994, + "443": 65.46999999999994, + "444": 58.889999999999986, + "445": 66.55999999999992, + "446": 48.54999999999988, + "447": 57.32999999999997, + "448": 71.9599999999999, + "449": 58.319999999999936, + "450": 45.3899999999999, + "451": 51.959999999999965, + "452": 63.91999999999983, + "453": 44.42999999999995, + "454": 66.66999999999987, + "455": 63.289999999999864, + "456": 56.299999999999876, + "457": 53.309999999999924, + "458": 58.79999999999992, + "459": 49.09999999999991, + "460": 39.46999999999994, + "461": 62.69999999999989, + "462": 67.79999999999987, + "463": 69.79999999999981, + "464": 56.479999999999926, + "465": 75.68999999999994, + "466": 77.67999999999982, + "467": 63.219999999999914, + "468": 58.65999999999991, + "469": 82.18999999999983, + "470": 77.21999999999989, + "471": 61.6899999999999, + "472": 66.9599999999999, + "473": 72.02999999999989, + "474": 17.75999999999995, + "475": 75.15999999999988, + "476": 39.530000000000015, + "477": 51.84999999999989, + "478": 20.799999999999905, + "479": 60.65999999999991, + "480": 78.91999999999989, + "481": 63.79999999999984, + "482": 48.69999999999991, + "483": 29.709999999999887, + "484": 87.03999999999985, + "485": 49.729999999999905, + "486": 79.38999999999992, + "487": 44.49999999999987, + "488": 64.10999999999987, + "489": 78.28999999999992, + "490": 55.85999999999986, + "491": 73.13999999999987, + "492": 57.13999999999986, + "493": 57.32999999999987, + "494": 42.10999999999992, + "495": 78.93999999999987, + "496": 60.34999999999987, + "497": 62.12999999999992, + "498": 55.62999999999986, + "499": 88.21999999999991, + "500": 80.0799999999999, + "501": 97.4499999999999, + "502": 40.44999999999989, + "503": 54.74999999999985, + "504": 65.29999999999987, + "505": 92.9599999999999, + "506": 60.41999999999986, + "507": 68.62999999999991, + "508": 55.53999999999993, + "509": 62.129999999999896, + "510": 29.11999999999988, + "511": 62.89999999999985, + "512": 89.11999999999986, + "513": 58.33999999999986, + "514": 65.95999999999982, + "515": 44.56999999999986, + "516": 67.79999999999991, + "517": 64.34999999999984, + "518": 70.67999999999986, + "519": 69.77999999999989, + "520": 34.339999999999904, + "521": -19.680000000000057, + "522": 41.51999999999989, + "523": 31.16999999999991, + "524": 55.42999999999986, + "525": 46.69999999999986, + "526": 41.74999999999991, + "527": 85.8899999999998, + "528": 53.35999999999988, + "529": 57.659999999999854, + "530": 53.68999999999987, + "531": 62.419999999999845, + "532": 69.86999999999993, + "533": 71.6499999999999, + "534": 71.10999999999993, + "535": 71.1699999999999, + "536": 69.13999999999987, + "537": 42.4699999999999, + "538": 62.81999999999986, + "539": 67.7799999999999, + "540": 64.84999999999987, + "541": 46.23999999999984, + "542": 60.33999999999992, + "543": 57.64999999999983, + "544": 59.76999999999987, + "545": 47.08999999999992, + "546": 83.2099999999999, + "547": 87.65999999999988, + "548": 59.2199999999999, + "549": 35.779999999999916, + "550": 75.05999999999987, + "551": 16.699999999999932, + "552": 80.55999999999989, + "553": 67.99999999999993, + "554": 4.33999999999993, + "555": 63.51999999999988, + "556": 76.58999999999983, + "557": 62.71999999999987, + "558": 66.86999999999989, + "559": 2.429999999999919, + "560": 22.04999999999995, + "561": 52.72999999999986, + "562": 79.3599999999999, + "563": 36.519999999999996, + "564": 40.33999999999993, + "565": 51.28999999999994, + "566": 83.02999999999984, + "567": 74.00999999999985, + "568": 59.53999999999989, + "569": 50.489999999999874, + "570": 89.86999999999988, + "571": 43.7999999999999, + "572": 40.98999999999989, + "573": 73.21999999999989, + "574": 38.84999999999992, + "575": 54.66999999999989, + "576": 75.30999999999986, + "577": 56.409999999999854, + "578": 15.349999999999891, + "579": 67.74999999999991, + "580": 44.49999999999987, + "581": 41.91999999999992, + "582": 81.98999999999992, + "583": 54.11999999999987, + "584": 45.53999999999987, + "585": 73.87999999999985, + "586": 65.20999999999991, + "587": 66.29999999999984, + "588": 27.9099999999999, + "589": 88.97999999999993, + "590": 41.349999999999866, + "591": 71.80999999999985, + "592": 30.209999999999944, + "593": 70.7599999999999, + "594": 57.809999999999874, + "595": 62.61999999999985, + "596": 78.34999999999994, + "597": 57.789999999999864, + "598": 88.19999999999986, + "599": 69.78999999999982, + "600": 84.57999999999988, + "601": 62.049999999999876, + "602": 61.70999999999988, + "603": 88.40999999999994, + "604": 44.849999999999845, + "605": 54.809999999999874, + "606": 40.05999999999993, + "607": 45.78999999999994, + "608": 71.36999999999995, + "609": 77.25999999999988, + "610": 55.13999999999985, + "611": 68.14999999999989, + "612": 40.43999999999994, + "613": 36.559999999999945, + "614": 27.049999999999994, + "615": 24.469999999999875, + "616": 48.049999999999855, + "617": 78.8499999999999, + "618": 59.3599999999999, + "619": 96.93999999999987, + "620": 70.88999999999989, + "621": 90.40999999999988, + "622": 16.789999999999907, + "623": 91.88999999999982, + "624": 63.09999999999991, + "625": 94.99999999999989, + "626": 84.2899999999999, + "627": 36.77999999999988, + "628": 55.23999999999994, + "629": 25.559999999999892, + "630": 85.34999999999991, + "631": 90.93999999999986, + "632": 56.59999999999993, + "633": 82.9999999999999, + "634": 82.40999999999984, + "635": 72.03999999999985, + "636": 73.71999999999986, + "637": 36.0599999999999, + "638": 29.63999999999993, + "639": 67.5299999999999, + "640": 70.25999999999988, + "641": 86.66999999999992, + "642": 26.199999999999903, + "643": 89.00999999999989, + "644": 77.11999999999993, + "645": 88.12999999999991, + "646": 74.42999999999984, + "647": 85.10999999999983, + "648": 95.67999999999988, + "649": 89.8199999999999, + "650": 94.53999999999988, + "651": 80.91999999999987, + "652": 77.1599999999999, + "653": 83.19999999999995, + "654": 80.42999999999978, + "655": 93.12999999999991, + "656": 79.25999999999989, + "657": 80.28999999999994, + "658": 90.05999999999989, + "659": 87.8999999999999, + "660": 82.50999999999985, + "661": 80.87999999999997, + "662": 85.82999999999984, + "663": 71.14999999999984, + "664": 67.14999999999986, + "665": 64.47999999999995, + "666": 81.85999999999987, + "667": 71.77999999999993, + "668": 74.49999999999987, + "669": 88.01999999999984, + "670": 49.619999999999905, + "671": 87.53999999999992, + "672": 75.94999999999989, + "673": 96.44999999999987, + "674": 61.069999999999865, + "675": 79.25999999999988, + "676": 96.96999999999994, + "677": 66.85999999999981, + "678": 78.09999999999985, + "679": 83.48999999999987, + "680": 84.3199999999999, + "681": 92.7499999999999, + "682": 102.71999999999987, + "683": 75.51999999999987, + "684": 59.20999999999992, + "685": 102.98999999999987, + "686": 89.12999999999985, + "687": 99.39999999999989, + "688": 90.64999999999986, + "689": 87.73999999999987, + "690": 74.44999999999982, + "691": 46.939999999999884, + "692": 99.60999999999989, + "693": 77.02999999999984, + "694": 74.4699999999998, + "695": 89.31999999999991, + "696": 79.11999999999983, + "697": 64.69999999999987, + "698": 99.41999999999993, + "699": 79.41999999999992, + "700": 91.34999999999988, + "701": 78.63999999999986, + "702": 84.61999999999986, + "703": 89.1999999999999, + "704": 86.58999999999986, + "705": 57.85999999999992, + "706": 63.02999999999995, + "707": 79.87999999999981, + "708": 85.51999999999987, + "709": 81.30999999999983, + "710": 58.74999999999985, + "711": 97.47999999999989, + "712": 56.329999999999885, + "713": 60.639999999999915, + "714": 81.75999999999988, + "715": 100.21999999999991, + "716": 53.55999999999998, + "717": 84.63999999999986, + "718": 77.35999999999993, + "719": 93.23999999999991, + "720": 80.8599999999999, + "721": 99.93999999999987, + "722": 93.12999999999985, + "723": 64.61999999999983, + "724": 83.14999999999984, + "725": 60.479999999999926, + "726": 56.24999999999991, + "727": 65.77999999999994, + "728": 100.52999999999989, + "729": 100.33999999999992, + "730": 52.29999999999993, + "731": 94.60999999999989, + "732": 68.58999999999988, + "733": 98.22999999999993, + "734": 86.17999999999988, + "735": 99.38999999999989, + "736": 87.50999999999985, + "737": 99.46999999999989, + "738": 16.159999999999954, + "739": 71.85999999999987, + "740": 82.37999999999988, + "741": 66.0499999999999, + "742": 92.3299999999999, + "743": 89.84999999999984, + "744": 95.31999999999987, + "745": 84.71999999999986, + "746": 78.92999999999984, + "747": 93.5699999999999, + "748": 93.52999999999983, + "749": 89.48999999999991, + "750": 85.6499999999999, + "751": 71.53999999999985, + "752": 67.47999999999982, + "753": 84.32999999999986, + "754": 50.4699999999999, + "755": 83.53999999999992, + "756": 77.1399999999999, + "757": 95.58999999999985, + "758": 53.07999999999993, + "759": 92.86999999999985, + "760": 98.02999999999989, + "761": 44.7499999999999, + "762": 95.08999999999992, + "763": 58.31999999999992, + "764": 62.00999999999991, + "765": 66.59999999999994, + "766": 34.289999999999885, + "767": 76.93999999999987, + "768": 80.9399999999999, + "769": 87.79999999999987, + "770": 91.29999999999981, + "771": 73.79999999999987, + "772": 80.84999999999984, + "773": 96.99999999999991, + "774": 84.76999999999991, + "775": 90.72999999999985, + "776": 86.16999999999992, + "777": 86.59999999999987, + "778": 79.21999999999986, + "779": 95.07999999999984, + "780": 52.3599999999999, + "781": 86.30999999999986, + "782": 93.44999999999996, + "783": 68.1199999999999, + "784": 85.38999999999987, + "785": 90.94999999999989, + "786": 89.04999999999984, + "787": 85.90999999999985, + "788": 52.78999999999987, + "789": 62.48999999999994, + "790": 55.99999999999992, + "791": 55.00999999999992, + "792": 89.63999999999984, + "793": 97.70999999999988, + "794": 75.07999999999986, + "795": 55.79999999999991, + "796": 56.49999999999991, + "797": 101.19999999999992, + "798": 93.62999999999985, + "799": 85.59999999999982, + "800": 95.69999999999985, + "801": 86.62999999999984, + "802": 54.78999999999992, + "803": 94.71999999999986, + "804": 91.83999999999983, + "805": 90.16999999999986, + "806": 99.02999999999989, + "807": 92.68999999999983, + "808": 68.22999999999986, + "809": 57.33999999999992, + "810": 87.91999999999989, + "811": 90.87999999999992, + "812": 100.60999999999987, + "813": 3.6900000000000235, + "814": 97.26999999999985, + "815": 62.34999999999993, + "816": 87.77999999999983, + "817": 46.459999999999866, + "818": 47.78999999999992, + "819": 76.31999999999996, + "820": 77.87999999999991, + "821": 90.05999999999986, + "822": 97.64999999999986, + "823": 100.41999999999987, + "824": 74.38999999999993, + "825": 96.60999999999987, + "826": 76.24999999999984, + "827": 94.95999999999984, + "828": 48.219999999999914, + "829": 66.92999999999995, + "830": 99.10999999999989, + "831": 81.76999999999984, + "832": 8.239999999999947, + "833": 101.3899999999999, + "834": 43.83999999999988, + "835": 99.39999999999996, + "836": 99.57999999999988, + "837": 92.05999999999985, + "838": 87.89999999999984, + "839": 89.14999999999989, + "840": 79.41999999999987, + "841": 51.6599999999999, + "842": 88.8999999999998, + "843": 91.83999999999982, + "844": 74.77999999999989, + "845": 94.56999999999996, + "846": 93.10999999999986, + "847": 100.83999999999989, + "848": 52.9199999999999, + "849": 60.219999999999914, + "850": 53.67999999999989, + "851": 105.73999999999987, + "852": 99.39999999999984, + "853": 96.62999999999991, + "854": 90.24999999999991, + "855": 88.84999999999987, + "856": 54.88999999999989, + "857": 90.78999999999992, + "858": 85.85999999999983, + "859": 66.09999999999994, + "860": 101.8299999999998, + "861": 101.04999999999987, + "862": 83.80999999999986, + "863": 51.71999999999988, + "864": 77.50999999999988, + "865": 95.10999999999987, + "866": 53.18999999999985, + "867": 92.32999999999984, + "868": 95.95999999999985, + "869": 88.75999999999985, + "870": 96.64999999999988, + "871": 96.99999999999989, + "872": 57.99999999999991, + "873": 46.03999999999991, + "874": 82.18999999999988, + "875": 96.30999999999989, + "876": 64.29999999999986, + "877": 84.95999999999984, + "878": 92.92999999999981, + "879": 27.729999999999983, + "880": 88.03999999999984, + "881": 58.23999999999988, + "882": 25.149999999999956, + "883": 101.3299999999999, + "884": 48.2599999999999, + "885": 78.05999999999986, + "886": 89.76999999999984, + "887": 60.55999999999991, + "888": 87.27999999999984, + "889": 102.47999999999993, + "890": 60.76999999999987, + "891": 90.43999999999984, + "892": 60.86999999999987, + "893": 54.6899999999999, + "894": 103.0199999999999, + "895": 89.71999999999987, + "896": 102.36999999999989, + "897": 95.31999999999987, + "898": 75.11999999999986, + "899": 97.11999999999985, + "900": 47.9699999999999, + "901": 89.46999999999984, + "902": 53.61999999999989, + "903": 74.33999999999989, + "904": 68.34999999999988, + "905": 71.1499999999999, + "906": 77.70999999999982, + "907": 87.9899999999999, + "908": 56.069999999999936, + "909": 75.80999999999989, + "910": 74.84999999999988, + "911": 38.589999999999876, + "912": 49.5699999999999, + "913": 84.3299999999999, + "914": 70.18999999999981, + "915": 87.53999999999986, + "916": 82.86999999999985, + "917": 75.60999999999991, + "918": 98.35999999999999, + "919": 62.9399999999999, + "920": 68.05999999999985, + "921": 79.29999999999987, + "922": 76.33999999999985, + "923": 52.62999999999988, + "924": 84.28999999999989, + "925": 80.52999999999994, + "926": 91.28999999999985, + "927": 78.93999999999996, + "928": 88.6499999999999, + "929": 81.71999999999987, + "930": 56.21999999999999, + "931": 82.62999999999985, + "932": 74.1899999999998, + "933": 57.57999999999993, + "934": 88.31999999999987, + "935": 86.20999999999985, + "936": 100.8999999999999, + "937": 91.45999999999984, + "938": 76.67999999999986, + "939": 86.47999999999988, + "940": 89.4699999999999, + "941": 75.60999999999986, + "942": 52.4599999999999, + "943": 95.0799999999998, + "944": 91.96999999999989, + "945": 93.71999999999983, + "946": 54.33999999999992, + "947": 84.18999999999987, + "948": 87.27999999999989, + "949": 77.61999999999985, + "950": 88.46999999999984, + "951": 96.07999999999983, + "952": 100.27999999999983, + "953": 98.31999999999981, + "954": 77.91999999999986, + "955": 83.55999999999982, + "956": 61.11999999999987, + "957": 97.04999999999988, + "958": 54.61999999999988, + "959": 83.34999999999982, + "960": 96.85999999999984, + "961": 93.34999999999984, + "962": 55.779999999999845, + "963": 80.25999999999985, + "964": 92.15999999999983, + "965": 95.46999999999989, + "966": 76.94999999999985, + "967": 30.23999999999989, + "968": 85.83999999999989, + "969": 90.8899999999998, + "970": 57.699999999999875, + "971": 45.76999999999988, + "972": 80.5199999999998, + "973": 62.19999999999989, + "974": 63.879999999999846, + "975": 92.03999999999982, + "976": 53.329999999999885, + "977": 60.069999999999915, + "978": 61.579999999999885, + "979": 61.72999999999988, + "980": 47.74999999999987, + "981": 59.079999999999906, + "982": 61.999999999999844, + "983": 90.0099999999998, + "984": 87.45999999999984, + "985": 63.13999999999986, + "986": 62.959999999999866, + "987": 92.44999999999983, + "988": 72.71999999999989, + "989": 62.64000000000001, + "990": 91.84999999999985, + "991": 54.72999999999989, + "992": 65.33999999999988, + "993": 66.32999999999988, + "994": 98.44999999999986, + "995": 97.79999999999981, + "996": 93.82999999999983, + "997": 84.76999999999984, + "998": 48.80999999999989, + "999": 64.89999999999985, + "1000": 63.309999999999846 + }, + "session_total_reward_per_episode": { + "1": { + "1": -22.899999999999963, + "2": -11.84999999999998, + "3": -45.15000000000006, + "4": -11.449999999999983, + "5": -22.449999999999953, + "6": -14.549999999999981, + "7": -69.80000000000005, + "8": -23.149999999999963, + "9": -92.95, + "10": -1.6499999999999995, + "11": -41.85000000000005, + "12": -20.199999999999953, + "13": -2.049999999999983, + "14": -23.34999999999995, + "15": -66.40000000000009, + "16": -60.350000000000094, + "17": -12.69999999999998, + "18": -19.599999999999987, + "19": -13.349999999999982, + "20": -23.24999999999995, + "21": -14.399999999999986, + "22": -45.600000000000065, + "23": -49.10000000000007, + "24": -21.649999999999956, + "25": -95.7000000000001, + "26": -45.55000000000019, + "27": -21.749999999999957, + "28": -39.05, + "29": -42.900000000000105, + "30": -19.849999999999966, + "31": 7.00000000000002, + "32": -52.75000000000008, + "33": -28.799999999999976, + "34": -4.099999999999985, + "35": -34.749999999999986, + "36": -21.09999999999996, + "37": -37.00000000000011, + "38": -16.24999999999998, + "39": -15.299999999999986, + "40": -12.499999999999995, + "41": -83.54999999999981, + "42": -22.2, + "43": -84.75000000000009, + "44": -16.89999999999997, + "45": -25.49999999999999, + "46": -18.89999999999997, + "47": -11.349999999999987, + "48": -21.049999999999958, + "49": -22.99999999999995, + "50": -22.499999999999954, + "51": -70.44999999999999, + "52": -62.300000000000104, + "53": 3.049999999999968, + "54": -7.399999999999997, + "55": -16.799999999999972, + "56": -73.75, + "57": -33.30000000000002, + "58": -3.0000000000000067, + "59": -16.74999999999997, + "60": -21.699999999999957, + "61": -69.05000000000005, + "62": -98.54999999999998, + "63": -7.099999999999993, + "64": -3.749999999999984, + "65": -98.19999999999999, + "66": -60.90000000000017, + "67": -97.2, + "68": -22.199999999999953, + "69": -14.549999999999965, + "70": -20.999999999999957, + "71": -20.399999999999963, + "72": -5.599999999999977, + "73": -13.300000000000004, + "74": -14.649999999999979, + "75": -11.399999999999993, + "76": -6.699999999999988, + "77": -43.300000000000125, + "78": -30.449999999999992, + "79": -23.29999999999995, + "80": -75.85, + "81": 11.55, + "82": -37.24999999999999, + "83": -94.24999999999997, + "84": -18.74999999999999, + "85": -89.3, + "86": -27.350000000000026, + "87": -103.15000000000006, + "88": -73.15000000000002, + "89": -16.999999999999975, + "90": -31.54999999999993, + "91": -16.699999999999974, + "92": -22.699999999999953, + "93": -91.19999999999999, + "94": -18.949999999999967, + "95": -87.8, + "96": -17.89999999999997, + "97": -65.3, + "98": -16.24999999999998, + "99": -12.749999999999995, + "100": -2.199999999999976, + "101": -30.199999999999978, + "102": -69.74999999999997, + "103": -75.4, + "104": -63.35000000000011, + "105": -21.749999999999957, + "106": -15.04999999999998, + "107": -11.149999999999993, + "108": -95.4, + "109": -9.299999999999994, + "110": -7.399999999999994, + "111": -67.90000000000006, + "112": -66.00000000000001, + "113": -88.4, + "114": -14.949999999999976, + "115": 0.20000000000001994, + "116": -7.80000000000002, + "117": -10.3, + "118": 4.550000000000024, + "119": -42.250000000000114, + "120": -23.89999999999997, + "121": 8.499999999999986, + "122": -73.14999999999999, + "123": -18.749999999999968, + "124": -18.14999999999997, + "125": -2.8999999999999737, + "126": -6.199999999999982, + "127": -13.09999999999999, + "128": -11.849999999999987, + "129": 14.849999999999994, + "130": -14.749999999999977, + "131": -50.60000000000007, + "132": -39.65000000000005, + "133": 14.300000000000018, + "134": -9.399999999999993, + "135": -21.949999999999953, + "136": -16.69999999999997, + "137": 29.599999999999888, + "138": -19.99999999999996, + "139": 0.7500000000000469, + "140": 25.200000000000024, + "141": -18.399999999999967, + "142": -97.19999999999999, + "143": -90.15, + "144": 20.800000000000008, + "145": -7.900000000000008, + "146": -56.750000000000014, + "147": -81.70000000000005, + "148": -91.45, + "149": -31.10000000000001, + "150": -64.35, + "151": -59.49999999999999, + "152": -15.89999999999998, + "153": 8.65000000000002, + "154": -80.35000000000001, + "155": -84.64999999999996, + "156": -20.79999999999996, + "157": 1.900000000000028, + "158": -53.599999999999994, + "159": -86.80000000000001, + "160": -93.6, + "161": -92.14999999999995, + "162": -66.75000000000001, + "163": -78.65000000000002, + "164": -8.049999999999995, + "165": -87.99999999999997, + "166": 27.249999999999893, + "167": -35.30000000000001, + "168": -0.7999999999999727, + "169": -96.44999999999999, + "170": -53.09999999999996, + "171": -7.750000000000002, + "172": -1.2499999999999776, + "173": -63.39999999999997, + "174": -36.79999999999995, + "175": -10.09999999999999, + "176": -9.699999999999998, + "177": -48.2, + "178": -76.7, + "179": -73.59999999999995, + "180": -76.2, + "181": -88.39999999999999, + "182": -15.649999999999977, + "183": -91.14999999999998, + "184": -11.499999999999995, + "185": -21.949999999999953, + "186": -30.85000000000002, + "187": 40.64999999999981, + "188": 8.850000000000062, + "189": -77.00000000000004, + "190": -75.45, + "191": -0.8999999999999571, + "192": -47.25, + "193": -61.69999999999993, + "194": 7.100000000000066, + "195": -7.099999999999988, + "196": -4.050000000000007, + "197": -6.499999999999984, + "198": -82.9, + "199": 1.300000000000014, + "200": 9.849999999999971, + "201": -3.7499999999999805, + "202": 84.9000000000002, + "203": 8.45000000000005, + "204": -32.749999999999964, + "205": -36.44999999999997, + "206": -90.1, + "207": -84.05, + "208": -12.199999999999989, + "209": 13.94999999999997, + "210": -18.849999999999994, + "211": 16.80000000000005, + "212": 26.599999999999895, + "213": -22.84999999999995, + "214": -74.05, + "215": -8.149999999999993, + "216": -28.949999999999967, + "217": -61.29999999999995, + "218": -3.8000000000000043, + "219": -56.799999999999976, + "220": 25.85000000000001, + "221": -87.0, + "222": -64.14999999999999, + "223": -40.10000000000003, + "224": 5.250000000000007, + "225": -11.449999999999992, + "226": -0.39999999999999414, + "227": -65.19999999999999, + "228": -34.400000000000006, + "229": -5.9499999999999895, + "230": -19.349999999999966, + "231": 32.99999999999977, + "232": 6.500000000000082, + "233": -1.399999999999994, + "234": -46.099999999999966, + "235": 51.249999999999815, + "236": -68.25000000000001, + "237": -74.30000000000001, + "238": -4.049999999999976, + "239": -82.25, + "240": -28.799999999999937, + "241": 5.90000000000005, + "242": -1.949999999999961, + "243": -80.85, + "244": -12.649999999999988, + "245": -1.5999999999999868, + "246": -53.999999999999986, + "247": -65.85, + "248": -25.799999999999994, + "249": 0.8000000000000482, + "250": 8.250000000000032, + "251": 8.55000000000004, + "252": 7.000000000000038, + "253": -30.549999999999972, + "254": -49.400000000000034, + "255": 2.2000000000000446, + "256": 2.550000000000025, + "257": -17.399999999999984, + "258": -71.35, + "259": 13.550000000000004, + "260": -80.0, + "261": -10.74999999999999, + "262": 27.84999999999992, + "263": -10.95, + "264": -57.65000000000002, + "265": 25.99999999999989, + "266": 31.899999999999963, + "267": 2.4000000000000163, + "268": -71.5, + "269": -63.45000000000001, + "270": 78.64999999999993, + "271": -78.9, + "272": -13.149999999999956, + "273": -17.599999999999973, + "274": -14.24999999999999, + "275": -0.19999999999996576, + "276": -34.44999999999999, + "277": -1.999999999999969, + "278": -16.700000000000017, + "279": -55.699999999999996, + "280": -63.64999999999999, + "281": -0.04999999999998295, + "282": -35.45, + "283": -31.89999999999997, + "284": -69.44999999999997, + "285": -78.5, + "286": -1.1000000000000014, + "287": -74.20000000000002, + "288": -78.35000000000002, + "289": -81.80000000000001, + "290": -32.50000000000001, + "291": 8.750000000000028, + "292": -22.49999999999997, + "293": 6.8500000000000005, + "294": -91.6, + "295": 36.099999999999795, + "296": -81.25, + "297": 5.149999999999975, + "298": 7.249999999999992, + "299": -10.149999999999983, + "300": -68.54999999999993, + "301": -61.24999999999994, + "302": -13.749999999999988, + "303": -66.64999999999995, + "304": -72.1, + "305": -53.400000000000034, + "306": -41.95000000000002, + "307": 22.650000000000034, + "308": -78.69999999999999, + "309": -62.0, + "310": -72.04999999999997, + "311": -60.74999999999993, + "312": -77.45, + "313": -51.69999999999997, + "314": -78.50000000000001, + "315": -44.65000000000001, + "316": 15.80000000000004, + "317": 39.44999999999979, + "318": -43.999999999999964, + "319": -48.29999999999999, + "320": 42.99999999999991, + "321": -23.049999999999983, + "322": -4.899999999999984, + "323": 34.099999999999795, + "324": -62.24999999999992, + "325": -76.95, + "326": 7.3000000000000504, + "327": -101.30000000000013, + "328": -16.95000000000004, + "329": -50.199999999999996, + "330": -41.8, + "331": -60.84999999999993, + "332": 13.500000000000007, + "333": -53.04999999999998, + "334": 0.7500000000000511, + "335": 60.79999999999987, + "336": 6.50000000000005, + "337": 8.10000000000003, + "338": -63.7, + "339": -22.79999999999995, + "340": -82.69999999999999, + "341": -39.10000000000001, + "342": 39.599999999999795, + "343": -32.35000000000003, + "344": -65.24999999999994, + "345": 85.15000000000003, + "346": 18.34999999999998, + "347": -86.14999999999999, + "348": 30.99999999999976, + "349": -79.75, + "350": 43.44999999999984, + "351": -78.65000000000003, + "352": 34.799999999999834, + "353": -4.249999999999974, + "354": -39.35, + "355": -75.14999999999999, + "356": -67.94999999999999, + "357": -64.94999999999996, + "358": -54.19999999999996, + "359": -68.19999999999996, + "360": -38.10000000000001, + "361": 10.249999999999986, + "362": -2.0999999999999925, + "363": -10.299999999999955, + "364": -70.75, + "365": -59.25000000000002, + "366": -46.25000000000003, + "367": -61.64999999999998, + "368": 5.250000000000063, + "369": -24.54999999999994, + "370": -32.00000000000002, + "371": 25.10000000000001, + "372": -92.89999999999998, + "373": 26.450000000000102, + "374": -49.60000000000004, + "375": 13.300000000000011, + "376": -17.49999999999998, + "377": 7.600000000000042, + "378": -66.69999999999993, + "379": -25.049999999999994, + "380": -64.74999999999997, + "381": -64.34999999999998, + "382": -38.20000000000001, + "383": 59.04999999999991, + "384": 0.6000000000000636, + "385": 21.85000000000011, + "386": 14.049999999999986, + "387": -28.49999999999998, + "388": -65.89999999999996, + "389": 31.79999999999977, + "390": -54.74999999999997, + "391": -58.699999999999946, + "392": -73.99999999999999, + "393": 7.249999999999879, + "394": -62.55000000000001, + "395": -64.75000000000003, + "396": -64.69999999999992, + "397": -72.95, + "398": -57.300000000000026, + "399": 17.350000000000023, + "400": -77.60000000000005, + "401": 49.599999999999916, + "402": -78.75000000000009, + "403": -32.750000000000036, + "404": -13.849999999999985, + "405": -57.54999999999998, + "406": -67.64999999999996, + "407": -14.549999999999986, + "408": -38.69999999999999, + "409": -42.34999999999999, + "410": -75.05000000000001, + "411": -73.25000000000001, + "412": 36.849999999999795, + "413": -43.14999999999998, + "414": 50.84999999999989, + "415": -64.3999999999999, + "416": -17.599999999999984, + "417": -3.6999999999999673, + "418": -65.64999999999998, + "419": -11.450000000000015, + "420": -57.24999999999999, + "421": -65.54999999999995, + "422": -59.34999999999998, + "423": -64.79999999999997, + "424": -8.000000000000071, + "425": -12.900000000000041, + "426": -18.499999999999975, + "427": -24.499999999999975, + "428": -55.39999999999993, + "429": -30.89999999999997, + "430": -28.44999999999996, + "431": -12.949999999999976, + "432": -65.84999999999995, + "433": -50.99999999999996, + "434": -19.099999999999973, + "435": -68.4, + "436": -60.800000000000004, + "437": -3.9499999999999735, + "438": -10.999999999999922, + "439": -62.49999999999996, + "440": -57.299999999999976, + "441": -61.749999999999936, + "442": -46.04999999999999, + "443": -67.99999999999994, + "444": -62.64999999999992, + "445": 25.599999999999856, + "446": -55.09999999999995, + "447": -68.19999999999993, + "448": 3.499999999999991, + "449": -34.300000000000004, + "450": -29.700000000000006, + "451": 34.25000000000014, + "452": -12.100000000000064, + "453": -42.04999999999998, + "454": -29.10000000000001, + "455": -23.09999999999998, + "456": -26.79999999999995, + "457": -20.24999999999999, + "458": -52.2, + "459": -72.34999999999995, + "460": -65.34999999999991, + "461": -55.79999999999996, + "462": -41.65000000000001, + "463": -33.04999999999998, + "464": 22.450000000000095, + "465": 95.45000000000006, + "466": 41.64999999999985, + "467": -50.09999999999998, + "468": 6.800000000000042, + "469": 37.55, + "470": -13.75000000000001, + "471": 10.600000000000062, + "472": -62.24999999999993, + "473": -16.699999999999964, + "474": -56.79999999999995, + "475": -27.550000000000004, + "476": 84.70000000000005, + "477": -59.54999999999993, + "478": -74.04999999999998, + "479": -74.99999999999987, + "480": 28.04999999999995, + "481": -25.19999999999999, + "482": -96.60000000000001, + "483": -20.700000000000014, + "484": 77.85000000000011, + "485": -50.849999999999945, + "486": 28.700000000000042, + "487": -58.649999999999935, + "488": -35.949999999999974, + "489": -24.349999999999934, + "490": -54.0, + "491": -14.149999999999975, + "492": -44.8499999999999, + "493": -66.24999999999991, + "494": -10.149999999999965, + "495": 1.499999999999983, + "496": 26.350000000000072, + "497": -57.949999999999974, + "498": -54.799999999999955, + "499": 33.00000000000012, + "500": 7.250000000000083, + "501": 74.15000000000002, + "502": 21.699999999999974, + "503": -73.0999999999999, + "504": -15.34999999999998, + "505": 70.99999999999991, + "506": -66.84999999999991, + "507": -61.649999999999935, + "508": -58.94999999999998, + "509": -41.09999999999997, + "510": -26.349999999999994, + "511": -65.6999999999999, + "512": 44.44999999999985, + "513": -70.19999999999989, + "514": 65.74999999999986, + "515": -50.59999999999994, + "516": -66.84999999999997, + "517": -64.04999999999998, + "518": -25.099999999999998, + "519": -59.99999999999991, + "520": -67.19999999999997, + "521": -21.99999999999999, + "522": -49.89999999999998, + "523": -27.19999999999997, + "524": -51.04999999999998, + "525": -30.54999999999997, + "526": -38.25, + "527": 67.24999999999982, + "528": -63.899999999999935, + "529": -62.44999999999999, + "530": -36.05000000000003, + "531": -67.59999999999995, + "532": -67.89999999999992, + "533": -64.14999999999999, + "534": -22.099999999999984, + "535": -62.64999999999992, + "536": -52.49999999999997, + "537": -76.15000000000006, + "538": -15.750000000000046, + "539": -59.89999999999995, + "540": -1.1999999999999718, + "541": -63.65000000000005, + "542": -65.94999999999992, + "543": -5.55000000000003, + "544": -59.99999999999997, + "545": -66.7499999999999, + "546": 15.80000000000001, + "547": 87.35000000000007, + "548": -72.19999999999989, + "549": -64.09999999999992, + "550": -52.69999999999996, + "551": -9.40000000000001, + "552": -19.750000000000057, + "553": -62.94999999999993, + "554": -60.09999999999995, + "555": -39.7, + "556": 27.00000000000004, + "557": -52.04999999999994, + "558": -30.59999999999998, + "559": -86.75, + "560": -51.39999999999996, + "561": -61.20000000000003, + "562": 0.5499999999999772, + "563": -37.59999999999999, + "564": -18.650000000000027, + "565": -58.349999999999945, + "566": 55.750000000000014, + "567": -15.649999999999968, + "568": -27.250000000000007, + "569": -47.499999999999986, + "570": 100.40000000000032, + "571": -43.05, + "572": -62.24999999999993, + "573": 28.100000000000087, + "574": -65.99999999999996, + "575": 28.39999999999995, + "576": -2.0499999999999177, + "577": -58.399999999999935, + "578": -57.19999999999993, + "579": -24.6, + "580": -63.69999999999992, + "581": -4.249999999999938, + "582": 13.300000000000011, + "583": -51.749999999999964, + "584": -49.64999999999997, + "585": 50.100000000000136, + "586": 82.85000000000016, + "587": -34.00000000000001, + "588": -26.950000000000024, + "589": 102.25000000000016, + "590": -33.900000000000034, + "591": -1.549999999999984, + "592": -61.99999999999995, + "593": -56.95, + "594": 14.499999999999964, + "595": -66.7499999999999, + "596": 52.29999999999995, + "597": -50.99999999999997, + "598": 88.75, + "599": -23.750000000000014, + "600": 68.1499999999999, + "601": -47.39999999999999, + "602": -68.29999999999997, + "603": 62.750000000000156, + "604": -65.84999999999991, + "605": -3.4000000000000314, + "606": -23.75000000000003, + "607": 3.1499999999999764, + "608": -52.29999999999997, + "609": -13.599999999999982, + "610": -51.59999999999997, + "611": -37.8, + "612": -19.049999999999997, + "613": -55.84999999999996, + "614": -7.299999999999946, + "615": -79.05000000000001, + "616": 29.05000000000002, + "617": 6.500000000000016, + "618": -26.70000000000005, + "619": 79.24999999999993, + "620": -34.80000000000003, + "621": 47.85000000000002, + "622": 32.150000000000006, + "623": 88.59999999999998, + "624": -19.449999999999946, + "625": 49.79999999999995, + "626": 15.09999999999998, + "627": 38.949999999999996, + "628": 19.950000000000063, + "629": -12.799999999999974, + "630": 10.050000000000054, + "631": 67.65000000000006, + "632": -1.949999999999986, + "633": 21.60000000000012, + "634": 92.8000000000001, + "635": 25.64999999999996, + "636": 73.35000000000002, + "637": 3.9999999999999902, + "638": 42.89999999999986, + "639": -49.499999999999964, + "640": -18.200000000000045, + "641": 63.14999999999993, + "642": -16.550000000000015, + "643": 64.95000000000006, + "644": -41.099999999999966, + "645": 24.10000000000002, + "646": 34.84999999999997, + "647": 42.84999999999999, + "648": 62.49999999999998, + "649": 45.649999999999906, + "650": 72.89999999999999, + "651": 32.30000000000004, + "652": -26.04999999999997, + "653": 68.10000000000008, + "654": 70.24999999999977, + "655": 90.7000000000001, + "656": 88.55000000000003, + "657": 24.04999999999996, + "658": 57.899999999999956, + "659": 33.700000000000045, + "660": 59.29999999999987, + "661": 101.15000000000005, + "662": 41.899999999999935, + "663": 36.14999999999995, + "664": -51.19999999999997, + "665": 81.8500000000001, + "666": 88.4000000000001, + "667": 61.69999999999994, + "668": 19.299999999999983, + "669": 70.64999999999995, + "670": 46.49999999999997, + "671": 40.69999999999995, + "672": -27.34999999999998, + "673": 107.95, + "674": 44.99999999999982, + "675": 11.40000000000002, + "676": 90.50000000000016, + "677": 38.84999999999977, + "678": 56.59999999999999, + "679": 93.05000000000005, + "680": 57.399999999999956, + "681": 41.05000000000001, + "682": 90.94999999999996, + "683": 64.69999999999997, + "684": -54.09999999999994, + "685": 101.75000000000003, + "686": 53.74999999999992, + "687": 100.40000000000002, + "688": 35.8999999999998, + "689": 47.50000000000003, + "690": 32.59999999999986, + "691": 42.99999999999987, + "692": 76.00000000000003, + "693": -5.800000000000042, + "694": 3.199999999999882, + "695": 24.200000000000006, + "696": 43.40000000000005, + "697": 91.05000000000003, + "698": 84.25000000000014, + "699": 37.04999999999994, + "700": 30.149999999999984, + "701": 94.55000000000007, + "702": 94.60000000000008, + "703": 24.45, + "704": 30.49999999999995, + "705": -24.300000000000033, + "706": 82.0, + "707": 55.3499999999999, + "708": 76.55000000000014, + "709": 40.09999999999989, + "710": -10.999999999999964, + "711": 75.35000000000007, + "712": 62.09999999999993, + "713": 82.65000000000018, + "714": 8.700000000000028, + "715": 87.75000000000017, + "716": 84.55000000000001, + "717": 12.949999999999957, + "718": 73.14999999999998, + "719": 50.79999999999997, + "720": 60.599999999999994, + "721": 91.55000000000008, + "722": 93.15000000000006, + "723": 42.74999999999994, + "724": 77.49999999999997, + "725": 86.10000000000015, + "726": 69.45000000000003, + "727": 63.299999999999926, + "728": 86.40000000000002, + "729": 78.8000000000001, + "730": 92.50000000000004, + "731": 75.10000000000008, + "732": 50.99999999999997, + "733": 91.25000000000016, + "734": 85.25000000000003, + "735": 93.4500000000001, + "736": 65.05, + "737": 76.20000000000003, + "738": 57.95000000000003, + "739": 48.85, + "740": 66.79999999999995, + "741": 66.65000000000003, + "742": 76.25000000000011, + "743": 73.75000000000004, + "744": 76.15000000000006, + "745": 5.349999999999966, + "746": 45.9500000000001, + "747": 72.94999999999999, + "748": 104.60000000000005, + "749": 78.95000000000002, + "750": 67.34999999999998, + "751": 32.549999999999926, + "752": 48.449999999999825, + "753": 84.25000000000004, + "754": 53.54999999999998, + "755": 79.40000000000008, + "756": 103.10000000000002, + "757": 83.95, + "758": 92.45000000000007, + "759": 100.00000000000006, + "760": 85.30000000000001, + "761": -24.09999999999999, + "762": 53.000000000000014, + "763": 42.849999999999916, + "764": 85.10000000000004, + "765": 72.24999999999999, + "766": -34.849999999999994, + "767": 61.199999999999974, + "768": 90.8000000000001, + "769": 61.14999999999984, + "770": 81.09999999999995, + "771": 53.55000000000009, + "772": 60.849999999999895, + "773": 63.05000000000005, + "774": 53.400000000000055, + "775": 77.84999999999997, + "776": 94.4, + "777": 66.94999999999996, + "778": 67.34999999999995, + "779": 52.44999999999994, + "780": 101.69999999999999, + "781": 78.05000000000007, + "782": 46.29999999999982, + "783": 100.85000000000001, + "784": 73.85000000000004, + "785": 53.25, + "786": 84.30000000000004, + "787": 76.89999999999998, + "788": 77.30000000000008, + "789": 68.04999999999997, + "790": 80.60000000000011, + "791": 86.50000000000018, + "792": 52.39999999999994, + "793": 95.65000000000006, + "794": 88.14999999999999, + "795": 87.40000000000009, + "796": 56.29999999999998, + "797": 93.30000000000011, + "798": 85.0500000000001, + "799": 85.30000000000011, + "800": 72.05000000000001, + "801": 69.79999999999998, + "802": 76.30000000000007, + "803": 56.150000000000006, + "804": 65.74999999999997, + "805": 73.30000000000004, + "806": 76.89999999999998, + "807": 86.79999999999986, + "808": 84.99999999999997, + "809": 76.80000000000005, + "810": 86.0, + "811": 62.39999999999998, + "812": 88.30000000000003, + "813": 91.55000000000001, + "814": 75.59999999999994, + "815": 76.5, + "816": 65.29999999999993, + "817": 29.899999999999984, + "818": 77.55000000000007, + "819": 90.95000000000009, + "820": 73.30000000000004, + "821": 58.69999999999999, + "822": 87.59999999999997, + "823": 89.94999999999996, + "824": 68.29999999999997, + "825": 91.89999999999998, + "826": 74.89999999999995, + "827": 71.24999999999996, + "828": 70.69999999999999, + "829": 93.04999999999998, + "830": 88.30000000000003, + "831": 102.65000000000009, + "832": 23.799999999999955, + "833": 96.55000000000001, + "834": 89.35000000000002, + "835": 74.05000000000005, + "836": 90.3000000000001, + "837": 75.65, + "838": 81.5, + "839": 29.04999999999999, + "840": 78.9, + "841": 61.69999999999996, + "842": 46.19999999999999, + "843": 65.54999999999998, + "844": 60.95000000000003, + "845": 100.65000000000019, + "846": 73.50000000000003, + "847": 96.75000000000001, + "848": 57.24999999999997, + "849": 64.3, + "850": 59.04999999999996, + "851": 103.14999999999996, + "852": 86.50000000000007, + "853": 63.150000000000034, + "854": 67.30000000000008, + "855": 69.74999999999997, + "856": 89.69999999999999, + "857": 73.50000000000014, + "858": 58.80000000000003, + "859": 93.35000000000008, + "860": 98.75000000000001, + "861": 80.49999999999999, + "862": 78.50000000000006, + "863": 68.25000000000003, + "864": 102.9000000000001, + "865": 94.05000000000001, + "866": 46.65000000000003, + "867": 96.39999999999999, + "868": 100.6000000000001, + "869": 48.44999999999997, + "870": 88.05, + "871": 68.70000000000006, + "872": 75.75000000000001, + "873": 100.00000000000003, + "874": 102.50000000000006, + "875": 85.50000000000001, + "876": 21.64999999999999, + "877": 59.69999999999999, + "878": 70.19999999999999, + "879": 85.15000000000003, + "880": 88.3, + "881": 70.00000000000009, + "882": 92.64999999999999, + "883": 96.00000000000004, + "884": 86.60000000000002, + "885": 70.70000000000005, + "886": 53.69999999999994, + "887": 104.5, + "888": 63.85000000000002, + "889": 86.85000000000004, + "890": 81.45000000000003, + "891": 73.30000000000003, + "892": 94.95000000000003, + "893": 42.05, + "894": 93.99999999999999, + "895": 94.80000000000018, + "896": 91.7, + "897": 62.349999999999945, + "898": 66.35000000000001, + "899": 86.85000000000002, + "900": 37.30000000000004, + "901": 74.94999999999997, + "902": 92.05000000000008, + "903": 92.34999999999998, + "904": 61.80000000000008, + "905": 85.14999999999999, + "906": 84.49999999999993, + "907": 66.30000000000004, + "908": 88.05000000000001, + "909": 81.55000000000001, + "910": 99.35000000000001, + "911": 71.79999999999994, + "912": 87.15, + "913": 82.39999999999998, + "914": 38.949999999999974, + "915": 91.35000000000002, + "916": 69.45000000000007, + "917": 73.04999999999998, + "918": 72.00000000000003, + "919": 62.45000000000006, + "920": 46.89999999999998, + "921": 66.95000000000003, + "922": 77.94999999999997, + "923": 84.45, + "924": 75.39999999999998, + "925": 91.70000000000013, + "926": 80.74999999999997, + "927": 77.20000000000005, + "928": 79.20000000000005, + "929": 59.05, + "930": 66.14999999999992, + "931": 47.899999999999935, + "932": 89.64999999999996, + "933": 78.3499999999999, + "934": 91.60000000000005, + "935": 70.89999999999996, + "936": 85.45, + "937": 84.65000000000003, + "938": 82.84999999999997, + "939": 102.19999999999999, + "940": 53.80000000000001, + "941": 50.199999999999974, + "942": 72.7, + "943": 63.90000000000002, + "944": 80.15000000000003, + "945": 92.15, + "946": 13.999999999999988, + "947": 62.400000000000034, + "948": 73.60000000000005, + "949": 56.29999999999998, + "950": 84.25000000000003, + "951": 80.85000000000001, + "952": 84.45000000000005, + "953": 86.70000000000002, + "954": 87.04999999999995, + "955": 30.700000000000024, + "956": 82.05, + "957": 78.55000000000007, + "958": 83.95000000000006, + "959": 57.44999999999996, + "960": 83.45000000000006, + "961": 72.25000000000001, + "962": 73.05000000000001, + "963": 79.30000000000001, + "964": 81.55, + "965": 69.99999999999997, + "966": 67.20000000000003, + "967": 92.80000000000003, + "968": 72.10000000000002, + "969": 48.64999999999985, + "970": 71.94999999999997, + "971": 15.949999999999934, + "972": 61.44999999999984, + "973": 90.85000000000004, + "974": 96.55000000000003, + "975": 78.15000000000003, + "976": 84.40000000000009, + "977": 84.75000000000003, + "978": 52.95000000000003, + "979": 84.84999999999995, + "980": 52.20000000000008, + "981": 67.1, + "982": 84.00000000000001, + "983": 87.8500000000001, + "984": 76.8000000000001, + "985": 91.4499999999999, + "986": 80.74999999999999, + "987": 83.09999999999998, + "988": 92.9000000000001, + "989": 63.34999999999997, + "990": 66.49999999999997, + "991": 96.65000000000002, + "992": 101.85000000000002, + "993": 84.79999999999993, + "994": 91.65000000000003, + "995": 77.25000000000009, + "996": 64.0, + "997": 59.04999999999998, + "998": 72.10000000000002, + "999": 85.40000000000005, + "1000": 38.94999999999991 + }, + "2": { + "1": -11.099999999999989, + "2": -32.05000000000004, + "3": -58.200000000000095, + "4": -8.599999999999987, + "5": -81.89999999999999, + "6": -63.89999999999999, + "7": 2.050000000000006, + "8": -25.199999999999996, + "9": -21.249999999999957, + "10": -31.65000000000001, + "11": -65.34999999999998, + "12": -19.39999999999996, + "13": -81.10000000000001, + "14": -12.099999999999989, + "15": -27.799999999999933, + "16": -97.5, + "17": -13.399999999999984, + "18": -66.80000000000008, + "19": -18.29999999999997, + "20": -11.59999999999998, + "21": -71.05000000000005, + "22": -15.149999999999983, + "23": -18.54999999999997, + "24": -51.90000000000001, + "25": -19.54999999999996, + "26": -64.8500000000001, + "27": -78.94999999999996, + "28": -24.649999999999935, + "29": -63.800000000000104, + "30": -15.949999999999978, + "31": -0.24999999999996536, + "32": -7.449999999999997, + "33": -13.29999999999999, + "34": -18.64999999999997, + "35": -22.499999999999954, + "36": -36.20000000000002, + "37": -15.999999999999979, + "38": -23.04999999999995, + "39": -20.099999999999966, + "40": -14.149999999999977, + "41": -13.849999999999989, + "42": -20.69999999999996, + "43": -15.349999999999977, + "44": -11.650000000000004, + "45": -19.74999999999996, + "46": -11.849999999999993, + "47": -10.049999999999992, + "48": -78.7999999999999, + "49": -23.29999999999995, + "50": -16.199999999999967, + "51": -22.399999999999952, + "52": -12.14999999999999, + "53": -20.849999999999962, + "54": -46.05000000000007, + "55": -19.199999999999964, + "56": -17.249999999999975, + "57": -19.649999999999963, + "58": -10.7, + "59": -41.35000000000013, + "60": -35.35000000000003, + "61": -19.899999999999963, + "62": -18.949999999999964, + "63": -1.7999999999999938, + "64": -18.19999999999997, + "65": -7.05000000000001, + "66": -5.949999999999997, + "67": -2.649999999999964, + "68": -14.55, + "69": -33.54999999999995, + "70": -104.4, + "71": -62.25000000000008, + "72": -14.099999999999989, + "73": -8.1, + "74": -15.299999999999986, + "75": -7.699999999999998, + "76": -14.649999999999984, + "77": 0.20000000000002705, + "78": -4.3999999999999995, + "79": -8.850000000000009, + "80": -1.399999999999962, + "81": -94.55, + "82": -26.200000000000006, + "83": -5.899999999999989, + "84": -10.299999999999997, + "85": 14.200000000000006, + "86": -67.09999999999997, + "87": -23.849999999999948, + "88": -19.74999999999996, + "89": -19.899999999999963, + "90": 2.4999999999999614, + "91": -15.899999999999983, + "92": -21.899999999999956, + "93": 8.400000000000063, + "94": -47.25000000000005, + "95": -11.949999999999987, + "96": -3.649999999999981, + "97": 3.550000000000037, + "98": -10.849999999999996, + "99": -17.74999999999997, + "100": -17.89999999999997, + "101": -6.999999999999993, + "102": -14.49999999999999, + "103": -31.800000000000008, + "104": -21.199999999999957, + "105": -14.39999999999998, + "106": -5.749999999999986, + "107": -2.4499999999999744, + "108": -25.14999999999999, + "109": 2.0000000000000373, + "110": -28.29999999999995, + "111": -14.14999999999999, + "112": -83.15, + "113": -1.4000000000000008, + "114": -2.14999999999997, + "115": -49.30000000000006, + "116": -9.449999999999987, + "117": 9.500000000000043, + "118": 13.65000000000003, + "119": -5.350000000000008, + "120": -10.849999999999982, + "121": -6.64999999999998, + "122": -18.74999999999999, + "123": 0.9500000000000433, + "124": -7.499999999999983, + "125": -18.09999999999997, + "126": -22.499999999999975, + "127": 7.350000000000016, + "128": 6.75000000000004, + "129": 14.700000000000049, + "130": 16.899999999999995, + "131": -52.54999999999996, + "132": -89.35, + "133": -17.34999999999997, + "134": 33.99999999999979, + "135": 8.900000000000048, + "136": 0.5499999999999989, + "137": -11.349999999999998, + "138": 14.650000000000023, + "139": -5.5000000000000036, + "140": -9.500000000000004, + "141": -40.30000000000011, + "142": 20.849999999999973, + "143": -2.049999999999981, + "144": 39.899999999999736, + "145": -87.6, + "146": 37.74999999999996, + "147": 14.400000000000034, + "148": 9.50000000000004, + "149": 10.95000000000001, + "150": -2.549999999999983, + "151": -0.8499999999999777, + "152": -8.100000000000007, + "153": 23.649999999999913, + "154": -4.599999999999987, + "155": -70.4, + "156": 39.649999999999956, + "157": -80.55000000000001, + "158": 8.550000000000013, + "159": 41.49999999999975, + "160": -28.099999999999977, + "161": -17.599999999999973, + "162": 42.6499999999998, + "163": -36.35, + "164": -41.80000000000015, + "165": -4.450000000000005, + "166": 6.150000000000029, + "167": 23.65000000000003, + "168": 36.250000000000014, + "169": 16.650000000000045, + "170": 15.200000000000077, + "171": 33.14999999999989, + "172": 63.14999999999998, + "173": -10.550000000000002, + "174": -23.29999999999995, + "175": 16.749999999999893, + "176": -74.75000000000003, + "177": 31.75000000000006, + "178": 14.049999999999985, + "179": 61.94999999999975, + "180": 20.499999999999943, + "181": 39.69999999999988, + "182": 6.100000000000064, + "183": -7.250000000000073, + "184": -6.950000000000007, + "185": -69.10000000000001, + "186": 29.150000000000073, + "187": -76.60000000000002, + "188": 45.299999999999905, + "189": -50.74999999999996, + "190": -48.10000000000014, + "191": 43.74999999999974, + "192": 10.300000000000058, + "193": 54.9499999999999, + "194": 38.9, + "195": 8.150000000000055, + "196": 7.00000000000001, + "197": 44.59999999999982, + "198": 72.34999999999984, + "199": -47.9, + "200": -49.45000000000003, + "201": 46.29999999999976, + "202": -46.099999999999994, + "203": -2.8499999999999783, + "204": -9.450000000000045, + "205": 2.600000000000059, + "206": 31.550000000000026, + "207": -65.50000000000004, + "208": 55.29999999999975, + "209": -39.000000000000014, + "210": 72.29999999999986, + "211": 64.34999999999982, + "212": 42.44999999999975, + "213": 13.750000000000027, + "214": -21.300000000000033, + "215": 21.99999999999996, + "216": -12.299999999999985, + "217": 12.149999999999999, + "218": -67.85000000000007, + "219": 9.400000000000013, + "220": 50.24999999999981, + "221": -7.300000000000061, + "222": 99.84999999999995, + "223": 53.84999999999977, + "224": 27.100000000000044, + "225": -50.69999999999999, + "226": 17.0, + "227": 62.94999999999987, + "228": 61.14999999999988, + "229": -33.30000000000001, + "230": -56.850000000000016, + "231": 7.650000000000029, + "232": 75.10000000000004, + "233": 10.900000000000059, + "234": 41.2, + "235": 20.80000000000005, + "236": 65.34999999999977, + "237": 92.25000000000003, + "238": -34.149999999999984, + "239": 55.24999999999974, + "240": 24.999999999999925, + "241": 50.74999999999988, + "242": 59.09999999999977, + "243": -67.4, + "244": 66.49999999999979, + "245": 10.300000000000068, + "246": 69.09999999999977, + "247": 98.94999999999973, + "248": -65.04999999999994, + "249": -79.70000000000003, + "250": 104.89999999999986, + "251": 71.6999999999999, + "252": 87.69999999999986, + "253": -50.74999999999998, + "254": -56.29999999999996, + "255": 91.54999999999977, + "256": 48.89999999999978, + "257": 34.55000000000007, + "258": 20.249999999999936, + "259": 68.99999999999976, + "260": 46.94999999999985, + "261": 68.79999999999974, + "262": 38.44999999999996, + "263": 84.59999999999978, + "264": -74.45000000000006, + "265": 44.349999999999866, + "266": 70.54999999999973, + "267": 29.450000000000028, + "268": 83.2499999999998, + "269": 3.2499999999999343, + "270": -70.75, + "271": -6.750000000000014, + "272": 66.94999999999982, + "273": 59.899999999999835, + "274": 83.99999999999979, + "275": -65.85000000000002, + "276": 40.94999999999991, + "277": 81.09999999999972, + "278": 7.450000000000036, + "279": 28.399999999999917, + "280": -59.750000000000064, + "281": 58.849999999999795, + "282": 68.55, + "283": 22.59999999999995, + "284": 0.4000000000000341, + "285": -93.34999999999998, + "286": -1.4999999999999711, + "287": 22.900000000000023, + "288": 11.349999999999937, + "289": 79.09999999999978, + "290": 91.54999999999995, + "291": 58.19999999999978, + "292": 36.849999999999994, + "293": 78.14999999999985, + "294": 15.799999999999962, + "295": 58.599999999999774, + "296": 7.149999999999958, + "297": 39.99999999999988, + "298": 34.80000000000001, + "299": 86.79999999999978, + "300": 54.09999999999981, + "301": 91.29999999999973, + "302": 61.09999999999973, + "303": -11.449999999999982, + "304": 75.79999999999986, + "305": 33.04999999999986, + "306": -17.800000000000043, + "307": 89.59999999999977, + "308": 68.39999999999988, + "309": -55.85000000000005, + "310": 69.39999999999975, + "311": 88.19999999999987, + "312": 57.09999999999975, + "313": 23.20000000000005, + "314": 94.19999999999975, + "315": 91.14999999999979, + "316": 33.54999999999974, + "317": 94.79999999999976, + "318": 98.44999999999976, + "319": 53.449999999999726, + "320": 81.09999999999974, + "321": -12.450000000000038, + "322": 95.29999999999973, + "323": -6.65000000000002, + "324": 88.59999999999977, + "325": 101.29999999999987, + "326": 107.70000000000005, + "327": 101.29999999999995, + "328": 102.39999999999979, + "329": 15.799999999999955, + "330": 38.05000000000004, + "331": 67.99999999999989, + "332": 74.34999999999978, + "333": -19.399999999999967, + "334": 96.14999999999979, + "335": -5.7500000000000036, + "336": -9.849999999999985, + "337": 87.19999999999975, + "338": 97.5499999999998, + "339": 27.20000000000004, + "340": 43.799999999999976, + "341": 92.39999999999976, + "342": 92.79999999999976, + "343": 90.64999999999978, + "344": 90.10000000000004, + "345": 20.650000000000023, + "346": 96.4999999999999, + "347": -85.49999999999997, + "348": 38.299999999999955, + "349": 99.84999999999987, + "350": 93.09999999999981, + "351": 59.24999999999997, + "352": 66.74999999999984, + "353": 89.54999999999976, + "354": 60.39999999999989, + "355": 13.699999999999973, + "356": 99.69999999999985, + "357": 25.949999999999886, + "358": 79.24999999999976, + "359": -9.149999999999986, + "360": 94.29999999999974, + "361": 103.09999999999992, + "362": 99.3499999999999, + "363": 95.34999999999974, + "364": -43.89999999999998, + "365": 103.39999999999976, + "366": 102.34999999999978, + "367": 106.49999999999972, + "368": 101.34999999999975, + "369": 103.99999999999991, + "370": -72.85000000000001, + "371": 86.74999999999973, + "372": -9.499999999999982, + "373": 97.89999999999976, + "374": 100.44999999999976, + "375": -84.24999999999997, + "376": 101.34999999999975, + "377": 78.59999999999984, + "378": 100.59999999999978, + "379": -28.049999999999972, + "380": 9.80000000000001, + "381": 104.94999999999978, + "382": 102.79999999999977, + "383": 93.59999999999994, + "384": 64.25000000000009, + "385": 77.09999999999992, + "386": 92.24999999999979, + "387": 98.04999999999973, + "388": -76.55, + "389": 94.49999999999979, + "390": 89.34999999999977, + "391": 26.050000000000026, + "392": 27.999999999999975, + "393": -24.05000000000006, + "394": 106.04999999999976, + "395": 105.99999999999974, + "396": 102.39999999999975, + "397": 59.6499999999999, + "398": 97.99999999999977, + "399": 101.49999999999973, + "400": -18.7, + "401": 105.6000000000001, + "402": 95.99999999999977, + "403": 103.69999999999982, + "404": 90.14999999999976, + "405": 96.34999999999987, + "406": 87.99999999999982, + "407": 93.29999999999971, + "408": 98.89999999999986, + "409": 104.39999999999972, + "410": 97.2499999999999, + "411": 100.04999999999974, + "412": 86.5499999999998, + "413": -60.74999999999996, + "414": 99.79999999999977, + "415": 82.84999999999978, + "416": 101.19999999999975, + "417": 1.5000000000000357, + "418": 102.49999999999976, + "419": 65.89999999999989, + "420": 103.89999999999974, + "421": 96.84999999999974, + "422": 101.39999999999976, + "423": 102.14999999999976, + "424": 101.99999999999972, + "425": 101.74999999999976, + "426": 102.39999999999976, + "427": 106.99999999999977, + "428": -73.45, + "429": 104.14999999999974, + "430": 100.09999999999984, + "431": 102.49999999999979, + "432": 101.19999999999975, + "433": 101.24999999999973, + "434": 102.44999999999976, + "435": 102.59999999999977, + "436": 98.84999999999977, + "437": 85.04999999999976, + "438": -77.7, + "439": -89.75, + "440": 33.79999999999973, + "441": 94.74999999999979, + "442": 99.84999999999975, + "443": 99.64999999999978, + "444": 103.6, + "445": 101.64999999999976, + "446": 52.39999999999978, + "447": 100.19999999999976, + "448": 80.09999999999977, + "449": 103.1999999999998, + "450": 97.54999999999977, + "451": 87.94999999999976, + "452": 103.49999999999974, + "453": 75.04999999999977, + "454": 94.59999999999977, + "455": 84.64999999999982, + "456": 99.49999999999977, + "457": -0.9500000000000004, + "458": 82.89999999999972, + "459": 103.79999999999977, + "460": 102.39999999999974, + "461": 106.64999999999976, + "462": 95.24999999999979, + "463": 97.79999999999977, + "464": 84.49999999999982, + "465": -11.800000000000033, + "466": 101.04999999999978, + "467": 106.29999999999974, + "468": 18.449999999999903, + "469": 105.19999999999975, + "470": 105.59999999999972, + "471": 82.29999999999977, + "472": 103.44999999999976, + "473": 104.19999999999978, + "474": 104.94999999999975, + "475": 106.19999999999972, + "476": 101.19999999999976, + "477": 106.44999999999973, + "478": -66.74999999999999, + "479": 98.14999999999978, + "480": 102.29999999999976, + "481": 102.44999999999976, + "482": 85.79999999999973, + "483": -77.75, + "484": 95.94999999999976, + "485": 101.19999999999976, + "486": 97.69999999999975, + "487": 104.59999999999975, + "488": 102.24999999999977, + "489": 103.8499999999998, + "490": 103.74999999999977, + "491": 104.39999999999971, + "492": 100.64999999999974, + "493": 105.04999999999976, + "494": -41.75000000000008, + "495": 105.84999999999972, + "496": 106.59999999999972, + "497": 99.04999999999977, + "498": 86.34999999999974, + "499": 104.54999999999976, + "500": 102.44999999999978, + "501": 104.69999999999979, + "502": 104.39999999999976, + "503": 107.34999999999974, + "504": 94.84999999999981, + "505": 104.34999999999977, + "506": 100.34999999999975, + "507": 104.14999999999976, + "508": 81.09999999999985, + "509": 97.69999999999976, + "510": -71.14999999999999, + "511": 101.94999999999973, + "512": 98.79999999999978, + "513": 104.79999999999976, + "514": 103.84999999999977, + "515": 103.94999999999973, + "516": 100.04999999999976, + "517": 104.74999999999974, + "518": 101.99999999999977, + "519": 105.14999999999975, + "520": 106.69999999999973, + "521": -84.45, + "522": 101.99999999999974, + "523": 60.84999999999993, + "524": 68.99999999999977, + "525": 84.59999999999974, + "526": 97.84999999999977, + "527": 104.34999999999977, + "528": 104.64999999999975, + "529": 104.89999999999975, + "530": 104.09999999999977, + "531": 103.44999999999976, + "532": 105.94999999999986, + "533": 104.24999999999977, + "534": 73.69999999999986, + "535": 106.49999999999973, + "536": 107.44999999999973, + "537": 85.34999999999978, + "538": 97.04999999999977, + "539": 103.79999999999977, + "540": 97.24999999999976, + "541": 42.84999999999979, + "542": 54.34999999999977, + "543": 67.29999999999974, + "544": 80.19999999999973, + "545": 19.350000000000026, + "546": 90.84999999999975, + "547": 49.69999999999974, + "548": 68.64999999999976, + "549": 82.89999999999972, + "550": 105.49999999999973, + "551": 4.50000000000003, + "552": 103.19999999999978, + "553": 98.44999999999976, + "554": 48.04999999999981, + "555": 58.39999999999973, + "556": 105.39999999999974, + "557": 84.39999999999976, + "558": 55.44999999999979, + "559": -74.25, + "560": 7.249999999999989, + "561": 103.24999999999976, + "562": 101.64999999999986, + "563": 105.40000000000003, + "564": -83.85000000000001, + "565": 20.699999999999964, + "566": 61.54999999999973, + "567": 108.19999999999976, + "568": 70.19999999999975, + "569": 40.29999999999995, + "570": 69.89999999999972, + "571": 35.2, + "572": 72.19999999999983, + "573": 112.84999999999992, + "574": 100.04999999999973, + "575": 30.000000000000053, + "576": 102.49999999999977, + "577": 103.89999999999976, + "578": 51.749999999999716, + "579": 74.24999999999977, + "580": 49.34999999999983, + "581": 1.2000000000000497, + "582": 103.49999999999976, + "583": 79.14999999999988, + "584": 36.14999999999989, + "585": 104.04999999999973, + "586": -4.849999999999975, + "587": 106.94999999999973, + "588": 48.39999999999986, + "589": 73.79999999999973, + "590": 71.19999999999976, + "591": 106.14999999999974, + "592": 90.19999999999982, + "593": 102.44999999999975, + "594": -26.200000000000017, + "595": 104.79999999999973, + "596": 54.59999999999989, + "597": 75.1999999999998, + "598": 88.89999999999976, + "599": 100.14999999999976, + "600": 99.24999999999977, + "601": 55.299999999999756, + "602": 68.29999999999973, + "603": 102.04999999999977, + "604": 101.69999999999978, + "605": 76.04999999999977, + "606": 105.39999999999972, + "607": 102.49999999999977, + "608": 102.59999999999977, + "609": 102.14999999999976, + "610": 105.94999999999972, + "611": 65.59999999999975, + "612": 104.44999999999975, + "613": 107.2499999999998, + "614": -82.45, + "615": -5.00000000000005, + "616": 80.24999999999972, + "617": 98.04999999999977, + "618": 67.89999999999984, + "619": 99.74999999999976, + "620": 103.09999999999977, + "621": 103.19999999999973, + "622": -49.15000000000008, + "623": 98.69999999999976, + "624": 56.09999999999976, + "625": 107.44999999999975, + "626": 103.59999999999977, + "627": 38.049999999999756, + "628": -41.949999999999996, + "629": -88.2, + "630": 104.7999999999998, + "631": 107.59999999999972, + "632": -9.949999999999976, + "633": 86.69999999999972, + "634": 104.49999999999977, + "635": 86.89999999999984, + "636": 61.24999999999979, + "637": 73.19999999999973, + "638": -31.000000000000007, + "639": 76.99999999999979, + "640": 80.19999999999976, + "641": 74.8999999999998, + "642": 103.79999999999976, + "643": 97.39999999999974, + "644": 107.44999999999976, + "645": 97.94999999999979, + "646": 104.74999999999976, + "647": 102.64999999999972, + "648": 104.89999999999972, + "649": 104.59999999999974, + "650": 102.74999999999976, + "651": 102.89999999999976, + "652": 105.34999999999972, + "653": 105.59999999999975, + "654": 101.99999999999977, + "655": 101.19999999999978, + "656": 106.59999999999972, + "657": 105.04999999999974, + "658": 76.14999999999978, + "659": 104.69999999999976, + "660": 103.04999999999977, + "661": -3.800000000000068, + "662": 103.94999999999976, + "663": 103.99999999999976, + "664": 101.04999999999976, + "665": 103.44999999999976, + "666": 98.09999999999977, + "667": 90.65, + "668": 69.89999999999976, + "669": 103.59999999999977, + "670": 105.09999999999975, + "671": 104.19999999999976, + "672": 104.79999999999974, + "673": 66.19999999999978, + "674": -6.449999999999992, + "675": 104.34999999999977, + "676": 79.89999999999976, + "677": 97.49999999999977, + "678": 81.64999999999975, + "679": 48.19999999999988, + "680": 89.09999999999975, + "681": 108.29999999999974, + "682": 105.74999999999973, + "683": 102.54999999999977, + "684": 38.79999999999977, + "685": 103.69999999999976, + "686": 80.2999999999998, + "687": 103.49999999999977, + "688": 107.7999999999998, + "689": 104.84999999999975, + "690": 100.39999999999976, + "691": -71.64999999999999, + "692": 104.24999999999976, + "693": 102.64999999999976, + "694": 104.09999999999977, + "695": 105.09999999999978, + "696": 93.24999999999976, + "697": 70.94999999999972, + "698": 104.84999999999975, + "699": 65.79999999999981, + "700": 108.39999999999974, + "701": 100.54999999999971, + "702": 104.79999999999976, + "703": 102.84999999999977, + "704": 103.49999999999977, + "705": 104.89999999999974, + "706": 101.14999999999975, + "707": 104.89999999999972, + "708": 103.94999999999975, + "709": 102.49999999999977, + "710": 62.44999999999977, + "711": 102.39999999999976, + "712": 105.99999999999973, + "713": 104.49999999999974, + "714": 105.34999999999974, + "715": 106.69999999999972, + "716": 38.44999999999985, + "717": 103.64999999999976, + "718": 103.09999999999977, + "719": 102.89999999999978, + "720": 35.2499999999998, + "721": 103.99999999999976, + "722": 105.04999999999974, + "723": 103.39999999999976, + "724": 104.54999999999977, + "725": -81.7, + "726": 104.54999999999976, + "727": 100.89999999999975, + "728": 105.44999999999975, + "729": 111.64999999999979, + "730": 104.69999999999976, + "731": 99.89999999999978, + "732": 2.8999999999999346, + "733": 104.39999999999976, + "734": 103.14999999999976, + "735": 102.99999999999977, + "736": 103.39999999999976, + "737": 105.69999999999975, + "738": -85.25, + "739": 41.99999999999972, + "740": 103.99999999999976, + "741": 17.600000000000016, + "742": 65.44999999999976, + "743": 102.24999999999977, + "744": 102.49999999999977, + "745": 105.89999999999972, + "746": 102.64999999999978, + "747": 104.69999999999976, + "748": 102.79999999999977, + "749": 102.19999999999978, + "750": 104.49999999999973, + "751": 102.64999999999978, + "752": 104.74999999999977, + "753": 104.54999999999976, + "754": 99.79999999999978, + "755": 103.94999999999976, + "756": 66.09999999999991, + "757": 103.99999999999976, + "758": -85.1, + "759": 103.29999999999977, + "760": 106.04999999999974, + "761": 99.29999999999973, + "762": 104.89999999999975, + "763": 104.34999999999977, + "764": 103.69999999999976, + "765": 102.14999999999978, + "766": 104.84999999999974, + "767": 103.09999999999977, + "768": 104.04999999999974, + "769": 104.69999999999975, + "770": 104.49999999999976, + "771": 108.84999999999974, + "772": 101.49999999999976, + "773": 103.69999999999976, + "774": 60.79999999999977, + "775": 103.29999999999977, + "776": 104.84999999999975, + "777": 104.29999999999976, + "778": 102.84999999999977, + "779": 103.89999999999976, + "780": 104.54999999999977, + "781": 103.79999999999976, + "782": 105.59999999999974, + "783": 102.84999999999977, + "784": 104.69999999999976, + "785": 101.59999999999977, + "786": 96.09999999999974, + "787": 105.99999999999972, + "788": 104.34999999999977, + "789": 103.79999999999977, + "790": 103.24999999999977, + "791": 102.89999999999976, + "792": 96.19999999999976, + "793": 105.09999999999978, + "794": 52.8499999999999, + "795": 105.24999999999974, + "796": 107.49999999999972, + "797": 111.64999999999986, + "798": 104.59999999999975, + "799": 73.74999999999973, + "800": 104.69999999999975, + "801": 105.49999999999974, + "802": -69.5, + "803": 105.69999999999975, + "804": 103.89999999999976, + "805": 105.19999999999973, + "806": 103.89999999999976, + "807": 107.24999999999974, + "808": 105.39999999999974, + "809": 106.69999999999972, + "810": 104.59999999999975, + "811": 81.64999999999984, + "812": 103.84999999999977, + "813": -63.90000000000002, + "814": 106.59999999999972, + "815": -68.5, + "816": 103.59999999999977, + "817": 104.99999999999974, + "818": 104.29999999999977, + "819": -9.799999999999994, + "820": 104.19999999999976, + "821": 109.09999999999977, + "822": 103.04999999999977, + "823": 108.49999999999974, + "824": 105.24999999999972, + "825": 103.94999999999976, + "826": 107.69999999999972, + "827": 103.49999999999977, + "828": 59.39999999999974, + "829": -74.35000000000001, + "830": 103.84999999999977, + "831": 91.04999999999978, + "832": 103.54999999999977, + "833": 105.19999999999979, + "834": 102.84999999999974, + "835": 106.09999999999972, + "836": 104.04999999999976, + "837": 104.59999999999977, + "838": 109.09999999999977, + "839": 103.29999999999977, + "840": 104.14999999999976, + "841": 103.34999999999977, + "842": 106.19999999999972, + "843": 103.59999999999977, + "844": 100.54999999999977, + "845": 103.84999999999977, + "846": 104.44999999999976, + "847": 103.89999999999978, + "848": 105.84999999999974, + "849": -61.300000000000004, + "850": 103.79999999999976, + "851": 105.59999999999974, + "852": 103.64999999999976, + "853": 105.74999999999974, + "854": 106.0999999999998, + "855": 109.14999999999975, + "856": 106.79999999999971, + "857": 105.39999999999975, + "858": 101.14999999999976, + "859": 104.24999999999973, + "860": 104.19999999999976, + "861": 106.94999999999973, + "862": 102.94999999999978, + "863": 104.84999999999975, + "864": 103.49999999999976, + "865": 103.04999999999977, + "866": 105.94999999999973, + "867": 102.34999999999978, + "868": 107.29999999999974, + "869": 104.14999999999976, + "870": 103.34999999999977, + "871": 103.89999999999974, + "872": -77.69999999999999, + "873": -86.14999999999999, + "874": 103.19999999999976, + "875": 108.6499999999998, + "876": 105.44999999999975, + "877": 105.89999999999974, + "878": 105.14999999999972, + "879": 103.64999999999976, + "880": 70.64999999999974, + "881": 103.09999999999977, + "882": 105.24999999999984, + "883": 103.69999999999976, + "884": 107.29999999999973, + "885": 103.59999999999975, + "886": 105.59999999999974, + "887": 104.69999999999978, + "888": 102.34999999999978, + "889": 102.99999999999977, + "890": 107.69999999999972, + "891": 104.24999999999976, + "892": 100.89999999999978, + "893": 103.69999999999978, + "894": 106.34999999999972, + "895": 107.39999999999974, + "896": 103.44999999999978, + "897": 103.54999999999977, + "898": 101.14999999999975, + "899": 104.14999999999976, + "900": 105.84999999999972, + "901": 69.94999999999983, + "902": -37.80000000000014, + "903": 9.05000000000001, + "904": -22.399999999999988, + "905": 49.749999999999915, + "906": 22.449999999999854, + "907": 66.74999999999989, + "908": 69.29999999999987, + "909": 30.299999999999937, + "910": 83.39999999999984, + "911": 23.899999999999878, + "912": -70.00000000000003, + "913": 43.499999999999865, + "914": 51.34999999999983, + "915": 40.50000000000001, + "916": 55.599999999999795, + "917": -5.299999999999992, + "918": 99.69999999999979, + "919": 24.89999999999985, + "920": 32.19999999999985, + "921": 94.69999999999978, + "922": 18.75000000000001, + "923": -1.2500000000000309, + "924": 49.799999999999876, + "925": 6.649999999999977, + "926": 92.04999999999981, + "927": 38.100000000000016, + "928": 52.099999999999895, + "929": 61.399999999999984, + "930": 53.399999999999935, + "931": 79.69999999999986, + "932": 66.04999999999977, + "933": -14.500000000000021, + "934": 73.94999999999979, + "935": 71.44999999999979, + "936": 104.69999999999982, + "937": 93.74999999999982, + "938": 21.799999999999986, + "939": 107.49999999999973, + "940": 83.3499999999999, + "941": 77.09999999999995, + "942": 92.24999999999986, + "943": 103.34999999999977, + "944": 104.64999999999979, + "945": 103.59999999999975, + "946": -20.79999999999999, + "947": 64.84999999999978, + "948": 104.14999999999976, + "949": 55.599999999999916, + "950": 97.54999999999976, + "951": 103.24999999999976, + "952": 107.04999999999974, + "953": 104.79999999999976, + "954": 103.04999999999977, + "955": 89.69999999999976, + "956": 86.39999999999988, + "957": 104.04999999999977, + "958": 103.89999999999976, + "959": 87.29999999999978, + "960": 95.09999999999981, + "961": 104.09999999999975, + "962": 103.49999999999977, + "963": 87.14999999999976, + "964": 101.9999999999998, + "965": 103.49999999999977, + "966": 82.94999999999978, + "967": 108.14999999999974, + "968": 77.59999999999977, + "969": 103.89999999999976, + "970": 109.74999999999976, + "971": 108.89999999999976, + "972": 103.59999999999977, + "973": 108.09999999999974, + "974": 103.24999999999977, + "975": 105.39999999999975, + "976": 105.04999999999976, + "977": 107.74999999999977, + "978": 103.74999999999977, + "979": 103.49999999999976, + "980": -77.8, + "981": 108.69999999999973, + "982": 105.54999999999973, + "983": 103.49999999999976, + "984": 106.99999999999973, + "985": 103.89999999999976, + "986": -63.9, + "987": 102.89999999999978, + "988": 109.24999999999977, + "989": 111.94999999999995, + "990": 106.79999999999974, + "991": -64.75000000000001, + "992": 107.59999999999972, + "993": 98.29999999999976, + "994": 103.39999999999976, + "995": 104.49999999999976, + "996": 88.94999999999982, + "997": 103.24999999999977, + "998": -62.95, + "999": -70.9, + "1000": 103.34999999999977 + }, + "3": { + "1": -64.2500000000001, + "2": -10.899999999999991, + "3": -30.800000000000004, + "4": -14.649999999999977, + "5": -75.69999999999999, + "6": -60.350000000000094, + "7": -21.8, + "8": -93.69999999999996, + "9": -19.499999999999964, + "10": -34.64999999999998, + "11": -17.999999999999968, + "12": -38.15000000000004, + "13": -15.749999999999979, + "14": -15.34999999999998, + "15": -21.599999999999955, + "16": -55.05000000000011, + "17": -10.049999999999995, + "18": -20.949999999999957, + "19": -53.30000000000008, + "20": -13.199999999999989, + "21": -19.29999999999997, + "22": -10.65000000000001, + "23": -9.000000000000002, + "24": -103.89999999999996, + "25": -12.64999999999999, + "26": -1.8999999999999888, + "27": -26.54999999999997, + "28": -34.600000000000044, + "29": -29.650000000000013, + "30": -64.30000000000015, + "31": -43.50000000000005, + "32": -29.600000000000023, + "33": -18.999999999999993, + "34": -101.0, + "35": -21.499999999999957, + "36": -21.499999999999957, + "37": -8.699999999999983, + "38": -6.550000000000001, + "39": -20.74999999999996, + "40": -17.999999999999968, + "41": -64.0500000000001, + "42": -17.349999999999977, + "43": -17.099999999999973, + "44": -14.899999999999965, + "45": -10.499999999999995, + "46": -13.849999999999985, + "47": -54.05000000000008, + "48": -16.79999999999998, + "49": -16.849999999999973, + "50": -61.650000000000155, + "51": -15.699999999999987, + "52": -47.80000000000007, + "53": -75.5, + "54": -25.049999999999944, + "55": -95.6, + "56": -7.65, + "57": -3.150000000000033, + "58": -16.649999999999977, + "59": -15.199999999999985, + "60": -17.099999999999977, + "61": -1.149999999999972, + "62": -93.65, + "63": -20.349999999999962, + "64": -7.749999999999991, + "65": -21.049999999999958, + "66": -23.19999999999995, + "67": -40.60000000000015, + "68": -18.699999999999967, + "69": -76.6999999999999, + "70": 5.15000000000003, + "71": -14.299999999999981, + "72": -8.399999999999997, + "73": -23.29999999999995, + "74": -21.550000000000004, + "75": -11.699999999999982, + "76": -66.05000000000005, + "77": -93.85, + "78": -15.749999999999982, + "79": -101.05000000000001, + "80": -11.600000000000007, + "81": -85.0000000000001, + "82": -6.999999999999995, + "83": 22.04999999999996, + "84": -47.15000000000007, + "85": -3.4999999999999805, + "86": -18.049999999999972, + "87": -97.4, + "88": -77.79999999999995, + "89": 9.00000000000001, + "90": -15.049999999999983, + "91": -4.350000000000004, + "92": -21.499999999999954, + "93": -3.6999999999999797, + "94": -39.69999999999998, + "95": -57.45000000000009, + "96": -17.349999999999973, + "97": -7.249999999999995, + "98": -14.199999999999978, + "99": -11.699999999999987, + "100": 2.5000000000000444, + "101": -12.649999999999984, + "102": -3.750000000000001, + "103": -20.349999999999962, + "104": -90.05, + "105": -18.299999999999972, + "106": -0.9000000000000015, + "107": -57.05, + "108": -2.399999999999965, + "109": -49.15000000000007, + "110": -20.49999999999996, + "111": -8.749999999999996, + "112": -79.10000000000001, + "113": -17.14999999999997, + "114": -3.0499999999999785, + "115": -64.35, + "116": -47.39999999999996, + "117": -10.999999999999996, + "118": -12.199999999999989, + "119": -16.89999999999998, + "120": -64.85000000000001, + "121": -6.749999999999996, + "122": 7.750000000000069, + "123": 13.75, + "124": -3.0999999999999863, + "125": -27.09999999999994, + "126": -16.649999999999977, + "127": 19.349999999999955, + "128": -49.350000000000044, + "129": -21.2, + "130": -39.49999999999999, + "131": -74.44999999999999, + "132": -5.449999999999989, + "133": -0.3499999999999986, + "134": -14.499999999999979, + "135": -21.699999999999953, + "136": -1.7499999999999736, + "137": -12.149999999999993, + "138": 23.949999999999946, + "139": -48.3, + "140": -11.49999999999999, + "141": -43.150000000000006, + "142": -11.04999999999999, + "143": -18.09999999999997, + "144": -11.1, + "145": 19.249999999999986, + "146": -90.4, + "147": 35.69999999999979, + "148": -78.85, + "149": -39.95000000000009, + "150": -6.799999999999987, + "151": -10.35, + "152": 16.45000000000007, + "153": 8.500000000000078, + "154": -18.199999999999967, + "155": -1.0999999999999819, + "156": 12.350000000000023, + "157": -36.69999999999998, + "158": 14.750000000000039, + "159": -13.999999999999991, + "160": -2.399999999999996, + "161": 14.250000000000043, + "162": -80.15000000000002, + "163": -19.499999999999964, + "164": 18.00000000000006, + "165": -41.499999999999964, + "166": 6.550000000000042, + "167": 5.700000000000033, + "168": -15.95000000000001, + "169": -10.549999999999978, + "170": -89.14999999999996, + "171": -0.2999999999999714, + "172": -17.70000000000004, + "173": -9.450000000000001, + "174": 14.849999999999959, + "175": -90.44999999999999, + "176": -11.799999999999978, + "177": -56.5, + "178": -13.249999999999984, + "179": -55.35, + "180": -17.699999999999974, + "181": -17.85000000000002, + "182": -7.799999999999989, + "183": -49.900000000000006, + "184": 27.400000000000055, + "185": 31.449999999999942, + "186": -49.59999999999996, + "187": 16.20000000000001, + "188": 2.5500000000000336, + "189": 27.44999999999993, + "190": -3.049999999999991, + "191": -60.84999999999995, + "192": 5.850000000000024, + "193": -7.199999999999984, + "194": -48.800000000000004, + "195": -69.60000000000004, + "196": 25.200000000000067, + "197": -37.649999999999956, + "198": -64.1500000000001, + "199": -48.59999999999997, + "200": -71.94999999999993, + "201": -18.249999999999968, + "202": -14.450000000000003, + "203": 4.750000000000047, + "204": 30.049999999999923, + "205": -5.549999999999984, + "206": -32.64999999999997, + "207": 16.450000000000045, + "208": -46.25, + "209": 11.549999999999937, + "210": 15.100000000000076, + "211": 23.450000000000063, + "212": -6.50000000000001, + "213": -35.00000000000002, + "214": 16.199999999999992, + "215": 46.099999999999845, + "216": -11.49999999999999, + "217": 5.550000000000029, + "218": 23.749999999999908, + "219": -56.75000000000003, + "220": 2.400000000000005, + "221": -9.299999999999951, + "222": 83.19999999999989, + "223": 46.249999999999915, + "224": 16.449999999999932, + "225": 34.49999999999993, + "226": -86.15, + "227": -8.049999999999992, + "228": -39.1, + "229": 15.749999999999899, + "230": -53.80000000000006, + "231": -24.649999999999956, + "232": 6.149999999999947, + "233": -27.50000000000003, + "234": 10.249999999999982, + "235": -9.850000000000056, + "236": -49.05, + "237": -25.099999999999987, + "238": 1.4500000000000328, + "239": 44.749999999999794, + "240": -23.800000000000022, + "241": 49.34999999999976, + "242": 26.250000000000018, + "243": 12.250000000000032, + "244": -5.773159728050814e-15, + "245": -15.94999999999996, + "246": 5.600000000000033, + "247": -12.049999999999978, + "248": 36.699999999999775, + "249": 27.94999999999998, + "250": -0.34999999999997033, + "251": -46.449999999999996, + "252": -21.749999999999957, + "253": 35.649999999999984, + "254": 47.79999999999981, + "255": 2.3000000000000114, + "256": 49.75000000000003, + "257": 48.54999999999982, + "258": 18.55000000000003, + "259": 25.85000000000007, + "260": -0.9500000000000135, + "261": 35.8999999999999, + "262": 62.64999999999988, + "263": -6.200000000000021, + "264": 41.94999999999994, + "265": 49.94999999999991, + "266": 49.49999999999995, + "267": -13.349999999999987, + "268": 67.94999999999983, + "269": 41.39999999999988, + "270": 15.000000000000068, + "271": -47.39999999999999, + "272": -82.35, + "273": 13.600000000000065, + "274": 43.84999999999982, + "275": 36.19999999999991, + "276": 39.64999999999994, + "277": 40.99999999999974, + "278": 11.800000000000047, + "279": 32.94999999999998, + "280": 81.80000000000007, + "281": 58.499999999999936, + "282": -15.399999999999983, + "283": 8.40000000000001, + "284": 30.95, + "285": 14.400000000000006, + "286": -10.149999999999995, + "287": 44.84999999999989, + "288": 49.5999999999999, + "289": 69.59999999999988, + "290": 63.049999999999784, + "291": 86.50000000000001, + "292": 47.64999999999981, + "293": 71.1499999999998, + "294": -7.049999999999991, + "295": 47.34999999999976, + "296": 102.65000000000008, + "297": 66.04999999999983, + "298": 63.899999999999935, + "299": 1.5000000000000955, + "300": 24.95000000000004, + "301": 54.74999999999998, + "302": -13.150000000000004, + "303": 52.64999999999996, + "304": 40.2999999999998, + "305": 83.10000000000001, + "306": -0.29999999999999305, + "307": -7.599999999999988, + "308": 58.74999999999989, + "309": 47.24999999999983, + "310": 70.59999999999985, + "311": 26.299999999999976, + "312": 38.4999999999999, + "313": 0.2999999999999834, + "314": 41.84999999999984, + "315": 91.40000000000015, + "316": -50.15000000000002, + "317": 56.24999999999983, + "318": 38.24999999999996, + "319": 5.849999999999965, + "320": 32.30000000000001, + "321": 47.35000000000001, + "322": 58.45000000000001, + "323": 11.04999999999999, + "324": -0.04999999999999771, + "325": 53.2999999999998, + "326": 84.10000000000014, + "327": 18.20000000000004, + "328": 68.14999999999982, + "329": 96.35000000000022, + "330": 64.09999999999994, + "331": 56.850000000000044, + "332": 95.80000000000014, + "333": 64.24999999999976, + "334": 13.299999999999962, + "335": 78.4499999999999, + "336": 55.099999999999945, + "337": 93.25, + "338": -9.449999999999983, + "339": 46.64999999999997, + "340": 82.04999999999998, + "341": 41.94999999999985, + "342": 94.45000000000003, + "343": 28.599999999999973, + "344": -11.29999999999999, + "345": 83.59999999999975, + "346": 12.250000000000037, + "347": 43.54999999999998, + "348": 85.2000000000001, + "349": 46.2999999999999, + "350": 48.49999999999998, + "351": 75.39999999999988, + "352": -18.69999999999997, + "353": 55.399999999999935, + "354": 97.35000000000015, + "355": 29.99999999999998, + "356": 87.15000000000005, + "357": 103.3500000000002, + "358": 16.800000000000047, + "359": 88.55000000000007, + "360": 36.15, + "361": 11.800000000000036, + "362": 58.44999999999989, + "363": 69.04999999999995, + "364": 20.099999999999994, + "365": 44.09999999999997, + "366": 82.40000000000019, + "367": 99.25000000000024, + "368": 74.4, + "369": 71.5000000000001, + "370": 105.35000000000024, + "371": 99.60000000000014, + "372": 66.64999999999998, + "373": 84.60000000000004, + "374": 81.05000000000011, + "375": 80.60000000000004, + "376": 59.999999999999915, + "377": 48.59999999999975, + "378": 63.6999999999998, + "379": 83.6, + "380": 82.09999999999994, + "381": -15.900000000000022, + "382": 59.14999999999999, + "383": 38.09999999999995, + "384": 98.75000000000016, + "385": 15.499999999999963, + "386": 102.85000000000022, + "387": 55.10000000000002, + "388": 68.54999999999988, + "389": 38.649999999999864, + "390": 105.55000000000022, + "391": 58.999999999999915, + "392": 67.6999999999999, + "393": 57.949999999999946, + "394": 94.20000000000017, + "395": 104.05000000000017, + "396": 96.35000000000018, + "397": 51.54999999999997, + "398": 105.35000000000015, + "399": 1.1499999999999722, + "400": 79.45000000000003, + "401": 101.05000000000004, + "402": 100.25000000000023, + "403": 76.55000000000001, + "404": 109.10000000000022, + "405": 65.04999999999995, + "406": 23.99999999999995, + "407": 52.29999999999998, + "408": 63.89999999999994, + "409": 97.10000000000015, + "410": 96.30000000000014, + "411": 54.949999999999825, + "412": 102.79999999999993, + "413": 60.199999999999996, + "414": 112.80000000000018, + "415": 41.849999999999945, + "416": 101.45, + "417": 61.75000000000006, + "418": 95.55000000000017, + "419": 98.70000000000019, + "420": 27.899999999999984, + "421": 86.70000000000005, + "422": 54.19999999999976, + "423": 95.19999999999978, + "424": 104.40000000000023, + "425": 102.59999999999984, + "426": 31.499999999999872, + "427": 12.299999999999995, + "428": 86.5000000000001, + "429": 22.599999999999987, + "430": 80.25000000000006, + "431": 101.30000000000011, + "432": 90.4999999999998, + "433": 98.25000000000006, + "434": 36.59999999999995, + "435": 56.649999999999906, + "436": 3.4499999999999584, + "437": -12.400000000000013, + "438": 80.20000000000003, + "439": 78.24999999999987, + "440": 85.50000000000007, + "441": 69.74999999999997, + "442": 108.8000000000003, + "443": 97.45000000000016, + "444": 90.2000000000002, + "445": 92.35000000000018, + "446": 50.95000000000005, + "447": 91.25000000000017, + "448": 82.90000000000016, + "449": 102.20000000000023, + "450": 59.39999999999996, + "451": 95.95000000000019, + "452": 29.04999999999998, + "453": 90.85000000000004, + "454": 78.85000000000001, + "455": 65.79999999999993, + "456": 52.50000000000005, + "457": 105.95000000000009, + "458": 91.10000000000024, + "459": 72.14999999999995, + "460": 97.20000000000013, + "461": 95.50000000000003, + "462": 102.59999999999997, + "463": 84.1499999999998, + "464": 35.199999999999925, + "465": 92.90000000000018, + "466": 60.79999999999991, + "467": 55.84999999999985, + "468": 81.05000000000011, + "469": 70.69999999999986, + "470": 100.45000000000017, + "471": 74.04999999999991, + "472": 104.45000000000017, + "473": 62.149999999999984, + "474": 54.949999999999996, + "475": 93.70000000000016, + "476": 100.90000000000025, + "477": 17.750000000000092, + "478": 59.64999999999989, + "479": 73.59999999999997, + "480": 65.99999999999987, + "481": 38.74999999999993, + "482": 102.6500000000002, + "483": 46.249999999999886, + "484": 63.749999999999936, + "485": 55.399999999999956, + "486": 109.40000000000022, + "487": 29.250000000000014, + "488": 58.700000000000045, + "489": 104.35000000000022, + "490": 59.49999999999989, + "491": 101.25000000000007, + "492": 53.249999999999915, + "493": 53.24999999999991, + "494": 79.4000000000001, + "495": 88.95000000000007, + "496": 20.14999999999995, + "497": 88.15000000000012, + "498": 66.89999999999999, + "499": 97.7000000000002, + "500": 94.10000000000011, + "501": 105.90000000000026, + "502": 41.849999999999945, + "503": 51.449999999999925, + "504": 50.84999999999991, + "505": 105.50000000000021, + "506": 67.54999999999993, + "507": 103.3500000000002, + "508": 97.4500000000002, + "509": 61.2000000000001, + "510": 69.74999999999993, + "511": 70.64999999999992, + "512": 96.05000000000014, + "513": 53.84999999999992, + "514": 63.29999999999977, + "515": -5.150000000000013, + "516": 102.50000000000018, + "517": 73.14999999999993, + "518": 74.95000000000002, + "519": 101.80000000000008, + "520": 102.05000000000004, + "521": 32.34999999999997, + "522": 47.699999999999946, + "523": 47.24999999999991, + "524": 59.1999999999999, + "525": 51.69999999999993, + "526": 38.35000000000001, + "527": 58.29999999999991, + "528": 28.94999999999998, + "529": 42.69999999999996, + "530": 34.19999999999997, + "531": 66.7999999999999, + "532": 105.80000000000021, + "533": 112.15000000000025, + "534": 103.3000000000002, + "535": 108.55000000000024, + "536": 80.05000000000005, + "537": 72.74999999999999, + "538": 54.64999999999994, + "539": 96.65000000000012, + "540": 63.64999999999992, + "541": 44.54999999999993, + "542": 104.45000000000023, + "543": 28.499999999999957, + "544": 82.9, + "545": 75.54999999999997, + "546": 96.45000000000013, + "547": 93.70000000000014, + "548": 97.45000000000013, + "549": 34.199999999999996, + "550": 112.20000000000017, + "551": 50.54999999999987, + "552": 108.80000000000024, + "553": 91.20000000000006, + "554": 4.450000000000015, + "555": 101.75000000000018, + "556": 53.84999999999991, + "557": 75.0, + "558": 100.8000000000002, + "559": 23.44999999999999, + "560": 12.899999999999986, + "561": 40.34999999999995, + "562": 95.30000000000011, + "563": 84.00000000000014, + "564": 96.35000000000014, + "565": 97.75000000000014, + "566": 97.79999999999995, + "567": 81.44999999999987, + "568": 94.65, + "569": 59.7499999999999, + "570": 71.39999999999993, + "571": 22.75, + "572": -5.800000000000004, + "573": 36.89999999999998, + "574": 63.29999999999989, + "575": 22.34999999999997, + "576": 70.19999999999992, + "577": 81.7499999999999, + "578": 61.94999999999994, + "579": 106.95000000000016, + "580": 29.20000000000001, + "581": 55.499999999999936, + "582": 79.25000000000004, + "583": 36.849999999999916, + "584": 32.69999999999993, + "585": 4.449999999999999, + "586": 38.7499999999999, + "587": 51.69999999999991, + "588": -0.6000000000000152, + "589": 60.84999999999991, + "590": 51.749999999999886, + "591": 66.49999999999993, + "592": 94.25000000000007, + "593": 91.25000000000014, + "594": 96.19999999999993, + "595": 64.29999999999988, + "596": 104.9000000000002, + "597": 59.299999999999955, + "598": 80.25, + "599": 68.84999999999981, + "600": 101.30000000000014, + "601": 98.95000000000012, + "602": 101.20000000000014, + "603": 93.45000000000005, + "604": 43.89999999999987, + "605": 52.349999999999945, + "606": 101.3500000000002, + "607": 98.20000000000019, + "608": 99.45000000000022, + "609": 91.50000000000013, + "610": 22.7, + "611": 103.40000000000013, + "612": 101.95000000000016, + "613": 84.0500000000001, + "614": 92.95000000000012, + "615": 38.39999999999995, + "616": -16.799999999999983, + "617": 80.15000000000002, + "618": 93.05000000000014, + "619": 98.45000000000016, + "620": 100.30000000000017, + "621": 95.14999999999998, + "622": 69.69999999999993, + "623": 60.04999999999989, + "624": 98.75000000000018, + "625": 108.40000000000026, + "626": 103.45000000000017, + "627": 89.34999999999988, + "628": 92.7000000000001, + "629": 19.199999999999925, + "630": 100.40000000000019, + "631": 89.89999999999999, + "632": 87.00000000000013, + "633": 102.20000000000017, + "634": 54.24999999999986, + "635": 41.19999999999994, + "636": 25.700000000000006, + "637": 79.35000000000005, + "638": 109.90000000000006, + "639": 103.00000000000016, + "640": 83.40000000000008, + "641": 110.35000000000025, + "642": 8.400000000000011, + "643": 88.65000000000009, + "644": 108.50000000000023, + "645": 109.2500000000002, + "646": 28.749999999999982, + "647": 71.59999999999991, + "648": 103.80000000000018, + "649": 115.35000000000026, + "650": 91.30000000000011, + "651": 62.799999999999926, + "652": 102.5000000000002, + "653": 100.7500000000001, + "654": 24.099999999999888, + "655": 107.95000000000019, + "656": 61.89999999999989, + "657": 105.65000000000018, + "658": 106.8000000000002, + "659": 104.90000000000023, + "660": 92.70000000000013, + "661": 101.45000000000024, + "662": 70.69999999999997, + "663": 24.700000000000003, + "664": 88.95000000000003, + "665": 91.95000000000012, + "666": 15.100000000000007, + "667": 52.64999999999994, + "668": 103.85000000000014, + "669": 56.999999999999936, + "670": 37.09999999999996, + "671": 101.85000000000021, + "672": 100.30000000000014, + "673": 100.15000000000013, + "674": 71.89999999999995, + "675": 77.95000000000006, + "676": 103.20000000000019, + "677": -4.400000000000001, + "678": 73.95, + "679": 68.2999999999999, + "680": 102.7000000000002, + "681": 106.95000000000019, + "682": 109.4500000000001, + "683": 74.64999999999996, + "684": 113.95000000000024, + "685": 102.95000000000017, + "686": 100.69999999999996, + "687": 90.15000000000008, + "688": 102.20000000000019, + "689": 78.3, + "690": 51.59999999999995, + "691": 76.60000000000001, + "692": 105.4000000000001, + "693": 77.90000000000003, + "694": 54.34999999999986, + "695": 108.60000000000022, + "696": 65.49999999999993, + "697": 45.899999999999956, + "698": 101.00000000000016, + "699": 105.85000000000022, + "700": 108.15000000000025, + "701": 35.54999999999997, + "702": 13.60000000000002, + "703": 112.25000000000024, + "704": 88.1, + "705": 102.3500000000002, + "706": 107.4500000000002, + "707": 36.5999999999999, + "708": 35.34999999999992, + "709": 63.49999999999995, + "710": 50.199999999999946, + "711": 108.15000000000015, + "712": 49.24999999999996, + "713": 47.39999999999993, + "714": 87.30000000000008, + "715": 101.80000000000017, + "716": 104.0000000000002, + "717": 97.80000000000007, + "718": 105.10000000000024, + "719": 109.20000000000024, + "720": 99.25000000000023, + "721": 98.30000000000001, + "722": 57.199999999999946, + "723": -32.70000000000001, + "724": 23.69999999999999, + "725": 101.44999999999997, + "726": 70.14999999999998, + "727": 102.90000000000022, + "728": 102.10000000000011, + "729": 104.35000000000022, + "730": 36.10000000000001, + "731": 91.05000000000008, + "732": 79.79999999999995, + "733": 92.29999999999995, + "734": 30.250000000000036, + "735": 89.5499999999999, + "736": 65.69999999999995, + "737": 102.5000000000002, + "738": 76.39999999999999, + "739": 63.44999999999994, + "740": 46.549999999999955, + "741": 37.09999999999995, + "742": 106.40000000000013, + "743": 64.94999999999993, + "744": 83.25000000000004, + "745": 99.8000000000001, + "746": 56.99999999999983, + "747": 94.70000000000017, + "748": 50.199999999999946, + "749": 100.70000000000024, + "750": 105.55000000000021, + "751": 50.64999999999995, + "752": 18.3, + "753": 36.55, + "754": 78.45000000000003, + "755": 18.149999999999995, + "756": 60.099999999999966, + "757": 82.99999999999997, + "758": 98.75000000000007, + "759": 51.099999999999945, + "760": 98.75000000000017, + "761": 42.049999999999955, + "762": 110.5500000000003, + "763": 105.80000000000021, + "764": 92.55000000000001, + "765": 101.35000000000015, + "766": 60.899999999999935, + "767": 10.500000000000057, + "768": 8.450000000000008, + "769": 110.70000000000024, + "770": 60.09999999999993, + "771": 51.69999999999993, + "772": 28.699999999999974, + "773": 110.35000000000022, + "774": 107.55000000000024, + "775": 62.44999999999991, + "776": 94.85000000000012, + "777": 73.54999999999998, + "778": 17.700000000000067, + "779": 109.95000000000007, + "780": 19.799999999999972, + "781": 35.35000000000003, + "782": 105.30000000000024, + "783": 75.94999999999999, + "784": 34.59999999999997, + "785": 88.55000000000004, + "786": 57.64999999999992, + "787": 34.850000000000115, + "788": 59.749999999999794, + "789": 67.45, + "790": 66.25, + "791": 55.449999999999925, + "792": 99.69999999999993, + "793": 80.75000000000009, + "794": 36.849999999999945, + "795": 62.74999999999996, + "796": 82.40000000000008, + "797": 88.10000000000005, + "798": 67.79999999999998, + "799": 58.04999999999991, + "800": 96.19999999999999, + "801": 49.09999999999993, + "802": 58.85000000000005, + "803": 101.30000000000005, + "804": 83.24999999999996, + "805": 58.04999999999985, + "806": 97.40000000000013, + "807": 60.15000000000003, + "808": -56.99999999999997, + "809": 99.75000000000014, + "810": 94.50000000000017, + "811": 95.45000000000016, + "812": 99.60000000000007, + "813": 100.25000000000016, + "814": 93.60000000000012, + "815": 93.95000000000016, + "816": 62.64999999999991, + "817": 58.64999999999985, + "818": 28.59999999999999, + "819": 82.50000000000004, + "820": 84.30000000000005, + "821": 75.60000000000001, + "822": 90.90000000000005, + "823": 95.15000000000015, + "824": 92.60000000000016, + "825": 78.80000000000008, + "826": 30.89999999999997, + "827": 93.09999999999997, + "828": 87.70000000000007, + "829": 105.30000000000027, + "830": 107.85000000000022, + "831": 94.24999999999984, + "832": 76.39999999999998, + "833": 96.20000000000017, + "834": 10.149999999999993, + "835": 94.25000000000001, + "836": 94.1500000000001, + "837": 69.84999999999997, + "838": 37.799999999999955, + "839": 101.1000000000002, + "840": 17.549999999999983, + "841": 78.10000000000001, + "842": 83.4999999999998, + "843": 79.54999999999986, + "844": 32.15, + "845": 51.249999999999915, + "846": 78.75000000000003, + "847": 91.60000000000011, + "848": 80.65000000000008, + "849": 88.9000000000001, + "850": 73.89999999999996, + "851": 109.00000000000018, + "852": 91.94999999999992, + "853": 107.10000000000028, + "854": 90.10000000000014, + "855": 88.9500000000001, + "856": 62.399999999999935, + "857": 61.299999999999905, + "858": 58.099999999999824, + "859": 99.55000000000021, + "860": 98.74999999999979, + "861": 106.45000000000005, + "862": 28.99999999999993, + "863": 59.599999999999866, + "864": -28.099999999999998, + "865": 73.35000000000002, + "866": 94.64999999999974, + "867": 52.94999999999982, + "868": 73.04999999999986, + "869": 82.5, + "870": 88.90000000000008, + "871": 104.40000000000013, + "872": 84.2, + "873": 7.800000000000001, + "874": 55.79999999999995, + "875": 88.45000000000012, + "876": -12.30000000000002, + "877": 48.399999999999885, + "878": 81.39999999999979, + "879": 102.65000000000012, + "880": 72.39999999999986, + "881": 92.04999999999976, + "882": 29.199999999999967, + "883": 98.70000000000014, + "884": 23.949999999999985, + "885": 10.499999999999943, + "886": 73.0, + "887": 67.35000000000001, + "888": 63.29999999999993, + "889": 106.95000000000012, + "890": 47.49999999999989, + "891": 66.39999999999988, + "892": 78.29999999999978, + "893": 96.19999999999997, + "894": 98.85000000000016, + "895": 44.44999999999992, + "896": 103.10000000000024, + "897": 97.55000000000008, + "898": 30.54999999999996, + "899": 88.09999999999991, + "900": 77.29999999999995, + "901": 94.39999999999988, + "902": 6.749999999999973, + "903": 64.29999999999995, + "904": 91.94999999999978, + "905": 10.450000000000053, + "906": 72.39999999999985, + "907": 96.35000000000005, + "908": 97.50000000000006, + "909": 60.69999999999993, + "910": -19.55, + "911": 80.24999999999983, + "912": 43.44999999999997, + "913": 82.79999999999981, + "914": 46.99999999999979, + "915": 92.09999999999978, + "916": 77.7999999999999, + "917": 98.40000000000003, + "918": 98.70000000000009, + "919": 62.74999999999975, + "920": 49.949999999999754, + "921": 21.599999999999998, + "922": 80.09999999999981, + "923": 70.69999999999979, + "924": 86.4000000000001, + "925": 95.00000000000009, + "926": 68.14999999999975, + "927": 63.04999999999991, + "928": 102.95000000000005, + "929": 78.94999999999983, + "930": 36.20000000000001, + "931": 73.89999999999999, + "932": 49.39999999999978, + "933": 77.75000000000007, + "934": 80.99999999999997, + "935": 77.85000000000004, + "936": 101.80000000000021, + "937": 69.29999999999987, + "938": 67.29999999999994, + "939": 90.7999999999999, + "940": 99.30000000000014, + "941": 40.79999999999979, + "942": 63.2499999999999, + "943": 96.79999999999977, + "944": 99.85000000000015, + "945": 62.499999999999886, + "946": 98.1000000000001, + "947": 87.44999999999999, + "948": 101.54999999999977, + "949": 81.39999999999984, + "950": 53.09999999999992, + "951": 80.09999999999975, + "952": 94.94999999999978, + "953": 92.14999999999978, + "954": 97.79999999999974, + "955": 87.79999999999983, + "956": 96.94999999999972, + "957": 94.20000000000003, + "958": 70.79999999999978, + "959": 65.5499999999998, + "960": 100.24999999999979, + "961": 102.64999999999993, + "962": 89.14999999999975, + "963": 21.099999999999984, + "964": 69.24999999999987, + "965": 93.15000000000018, + "966": 22.149999999999956, + "967": 87.44999999999975, + "968": 69.29999999999981, + "969": 91.24999999999977, + "970": 51.74999999999979, + "971": 78.74999999999993, + "972": 30.599999999999845, + "973": 94.5999999999999, + "974": 46.84999999999975, + "975": 64.99999999999977, + "976": 53.299999999999756, + "977": 87.60000000000004, + "978": 92.59999999999972, + "979": 93.34999999999977, + "980": 48.94999999999978, + "981": 97.70000000000009, + "982": 97.14999999999975, + "983": 48.74999999999975, + "984": 91.74999999999982, + "985": 101.14999999999986, + "986": 81.0499999999998, + "987": 63.1999999999999, + "988": 106.24999999999982, + "989": 66.89999999999992, + "990": 74.19999999999976, + "991": 92.29999999999974, + "992": 91.79999999999977, + "993": 81.50000000000001, + "994": 88.85000000000001, + "995": 99.09999999999972, + "996": 108.34999999999977, + "997": 54.049999999999926, + "998": 44.44999999999992, + "999": 101.74999999999973, + "1000": -39.899999999999984 + }, + "4": { + "1": -53.10000000000009, + "2": -17.299999999999972, + "3": -51.25000000000008, + "4": -48.30000000000006, + "5": -29.899999999999956, + "6": -23.449999999999964, + "7": -16.149999999999984, + "8": -38.750000000000036, + "9": -22.449999999999953, + "10": -48.40000000000015, + "11": -27.99999999999999, + "12": -7.199999999999988, + "13": -31.100000000000016, + "14": -25.95000000000002, + "15": -12.349999999999994, + "16": -17.799999999999976, + "17": -98.6, + "18": -43.65000000000011, + "19": -21.449999999999957, + "20": -52.95000000000008, + "21": -66.30000000000008, + "22": -39.55000000000012, + "23": -42.600000000000044, + "24": -81.64999999999998, + "25": -21.999999999999954, + "26": -15.499999999999979, + "27": -63.50000000000011, + "28": -20.249999999999982, + "29": -20.799999999999958, + "30": -13.249999999999982, + "31": -18.34999999999997, + "32": -53.20000000000015, + "33": -7.799999999999997, + "34": 5.850000000000034, + "35": -0.6999999999999571, + "36": -6.050000000000013, + "37": -20.19999999999996, + "38": -20.54999999999996, + "39": -13.349999999999985, + "40": -7.3499999999999925, + "41": -66.85000000000004, + "42": 8.750000000000043, + "43": -27.30000000000002, + "44": -12.34999999999999, + "45": -18.499999999999964, + "46": -33.24999999999999, + "47": -86.95, + "48": -16.8, + "49": -64.25000000000006, + "50": 3.5000000000000275, + "51": -7.499999999999999, + "52": -15.299999999999978, + "53": -23.94999999999995, + "54": -34.59999999999999, + "55": -11.35000000000001, + "56": -10.599999999999987, + "57": -31.75000000000003, + "58": -107.1, + "59": -30.550000000000022, + "60": -50.90000000000005, + "61": -103.75, + "62": -27.749999999999936, + "63": -13.699999999999983, + "64": 4.0500000000000576, + "65": -80.45000000000002, + "66": -18.549999999999965, + "67": -55.40000000000009, + "68": 21.0, + "69": 0.10000000000001108, + "70": -85.60000000000002, + "71": -67.35000000000008, + "72": -48.90000000000001, + "73": -13.649999999999986, + "74": -47.75000000000005, + "75": -15.749999999999979, + "76": -45.75000000000005, + "77": -63.7000000000001, + "78": -12.949999999999987, + "79": 21.599999999999916, + "80": -100.49999999999999, + "81": -56.700000000000095, + "82": -4.249999999999967, + "83": -19.099999999999966, + "84": -46.19999999999993, + "85": -21.299999999999965, + "86": 10.850000000000058, + "87": -17.94999999999997, + "88": -93.25, + "89": 11.400000000000013, + "90": -90.45, + "91": -19.299999999999965, + "92": -23.29999999999995, + "93": -60.950000000000095, + "94": -19.999999999999964, + "95": -16.699999999999974, + "96": -16.49999999999998, + "97": -18.24999999999997, + "98": -61.20000000000009, + "99": -86.45000000000002, + "100": -14.000000000000007, + "101": -7.049999999999984, + "102": -18.199999999999967, + "103": -28.550000000000004, + "104": -10.149999999999991, + "105": 5.500000000000013, + "106": -36.200000000000045, + "107": 31.4499999999998, + "108": -14.249999999999984, + "109": -69.05000000000005, + "110": 13.29999999999998, + "111": -81.05, + "112": 5.85000000000002, + "113": 2.7500000000000577, + "114": 2.05000000000003, + "115": 4.0500000000000504, + "116": -10.849999999999996, + "117": -90.05000000000001, + "118": -17.00000000000003, + "119": 4.550000000000038, + "120": -9.449999999999985, + "121": -11.75000000000004, + "122": -10.299999999999981, + "123": -18.949999999999967, + "124": 13.150000000000013, + "125": -63.050000000000104, + "126": -14.649999999999984, + "127": -22.59999999999995, + "128": 5.7499999999999005, + "129": 27.45000000000002, + "130": 5.699999999999998, + "131": -14.199999999999964, + "132": 25.299999999999972, + "133": -45.70000000000005, + "134": -5.649999999999976, + "135": -18.100000000000044, + "136": -13.150000000000006, + "137": 4.9499999999999655, + "138": 35.549999999999876, + "139": -3.3000000000001, + "140": 14.70000000000002, + "141": -9.150000000000004, + "142": -44.44999999999999, + "143": -14.649999999999977, + "144": -67.49999999999997, + "145": -67.69999999999997, + "146": -81.55000000000001, + "147": -34.65000000000004, + "148": 12.049999999999867, + "149": -7.5999999999999845, + "150": -8.249999999999984, + "151": 24.850000000000065, + "152": -15.649999999999979, + "153": 3.350000000000044, + "154": 40.74999999999999, + "155": -46.250000000000014, + "156": -6.149999999999986, + "157": 37.29999999999989, + "158": -15.699999999999973, + "159": -10.100000000000007, + "160": 7.750000000000041, + "161": 7.600000000000026, + "162": -13.49999999999997, + "163": -26.54999999999995, + "164": 30.449999999999932, + "165": -87.79999999999998, + "166": -68.70000000000002, + "167": -13.14999999999999, + "168": -7.299999999999983, + "169": 38.149999999999814, + "170": -79.34999999999998, + "171": -17.149999999999956, + "172": -16.649999999999974, + "173": 19.750000000000025, + "174": -63.00000000000011, + "175": 62.44999999999998, + "176": -0.44999999999996, + "177": -22.899999999999984, + "178": -7.2999999999999865, + "179": -94.5, + "180": -33.549999999999955, + "181": -94.2, + "182": -2.7499999999999885, + "183": -8.149999999999988, + "184": 5.450000000000016, + "185": 5.150000000000009, + "186": -4.550000000000033, + "187": 26.00000000000004, + "188": -38.09999999999999, + "189": -71.90000000000003, + "190": -73.1, + "191": 21.55000000000002, + "192": -31.149999999999963, + "193": -81.7, + "194": 50.449999999999854, + "195": -13.750000000000012, + "196": -41.70000000000003, + "197": -56.850000000000094, + "198": -5.399999999999981, + "199": 57.49999999999982, + "200": -52.40000000000001, + "201": -37.000000000000064, + "202": -73.34999999999994, + "203": 16.79999999999994, + "204": 44.499999999999744, + "205": 14.450000000000077, + "206": 48.34999999999985, + "207": 71.29999999999991, + "208": 23.250000000000014, + "209": -21.499999999999957, + "210": -21.34999999999996, + "211": 22.599999999999966, + "212": 34.799999999999805, + "213": 67.64999999999989, + "214": 73.19999999999978, + "215": -70.24999999999999, + "216": -48.74999999999998, + "217": -37.49999999999997, + "218": 82.90000000000016, + "219": -75.79999999999998, + "220": -89.75, + "221": 61.34999999999975, + "222": 30.749999999999762, + "223": -65.69999999999993, + "224": 20.500000000000025, + "225": 19.40000000000006, + "226": 14.349999999999914, + "227": 25.150000000000055, + "228": -50.04999999999994, + "229": 28.75000000000007, + "230": 51.749999999999815, + "231": 71.89999999999988, + "232": 73.29999999999981, + "233": -10.599999999999962, + "234": 69.34999999999987, + "235": -10.749999999999964, + "236": 44.24999999999973, + "237": 31.649999999999945, + "238": -16.09999999999998, + "239": -20.59999999999999, + "240": 21.25000000000007, + "241": 0.600000000000027, + "242": -2.7999999999999785, + "243": 38.84999999999985, + "244": 6.450000000000021, + "245": -63.199999999999974, + "246": 58.8999999999998, + "247": 38.30000000000006, + "248": 92.59999999999974, + "249": -9.850000000000032, + "250": -57.20000000000011, + "251": 57.74999999999977, + "252": 83.04999999999974, + "253": 64.39999999999992, + "254": -10.049999999999992, + "255": -57.05000000000007, + "256": 30.000000000000007, + "257": -0.8999999999999915, + "258": 21.20000000000008, + "259": 55.5999999999998, + "260": 83.64999999999976, + "261": 21.499999999999982, + "262": 32.65000000000008, + "263": -84.10000000000001, + "264": 98.24999999999974, + "265": 48.29999999999984, + "266": 60.7999999999999, + "267": 87.09999999999975, + "268": 33.10000000000005, + "269": -48.59999999999994, + "270": 78.24999999999987, + "271": 60.6499999999999, + "272": 34.199999999999896, + "273": 77.79999999999973, + "274": 26.800000000000033, + "275": -12.200000000000014, + "276": 88.24999999999977, + "277": 9.700000000000067, + "278": 101.29999999999971, + "279": 64.9999999999998, + "280": 17.199999999999896, + "281": 95.39999999999976, + "282": 84.74999999999977, + "283": 71.14999999999989, + "284": 34.29999999999986, + "285": 36.90000000000001, + "286": 61.74999999999984, + "287": 99.34999999999972, + "288": 86.09999999999981, + "289": 74.09999999999977, + "290": 74.9499999999998, + "291": 63.29999999999985, + "292": 80.34999999999977, + "293": 24.899999999999956, + "294": 82.44999999999978, + "295": -32.3, + "296": 77.89999999999985, + "297": -12.899999999999988, + "298": 54.99999999999979, + "299": 50.74999999999978, + "300": 77.74999999999984, + "301": 82.19999999999979, + "302": 98.29999999999978, + "303": 38.049999999999976, + "304": 78.04999999999977, + "305": -23.89999999999995, + "306": 85.59999999999984, + "307": 9.450000000000026, + "308": 39.24999999999997, + "309": 61.19999999999976, + "310": 49.149999999999764, + "311": 89.84999999999982, + "312": 11.500000000000043, + "313": 85.79999999999976, + "314": 54.1499999999998, + "315": 93.39999999999976, + "316": 91.59999999999977, + "317": 99.94999999999978, + "318": 80.19999999999978, + "319": 99.44999999999976, + "320": -71.65000000000002, + "321": 87.69999999999978, + "322": 94.69999999999979, + "323": 92.19999999999979, + "324": 77.49999999999987, + "325": 68.59999999999977, + "326": 99.74999999999976, + "327": 82.59999999999982, + "328": 94.94999999999973, + "329": 88.49999999999979, + "330": 85.94999999999976, + "331": 28.299999999999844, + "332": 85.24999999999977, + "333": 89.9499999999998, + "334": -50.50000000000008, + "335": 85.99999999999984, + "336": -1.0999999999999823, + "337": 92.64999999999978, + "338": 99.99999999999976, + "339": 29.99999999999981, + "340": 87.29999999999976, + "341": 81.7499999999998, + "342": 95.59999999999975, + "343": 91.99999999999974, + "344": 97.39999999999978, + "345": 96.09999999999978, + "346": 98.94999999999975, + "347": 99.6999999999998, + "348": 98.89999999999975, + "349": 84.34999999999975, + "350": 91.24999999999977, + "351": 83.09999999999978, + "352": 80.39999999999984, + "353": -86.1, + "354": 69.79999999999976, + "355": 82.64999999999972, + "356": 100.24999999999979, + "357": 91.74999999999977, + "358": 100.14999999999978, + "359": 88.5999999999998, + "360": 102.74999999999976, + "361": 21.94999999999991, + "362": 89.49999999999979, + "363": 95.79999999999976, + "364": 83.89999999999979, + "365": 83.2999999999998, + "366": 100.69999999999978, + "367": -84.44999999999999, + "368": 10.150000000000038, + "369": -11.899999999999991, + "370": 100.19999999999976, + "371": 102.44999999999973, + "372": 76.44999999999979, + "373": 101.89999999999976, + "374": 99.94999999999979, + "375": 8.650000000000025, + "376": 32.750000000000014, + "377": 82.79999999999984, + "378": 73.24999999999972, + "379": 103.49999999999976, + "380": 92.99999999999977, + "381": 103.09999999999977, + "382": 103.84999999999975, + "383": 96.04999999999974, + "384": 103.09999999999977, + "385": 95.09999999999977, + "386": 83.4999999999998, + "387": 103.39999999999976, + "388": -80.0, + "389": 90.1499999999998, + "390": 97.59999999999972, + "391": 83.09999999999978, + "392": 94.44999999999976, + "393": 100.39999999999976, + "394": 97.99999999999979, + "395": 90.99999999999982, + "396": 82.79999999999977, + "397": 99.09999999999977, + "398": 105.24999999999974, + "399": 65.94999999999976, + "400": 98.39999999999976, + "401": 103.09999999999977, + "402": 100.69999999999976, + "403": 98.74999999999976, + "404": -44.05000000000007, + "405": 91.44999999999976, + "406": 27.000000000000046, + "407": 98.64999999999976, + "408": 96.44999999999978, + "409": 104.99999999999983, + "410": 90.99999999999979, + "411": 101.9999999999998, + "412": 103.34999999999988, + "413": 98.29999999999974, + "414": 78.59999999999987, + "415": 99.34999999999987, + "416": 101.69999999999978, + "417": 77.8999999999998, + "418": 101.14999999999978, + "419": 103.09999999999977, + "420": 103.49999999999976, + "421": 100.69999999999978, + "422": 91.44999999999978, + "423": 100.54999999999977, + "424": -23.149999999999952, + "425": 99.79999999999976, + "426": -9.349999999999985, + "427": 106.89999999999979, + "428": 101.99999999999977, + "429": 84.2499999999998, + "430": 93.34999999999981, + "431": 104.69999999999982, + "432": 83.09999999999977, + "433": 59.49999999999984, + "434": 101.59999999999975, + "435": 98.99999999999977, + "436": 96.89999999999976, + "437": 105.84999999999984, + "438": 97.19999999999976, + "439": -11.799999999999985, + "440": 102.49999999999976, + "441": 98.44999999999979, + "442": 108.44999999999986, + "443": 103.54999999999977, + "444": 101.14999999999976, + "445": 104.74999999999976, + "446": 100.69999999999976, + "447": 87.79999999999976, + "448": 96.5499999999998, + "449": 75.94999999999978, + "450": 103.49999999999977, + "451": 84.84999999999974, + "452": 101.14999999999976, + "453": 23.049999999999898, + "454": 98.69999999999978, + "455": 102.39999999999978, + "456": 62.19999999999973, + "457": 102.74999999999976, + "458": 105.44999999999975, + "459": 65.6499999999999, + "460": -38.89999999999997, + "461": 62.44999999999979, + "462": 97.44999999999978, + "463": 102.69999999999976, + "464": 107.89999999999979, + "465": 103.59999999999977, + "466": 104.99999999999974, + "467": 103.04999999999977, + "468": 102.84999999999977, + "469": 104.79999999999974, + "470": 100.19999999999978, + "471": 104.59999999999977, + "472": 102.79999999999977, + "473": 104.94999999999975, + "474": -80.69999999999999, + "475": 102.34999999999977, + "476": -84.00000000000003, + "477": 96.09999999999975, + "478": 80.79999999999973, + "479": 102.24999999999977, + "480": 98.30000000000008, + "481": 103.39999999999975, + "482": 56.09999999999979, + "483": 103.54999999999977, + "484": 103.74999999999972, + "485": 67.74999999999976, + "486": 62.94999999999975, + "487": 101.99999999999977, + "488": 103.24999999999974, + "489": 104.49999999999974, + "490": 75.29999999999983, + "491": 72.84999999999977, + "492": 77.44999999999978, + "493": 102.69999999999978, + "494": 96.14999999999976, + "495": 94.8499999999998, + "496": 106.64999999999972, + "497": 80.89999999999989, + "498": 84.44999999999976, + "499": 101.94999999999976, + "500": 99.89999999999978, + "501": 105.89999999999974, + "502": -35.199999999999996, + "503": 106.69999999999973, + "504": 94.59999999999981, + "505": 101.49999999999977, + "506": 103.19999999999976, + "507": 103.99999999999972, + "508": 96.74999999999982, + "509": 97.8499999999998, + "510": 104.59999999999974, + "511": 102.74999999999977, + "512": 103.64999999999976, + "513": 100.39999999999975, + "514": 99.19999999999978, + "515": 71.64999999999976, + "516": 104.09999999999974, + "517": 104.34999999999975, + "518": 102.94999999999978, + "519": 97.54999999999977, + "520": 106.24999999999973, + "521": -75.3, + "522": 4.75, + "523": 100.34999999999975, + "524": 106.69999999999975, + "525": 56.699999999999854, + "526": 16.30000000000001, + "527": 101.29999999999977, + "528": 93.09999999999977, + "529": 103.54999999999977, + "530": 66.2999999999999, + "531": 102.59999999999977, + "532": 102.74999999999977, + "533": 102.84999999999977, + "534": 102.84999999999977, + "535": 104.59999999999977, + "536": 107.89999999999974, + "537": 104.04999999999974, + "538": 75.74999999999977, + "539": 102.29999999999977, + "540": 81.34999999999978, + "541": 102.09999999999977, + "542": 105.89999999999974, + "543": 99.54999999999974, + "544": 102.84999999999975, + "545": 105.84999999999972, + "546": 105.59999999999972, + "547": 103.54999999999977, + "548": 98.64999999999978, + "549": 103.39999999999976, + "550": 106.34999999999975, + "551": 84.14999999999976, + "552": 108.59999999999974, + "553": 104.34999999999975, + "554": -78.9, + "555": 91.19999999999978, + "556": 101.54999999999977, + "557": 103.49999999999977, + "558": 104.79999999999974, + "559": 48.14999999999982, + "560": 40.99999999999998, + "561": 93.69999999999982, + "562": 104.44999999999976, + "563": 105.49999999999974, + "564": 102.24999999999977, + "565": 93.59999999999978, + "566": 105.74999999999974, + "567": 99.74999999999976, + "568": 62.39999999999994, + "569": 100.64999999999974, + "570": 104.39999999999972, + "571": 103.89999999999976, + "572": 103.34999999999977, + "573": 85.79999999999977, + "574": 3.1499999999999915, + "575": 102.04999999999977, + "576": 104.64999999999978, + "577": 59.09999999999975, + "578": -81.00000000000001, + "579": 103.59999999999977, + "580": 105.69999999999972, + "581": 53.79999999999983, + "582": 104.79999999999974, + "583": 102.84999999999977, + "584": 104.44999999999978, + "585": 104.39999999999975, + "586": 104.54999999999976, + "587": 103.79999999999977, + "588": 105.59999999999972, + "589": 102.54999999999976, + "590": 103.54999999999977, + "591": 83.54999999999977, + "592": -75.8, + "593": 105.89999999999972, + "594": 102.09999999999977, + "595": 105.74999999999973, + "596": 103.19999999999976, + "597": 102.94999999999978, + "598": 107.04999999999974, + "599": 103.89999999999976, + "600": 104.39999999999976, + "601": 100.99999999999976, + "602": 106.09999999999974, + "603": 105.34999999999975, + "604": 105.09999999999974, + "605": 103.74999999999977, + "606": 102.89999999999976, + "607": -78.65, + "608": 102.89999999999978, + "609": 107.24999999999973, + "610": 102.64999999999976, + "611": 106.94999999999973, + "612": -82.80000000000001, + "613": 104.09999999999977, + "614": 104.39999999999976, + "615": 104.14999999999976, + "616": 43.74999999999976, + "617": 104.49999999999976, + "618": 60.74999999999977, + "619": 105.39999999999975, + "620": 103.29999999999977, + "621": 106.49999999999993, + "622": 107.54999999999974, + "623": 107.99999999999974, + "624": 76.69999999999978, + "625": 108.29999999999974, + "626": 102.99999999999977, + "627": 104.54999999999976, + "628": 103.74999999999977, + "629": 105.54999999999973, + "630": 104.64999999999975, + "631": 102.89999999999976, + "632": 105.39999999999974, + "633": 104.14999999999976, + "634": 104.59999999999975, + "635": 104.29999999999977, + "636": 103.94999999999976, + "637": 97.84999999999977, + "638": -79.04999999999998, + "639": 103.04999999999977, + "640": 100.74999999999979, + "641": 102.74999999999977, + "642": 104.09999999999977, + "643": 106.04999999999971, + "644": 106.64999999999989, + "645": 104.09999999999977, + "646": 103.24999999999976, + "647": 103.04999999999977, + "648": 103.99999999999976, + "649": 81.19999999999976, + "650": 102.79999999999977, + "651": 102.99999999999977, + "652": 101.94999999999978, + "653": 39.1, + "654": 105.84999999999972, + "655": 60.34999999999975, + "656": 96.39999999999979, + "657": 62.69999999999998, + "658": 105.24999999999974, + "659": 92.44999999999975, + "660": 103.69999999999976, + "661": 101.39999999999978, + "662": 103.09999999999977, + "663": 103.24999999999976, + "664": 94.4499999999998, + "665": 89.79999999999987, + "666": 103.54999999999976, + "667": 103.99999999999976, + "668": 73.64999999999976, + "669": 103.69999999999976, + "670": -14.500000000000007, + "671": 105.04999999999976, + "672": 104.94999999999975, + "673": 103.34999999999977, + "674": 90.89999999999982, + "675": 100.99999999999977, + "676": 105.09999999999981, + "677": 103.64999999999976, + "678": 71.59999999999977, + "679": 107.94999999999973, + "680": 73.14999999999976, + "681": 103.24999999999977, + "682": 103.29999999999977, + "683": 54.29999999999975, + "684": 98.69999999999975, + "685": 104.54999999999973, + "686": 105.6499999999998, + "687": 103.09999999999977, + "688": 103.84999999999977, + "689": 104.34999999999974, + "690": 83.54999999999977, + "691": 84.8499999999998, + "692": 105.44999999999982, + "693": 106.54999999999973, + "694": 106.24999999999983, + "695": 103.94999999999976, + "696": 105.94999999999973, + "697": 12.799999999999969, + "698": 103.29999999999984, + "699": 109.09999999999975, + "700": 101.99999999999974, + "701": 104.79999999999977, + "702": 103.79999999999976, + "703": 102.64999999999976, + "704": 103.29999999999977, + "705": 106.94999999999973, + "706": 104.69999999999976, + "707": 103.09999999999977, + "708": 103.74999999999976, + "709": 103.14999999999978, + "710": 102.79999999999974, + "711": 99.24999999999977, + "712": 103.04999999999977, + "713": 102.69999999999978, + "714": 103.74999999999976, + "715": 102.74999999999976, + "716": 83.99999999999983, + "717": 104.39999999999975, + "718": 104.84999999999975, + "719": 103.59999999999977, + "720": 103.39999999999976, + "721": 102.74999999999977, + "722": 104.84999999999974, + "723": 104.49999999999976, + "724": 105.64999999999974, + "725": 92.49999999999977, + "726": 102.49999999999976, + "727": 104.34999999999988, + "728": 104.39999999999975, + "729": 103.44999999999976, + "730": 106.94999999999979, + "731": 103.14999999999978, + "732": 103.69999999999976, + "733": 111.44999999999993, + "734": 102.94999999999978, + "735": 100.39999999999976, + "736": 99.54999999999978, + "737": 104.89999999999975, + "738": 104.19999999999976, + "739": 95.89999999999995, + "740": 105.29999999999994, + "741": 105.59999999999972, + "742": 104.19999999999976, + "743": 105.44999999999972, + "744": 105.84999999999974, + "745": 106.94999999999973, + "746": 107.84999999999972, + "747": 94.49999999999974, + "748": 104.84999999999975, + "749": 107.29999999999973, + "750": 104.04999999999976, + "751": 103.99999999999976, + "752": 62.34999999999977, + "753": 107.54999999999973, + "754": -84.6, + "755": 106.64999999999972, + "756": 85.69999999999983, + "757": 103.04999999999977, + "758": 57.54999999999978, + "759": 104.79999999999976, + "760": 96.04999999999976, + "761": 3.80000000000006, + "762": 102.79999999999977, + "763": -65.80000000000001, + "764": 106.64999999999974, + "765": 31.64999999999985, + "766": -68.35000000000001, + "767": 103.54999999999976, + "768": 104.84999999999977, + "769": 58.199999999999754, + "770": 103.89999999999976, + "771": 49.699999999999775, + "772": 109.94999999999985, + "773": 104.74999999999976, + "774": 104.59999999999975, + "775": 105.79999999999981, + "776": 31.39999999999987, + "777": 103.64999999999976, + "778": 103.34999999999977, + "779": 105.04999999999974, + "780": -68.1, + "781": 107.39999999999975, + "782": 109.50000000000018, + "783": -19.25, + "784": 108.39999999999989, + "785": 107.59999999999982, + "786": 102.39999999999978, + "787": 104.29999999999977, + "788": -84.00000000000003, + "789": 112.49999999999994, + "790": -77.4, + "791": 104.19999999999975, + "792": 92.74999999999983, + "793": 104.19999999999976, + "794": 104.29999999999976, + "795": -84.54999999999998, + "796": 103.89999999999979, + "797": 109.0499999999998, + "798": 105.29999999999977, + "799": 105.89999999999972, + "800": 103.14999999999976, + "801": 101.99999999999977, + "802": 105.19999999999973, + "803": 105.04999999999974, + "804": 105.74999999999972, + "805": 104.89999999999993, + "806": 105.34999999999974, + "807": 104.39999999999976, + "808": 103.64999999999976, + "809": 105.74999999999973, + "810": 49.74999999999981, + "811": 108.69999999999995, + "812": 106.39999999999971, + "813": -36.95000000000001, + "814": 105.59999999999975, + "815": 105.94999999999973, + "816": 103.34999999999977, + "817": 103.39999999999976, + "818": -76.6, + "819": 111.94999999999992, + "820": 103.34999999999977, + "821": 104.14999999999976, + "822": 106.59999999999972, + "823": 104.29999999999976, + "824": -3.8000000000000043, + "825": 103.29999999999977, + "826": 65.14999999999979, + "827": 103.99999999999976, + "828": 100.14999999999978, + "829": 104.44999999999976, + "830": 104.99999999999973, + "831": 78.94999999999978, + "832": -75.35, + "833": 102.49999999999977, + "834": -86.45000000000013, + "835": 116.35000000000032, + "836": 103.14999999999976, + "837": 105.34999999999972, + "838": 105.79999999999974, + "839": 108.69999999999975, + "840": 105.44999999999973, + "841": -88.44999999999999, + "842": 104.59999999999975, + "843": 104.24999999999976, + "844": 105.24999999999973, + "845": 113.80000000000021, + "846": 104.79999999999974, + "847": 104.94999999999982, + "848": 104.59999999999975, + "849": 103.39999999999976, + "850": 107.94999999999979, + "851": 105.69999999999972, + "852": 109.09999999999977, + "853": 106.29999999999971, + "854": 82.74999999999974, + "855": 71.29999999999978, + "856": -68.34999999999998, + "857": 106.49999999999996, + "858": 107.69999999999975, + "859": 105.39999999999972, + "860": 103.34999999999977, + "861": 107.74999999999974, + "862": 103.74999999999976, + "863": 100.79999999999973, + "864": 106.19999999999973, + "865": 100.79999999999976, + "866": -81.0, + "867": 105.69999999999986, + "868": 103.09999999999977, + "869": 104.09999999999977, + "870": 102.69999999999978, + "871": 103.94999999999976, + "872": 105.09999999999975, + "873": 103.94999999999973, + "874": 46.699999999999896, + "875": 94.54999999999978, + "876": 103.79999999999977, + "877": 106.24999999999973, + "878": 104.14999999999975, + "879": -73.69999999999997, + "880": 104.59999999999982, + "881": -77.44999999999999, + "882": -15.000000000000014, + "883": 104.84999999999975, + "884": -81.95, + "885": 105.14999999999975, + "886": 109.24999999999979, + "887": -77.85, + "888": 104.19999999999976, + "889": 113.75, + "890": -38.10000000000001, + "891": 104.69999999999976, + "892": -72.05000000000001, + "893": -73.80000000000001, + "894": 113.64999999999988, + "895": 104.19999999999976, + "896": 107.64999999999974, + "897": 109.29999999999978, + "898": 109.04999999999981, + "899": 109.24999999999976, + "900": 104.84999999999977, + "901": 104.24999999999976, + "902": 105.79999999999974, + "903": 104.04999999999977, + "904": 104.54999999999974, + "905": 104.94999999999975, + "906": 105.09999999999975, + "907": 101.49999999999972, + "908": -79.94999999999999, + "909": 103.29999999999977, + "910": 105.89999999999972, + "911": 102.64999999999976, + "912": 85.34999999999981, + "913": 104.69999999999976, + "914": 106.59999999999972, + "915": 106.44999999999972, + "916": 106.59999999999974, + "917": 107.64999999999974, + "918": 116.70000000000027, + "919": 59.150000000000034, + "920": 102.74999999999977, + "921": 104.89999999999972, + "922": 104.89999999999974, + "923": 107.19999999999972, + "924": 106.19999999999975, + "925": 104.79999999999976, + "926": 111.64999999999999, + "927": 109.04999999999976, + "928": 104.39999999999975, + "929": 105.34999999999975, + "930": 115.10000000000018, + "931": 108.39999999999975, + "932": 60.249999999999766, + "933": 40.69999999999997, + "934": 97.94999999999975, + "935": 105.39999999999974, + "936": 108.44999999999976, + "937": 105.89999999999974, + "938": 106.14999999999972, + "939": 106.09999999999972, + "940": 105.29999999999973, + "941": 104.44999999999978, + "942": 108.59999999999977, + "943": 105.79999999999973, + "944": 71.04999999999976, + "945": 106.94999999999973, + "946": 75.59999999999977, + "947": 103.14999999999978, + "948": 102.74999999999977, + "949": 106.99999999999973, + "950": 103.24999999999976, + "951": 110.54999999999983, + "952": 110.44999999999989, + "953": 104.49999999999974, + "954": 39.849999999999824, + "955": 104.94999999999975, + "956": -63.350000000000016, + "957": 104.04999999999977, + "958": -88.25, + "959": 103.24999999999977, + "960": 102.44999999999976, + "961": 83.34999999999975, + "962": -69.80000000000001, + "963": 108.59999999999974, + "964": 103.94999999999976, + "965": 105.39999999999974, + "966": 107.39999999999974, + "967": -45.80000000000007, + "968": 105.10000000000008, + "969": 103.69999999999973, + "970": 105.59999999999985, + "971": -79.0, + "972": 102.84999999999977, + "973": 103.44999999999976, + "974": 104.74999999999973, + "975": 103.29999999999977, + "976": -82.45000000000002, + "977": 105.59999999999974, + "978": 104.49999999999983, + "979": -83.35, + "980": 106.89999999999974, + "981": -83.85, + "982": -81.3, + "983": 103.49999999999972, + "984": 56.149999999999764, + "985": 106.19999999999978, + "986": 110.19999999999976, + "987": 108.69999999999976, + "988": 108.39999999999975, + "989": -41.05000000000001, + "990": 107.40000000000003, + "991": 46.79999999999997, + "992": 110.34999999999987, + "993": 106.39999999999972, + "994": 104.39999999999975, + "995": 106.09999999999972, + "996": 104.19999999999976, + "997": 107.14999999999974, + "998": 105.64999999999972, + "999": 103.14999999999976, + "1000": 107.24999999999979 + }, + "5": { + "1": -4.399999999999995, + "2": -48.50000000000004, + "3": -109.5, + "4": -54.500000000000085, + "5": -15.949999999999978, + "6": -80.89999999999992, + "7": -15.349999999999982, + "8": -23.29999999999995, + "9": -34.350000000000016, + "10": -49.800000000000054, + "11": -46.95000000000006, + "12": -22.699999999999953, + "13": -32.35000000000003, + "14": -24.199999999999942, + "15": -51.150000000000176, + "16": -52.20000000000008, + "17": -68.60000000000007, + "18": -30.400000000000006, + "19": -19.99999999999996, + "20": -73.15000000000002, + "21": -17.949999999999974, + "22": -12.949999999999987, + "23": -61.25, + "24": -35.19999999999998, + "25": -70.70000000000005, + "26": -96.6, + "27": -48.550000000000146, + "28": -4.599999999999975, + "29": -7.4, + "30": -44.050000000000175, + "31": -4.29999999999998, + "32": -21.999999999999957, + "33": -78.30000000000004, + "34": -15.099999999999985, + "35": -55.60000000000003, + "36": -51.800000000000075, + "37": -20.39999999999996, + "38": -22.499999999999954, + "39": -104.3, + "40": -45.75000000000005, + "41": 2.100000000000044, + "42": -21.099999999999987, + "43": -14.99999999999998, + "44": -94.15, + "45": -45.70000000000012, + "46": -17.399999999999974, + "47": -29.099999999999948, + "48": -13.749999999999986, + "49": -87.25, + "50": -47.04999999999999, + "51": -23.89999999999995, + "52": -47.75000000000007, + "53": -13.699999999999976, + "54": -17.74999999999997, + "55": -23.799999999999972, + "56": -16.49999999999998, + "57": -21.299999999999958, + "58": -13.099999999999985, + "59": -96.69999999999997, + "60": -23.44999999999995, + "61": -8.399999999999995, + "62": -37.65000000000005, + "63": -20.349999999999962, + "64": -19.049999999999958, + "65": -17.24999999999998, + "66": -8.550000000000006, + "67": -18.14999999999997, + "68": -69.45000000000005, + "69": -16.999999999999975, + "70": -72.19999999999999, + "71": -29.599999999999994, + "72": -19.049999999999965, + "73": -7.249999999999993, + "74": -16.049999999999983, + "75": -17.49999999999997, + "76": -18.29999999999997, + "77": -15.799999999999976, + "78": -6.299999999999986, + "79": -17.24999999999997, + "80": -20.999999999999957, + "81": -12.84999999999996, + "82": -77.1, + "83": -18.34999999999997, + "84": -16.24999999999998, + "85": -51.75000000000008, + "86": -19.649999999999963, + "87": -0.5999999999999621, + "88": 4.700000000000018, + "89": -39.45000000000005, + "90": -19.79999999999996, + "91": -15.999999999999979, + "92": -45.39999999999999, + "93": 9.800000000000018, + "94": -81.9, + "95": 0.5499999999999933, + "96": -3.149999999999971, + "97": -16.899999999999974, + "98": -3.899999999999987, + "99": -36.55000000000004, + "100": -63.0000000000001, + "101": 13.300000000000004, + "102": -51.25000000000008, + "103": -1.849999999999997, + "104": -42.54999999999998, + "105": -84.10000000000001, + "106": -97.94999999999999, + "107": -17.65, + "108": -18.44999999999997, + "109": -15.399999999999975, + "110": 24.44999999999998, + "111": -40.20000000000011, + "112": -6.250000000000002, + "113": -22.74999999999996, + "114": -5.699999999999991, + "115": -18.64999999999996, + "116": -1.3500000000000008, + "117": 14.250000000000053, + "118": -53.49999999999998, + "119": -71.89999999999999, + "120": -29.299999999999994, + "121": 24.949999999999925, + "122": 17.000000000000025, + "123": -26.649999999999945, + "124": -50.449999999999974, + "125": 27.949999999999967, + "126": -71.39999999999999, + "127": -21.999999999999954, + "128": -15.499999999999979, + "129": -19.799999999999965, + "130": 10.100000000000012, + "131": -56.80000000000011, + "132": 19.60000000000007, + "133": -1.2499999999999811, + "134": -16.149999999999945, + "135": 1.1000000000000354, + "136": -8.749999999999972, + "137": -9.65, + "138": -16.649999999999977, + "139": -14.499999999999984, + "140": -9.949999999999998, + "141": 3.2500000000000187, + "142": 32.10000000000004, + "143": -3.199999999999998, + "144": 9.300000000000034, + "145": -26.29999999999997, + "146": 11.149999999999995, + "147": -3.199999999999984, + "148": -26.599999999999973, + "149": -12.699999999999967, + "150": -0.19999999999997642, + "151": -18.649999999999967, + "152": -42.80000000000001, + "153": 14.649999999999956, + "154": 5.300000000000017, + "155": -9.89999999999999, + "156": -0.4499999999999653, + "157": -40.65000000000006, + "158": 0.2000000000000146, + "159": -2.250000000000001, + "160": -85.30000000000001, + "161": 2.050000000000021, + "162": 3.450000000000025, + "163": -85.69999999999999, + "164": 2.5000000000000036, + "165": -0.5999999999999849, + "166": -10.249999999999996, + "167": -24.849999999999977, + "168": -8.5, + "169": -25.899999999999984, + "170": 18.2, + "171": -94.3, + "172": 5.500000000000007, + "173": 17.050000000000065, + "174": -19.39999999999999, + "175": -8.04999999999999, + "176": -9.949999999999987, + "177": -42.550000000000054, + "178": 27.35000000000007, + "179": -0.19999999999995866, + "180": 23.549999999999894, + "181": 44.899999999999885, + "182": 32.14999999999996, + "183": -15.999999999999975, + "184": 57.5999999999998, + "185": 16.999999999999996, + "186": -10.549999999999995, + "187": 10.550000000000061, + "188": -90.0, + "189": -9.900000000000002, + "190": 20.500000000000007, + "191": 19.15000000000004, + "192": -2.2999999999999963, + "193": -1.799999999999986, + "194": 22.149999999999963, + "195": -14.949999999999976, + "196": 17.100000000000044, + "197": -13.999999999999964, + "198": -0.6499999999999884, + "199": 46.599999999999746, + "200": 43.94999999999977, + "201": 27.95000000000003, + "202": 20.10000000000007, + "203": 51.59999999999976, + "204": 5.600000000000055, + "205": 40.349999999999866, + "206": -56.4, + "207": -7.200000000000007, + "208": 10.650000000000082, + "209": 53.599999999999795, + "210": -49.849999999999994, + "211": 46.29999999999992, + "212": -30.24999999999998, + "213": 52.39999999999978, + "214": 87.45000000000012, + "215": -84.95, + "216": 67.34999999999988, + "217": 46.09999999999985, + "218": 77.1499999999999, + "219": 1.9499999999999933, + "220": 46.24999999999981, + "221": 21.699999999999953, + "222": 34.3499999999999, + "223": -7.899999999999994, + "224": 41.84999999999974, + "225": 7.3500000000000085, + "226": 66.79999999999977, + "227": -2.6999999999999664, + "228": 11.500000000000039, + "229": 0.800000000000008, + "230": -7.500000000000008, + "231": -87.85, + "232": 62.39999999999993, + "233": -1.3500000000000272, + "234": 36.59999999999988, + "235": 91.64999999999998, + "236": 8.9, + "237": -81.39999999999996, + "238": 47.749999999999886, + "239": -9.55000000000004, + "240": 28.299999999999844, + "241": 83.0500000000001, + "242": 40.69999999999978, + "243": 28.649999999999892, + "244": -62.500000000000014, + "245": 72.35000000000011, + "246": -23.900000000000006, + "247": 81.30000000000008, + "248": 63.649999999999764, + "249": 25.249999999999947, + "250": 12.100000000000067, + "251": 58.84999999999993, + "252": 14.199999999999969, + "253": 86.15000000000022, + "254": -10.150000000000007, + "255": 69.09999999999984, + "256": 42.04999999999995, + "257": 36.2, + "258": 56.79999999999995, + "259": 64.35000000000014, + "260": 68.44999999999978, + "261": 81.19999999999982, + "262": 35.15000000000003, + "263": -4.699999999999989, + "264": 106.55000000000028, + "265": 48.44999999999992, + "266": 24.25000000000001, + "267": -54.64999999999994, + "268": 59.049999999999926, + "269": 46.24999999999976, + "270": 32.99999999999999, + "271": 89.09999999999981, + "272": 65.14999999999978, + "273": 89.64999999999995, + "274": 43.44999999999994, + "275": -33.30000000000004, + "276": 103.50000000000017, + "277": -56.74999999999996, + "278": 42.0999999999999, + "279": 92.80000000000008, + "280": -25.349999999999973, + "281": -38.799999999999976, + "282": -83.99999999999997, + "283": 85.84999999999978, + "284": 25.099999999999923, + "285": 42.24999999999992, + "286": 19.150000000000002, + "287": 72.99999999999984, + "288": -71.55000000000001, + "289": 26.99999999999999, + "290": 41.49999999999989, + "291": 31.899999999999864, + "292": -70.84999999999997, + "293": 94.0500000000001, + "294": 36.04999999999999, + "295": -25.900000000000027, + "296": 107.00000000000024, + "297": 59.099999999999866, + "298": 106.05000000000018, + "299": -21.799999999999986, + "300": 31.29999999999999, + "301": 47.9499999999999, + "302": 67.9499999999999, + "303": -39.30000000000008, + "304": 87.04999999999998, + "305": -16.950000000000028, + "306": 57.3499999999999, + "307": 106.35000000000026, + "308": 62.04999999999991, + "309": -21.999999999999982, + "310": 60.59999999999983, + "311": -3.1500000000000057, + "312": 94.70000000000009, + "313": 102.45000000000014, + "314": 92.20000000000016, + "315": -74.55, + "316": 89.00000000000003, + "317": 9.649999999999999, + "318": -70.24999999999997, + "319": -43.899999999999984, + "320": -54.94999999999995, + "321": -13.600000000000005, + "322": 26.799999999999976, + "323": 66.69999999999987, + "324": -12.59999999999998, + "325": -16.349999999999984, + "326": -64.74999999999986, + "327": 61.29999999999982, + "328": 29.900000000000013, + "329": 59.699999999999875, + "330": 67.79999999999981, + "331": -45.15000000000005, + "332": -68.54999999999997, + "333": 21.650000000000002, + "334": 1.5999999999999868, + "335": 48.249999999999915, + "336": 84.09999999999981, + "337": 7.899999999999989, + "338": 78.59999999999984, + "339": -9.949999999999996, + "340": 75.14999999999996, + "341": -44.34999999999996, + "342": 91.85000000000001, + "343": 94.60000000000016, + "344": 73.64999999999999, + "345": 33.3, + "346": 13.299999999999997, + "347": 107.25000000000024, + "348": 40.049999999999976, + "349": -63.89999999999992, + "350": 102.9500000000002, + "351": 51.54999999999981, + "352": 77.19999999999999, + "353": 95.70000000000012, + "354": 47.54999999999994, + "355": 28.650000000000055, + "356": 6.55, + "357": 22.949999999999967, + "358": 103.40000000000018, + "359": 51.34999999999995, + "360": 93.05000000000014, + "361": 95.5000000000001, + "362": 31.199999999999985, + "363": 98.0500000000002, + "364": 52.69999999999979, + "365": -7.450000000000001, + "366": 37.69999999999999, + "367": 64.69999999999985, + "368": 66.89999999999988, + "369": 89.24999999999979, + "370": -78.4, + "371": 93.25000000000013, + "372": 94.99999999999991, + "373": -2.849999999999964, + "374": 75.34999999999977, + "375": 85.70000000000003, + "376": 98.55000000000013, + "377": 90.14999999999976, + "378": -72.09999999999992, + "379": 97.10000000000015, + "380": 24.199999999999978, + "381": 57.94999999999993, + "382": 72.89999999999978, + "383": 49.799999999999926, + "384": 86.60000000000001, + "385": 78.34999999999981, + "386": 86.70000000000007, + "387": 31.84999999999996, + "388": 51.24999999999995, + "389": 50.29999999999987, + "390": 65.79999999999991, + "391": 83.45, + "392": 61.59999999999988, + "393": 89.7500000000001, + "394": 81.09999999999984, + "395": 95.04999999999974, + "396": 70.14999999999976, + "397": 67.84999999999984, + "398": 0.1999999999999731, + "399": 66.84999999999991, + "400": 55.949999999999775, + "401": 91.90000000000013, + "402": 92.0000000000001, + "403": 81.7999999999998, + "404": 96.0999999999998, + "405": 35.499999999999794, + "406": 48.799999999999955, + "407": 40.050000000000004, + "408": 92.00000000000006, + "409": 104.35, + "410": 86.29999999999978, + "411": -5.849999999999988, + "412": 51.099999999999746, + "413": 64.79999999999974, + "414": 15.150000000000034, + "415": 77.00000000000006, + "416": 68.79999999999991, + "417": 59.64999999999974, + "418": 75.04999999999976, + "419": 38.39999999999998, + "420": 84.29999999999978, + "421": 51.8499999999998, + "422": 37.84999999999993, + "423": 92.69999999999979, + "424": 71.39999999999985, + "425": 75.04999999999986, + "426": 77.64999999999975, + "427": 15.799999999999992, + "428": 25.150000000000013, + "429": 96.44999999999975, + "430": 85.69999999999978, + "431": 78.09999999999994, + "432": 82.39999999999974, + "433": 103.0000000000002, + "434": 95.99999999999973, + "435": 15.200000000000014, + "436": 80.79999999999978, + "437": 63.09999999999979, + "438": 90.84999999999981, + "439": 58.799999999999876, + "440": 75.24999999999987, + "441": 99.05000000000011, + "442": 72.39999999999982, + "443": 94.69999999999992, + "444": 62.14999999999993, + "445": 8.450000000000015, + "446": 93.79999999999976, + "447": 75.60000000000014, + "448": 96.74999999999976, + "449": 44.54999999999987, + "450": -3.7999999999999967, + "451": -43.19999999999999, + "452": 97.99999999999974, + "453": 75.25000000000003, + "454": 90.29999999999978, + "455": 86.69999999999976, + "456": 94.09999999999978, + "457": 79.04999999999977, + "458": 66.74999999999987, + "459": 76.2499999999999, + "460": 101.99999999999974, + "461": 104.69999999999983, + "462": 85.34999999999978, + "463": 97.39999999999975, + "464": 32.35, + "465": 98.29999999999976, + "466": 79.89999999999982, + "467": 101.00000000000016, + "468": 84.14999999999975, + "469": 92.69999999999979, + "470": 93.59999999999977, + "471": 36.899999999999956, + "472": 86.34999999999977, + "473": 105.54999999999991, + "474": 66.39999999999993, + "475": 101.09999999999975, + "476": -5.149999999999967, + "477": 98.49999999999977, + "478": 104.34999999999987, + "479": 104.29999999999991, + "480": 99.94999999999979, + "481": 99.59999999999974, + "482": 95.54999999999986, + "483": 97.19999999999982, + "484": 93.89999999999976, + "485": 75.14999999999996, + "486": 98.19999999999982, + "487": 45.299999999999784, + "488": 92.29999999999977, + "489": 103.09999999999977, + "490": 94.74999999999977, + "491": 101.34999999999977, + "492": 99.19999999999976, + "493": 91.89999999999979, + "494": 86.89999999999976, + "495": 103.54999999999976, + "496": 41.999999999999964, + "497": 100.49999999999977, + "498": 95.24999999999977, + "499": 103.89999999999974, + "500": 96.69999999999976, + "501": 96.59999999999977, + "502": 69.49999999999974, + "503": 81.34999999999978, + "504": 101.54999999999977, + "505": 82.44999999999978, + "506": 97.84999999999975, + "507": 93.29999999999977, + "508": 61.34999999999976, + "509": 94.99999999999979, + "510": 68.74999999999973, + "511": 104.84999999999975, + "512": 102.64999999999974, + "513": 102.84999999999975, + "514": -2.3000000000000753, + "515": 102.99999999999974, + "516": 99.1999999999998, + "517": 103.54999999999978, + "518": 98.59999999999978, + "519": 104.39999999999974, + "520": -76.1, + "521": 50.99999999999976, + "522": 103.04999999999976, + "523": -25.400000000000066, + "524": 93.29999999999983, + "525": 71.04999999999976, + "526": 94.49999999999976, + "527": 98.24999999999976, + "528": 103.99999999999986, + "529": 99.59999999999977, + "530": 99.89999999999976, + "531": 106.84999999999974, + "532": 102.74999999999976, + "533": 103.14999999999976, + "534": 97.79999999999974, + "535": 98.84999999999975, + "536": 102.79999999999977, + "537": 26.35000000000006, + "538": 102.39999999999985, + "539": 96.04999999999976, + "540": 83.19999999999983, + "541": 105.34999999999974, + "542": 102.94999999999975, + "543": 98.44999999999975, + "544": 92.8999999999998, + "545": 101.44999999999975, + "546": 107.34999999999982, + "547": 103.99999999999973, + "548": 103.54999999999974, + "549": 22.50000000000004, + "550": 103.94999999999972, + "551": -46.300000000000004, + "552": 101.94999999999975, + "553": 108.94999999999999, + "554": 108.19999999999978, + "555": 105.94999999999972, + "556": 95.14999999999975, + "557": 102.74999999999973, + "558": 103.89999999999972, + "559": 101.54999999999978, + "560": 100.49999999999977, + "561": 87.5499999999998, + "562": 94.84999999999977, + "563": -74.69999999999999, + "564": 105.59999999999975, + "565": 102.74999999999976, + "566": 94.29999999999977, + "567": 96.29999999999978, + "568": 97.69999999999978, + "569": 99.24999999999977, + "570": 103.24999999999973, + "571": 100.19999999999973, + "572": 97.44999999999979, + "573": 102.44999999999972, + "574": 93.74999999999999, + "575": 90.54999999999976, + "576": 101.24999999999977, + "577": 95.69999999999978, + "578": 101.24999999999974, + "579": 78.54999999999987, + "580": 101.94999999999972, + "581": 103.34999999999975, + "582": 109.10000000000002, + "583": 103.49999999999976, + "584": 104.04999999999971, + "585": 106.39999999999972, + "586": 104.74999999999973, + "587": 103.04999999999977, + "588": 13.099999999999978, + "589": 105.45000000000007, + "590": 14.149999999999906, + "591": 104.39999999999975, + "592": 104.3999999999998, + "593": 111.14999999999989, + "594": 102.44999999999976, + "595": 104.99999999999976, + "596": 76.74999999999986, + "597": 102.49999999999976, + "598": 76.04999999999984, + "599": 99.79999999999977, + "600": 49.79999999999981, + "601": 102.39999999999976, + "602": 101.24999999999977, + "603": 78.44999999999993, + "604": 39.39999999999976, + "605": 45.299999999999955, + "606": -85.6, + "607": 103.74999999999976, + "608": 104.19999999999995, + "609": 98.99999999999976, + "610": 95.99999999999974, + "611": 102.59999999999985, + "612": 97.64999999999979, + "613": -56.74999999999997, + "614": 27.650000000000027, + "615": 63.849999999999724, + "616": 103.99999999999976, + "617": 105.04999999999991, + "618": 101.79999999999977, + "619": 101.84999999999977, + "620": 82.54999999999974, + "621": 99.34999999999972, + "622": -76.30000000000007, + "623": 104.09999999999975, + "624": 103.39999999999975, + "625": 101.04999999999976, + "626": 96.29999999999986, + "627": -87.0, + "628": 101.74999999999977, + "629": 104.04999999999977, + "630": 106.84999999999974, + "631": 86.64999999999978, + "632": 102.49999999999974, + "633": 100.34999999999974, + "634": 55.89999999999977, + "635": 102.14999999999975, + "636": 104.34999999999974, + "637": -74.10000000000002, + "638": 105.44999999999972, + "639": 104.09999999999977, + "640": 105.1499999999998, + "641": 82.19999999999985, + "642": -68.75, + "643": 87.99999999999983, + "644": 104.09999999999977, + "645": 105.24999999999974, + "646": 100.54999999999974, + "647": 105.39999999999974, + "648": 103.19999999999976, + "649": 102.29999999999977, + "650": 102.94999999999976, + "651": 103.59999999999977, + "652": 102.04999999999976, + "653": 102.44999999999976, + "654": 99.94999999999976, + "655": 105.44999999999973, + "656": 42.85000000000001, + "657": 103.99999999999976, + "658": 104.19999999999976, + "659": 103.74999999999974, + "660": 53.79999999999975, + "661": 104.19999999999976, + "662": 109.49999999999976, + "663": 87.64999999999975, + "664": 102.49999999999973, + "665": -44.65000000000006, + "666": 104.14999999999976, + "667": 49.89999999999995, + "668": 105.79999999999973, + "669": 105.14999999999974, + "670": 73.8999999999998, + "671": 85.89999999999984, + "672": 97.04999999999978, + "673": 104.59999999999975, + "674": 103.99999999999976, + "675": 101.59999999999977, + "676": 106.14999999999982, + "677": 98.69999999999978, + "678": 106.69999999999975, + "679": 99.94999999999978, + "680": 99.24999999999976, + "681": 104.19999999999976, + "682": 104.14999999999976, + "683": 81.3999999999999, + "684": 98.69999999999976, + "685": 101.99999999999972, + "686": 105.24999999999974, + "687": 99.84999999999977, + "688": 103.49999999999976, + "689": 103.69999999999975, + "690": 104.09999999999972, + "691": 101.89999999999976, + "692": 106.94999999999976, + "693": 103.84999999999974, + "694": 104.44999999999973, + "695": 104.74999999999973, + "696": 87.49999999999972, + "697": 102.79999999999974, + "698": 103.69999999999976, + "699": 79.29999999999987, + "700": 108.04999999999973, + "701": 57.749999999999766, + "702": 106.29999999999973, + "703": 103.79999999999977, + "704": 107.54999999999983, + "705": -0.600000000000033, + "706": -80.14999999999999, + "707": 99.44999999999975, + "708": 107.99999999999976, + "709": 97.29999999999976, + "710": 89.29999999999977, + "711": 102.24999999999973, + "712": -38.75000000000003, + "713": -34.04999999999999, + "714": 103.69999999999973, + "715": 102.09999999999974, + "716": -43.19999999999999, + "717": 104.39999999999975, + "718": 0.5999999999999659, + "719": 99.69999999999976, + "720": 105.79999999999973, + "721": 103.09999999999977, + "722": 105.39999999999976, + "723": 105.14999999999976, + "724": 104.34999999999977, + "725": 104.04999999999976, + "726": -65.4, + "727": -42.550000000000054, + "728": 104.29999999999984, + "729": 103.44999999999976, + "730": -78.75, + "731": 103.84999999999975, + "732": 105.54999999999973, + "733": 91.7499999999998, + "734": 109.29999999999976, + "735": 110.54999999999991, + "736": 103.84999999999975, + "737": 108.04999999999973, + "738": -72.50000000000001, + "739": 109.09999999999977, + "740": 89.24999999999977, + "741": 103.29999999999976, + "742": 109.34999999999975, + "743": 102.84999999999977, + "744": 108.84999999999975, + "745": 105.59999999999974, + "746": 81.19999999999978, + "747": 100.99999999999977, + "748": 105.19999999999972, + "749": 58.29999999999976, + "750": 46.79999999999987, + "751": 67.84999999999987, + "752": 103.54999999999974, + "753": 88.74999999999976, + "754": 105.14999999999974, + "755": 109.55, + "756": 70.69999999999975, + "757": 103.94999999999975, + "758": 101.74999999999972, + "759": 105.14999999999972, + "760": 103.99999999999974, + "761": 102.69999999999978, + "762": 104.19999999999975, + "763": 104.39999999999975, + "764": -77.95, + "765": 25.599999999999895, + "766": 108.89999999999975, + "767": 106.34999999999977, + "768": 96.54999999999981, + "769": 104.24999999999976, + "770": 106.89999999999972, + "771": 105.19999999999973, + "772": 103.24999999999977, + "773": 103.14999999999976, + "774": 97.49999999999976, + "775": 104.24999999999977, + "776": 105.34999999999978, + "777": 84.54999999999984, + "778": 104.84999999999975, + "779": 104.04999999999973, + "780": 103.84999999999977, + "781": 106.94999999999973, + "782": 100.54999999999976, + "783": 80.19999999999978, + "784": 105.39999999999972, + "785": 103.74999999999977, + "786": 104.79999999999976, + "787": 107.49999999999973, + "788": 106.54999999999973, + "789": -39.34999999999994, + "790": 107.29999999999974, + "791": -74.0, + "792": 107.14999999999974, + "793": 102.84999999999977, + "794": 93.24999999999972, + "795": 108.14999999999974, + "796": -67.60000000000002, + "797": 103.89999999999976, + "798": 105.39999999999972, + "799": 104.99999999999973, + "800": 102.39999999999975, + "801": 106.74999999999977, + "802": 103.09999999999972, + "803": 105.39999999999974, + "804": 100.54999999999977, + "805": 109.39999999999976, + "806": 111.59999999999977, + "807": 104.84999999999974, + "808": 104.09999999999977, + "809": -102.30000000000001, + "810": 104.74999999999974, + "811": 106.19999999999973, + "812": 104.89999999999974, + "813": -72.50000000000001, + "814": 104.94999999999975, + "815": 103.84999999999977, + "816": 103.99999999999974, + "817": -64.64999999999999, + "818": 105.09999999999974, + "819": 105.99999999999972, + "820": 24.24999999999989, + "821": 102.74999999999977, + "822": 100.09999999999977, + "823": 104.19999999999976, + "824": 109.59999999999981, + "825": 105.09999999999972, + "826": 102.59999999999977, + "827": 102.94999999999976, + "828": -76.85000000000002, + "829": 106.19999999999975, + "830": 90.54999999999974, + "831": 41.94999999999977, + "832": -87.19999999999999, + "833": 106.49999999999974, + "834": 103.29999999999977, + "835": 106.24999999999972, + "836": 106.24999999999973, + "837": 104.84999999999975, + "838": 105.29999999999974, + "839": 103.59999999999977, + "840": 91.04999999999981, + "841": 103.59999999999975, + "842": 103.99999999999976, + "843": 106.24999999999973, + "844": 74.99999999999987, + "845": 103.29999999999977, + "846": 104.04999999999976, + "847": 106.99999999999973, + "848": -83.75000000000001, + "849": 105.79999999999973, + "850": -76.30000000000001, + "851": 105.24999999999972, + "852": 105.79999999999973, + "853": 100.84999999999977, + "854": 104.99999999999976, + "855": 105.09999999999972, + "856": 83.89999999999976, + "857": 107.24999999999983, + "858": 103.54999999999977, + "859": -72.05, + "860": 104.09999999999972, + "861": 103.59999999999977, + "862": 104.84999999999975, + "863": -74.89999999999999, + "864": 103.04999999999977, + "865": 104.29999999999977, + "866": 99.69999999999975, + "867": 104.24999999999974, + "868": 95.74999999999976, + "869": 104.59999999999975, + "870": 100.24999999999977, + "871": 104.04999999999976, + "872": 102.64999999999976, + "873": 104.59999999999975, + "874": 102.74999999999977, + "875": 104.39999999999975, + "876": 102.89999999999978, + "877": 104.54999999999977, + "878": 103.74999999999977, + "879": -79.10000000000001, + "880": 104.24999999999976, + "881": 103.49999999999977, + "882": -86.35000000000002, + "883": 103.39999999999976, + "884": 105.39999999999975, + "885": 100.3499999999998, + "886": 107.29999999999973, + "887": 104.09999999999975, + "888": 102.69999999999978, + "889": 101.84999999999977, + "890": 105.29999999999974, + "891": 103.54999999999977, + "892": 102.24999999999976, + "893": 105.29999999999973, + "894": 102.24999999999977, + "895": 97.74999999999973, + "896": 105.94999999999972, + "897": 103.84999999999977, + "898": 68.49999999999977, + "899": 97.24999999999977, + "900": -85.44999999999999, + "901": 103.79999999999977, + "902": 101.29999999999978, + "903": 101.94999999999976, + "904": 105.84999999999981, + "905": 105.44999999999982, + "906": 104.09999999999975, + "907": 109.04999999999977, + "908": 105.44999999999975, + "909": 103.19999999999978, + "910": 105.14999999999979, + "911": -85.65, + "912": 101.89999999999976, + "913": 108.25000000000006, + "914": 107.04999999999971, + "915": 107.29999999999977, + "916": 104.89999999999978, + "917": 104.24999999999976, + "918": 104.69999999999975, + "919": 105.4499999999998, + "920": 108.49999999999986, + "921": 108.34999999999987, + "922": 99.99999999999974, + "923": 2.0499999999999616, + "924": 103.64999999999975, + "925": 104.49999999999974, + "926": 103.84999999999977, + "927": 107.3, + "928": 104.59999999999977, + "929": 103.84999999999977, + "930": 10.249999999999922, + "931": 103.24999999999976, + "932": 105.59999999999974, + "933": 105.59999999999977, + "934": 97.09999999999977, + "935": 105.44999999999973, + "936": 104.09999999999972, + "937": 103.69999999999976, + "938": 105.29999999999974, + "939": 25.800000000000054, + "940": 105.59999999999975, + "941": 105.49999999999974, + "942": -74.49999999999999, + "943": 105.54999999999974, + "944": 104.14999999999976, + "945": 103.39999999999976, + "946": 104.79999999999976, + "947": 103.09999999999977, + "948": 54.35, + "949": 87.79999999999978, + "950": 104.19999999999976, + "951": 105.64999999999974, + "952": 104.49999999999974, + "953": 103.44999999999976, + "954": 61.849999999999945, + "955": 104.64999999999974, + "956": 103.54999999999977, + "957": 104.39999999999976, + "958": 102.69999999999978, + "959": 103.19999999999976, + "960": 103.04999999999977, + "961": 104.39999999999976, + "962": 82.99999999999973, + "963": 105.14999999999974, + "964": 104.04999999999974, + "965": 105.29999999999981, + "966": 105.04999999999974, + "967": -91.4, + "968": 105.09999999999974, + "969": 106.94999999999995, + "970": -50.55, + "971": 104.24999999999976, + "972": 104.09999999999981, + "973": -86.0, + "974": -32.00000000000002, + "975": 108.34999999999977, + "976": 106.34999999999984, + "977": -85.35, + "978": -45.9, + "979": 110.29999999999991, + "980": 108.49999999999976, + "981": 105.74999999999972, + "982": 104.59999999999977, + "983": 106.44999999999972, + "984": 105.59999999999974, + "985": -87.0, + "986": 106.69999999999978, + "987": 104.34999999999975, + "988": -53.199999999999974, + "989": 112.05000000000018, + "990": 104.34999999999972, + "991": 102.64999999999976, + "992": -84.9, + "993": -39.350000000000044, + "994": 103.94999999999976, + "995": 102.04999999999977, + "996": 103.64999999999976, + "997": 100.3499999999998, + "998": 84.7999999999998, + "999": 105.09999999999974, + "1000": 106.89999999999974 + } + }, + "config": { + "io_settings": { + "save_agent_actions": true, + "save_step_metadata": false, + "save_pcap_logs": false, + "save_sys_logs": false, + "sys_log_level": "WARNING" + }, + "game": { + "max_episode_length": 128, + "ports": [ + "HTTP", + "POSTGRES_SERVER" + ], + "protocols": [ + "ICMP", + "TCP", + "UDP" + ], + "thresholds": { + "nmne": { + "high": 10, + "medium": 5, + "low": 0 + } + } + }, + "agents": [ + { + "ref": "client_2_green_user", + "team": "GREEN", + "type": "ProbabilisticAgent", + "agent_settings": { + "action_probabilities": { + "0": 0.3, + "1": 0.6, + "2": 0.1 + } + }, + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_2", + "applications": [ + { + "application_name": "WebBrowser" + }, + { + "application_name": "DatabaseClient" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1, + "max_applications_per_node": 2 + }, + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 0 + } + }, + "2": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 1 + } + } + } + }, + "reward_function": { + "reward_components": [ + { + "type": "WEBPAGE_UNAVAILABLE_PENALTY", + "weight": 0.25, + "options": { + "node_hostname": "client_2" + } + }, + { + "type": "GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY", + "weight": 0.05, + "options": { + "node_hostname": "client_2" + } + } + ] + } + }, + { + "ref": "client_1_green_user", + "team": "GREEN", + "type": "ProbabilisticAgent", + "agent_settings": { + "action_probabilities": { + "0": 0.3, + "1": 0.6, + "2": 0.1 + } + }, + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_1", + "applications": [ + { + "application_name": "WebBrowser" + }, + { + "application_name": "DatabaseClient" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1, + "max_applications_per_node": 2 + }, + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 0 + } + }, + "2": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 1 + } + } + } + }, + "reward_function": { + "reward_components": [ + { + "type": "WEBPAGE_UNAVAILABLE_PENALTY", + "weight": 0.25, + "options": { + "node_hostname": "client_1" + } + }, + { + "type": "GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY", + "weight": 0.05, + "options": { + "node_hostname": "client_1" + } + } + ] + } + }, + { + "ref": "data_manipulation_attacker", + "team": "RED", + "type": "RedDatabaseCorruptingAgent", + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_1", + "applications": [ + { + "application_name": "DataManipulationBot" + } + ] + }, + { + "node_name": "client_2", + "applications": [ + { + "application_name": "DataManipulationBot" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1 + } + }, + "reward_function": { + "reward_components": [ + { + "type": "DUMMY" + } + ] + }, + "agent_settings": { + "start_settings": { + "start_step": 25, + "frequency": 20, + "variance": 5 + } + } + }, + { + "ref": "defender", + "team": "BLUE", + "type": "ProxyAgent", + "observation_space": { + "type": "CUSTOM", + "options": { + "components": [ + { + "type": "NODES", + "label": "NODES", + "options": { + "hosts": [ + { + "hostname": "domain_controller" + }, + { + "hostname": "web_server", + "services": [ + { + "service_name": "WebServer" + } + ] + }, + { + "hostname": "database_server", + "folders": [ + { + "folder_name": "database", + "files": [ + { + "file_name": "database.db" + } + ] + } + ] + }, + { + "hostname": "backup_server" + }, + { + "hostname": "security_suite" + }, + { + "hostname": "client_1" + }, + { + "hostname": "client_2" + } + ], + "num_services": 1, + "num_applications": 0, + "num_folders": 1, + "num_files": 1, + "num_nics": 2, + "include_num_access": false, + "include_nmne": true, + "monitored_traffic": { + "icmp": [ + "NONE" + ], + "tcp": [ + "DNS" + ] + }, + "routers": [ + { + "hostname": "router_1" + } + ], + "num_ports": 0, + "ip_list": [ + "192.168.1.10", + "192.168.1.12", + "192.168.1.14", + "192.168.1.16", + "192.168.1.110", + "192.168.10.21", + "192.168.10.22", + "192.168.10.110" + ], + "wildcard_list": [ + "0.0.0.1" + ], + "port_list": [ + 80, + 5432 + ], + "protocol_list": [ + "ICMP", + "TCP", + "UDP" + ], + "num_rules": 10 + } + }, + { + "type": "LINKS", + "label": "LINKS", + "options": { + "link_references": [ + "router_1:eth-1<->switch_1:eth-8", + "router_1:eth-2<->switch_2:eth-8", + "switch_1:eth-1<->domain_controller:eth-1", + "switch_1:eth-2<->web_server:eth-1", + "switch_1:eth-3<->database_server:eth-1", + "switch_1:eth-4<->backup_server:eth-1", + "switch_1:eth-7<->security_suite:eth-1", + "switch_2:eth-1<->client_1:eth-1", + "switch_2:eth-2<->client_2:eth-1", + "switch_2:eth-7<->security_suite:eth-2" + ] + } + }, + { + "type": "NONE", + "label": "ICS", + "options": {} + } + ] + } + }, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_SERVICE_SCAN" + }, + { + "type": "NODE_SERVICE_STOP" + }, + { + "type": "NODE_SERVICE_START" + }, + { + "type": "NODE_SERVICE_PAUSE" + }, + { + "type": "NODE_SERVICE_RESUME" + }, + { + "type": "NODE_SERVICE_RESTART" + }, + { + "type": "NODE_SERVICE_DISABLE" + }, + { + "type": "NODE_SERVICE_ENABLE" + }, + { + "type": "NODE_SERVICE_FIX" + }, + { + "type": "NODE_FILE_SCAN" + }, + { + "type": "NODE_FILE_CHECKHASH" + }, + { + "type": "NODE_FILE_DELETE" + }, + { + "type": "NODE_FILE_REPAIR" + }, + { + "type": "NODE_FILE_RESTORE" + }, + { + "type": "NODE_FOLDER_SCAN" + }, + { + "type": "NODE_FOLDER_CHECKHASH" + }, + { + "type": "NODE_FOLDER_REPAIR" + }, + { + "type": "NODE_FOLDER_RESTORE" + }, + { + "type": "NODE_OS_SCAN" + }, + { + "type": "NODE_SHUTDOWN" + }, + { + "type": "NODE_STARTUP" + }, + { + "type": "NODE_RESET" + }, + { + "type": "ROUTER_ACL_ADDRULE" + }, + { + "type": "ROUTER_ACL_REMOVERULE" + }, + { + "type": "HOST_NIC_ENABLE" + }, + { + "type": "HOST_NIC_DISABLE" + } + ], + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_SERVICE_SCAN", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "2": { + "action": "NODE_SERVICE_STOP", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "3": { + "action": "NODE_SERVICE_START", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "4": { + "action": "NODE_SERVICE_PAUSE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "5": { + "action": "NODE_SERVICE_RESUME", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "6": { + "action": "NODE_SERVICE_RESTART", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "7": { + "action": "NODE_SERVICE_DISABLE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "8": { + "action": "NODE_SERVICE_ENABLE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "9": { + "action": "NODE_FILE_SCAN", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "10": { + "action": "NODE_FILE_CHECKHASH", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "11": { + "action": "NODE_FILE_DELETE", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "12": { + "action": "NODE_FILE_REPAIR", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "13": { + "action": "NODE_SERVICE_FIX", + "options": { + "node_id": 2, + "service_id": 0 + } + }, + "14": { + "action": "NODE_FOLDER_SCAN", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "15": { + "action": "NODE_FOLDER_CHECKHASH", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "16": { + "action": "NODE_FOLDER_REPAIR", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "17": { + "action": "NODE_FOLDER_RESTORE", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "18": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 0 + } + }, + "19": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 0 + } + }, + "20": { + "action": "NODE_STARTUP", + "options": { + "node_id": 0 + } + }, + "21": { + "action": "NODE_RESET", + "options": { + "node_id": 0 + } + }, + "22": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 1 + } + }, + "23": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 1 + } + }, + "24": { + "action": "NODE_STARTUP", + "options": { + "node_id": 1 + } + }, + "25": { + "action": "NODE_RESET", + "options": { + "node_id": 1 + } + }, + "26": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 2 + } + }, + "27": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 2 + } + }, + "28": { + "action": "NODE_STARTUP", + "options": { + "node_id": 2 + } + }, + "29": { + "action": "NODE_RESET", + "options": { + "node_id": 2 + } + }, + "30": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 3 + } + }, + "31": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 3 + } + }, + "32": { + "action": "NODE_STARTUP", + "options": { + "node_id": 3 + } + }, + "33": { + "action": "NODE_RESET", + "options": { + "node_id": 3 + } + }, + "34": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 4 + } + }, + "35": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 4 + } + }, + "36": { + "action": "NODE_STARTUP", + "options": { + "node_id": 4 + } + }, + "37": { + "action": "NODE_RESET", + "options": { + "node_id": 4 + } + }, + "38": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 5 + } + }, + "39": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 5 + } + }, + "40": { + "action": "NODE_STARTUP", + "options": { + "node_id": 5 + } + }, + "41": { + "action": "NODE_RESET", + "options": { + "node_id": 5 + } + }, + "42": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 6 + } + }, + "43": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 6 + } + }, + "44": { + "action": "NODE_STARTUP", + "options": { + "node_id": 6 + } + }, + "45": { + "action": "NODE_RESET", + "options": { + "node_id": 6 + } + }, + "46": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 1, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 1, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 1, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "47": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 2, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 1, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 1, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "48": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 3, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 3, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "49": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 4, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 3, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "50": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 5, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 4, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "51": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router": "router_1", + "position": 6, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 4, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "52": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 0 + } + }, + "53": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 1 + } + }, + "54": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 2 + } + }, + "55": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 3 + } + }, + "56": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 4 + } + }, + "57": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 5 + } + }, + "58": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 6 + } + }, + "59": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 7 + } + }, + "60": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 8 + } + }, + "61": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router": "router_1", + "position": 9 + } + }, + "62": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 0, + "nic_id": 0 + } + }, + "63": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 0, + "nic_id": 0 + } + }, + "64": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 1, + "nic_id": 0 + } + }, + "65": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 1, + "nic_id": 0 + } + }, + "66": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 2, + "nic_id": 0 + } + }, + "67": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 2, + "nic_id": 0 + } + }, + "68": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 3, + "nic_id": 0 + } + }, + "69": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 3, + "nic_id": 0 + } + }, + "70": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 4, + "nic_id": 0 + } + }, + "71": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 4, + "nic_id": 0 + } + }, + "72": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 4, + "nic_id": 1 + } + }, + "73": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 4, + "nic_id": 1 + } + }, + "74": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 5, + "nic_id": 0 + } + }, + "75": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 5, + "nic_id": 0 + } + }, + "76": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 6, + "nic_id": 0 + } + }, + "77": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 6, + "nic_id": 0 + } + } + }, + "options": { + "nodes": [ + { + "node_name": "domain_controller" + }, + { + "node_name": "web_server", + "applications": [ + { + "application_name": "DatabaseClient" + } + ], + "services": [ + { + "service_name": "WebServer" + } + ] + }, + { + "node_name": "database_server", + "folders": [ + { + "folder_name": "database", + "files": [ + { + "file_name": "database.db" + } + ] + } + ], + "services": [ + { + "service_name": "DatabaseService" + } + ] + }, + { + "node_name": "backup_server" + }, + { + "node_name": "security_suite" + }, + { + "node_name": "client_1" + }, + { + "node_name": "client_2" + } + ], + "max_folders_per_node": 2, + "max_files_per_folder": 2, + "max_services_per_node": 2, + "max_nics_per_node": 8, + "max_acl_rules": 10, + "ip_list": [ + "192.168.1.10", + "192.168.1.12", + "192.168.1.14", + "192.168.1.16", + "192.168.1.110", + "192.168.10.21", + "192.168.10.22", + "192.168.10.110" + ] + } + }, + "reward_function": { + "reward_components": [ + { + "type": "DATABASE_FILE_INTEGRITY", + "weight": 0.4, + "options": { + "node_hostname": "database_server", + "folder_name": "database", + "file_name": "database.db" + } + }, + { + "type": "SHARED_REWARD", + "weight": 1.0, + "options": { + "agent_name": "client_1_green_user" + } + }, + { + "type": "SHARED_REWARD", + "weight": 1.0, + "options": { + "agent_name": "client_2_green_user" + } + } + ] + }, + "agent_settings": { + "flatten_obs": true, + "action_masking": true + } + } + ], + "simulation": { + "network": { + "nmne_config": { + "capture_nmne": true, + "nmne_capture_keywords": [ + "DELETE" + ] + }, + "nodes": [ + { + "hostname": "router_1", + "type": "router", + "num_ports": 5, + "ports": { + "1": { + "ip_address": "192.168.1.1", + "subnet_mask": "255.255.255.0" + }, + "2": { + "ip_address": "192.168.10.1", + "subnet_mask": "255.255.255.0" + } + }, + "acl": { + "18": { + "action": "PERMIT", + "src_port": "POSTGRES_SERVER", + "dst_port": "POSTGRES_SERVER" + }, + "19": { + "action": "PERMIT", + "src_port": "DNS", + "dst_port": "DNS" + }, + "20": { + "action": "PERMIT", + "src_port": "FTP", + "dst_port": "FTP" + }, + "21": { + "action": "PERMIT", + "src_port": "HTTP", + "dst_port": "HTTP" + }, + "22": { + "action": "PERMIT", + "src_port": "ARP", + "dst_port": "ARP" + }, + "23": { + "action": "PERMIT", + "protocol": "ICMP" + } + } + }, + { + "hostname": "switch_1", + "type": "switch", + "num_ports": 8 + }, + { + "hostname": "switch_2", + "type": "switch", + "num_ports": 8 + }, + { + "hostname": "domain_controller", + "type": "server", + "ip_address": "192.168.1.10", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "services": [ + { + "type": "DNSServer", + "options": { + "domain_mapping": { + "arcd.com": "192.168.1.12" + } + } + } + ] + }, + { + "hostname": "web_server", + "type": "server", + "ip_address": "192.168.1.12", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "WebServer" + } + ], + "applications": [ + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ] + }, + { + "hostname": "database_server", + "type": "server", + "ip_address": "192.168.1.14", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "DatabaseService", + "options": { + "backup_server_ip": "192.168.1.16" + } + }, + { + "type": "FTPClient" + } + ] + }, + { + "hostname": "backup_server", + "type": "server", + "ip_address": "192.168.1.16", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "FTPServer" + } + ] + }, + { + "hostname": "security_suite", + "type": "server", + "ip_address": "192.168.1.110", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "network_interfaces": { + "2": { + "ip_address": "192.168.10.110", + "subnet_mask": "255.255.255.0" + } + } + }, + { + "hostname": "client_1", + "type": "computer", + "ip_address": "192.168.10.21", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.10.1", + "dns_server": "192.168.1.10", + "applications": [ + { + "type": "DataManipulationBot", + "options": { + "port_scan_p_of_success": 0.8, + "data_manipulation_p_of_success": 0.8, + "payload": "DELETE", + "server_ip": "192.168.1.14" + } + }, + { + "type": "WebBrowser", + "options": { + "target_url": "http://arcd.com/users/" + } + }, + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ], + "services": [ + { + "type": "DNSClient" + } + ] + }, + { + "hostname": "client_2", + "type": "computer", + "ip_address": "192.168.10.22", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.10.1", + "dns_server": "192.168.1.10", + "applications": [ + { + "type": "WebBrowser", + "options": { + "target_url": "http://arcd.com/users/" + } + }, + { + "type": "DataManipulationBot", + "options": { + "port_scan_p_of_success": 0.8, + "data_manipulation_p_of_success": 0.8, + "payload": "DELETE", + "server_ip": "192.168.1.14" + } + }, + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ], + "services": [ + { + "type": "DNSClient" + } + ] + } + ], + "links": [ + { + "endpoint_a_hostname": "router_1", + "endpoint_a_port": 1, + "endpoint_b_hostname": "switch_1", + "endpoint_b_port": 8 + }, + { + "endpoint_a_hostname": "router_1", + "endpoint_a_port": 2, + "endpoint_b_hostname": "switch_2", + "endpoint_b_port": 8 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 1, + "endpoint_b_hostname": "domain_controller", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 2, + "endpoint_b_hostname": "web_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 3, + "endpoint_b_hostname": "database_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 4, + "endpoint_b_hostname": "backup_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 7, + "endpoint_b_hostname": "security_suite", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 1, + "endpoint_b_hostname": "client_1", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 2, + "endpoint_b_hostname": "client_2", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 7, + "endpoint_b_hostname": "security_suite", + "endpoint_b_port": 2 + } + ] + } + } + } +} \ No newline at end of file From a5d84c12544e23eaa8766b92d16b78b2ce6675aa Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 2 Sep 2024 11:40:39 +0100 Subject: [PATCH 192/206] Reduce evaluation on Ray notebooks and fix precommit issues [skip ci] --- benchmark/results/v3/v3.3.0/session_metadata/1.json | 2 +- benchmark/results/v3/v3.3.0/session_metadata/2.json | 2 +- benchmark/results/v3/v3.3.0/session_metadata/3.json | 2 +- benchmark/results/v3/v3.3.0/session_metadata/4.json | 2 +- benchmark/results/v3/v3.3.0/session_metadata/5.json | 2 +- benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json | 2 +- src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb | 1 + src/primaite/notebooks/Training-an-RLLib-Agent.ipynb | 1 + 8 files changed, 8 insertions(+), 6 deletions(-) diff --git a/benchmark/results/v3/v3.3.0/session_metadata/1.json b/benchmark/results/v3/v3.3.0/session_metadata/1.json index 836363b8..c2a234ec 100644 --- a/benchmark/results/v3/v3.3.0/session_metadata/1.json +++ b/benchmark/results/v3/v3.3.0/session_metadata/1.json @@ -1006,4 +1006,4 @@ "999": 85.40000000000005, "1000": 38.94999999999991 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.3.0/session_metadata/2.json b/benchmark/results/v3/v3.3.0/session_metadata/2.json index 62f351cb..bc5243d2 100644 --- a/benchmark/results/v3/v3.3.0/session_metadata/2.json +++ b/benchmark/results/v3/v3.3.0/session_metadata/2.json @@ -1006,4 +1006,4 @@ "999": -70.9, "1000": 103.34999999999977 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.3.0/session_metadata/3.json b/benchmark/results/v3/v3.3.0/session_metadata/3.json index 7b4fd0a2..fb81d2b1 100644 --- a/benchmark/results/v3/v3.3.0/session_metadata/3.json +++ b/benchmark/results/v3/v3.3.0/session_metadata/3.json @@ -1006,4 +1006,4 @@ "999": 101.74999999999973, "1000": -39.899999999999984 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.3.0/session_metadata/4.json b/benchmark/results/v3/v3.3.0/session_metadata/4.json index cd4acfc0..49d8728b 100644 --- a/benchmark/results/v3/v3.3.0/session_metadata/4.json +++ b/benchmark/results/v3/v3.3.0/session_metadata/4.json @@ -1006,4 +1006,4 @@ "999": 103.14999999999976, "1000": 107.24999999999979 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.3.0/session_metadata/5.json b/benchmark/results/v3/v3.3.0/session_metadata/5.json index d6fc6124..018d05a9 100644 --- a/benchmark/results/v3/v3.3.0/session_metadata/5.json +++ b/benchmark/results/v3/v3.3.0/session_metadata/5.json @@ -1006,4 +1006,4 @@ "999": 105.09999999999974, "1000": 106.89999999999974 } -} \ No newline at end of file +} diff --git a/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json b/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json index b87c59c4..5aa47d95 100644 --- a/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json +++ b/benchmark/results/v3/v3.3.0/v3.3.0_benchmark_metadata.json @@ -7442,4 +7442,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb index 49801a2c..19e95a95 100644 --- a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb +++ b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb @@ -62,6 +62,7 @@ " .environment(env=PrimaiteRayMARLEnv, env_config=cfg)\n", " .env_runners(num_env_runners=0)\n", " .training(train_batch_size=128)\n", + " .evaluation(evaluation_duration=1)\n", " )\n" ] }, diff --git a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb index 2c35048d..dbe8871c 100644 --- a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb +++ b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb @@ -55,6 +55,7 @@ " .environment(env=PrimaiteRayEnv, env_config=env_config)\n", " .env_runners(num_env_runners=0)\n", " .training(train_batch_size=128)\n", + " .evaluation(evaluation_duration=1)\n", ")\n" ] }, From d282575467257bd3fe8095fe7dcd2f2b7cd5f3a6 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 4 Sep 2024 12:07:32 +0100 Subject: [PATCH 193/206] #2837 - Updating the User Guide as per review comments. [skip ci] --- .../nodes/common/common_node_attributes.rst | 9 +++----- docs/source/primaite-dependencies.rst | 10 ++++---- .../network/nodes/wireless_router.rst | 2 +- .../system/applications/c2_suite.rst | 20 ++++++++-------- .../system/common/common_configuration.rst | 8 +++---- .../system/services/terminal.rst | 23 +++++++++++++------ .../Command-&-Control-E2E-Demonstration.ipynb | 10 ++++---- 7 files changed, 43 insertions(+), 39 deletions(-) diff --git a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst index 7cf11eb4..6a95911f 100644 --- a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst +++ b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst @@ -58,7 +58,7 @@ The number of time steps required to occur in order for the node to cycle from ` --------- The list of pre-existing users that are additional to the default admin user (``username=admin``, ``password=admin``). -Additional users are configured as an array nd must contain a ``username``, ``password``, and can contain an optional +Additional users are configured as an array and must contain a ``username``, ``password``, and can contain an optional boolean ``is_admin``. Example of adding two additional users to a node: @@ -68,11 +68,8 @@ Example of adding two additional users to a node: simulation: network: nodes: - - hostname: client_1 - type: computer - ip_address: 192.168.10.11 - subnet_mask: 255.255.255.0 - default_gateway: 192.168.10.1 + - hostname: [hostname] + type: [Node Type] users: - username: jane.doe password: '1234' diff --git a/docs/source/primaite-dependencies.rst b/docs/source/primaite-dependencies.rst index 04987054..8367ee61 100644 --- a/docs/source/primaite-dependencies.rst +++ b/docs/source/primaite-dependencies.rst @@ -7,7 +7,7 @@ +===================+=========+====================================+=======================================================================================================+====================================================================+ | gymnasium | 0.28.1 | MIT License | A standard API for reinforcement learning and a diverse set of reference environments (formerly Gym). | https://farama.org | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ -| ipywidgets | 8.1.3 | BSD License | Jupyter interactive widgets | http://jupyter.org | +| ipywidgets | 8.1.5 | BSD License | Jupyter interactive widgets | http://jupyter.org | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | jupyterlab | 3.6.1 | BSD License | JupyterLab computational environment | https://jupyter.org | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ @@ -23,7 +23,7 @@ +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | plotly | 5.15.0 | MIT License | An open-source, interactive data visualization library for Python | https://plotly.com/python/ | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ -| polars | 0.18.4 | MIT License | Blazingly fast DataFrame library | https://www.pola.rs/ | +| polars | 0.20.30 | MIT License | Blazingly fast DataFrame library | https://www.pola.rs/ | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | prettytable | 3.8.0 | BSD License (BSD (3 clause)) | A simple Python library for easily displaying tabular data in a visually appealing ASCII table format | https://github.com/jazzband/prettytable | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ @@ -31,7 +31,7 @@ +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | PyYAML | 6.0 | MIT License | YAML parser and emitter for Python | https://pyyaml.org/ | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ -| ray | 2.23.0 | Apache 2.0 | Ray provides a simple, universal API for building distributed applications. | https://github.com/ray-project/ray | +| ray | 2.32.0 | Apache 2.0 | Ray provides a simple, universal API for building distributed applications. | https://github.com/ray-project/ray | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | stable-baselines3 | 2.1.0 | MIT | Pytorch version of Stable Baselines, implementations of reinforcement learning algorithms. | https://github.com/DLR-RM/stable-baselines3 | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ @@ -39,7 +39,7 @@ +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ | typer | 0.9.0 | MIT License | Typer, build great CLIs. Easy to code. Based on Python type hints. | https://github.com/tiangolo/typer | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ -| Deepdiff | 7.0.1 | MIT License | Deep difference of dictionaries, iterables, strings, and any other object objects. | https://github.com/seperman/deepdiff | +| Deepdiff | 8.0.1 | MIT License | Deep difference of dictionaries, iterables, strings, and any other object objects. | https://github.com/seperman/deepdiff | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ -| sb3_contrib | 2.3.0 | MIT License | Contrib package for Stable-Baselines3 - Experimental reinforcement learning (RL) code (Action Masking)| https://github.com/Stable-Baselines-Team/stable-baselines3-contrib | +| sb3_contrib | 2.1.0 | MIT License | Contrib package for Stable-Baselines3 - Experimental reinforcement learning (RL) code (Action Masking)| https://github.com/Stable-Baselines-Team/stable-baselines3-contrib | +-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------+ diff --git a/docs/source/simulation_components/network/nodes/wireless_router.rst b/docs/source/simulation_components/network/nodes/wireless_router.rst index c78c8419..80f0e124 100644 --- a/docs/source/simulation_components/network/nodes/wireless_router.rst +++ b/docs/source/simulation_components/network/nodes/wireless_router.rst @@ -3,7 +3,7 @@ © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK ###### -Router +Wireless Router ###### The ``WirelessRouter`` class extends the functionality of the standard ``Router`` class within PrimAITE, diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 034158d7..5fd1021e 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -7,8 +7,8 @@ Command and Control Application Suite ##################################### -Comprising of two applications, the Command and Control (C2) suites intends to introduce -malicious network architecture and begin to further the realism of red agents within primAITE. +Comprising of two applications, the Command and Control (C2) suite intends to introduce +malicious network architecture and further the realism of red agents within PrimAITE. Overview: ========= @@ -24,7 +24,7 @@ The C2 Server application is intended to represent the malicious infrastructure The C2 Server is configured to listen and await ``keep alive`` traffic from a C2 beacon. Once received the C2 Server is able to send and receive C2 commands. -Currently, the C2 Server offers three commands: +Currently, the C2 Server offers four commands: +---------------------+---------------------------------------------------------------------------+ |C2 Command | Meaning | @@ -40,12 +40,12 @@ Currently, the C2 Server offers three commands: It's important to note that in order to keep PrimAITE realistic from a cyber perspective, -The C2 Server application should never be visible or actionable upon directly by the blue agent. +the C2 Server application should never be visible or actionable upon directly by the blue agent. This is because in the real world, C2 servers are hosted on ephemeral public domains that would not be accessible by private network blue agent. Therefore granting blue agent(s) the ability to perform counter measures directly against the application would be unrealistic. -It is more accurate to see the host that the C2 Server is installed on as being able to route to the C2 Server (Internet Access). +It is more accurate to see the host that the C2 Beacon is installed on as being able to route to the C2 Server (Internet Access). ``C2 Beacon`` """"""""""""" @@ -54,19 +54,19 @@ The C2 Beacon application is intended to represent malware that is used to estab A C2 Beacon will need to be first configured with the C2 Server IP Address which can be done via the ``configure`` method. -Once installed and configured; the c2 beacon can establish connection with the C2 Server via executing the application. +Once installed and configured; the C2 beacon can establish connection with the C2 Server via executing the application. This will send an initial ``keep alive`` to the given C2 Server (The C2 Server IPv4Address must be given upon C2 Beacon configuration). -Which is then resolved and responded by another ``Keep Alive`` by the c2 server back to the C2 beacon to confirm connection. +Which is then resolved and responded by another ``Keep Alive`` by the C2 server back to the C2 beacon to confirm connection. -The C2 Beacon will send out periodic keep alive based on it's configuration parameters to configure it's active connection with the c2 server. +The C2 Beacon will send out periodic keep alive based on it's configuration parameters to configure it's active connection with the C2 server. It's recommended that a C2 Beacon is installed and configured mid episode by a Red Agent for a more cyber realistic simulation. Usage ===== -As mentioned, the C2 Suite is intended to grant Red Agents further flexibility whilst also expanding a blue agent's observation_space. +As mentioned, the C2 Suite is intended to grant Red Agents further flexibility whilst also expanding a blue agent's observation space. Adding to this, the following behaviour of the C2 beacon can be configured by users for increased domain randomisation: @@ -301,7 +301,7 @@ What port that the C2 Beacon will use to communicate to the C2 Server with. Currently only ``FTP``, ``HTTP`` and ``DNS`` are valid masquerade port options. -It's worth noting that this may be useful option to bypass ACL rules. +It's worth noting that this may be a useful option to bypass ACL rules. This must be a string i.e ``DNS``. Defaults to ``HTTP``. diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst index 420166dd..49e3188b 100644 --- a/docs/source/simulation_components/system/common/common_configuration.rst +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -30,7 +30,7 @@ The number of timesteps the |SOFTWARE_NAME| will remain in a ``FIXING`` state be ``listen_on_ports`` """"""""""""""""""" -The set of ports to listen on. This is in addition to the main port the software is designated. This set can either be +The set of ports to listen on. This is in addition to the main port the software is designated. This can either be the string name of ports or the port integers Example: @@ -46,14 +46,12 @@ Example: subnet_mask: 255.255.255.0 default_gateway: 192.168.10.1 services: - - type: DatabaseService + - type: [Service Type] options: - backup_server_ip: 10.10.1.12 listen_on_ports: - 631 applications: - - type: WebBrowser + - type: [Application Type] options: - target_url: http://sometech.ai listen_on_ports: - SMB diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index f982145d..9db2ac7a 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -23,13 +23,6 @@ Key capabilities - Simulates common Terminal processes/commands. - Leverages the Service base class for install/uninstall, status tracking etc. -Usage -""""" - - - Pre-Installs on any `Node` (component with the exception of `Switches`). - - Terminal Clients connect, execute commands and disconnect from remote nodes. - - Ensures that users are logged in to the component before executing any commands. - - Service runs on SSH port 22 by default. Implementation """""""""""""" @@ -40,6 +33,14 @@ Implementation - A detailed guide on the implementation and functionality of the Terminal class can be found in the "Terminal-Processing" jupyter notebook. +Usage +""""" + + - Pre-Installs on all ``Node`` (with the exception of ``Switch``). + - Terminal Clients connect, execute commands and disconnect from remote nodes. + - Ensures that users are logged in to the component before executing any commands. + - Service runs on SSH port 22 by default. + Usage ===== @@ -172,3 +173,11 @@ Disconnect from Remote Node term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") term_a_term_b_remote_connection.disconnect() + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: Terminal +.. |SOFTWARE_NAME_BACKTICK| replace:: ``Terminal`` \ No newline at end of file diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index b6b13f28..4e36db17 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -188,7 +188,7 @@ "source": [ "## **Notebook Setup** | Network Prerequisites\n", "\n", - "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run before the Red Agent can perform any C2 specific action.\n", + "Before the Red Agent is able to perform any C2 specific actions, the C2 Server needs to be installed and run.\n", "This is because in higher fidelity environments (and the real-world) a C2 server would not be accessible by a private network blue agent and the C2 Server would already be in place before the an adversary (Red Agent) starts.\n", "\n", "The cells below install and run the C2 Server on client_1 directly via the simulation API." @@ -1164,7 +1164,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Now we are unable to do so as the C2 Server is unable has lost it's connection to the C2 Beacon:" + "Now we are unable to do so as the C2 Server has lost it's connection to the C2 Beacon:" ] }, { @@ -1276,7 +1276,7 @@ "source": [ "#### Blocking C2 Traffic via ACL.\n", "\n", - "Another potential option a blue agent could take is by placing an ACL rule which blocks traffic between the C2 Server can C2 Beacon.\n", + "Another potential option a blue agent could take is by placing an ACL rule which blocks traffic between the C2 Server and C2 Beacon.\n", "\n", "It's worth noting the potential effectiveness of this approach is connected to the current green agent traffic on the network. For example, if there are multiple green agents using the C2 Beacon's host node then blocking all traffic would lead to a negative reward. The same applies for the previous example." ] @@ -1450,7 +1450,7 @@ "source": [ "### **Command and Control** | Configurability | C2 Server IP Address\n", "\n", - "As with a majority of client and server based application configuration in primaite, the remote IP of server must be supplied.\n", + "As with a majority of client and server based application configuration in primaite, the remote IP of a server must be supplied.\n", "\n", "In the case of the C2 Beacon, the C2 Server's IP address must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", "\n", @@ -1727,7 +1727,7 @@ "\n", "\n", "\n", - "The next set of code cells will demonstrate the impact this option from a blue agent perspective." + "The next set of code cells will demonstrate the impact of this option from a blue agent perspective." ] }, { From b3080100fd9883edffa6141c986109cb4fe8441c Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 4 Sep 2024 12:08:12 +0100 Subject: [PATCH 194/206] #2837 - Updating the User Guide as per review comments. [skip ci] --- docs/source/simulation_components/system/services/terminal.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 9db2ac7a..041169b1 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -180,4 +180,4 @@ Configuration .. include:: ../common/common_configuration.rst .. |SOFTWARE_NAME| replace:: Terminal -.. |SOFTWARE_NAME_BACKTICK| replace:: ``Terminal`` \ No newline at end of file +.. |SOFTWARE_NAME_BACKTICK| replace:: ``Terminal`` From f0cc821ff85b2f2cfbeef76cf416f7b73a8e0f84 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 4 Sep 2024 14:12:10 +0100 Subject: [PATCH 195/206] #2837 - Updates to some more documentation files to cover new features [skip ci] --- CHANGELOG.md | 3 ++- docs/source/configuration/agents.rst | 5 +++++ docs/source/configuration/game.rst | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d08974c..3c4b949a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.3.0] - 2024-08-30 + ### Added - Random Number Generator Seeding by specifying a random number seed in the config file. - Implemented Terminal service class, providing a generic terminal simulation. diff --git a/docs/source/configuration/agents.rst b/docs/source/configuration/agents.rst index 2fe35ac7..39a71fb5 100644 --- a/docs/source/configuration/agents.rst +++ b/docs/source/configuration/agents.rst @@ -172,3 +172,8 @@ The amount of timesteps that the frequency can randomly change. --------------- If ``True``, gymnasium flattening will be performed on the observation space before sending to the agent. Set this to ``True`` if your agent does not support nested observation spaces. + +``Agent History`` +----------------- + +Agents will record their action log for each step. This is a summary of what the agent did, along with response information from requests within the simulation. \ No newline at end of file diff --git a/docs/source/configuration/game.rst b/docs/source/configuration/game.rst index 02ee8110..1d08b8e4 100644 --- a/docs/source/configuration/game.rst +++ b/docs/source/configuration/game.rst @@ -28,6 +28,7 @@ This section defines high-level settings that apply across the game, currently i high: 10 medium: 5 low: 0 + seed: 1 ``max_episode_length`` ---------------------- @@ -54,3 +55,8 @@ See :ref:`List of IPProtocols ` for a list of protocols. -------------- These are used to determine the thresholds of high, medium and low categories for counted observation occurrences. + +``seed`` +-------- + +Used to configure the random seeds used within PrimAITE, ensuring determinism within episode/session runs. If empty or set to -1, no seed is set. \ No newline at end of file From 16e0df5cfc9422548b9c292164cf851f0c19208d Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 4 Sep 2024 14:12:39 +0100 Subject: [PATCH 196/206] #2837 - Updates to some more documentation files to cover new features [skip ci] --- docs/source/configuration/agents.rst | 2 +- docs/source/configuration/game.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/configuration/agents.rst b/docs/source/configuration/agents.rst index 39a71fb5..dece94c5 100644 --- a/docs/source/configuration/agents.rst +++ b/docs/source/configuration/agents.rst @@ -176,4 +176,4 @@ If ``True``, gymnasium flattening will be performed on the observation space bef ``Agent History`` ----------------- -Agents will record their action log for each step. This is a summary of what the agent did, along with response information from requests within the simulation. \ No newline at end of file +Agents will record their action log for each step. This is a summary of what the agent did, along with response information from requests within the simulation. diff --git a/docs/source/configuration/game.rst b/docs/source/configuration/game.rst index 1d08b8e4..2048708c 100644 --- a/docs/source/configuration/game.rst +++ b/docs/source/configuration/game.rst @@ -59,4 +59,4 @@ These are used to determine the thresholds of high, medium and low categories fo ``seed`` -------- -Used to configure the random seeds used within PrimAITE, ensuring determinism within episode/session runs. If empty or set to -1, no seed is set. \ No newline at end of file +Used to configure the random seeds used within PrimAITE, ensuring determinism within episode/session runs. If empty or set to -1, no seed is set. From ba737c57a8fac390dcf00c46feabaea2463d0441 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Wed, 4 Sep 2024 20:46:35 +0100 Subject: [PATCH 197/206] #2837 - Minor structure reshuffle to address confusion around the listen_on_ports variable [skip ci] --- .../system/applications/c2_suite.rst | 9 ++-- .../applications/data_manipulation_bot.rst | 4 -- .../system/applications/database_client.rst | 5 --- .../system/applications/dos_bot.rst | 3 ++ .../system/applications/nmap.rst | 8 ++-- .../system/applications/ransomware_script.rst | 4 -- .../system/applications/web_browser.rst | 4 -- .../system/common/common_configuration.rst | 42 +++++++++---------- .../system/services/database_service.rst | 10 ++--- .../system/services/dns_client.rst | 4 -- .../system/services/dns_server.rst | 8 +--- .../system/services/ftp_client.rst | 5 --- .../system/services/ftp_server.rst | 5 --- .../system/services/ntp_client.rst | 5 --- .../system/services/ntp_server.rst | 8 ---- .../system/services/terminal.rst | 8 ---- .../system/services/web_server.rst | 8 ++-- .../simulation_components/system/software.rst | 9 ++++ 18 files changed, 49 insertions(+), 100 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 5fd1021e..82519ab6 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -254,6 +254,9 @@ Via Configuration C2 Beacon Configuration ======================= +``Common Configuration`` +"""""""""""""""""""""""" + .. include:: ../common/common_configuration.rst .. |SOFTWARE_NAME| replace:: C2Beacon @@ -311,9 +314,3 @@ C2 Server Configuration ======================= *The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour.* - - -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: C2Server -.. |SOFTWARE_NAME_BACKTICK| replace:: ``C2Server`` diff --git a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst index 8bcbb265..dd8b7114 100644 --- a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst +++ b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst @@ -158,10 +158,6 @@ If not using the data manipulation bot manually, it needs to be used with a data Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: DataManipulationBot -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DataManipulationBot`` ``server_ip`` """"""""""""" diff --git a/docs/source/simulation_components/system/applications/database_client.rst b/docs/source/simulation_components/system/applications/database_client.rst index d51465b2..45252e67 100644 --- a/docs/source/simulation_components/system/applications/database_client.rst +++ b/docs/source/simulation_components/system/applications/database_client.rst @@ -90,11 +90,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: DatabaseClient -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DatabaseClient`` - ``db_server_ip`` """""""""""""""" diff --git a/docs/source/simulation_components/system/applications/dos_bot.rst b/docs/source/simulation_components/system/applications/dos_bot.rst index 9925dc93..5be5383e 100644 --- a/docs/source/simulation_components/system/applications/dos_bot.rst +++ b/docs/source/simulation_components/system/applications/dos_bot.rst @@ -98,6 +98,9 @@ Via Configuration Configuration ============= +``Common Configuration`` +"""""""""""""""""""""""" + .. include:: ../common/common_configuration.rst .. |SOFTWARE_NAME| replace:: DoSBot diff --git a/docs/source/simulation_components/system/applications/nmap.rst b/docs/source/simulation_components/system/applications/nmap.rst index 1e7f5ea4..dbb8a022 100644 --- a/docs/source/simulation_components/system/applications/nmap.rst +++ b/docs/source/simulation_components/system/applications/nmap.rst @@ -346,10 +346,8 @@ Perform a full box scan on all ports, over both TCP and UDP, on a whole subnet: | 192.168.1.13 | 219 | ARP | UDP | +--------------+------+-----------------+----------+ -Configuration -============= -.. include:: ../common/common_configuration.rst +``Common Attributes`` +""""""""""""""""""""" -.. |SOFTWARE_NAME| replace:: NMAP -.. |SOFTWARE_NAME_BACKTICK| replace:: ``NMAP`` +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/ransomware_script.rst b/docs/source/simulation_components/system/applications/ransomware_script.rst index a2a853e9..a5ee990c 100644 --- a/docs/source/simulation_components/system/applications/ransomware_script.rst +++ b/docs/source/simulation_components/system/applications/ransomware_script.rst @@ -72,10 +72,6 @@ Configuration The RansomwareScript inherits configuration options such as ``fix_duration`` from its parent class. However, for the ``RansomwareScript`` the most relevant option is ``server_ip``. -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: RansomwareScript -.. |SOFTWARE_NAME_BACKTICK| replace:: ``RansomwareScript`` ``server_ip`` """"""""""""" diff --git a/docs/source/simulation_components/system/applications/web_browser.rst b/docs/source/simulation_components/system/applications/web_browser.rst index dbe2da28..52cfce28 100644 --- a/docs/source/simulation_components/system/applications/web_browser.rst +++ b/docs/source/simulation_components/system/applications/web_browser.rst @@ -92,10 +92,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: WebBrowser -.. |SOFTWARE_NAME_BACKTICK| replace:: ``WebBrowser`` ``target_url`` """""""""""""" diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst index 49e3188b..73971b37 100644 --- a/docs/source/simulation_components/system/common/common_configuration.rst +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -2,35 +2,38 @@ © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -``ref`` -======= +.. _Common Configuration: -Human readable name used as reference for the |SOFTWARE_NAME_BACKTICK|. Not used in code. +Common Configuration +-------------------- -``type`` -======== +ref +""" -The type of software that should be added. To add |SOFTWARE_NAME| this must be |SOFTWARE_NAME_BACKTICK|. +Human readable name used as reference for the software class. Not used in code. -``options`` -=========== +type +"""" + +The type of software that should be added. To add the required software, this must be it's name. + +options +""""""" The configuration options are the attributes that fall under the options for an application. - - -``fix_duration`` -"""""""""""""""" +fix_duration +"""""""""""" Optional. Default value is ``2``. -The number of timesteps the |SOFTWARE_NAME| will remain in a ``FIXING`` state before going into a ``GOOD`` state. +The number of timesteps the software will remain in a ``FIXING`` state before going into a ``GOOD`` state. -``listen_on_ports`` -""""""""""""""""""" +listen_on_ports +^^^^^^^^^^^^^^^ -The set of ports to listen on. This is in addition to the main port the software is designated. This can either be +Optional. The set of ports to listen on. This is in addition to the main port the software is designated. This can either be the string name of ports or the port integers Example: @@ -40,11 +43,8 @@ Example: simulation: network: nodes: - - hostname: client - type: computer - ip_address: 192.168.10.11 - subnet_mask: 255.255.255.0 - default_gateway: 192.168.10.1 + - hostname: [hostname] + type: [Node Type] services: - type: [Service Type] options: diff --git a/docs/source/simulation_components/system/services/database_service.rst b/docs/source/simulation_components/system/services/database_service.rst index 2f0452f0..7613b8ca 100644 --- a/docs/source/simulation_components/system/services/database_service.rst +++ b/docs/source/simulation_components/system/services/database_service.rst @@ -94,11 +94,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: DatabaseService -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DatabaseService`` - ``backup_server_ip`` """""""""""""""""""" @@ -114,3 +109,8 @@ This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.25 Optional. Default value is ``None``. The password that needs to be provided by connecting clients in order to create a successful connection. + +``Common Configuration`` +"""""""""""""""""""""""" + +Common configuration variables are detailed within :ref:`software` diff --git a/docs/source/simulation_components/system/services/dns_client.rst b/docs/source/simulation_components/system/services/dns_client.rst index c0025114..2cab953e 100644 --- a/docs/source/simulation_components/system/services/dns_client.rst +++ b/docs/source/simulation_components/system/services/dns_client.rst @@ -84,10 +84,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: DNSClient -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DNSClient`` ``dns_server`` """""""""""""" diff --git a/docs/source/simulation_components/system/services/dns_server.rst b/docs/source/simulation_components/system/services/dns_server.rst index b681f32f..3e90a551 100644 --- a/docs/source/simulation_components/system/services/dns_server.rst +++ b/docs/source/simulation_components/system/services/dns_server.rst @@ -83,13 +83,9 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst -.. |SOFTWARE_NAME| replace:: DNSServer -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DNSServer`` - -domain_mapping -"""""""""""""" +``domain_mapping`` +"""""""""""""""""" Domain mapping takes the domain and IP Addresses as a key-value pairs i.e. diff --git a/docs/source/simulation_components/system/services/ftp_client.rst b/docs/source/simulation_components/system/services/ftp_client.rst index fdf9cfcf..21bd9f2e 100644 --- a/docs/source/simulation_components/system/services/ftp_client.rst +++ b/docs/source/simulation_components/system/services/ftp_client.rst @@ -82,8 +82,3 @@ Via Configuration Configuration ============= - -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: FTPClient -.. |SOFTWARE_NAME_BACKTICK| replace:: ``FTPClient`` diff --git a/docs/source/simulation_components/system/services/ftp_server.rst b/docs/source/simulation_components/system/services/ftp_server.rst index 9b26157d..e0e1a394 100644 --- a/docs/source/simulation_components/system/services/ftp_server.rst +++ b/docs/source/simulation_components/system/services/ftp_server.rst @@ -81,11 +81,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: FTPServer -.. |SOFTWARE_NAME_BACKTICK| replace:: ``FTPServer`` - ``server_password`` """"""""""""""""""" diff --git a/docs/source/simulation_components/system/services/ntp_client.rst b/docs/source/simulation_components/system/services/ntp_client.rst index 6faad108..e578651b 100644 --- a/docs/source/simulation_components/system/services/ntp_client.rst +++ b/docs/source/simulation_components/system/services/ntp_client.rst @@ -80,11 +80,6 @@ Via Configuration Configuration ============= -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: NTPClient -.. |SOFTWARE_NAME_BACKTICK| replace:: ``NTPClient`` - ``ntp_server_ip`` """"""""""""""""" diff --git a/docs/source/simulation_components/system/services/ntp_server.rst b/docs/source/simulation_components/system/services/ntp_server.rst index 3ddb51ea..30d0b2fa 100644 --- a/docs/source/simulation_components/system/services/ntp_server.rst +++ b/docs/source/simulation_components/system/services/ntp_server.rst @@ -74,11 +74,3 @@ Via Configuration services: - ref: ntp_server type: NTPServer - -Configuration -============= - -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: NTPServer -.. |SOFTWARE_NAME_BACKTICK| replace:: ``NTPServer`` diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 041169b1..24cfe6e1 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -173,11 +173,3 @@ Disconnect from Remote Node term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") term_a_term_b_remote_connection.disconnect() - -Configuration -============= - -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: Terminal -.. |SOFTWARE_NAME_BACKTICK| replace:: ``Terminal`` diff --git a/docs/source/simulation_components/system/services/web_server.rst b/docs/source/simulation_components/system/services/web_server.rst index f0294223..04b9b16a 100644 --- a/docs/source/simulation_components/system/services/web_server.rst +++ b/docs/source/simulation_components/system/services/web_server.rst @@ -75,10 +75,8 @@ Via Configuration - ref: web_server type: WebServer -Configuration -============= -.. include:: ../common/common_configuration.rst +``Common Attributes`` +""""""""""""""""""""" -.. |SOFTWARE_NAME| replace:: WebServer -.. |SOFTWARE_NAME_BACKTICK| replace:: ``WebServer`` +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/software.rst b/docs/source/simulation_components/system/software.rst index 3acfb9b4..c8f0e2d3 100644 --- a/docs/source/simulation_components/system/software.rst +++ b/docs/source/simulation_components/system/software.rst @@ -2,6 +2,8 @@ © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +.. _software: + Software ======== @@ -63,3 +65,10 @@ Processes ######### `To be implemented` + +Common Software Configuration +############################# + +Below is a list of the common configuration items within Software components of PrimAITE: + +.. include:: common/common_configuration.rst From 0140982d5e666914cae439c9a4dc71630cba45bd Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 08:41:04 +0100 Subject: [PATCH 198/206] #2837 - Updating link to common attributes within Software components following Review comments. [skip ci] --- .../system/applications/c2_suite.rst | 13 ++++++++----- .../system/applications/data_manipulation_bot.rst | 5 +++++ .../system/applications/database_client.rst | 5 +++++ .../system/applications/dos_bot.rst | 13 +++++-------- .../system/applications/ransomware_script.rst | 5 +++++ .../system/applications/web_browser.rst | 6 ++++++ .../system/services/database_service.rst | 6 +++--- .../system/services/dns_client.rst | 5 +++++ .../system/services/dns_server.rst | 5 +++++ .../system/services/ftp_client.rst | 5 +++++ .../system/services/ftp_server.rst | 5 +++++ .../system/services/ntp_client.rst | 5 +++++ .../system/services/ntp_server.rst | 6 ++++++ .../system/services/terminal.rst | 6 ++++++ 14 files changed, 74 insertions(+), 16 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 82519ab6..3e2b669c 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -254,13 +254,11 @@ Via Configuration C2 Beacon Configuration ======================= -``Common Configuration`` -"""""""""""""""""""""""" +``Common Attributes`` +""""""""""""""""""""" -.. include:: ../common/common_configuration.rst +See :ref:`Common Configuration` -.. |SOFTWARE_NAME| replace:: C2Beacon -.. |SOFTWARE_NAME_BACKTICK| replace:: ``C2Beacon`` ``c2_server_ip_address`` """""""""""""""""""""""" @@ -314,3 +312,8 @@ C2 Server Configuration ======================= *The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour.* + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst index dd8b7114..ade46d3a 100644 --- a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst +++ b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst @@ -199,3 +199,8 @@ Optional. Default value is ``0.1``. The chance of the ``DataManipulationBot`` to succeed with a data manipulation attack. This must be a float value between ``0`` and ``1``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/database_client.rst b/docs/source/simulation_components/system/applications/database_client.rst index 45252e67..4a5e17c2 100644 --- a/docs/source/simulation_components/system/applications/database_client.rst +++ b/docs/source/simulation_components/system/applications/database_client.rst @@ -104,3 +104,8 @@ This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.25 Optional. Default value is ``None``. The password that the ``DatabaseClient`` will use to access the :ref:`DatabaseService`. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/dos_bot.rst b/docs/source/simulation_components/system/applications/dos_bot.rst index 5be5383e..bf7b1037 100644 --- a/docs/source/simulation_components/system/applications/dos_bot.rst +++ b/docs/source/simulation_components/system/applications/dos_bot.rst @@ -98,14 +98,6 @@ Via Configuration Configuration ============= -``Common Configuration`` -"""""""""""""""""""""""" - -.. include:: ../common/common_configuration.rst - -.. |SOFTWARE_NAME| replace:: DoSBot -.. |SOFTWARE_NAME_BACKTICK| replace:: ``DoSBot`` - ``target_ip_address`` """"""""""""""""""""" @@ -164,3 +156,8 @@ Optional. Default value is ``1000``. The maximum number of sessions the ``DoSBot`` is able to make. This must be an integer value equal to or greater than ``0``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/ransomware_script.rst b/docs/source/simulation_components/system/applications/ransomware_script.rst index a5ee990c..db5be2ed 100644 --- a/docs/source/simulation_components/system/applications/ransomware_script.rst +++ b/docs/source/simulation_components/system/applications/ransomware_script.rst @@ -79,3 +79,8 @@ The RansomwareScript inherits configuration options such as ``fix_duration`` fro IP address of the :ref:`DatabaseService` which the ``RansomwareScript`` will encrypt. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/web_browser.rst b/docs/source/simulation_components/system/applications/web_browser.rst index 52cfce28..b0466ad1 100644 --- a/docs/source/simulation_components/system/applications/web_browser.rst +++ b/docs/source/simulation_components/system/applications/web_browser.rst @@ -105,3 +105,9 @@ The domain ``arcd.com`` can be matched by - http://arcd.com/ - http://arcd.com/users/ - arcd.com + + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/database_service.rst b/docs/source/simulation_components/system/services/database_service.rst index 7613b8ca..f1b617e6 100644 --- a/docs/source/simulation_components/system/services/database_service.rst +++ b/docs/source/simulation_components/system/services/database_service.rst @@ -110,7 +110,7 @@ Optional. Default value is ``None``. The password that needs to be provided by connecting clients in order to create a successful connection. -``Common Configuration`` -"""""""""""""""""""""""" +``Common Attributes`` +""""""""""""""""""""" -Common configuration variables are detailed within :ref:`software` +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/dns_client.rst b/docs/source/simulation_components/system/services/dns_client.rst index 2cab953e..17fe0219 100644 --- a/docs/source/simulation_components/system/services/dns_client.rst +++ b/docs/source/simulation_components/system/services/dns_client.rst @@ -93,3 +93,8 @@ Optional. Default value is ``None``. The IP Address of the :ref:`DNSServer`. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/dns_server.rst b/docs/source/simulation_components/system/services/dns_server.rst index 3e90a551..0b6acb01 100644 --- a/docs/source/simulation_components/system/services/dns_server.rst +++ b/docs/source/simulation_components/system/services/dns_server.rst @@ -92,3 +92,8 @@ Domain mapping takes the domain and IP Addresses as a key-value pairs i.e. If the domain is "arcd.com" and the IP Address attributed to the domain is 192.168.0.10, then the value should be ``arcd.com: 192.168.0.10`` The key must be a string and the IP Address must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ftp_client.rst b/docs/source/simulation_components/system/services/ftp_client.rst index 21bd9f2e..265a03ea 100644 --- a/docs/source/simulation_components/system/services/ftp_client.rst +++ b/docs/source/simulation_components/system/services/ftp_client.rst @@ -82,3 +82,8 @@ Via Configuration Configuration ============= + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ftp_server.rst b/docs/source/simulation_components/system/services/ftp_server.rst index e0e1a394..9b068d68 100644 --- a/docs/source/simulation_components/system/services/ftp_server.rst +++ b/docs/source/simulation_components/system/services/ftp_server.rst @@ -87,3 +87,8 @@ Configuration Optional. Default value is ``None``. The password that needs to be provided by a connecting :ref:`FTPClient` in order to create a successful connection. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ntp_client.rst b/docs/source/simulation_components/system/services/ntp_client.rst index e578651b..8096a4fe 100644 --- a/docs/source/simulation_components/system/services/ntp_client.rst +++ b/docs/source/simulation_components/system/services/ntp_client.rst @@ -88,3 +88,8 @@ Optional. Default value is ``None``. The IP address of an NTP Server which provides a time that the ``NTPClient`` can synchronise to. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ntp_server.rst b/docs/source/simulation_components/system/services/ntp_server.rst index 30d0b2fa..f2bb6684 100644 --- a/docs/source/simulation_components/system/services/ntp_server.rst +++ b/docs/source/simulation_components/system/services/ntp_server.rst @@ -74,3 +74,9 @@ Via Configuration services: - ref: ntp_server type: NTPServer + + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 24cfe6e1..c319d264 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -173,3 +173,9 @@ Disconnect from Remote Node term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username="admin", password="Admin123!", ip_address="192.168.0.11") term_a_term_b_remote_connection.disconnect() + + +``Common Attributes`` +""""""""""""""""""""" + +See :ref:`Common Configuration` From e18ac0914fbe95809f9bba6cfafe71137f3883e7 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 08:42:38 +0100 Subject: [PATCH 199/206] #2837 - Correcting date on changelog v3.3 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4b949a..4b9ca8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.3.0] - 2024-08-30 +## [3.3.0] - 2024-09-04 ### Added - Random Number Generator Seeding by specifying a random number seed in the config file. From 3feb908900309b1445236bc79c9f1541307254e9 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 09:02:07 +0100 Subject: [PATCH 200/206] #2837 - Added a description of how some rewards can be made sticky/instantaneous. [skip ci] --- docs/source/rewards.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/source/rewards.rst b/docs/source/rewards.rst index 921544e8..0163284c 100644 --- a/docs/source/rewards.rst +++ b/docs/source/rewards.rst @@ -7,6 +7,9 @@ Rewards Rewards in PrimAITE are based on a system of individual components that react to events in the simulation. An agent's reward function is calculated as the weighted sum of several reward components. +Some rewards, such as the ``GreenAdminDatabaseUnreachablePenalty``, can be marked as 'sticky' in their configuration. Setting this to ``True`` will mean that they continue to output the same value after an event until another event of that type. +In the instance of the ``GreenAdminDatabaseUnreachablePenalty``, the database admin reward will stay negative until the next successful database request is made, even if the database admin agents do nothing and the database returns a good state. + Components ********** The following API pages describe the use of each reward component and the possible configuration options. An example of configuring each via yaml is also provided. From fcbde31dad1722bd98ba0a8f49f9239c4f649200 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 11:23:52 +0100 Subject: [PATCH 201/206] #2837 - Actioning review comments and fixing a bug with links to the common attributes within software documents --- .../system/applications/c2_suite.rst | 16 ++++++++-------- .../applications/data_manipulation_bot.rst | 2 +- .../system/applications/database_client.rst | 2 +- .../system/applications/dos_bot.rst | 2 +- .../system/applications/ransomware_script.rst | 2 +- .../system/applications/web_browser.rst | 2 +- .../system/common/common_configuration.rst | 2 +- .../system/services/database_service.rst | 2 +- .../system/services/dns_client.rst | 2 +- .../system/services/dns_server.rst | 2 +- .../system/services/ftp_client.rst | 2 +- .../system/services/ftp_server.rst | 2 +- .../system/services/ntp_client.rst | 2 +- .../system/services/ntp_server.rst | 2 +- .../system/services/terminal.rst | 4 ++-- .../system/services/web_server.rst | 2 +- 16 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index 3e2b669c..fd9ee546 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -59,7 +59,7 @@ Once installed and configured; the C2 beacon can establish connection with the C This will send an initial ``keep alive`` to the given C2 Server (The C2 Server IPv4Address must be given upon C2 Beacon configuration). Which is then resolved and responded by another ``Keep Alive`` by the C2 server back to the C2 beacon to confirm connection. -The C2 Beacon will send out periodic keep alive based on it's configuration parameters to configure it's active connection with the C2 server. +The C2 Beacon will send out periodic keep alive based on its configuration parameters to configure it's active connection with the C2 server. It's recommended that a C2 Beacon is installed and configured mid episode by a Red Agent for a more cyber realistic simulation. @@ -254,12 +254,6 @@ Via Configuration C2 Beacon Configuration ======================= -``Common Attributes`` -""""""""""""""""""""" - -See :ref:`Common Configuration` - - ``c2_server_ip_address`` """""""""""""""""""""""" @@ -308,12 +302,18 @@ This must be a string i.e ``DNS``. Defaults to ``HTTP``. *Please refer to the ``IPProtocol`` class for further reference.* +``Common Attributes`` +^^^^^^^^^^^^^^^^^^^^^ + +See :ref:`Common Configuration` + + C2 Server Configuration ======================= *The C2 Server does not currently offer any unique configuration options and will configure itself to match the C2 beacon's network behaviour.* ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst index ade46d3a..1a387514 100644 --- a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst +++ b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst @@ -201,6 +201,6 @@ The chance of the ``DataManipulationBot`` to succeed with a data manipulation at This must be a float value between ``0`` and ``1``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/database_client.rst b/docs/source/simulation_components/system/applications/database_client.rst index 4a5e17c2..1fea78ab 100644 --- a/docs/source/simulation_components/system/applications/database_client.rst +++ b/docs/source/simulation_components/system/applications/database_client.rst @@ -106,6 +106,6 @@ Optional. Default value is ``None``. The password that the ``DatabaseClient`` will use to access the :ref:`DatabaseService`. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/dos_bot.rst b/docs/source/simulation_components/system/applications/dos_bot.rst index bf7b1037..6ad45424 100644 --- a/docs/source/simulation_components/system/applications/dos_bot.rst +++ b/docs/source/simulation_components/system/applications/dos_bot.rst @@ -158,6 +158,6 @@ The maximum number of sessions the ``DoSBot`` is able to make. This must be an integer value equal to or greater than ``0``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/ransomware_script.rst b/docs/source/simulation_components/system/applications/ransomware_script.rst index db5be2ed..5bff6991 100644 --- a/docs/source/simulation_components/system/applications/ransomware_script.rst +++ b/docs/source/simulation_components/system/applications/ransomware_script.rst @@ -81,6 +81,6 @@ IP address of the :ref:`DatabaseService` which the ``RansomwareScript`` will enc This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/applications/web_browser.rst b/docs/source/simulation_components/system/applications/web_browser.rst index b0466ad1..c56c450d 100644 --- a/docs/source/simulation_components/system/applications/web_browser.rst +++ b/docs/source/simulation_components/system/applications/web_browser.rst @@ -108,6 +108,6 @@ The domain ``arcd.com`` can be matched by ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst index 73971b37..7b32a463 100644 --- a/docs/source/simulation_components/system/common/common_configuration.rst +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -5,7 +5,7 @@ .. _Common Configuration: Common Configuration --------------------- +"""""""""""""""""""" ref """ diff --git a/docs/source/simulation_components/system/services/database_service.rst b/docs/source/simulation_components/system/services/database_service.rst index f1b617e6..f3e800cd 100644 --- a/docs/source/simulation_components/system/services/database_service.rst +++ b/docs/source/simulation_components/system/services/database_service.rst @@ -111,6 +111,6 @@ Optional. Default value is ``None``. The password that needs to be provided by connecting clients in order to create a successful connection. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/dns_client.rst b/docs/source/simulation_components/system/services/dns_client.rst index 17fe0219..eca152f0 100644 --- a/docs/source/simulation_components/system/services/dns_client.rst +++ b/docs/source/simulation_components/system/services/dns_client.rst @@ -95,6 +95,6 @@ The IP Address of the :ref:`DNSServer`. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/dns_server.rst b/docs/source/simulation_components/system/services/dns_server.rst index 0b6acb01..1e30b9bd 100644 --- a/docs/source/simulation_components/system/services/dns_server.rst +++ b/docs/source/simulation_components/system/services/dns_server.rst @@ -94,6 +94,6 @@ If the domain is "arcd.com" and the IP Address attributed to the domain is 192.1 The key must be a string and the IP Address must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ftp_client.rst b/docs/source/simulation_components/system/services/ftp_client.rst index 265a03ea..c8a21743 100644 --- a/docs/source/simulation_components/system/services/ftp_client.rst +++ b/docs/source/simulation_components/system/services/ftp_client.rst @@ -84,6 +84,6 @@ Configuration ============= ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ftp_server.rst b/docs/source/simulation_components/system/services/ftp_server.rst index 9b068d68..f52fa043 100644 --- a/docs/source/simulation_components/system/services/ftp_server.rst +++ b/docs/source/simulation_components/system/services/ftp_server.rst @@ -89,6 +89,6 @@ Optional. Default value is ``None``. The password that needs to be provided by a connecting :ref:`FTPClient` in order to create a successful connection. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ntp_client.rst b/docs/source/simulation_components/system/services/ntp_client.rst index 8096a4fe..7af831bf 100644 --- a/docs/source/simulation_components/system/services/ntp_client.rst +++ b/docs/source/simulation_components/system/services/ntp_client.rst @@ -90,6 +90,6 @@ The IP address of an NTP Server which provides a time that the ``NTPClient`` can This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/ntp_server.rst b/docs/source/simulation_components/system/services/ntp_server.rst index f2bb6684..a09c8bdd 100644 --- a/docs/source/simulation_components/system/services/ntp_server.rst +++ b/docs/source/simulation_components/system/services/ntp_server.rst @@ -77,6 +77,6 @@ Via Configuration ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index c319d264..6909786e 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -36,7 +36,7 @@ Implementation Usage """"" - - Pre-Installs on all ``Node`` (with the exception of ``Switch``). + - Pre-Installs on all ``Nodes`` (with the exception of ``Switches``). - Terminal Clients connect, execute commands and disconnect from remote nodes. - Ensures that users are logged in to the component before executing any commands. - Service runs on SSH port 22 by default. @@ -176,6 +176,6 @@ Disconnect from Remote Node ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` diff --git a/docs/source/simulation_components/system/services/web_server.rst b/docs/source/simulation_components/system/services/web_server.rst index 04b9b16a..cec20a60 100644 --- a/docs/source/simulation_components/system/services/web_server.rst +++ b/docs/source/simulation_components/system/services/web_server.rst @@ -77,6 +77,6 @@ Via Configuration ``Common Attributes`` -""""""""""""""""""""" +^^^^^^^^^^^^^^^^^^^^^ See :ref:`Common Configuration` From a5e75f9fed4044e4b9aab4f631945b7c99572023 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 11:24:52 +0100 Subject: [PATCH 202/206] #2837 - Actioning notebook review comments --- .../notebooks/Command-&-Control-E2E-Demonstration.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 4e36db17..97b436cb 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -1450,7 +1450,7 @@ "source": [ "### **Command and Control** | Configurability | C2 Server IP Address\n", "\n", - "As with a majority of client and server based application configuration in primaite, the remote IP of a server must be supplied.\n", + "As with a majority of client and server based application configurations in primaite, the remote IP of a server must be supplied.\n", "\n", "In the case of the C2 Beacon, the C2 Server's IP address must be supplied before the C2 beacon will be able to perform any other actions (including ``APPLICATION EXECUTE``).\n", "\n", @@ -1818,7 +1818,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.10.11" } }, "nbformat": 4, From 9fe48bb2410b027b0be08ccd1909f6845b07ffa1 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 11:32:39 +0100 Subject: [PATCH 203/206] #2837 - Commiting a typo correction in Using Episode Schedules notebook [skip ci] --- src/primaite/notebooks/Using-Episode-Schedules.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/notebooks/Using-Episode-Schedules.ipynb b/src/primaite/notebooks/Using-Episode-Schedules.ipynb index 14012264..cb06e0f9 100644 --- a/src/primaite/notebooks/Using-Episode-Schedules.ipynb +++ b/src/primaite/notebooks/Using-Episode-Schedules.ipynb @@ -199,7 +199,7 @@ "metadata": {}, "source": [ "### Episode 0\n", - "Let' run the episodes to verify that the agents are changing as expected. In episode 0, there should be no green or red agents, just the defender blue agent." + "Let's run the episodes to verify that the agents are changing as expected. In episode 0, there should be no green or red agents, just the defender blue agent." ] }, { From 60e2225a2c6d3f0f7d387248eb2d5552f04ebb52 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 12:03:20 +0100 Subject: [PATCH 204/206] #2837 - Correcting formatting on action masking table [skip ci] --- docs/source/action_masking.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/source/action_masking.rst b/docs/source/action_masking.rst index 2b17075b..264ab254 100644 --- a/docs/source/action_masking.rst +++ b/docs/source/action_masking.rst @@ -111,35 +111,35 @@ The following logic is applied: +------------------------------------------+---------------------------------------------------------------------+ | **FIREWALL_ACL_REMOVERULE** | Firewall is on. | +------------------------------------------+---------------------------------------------------------------------+ -| NODE_NMAP_PING_SCAN | Node is on. | +| **NODE_NMAP_PING_SCAN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| NODE_NMAP_PORT_SCAN | Node is on. | +| **NODE_NMAP_PORT_SCAN** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| NODE_NMAP_NETWORK_SERVICE_RECON | Node is on. | +| **NODE_NMAP_NETWORK_SERVICE_RECON** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| CONFIGURE_DATABASE_CLIENT | Node is on. | +| **CONFIGURE_DATABASE_CLIENT** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| CONFIGURE_RANSOMWARE_SCRIPT | Node is on. | +| **CONFIGURE_RANSOMWARE_SCRIPT** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| CONFIGURE_DOSBOT | Node is on. | +| **CONFIGURE_DOSBOT** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| CONFIGURE_C2_BEACON | Node is on. | +| **CONFIGURE_C2_BEACON** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| C2_SERVER_RANSOMWARE_LAUNCH | Node is on. | +| **C2_SERVER_RANSOMWARE_LAUNCH** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| C2_SERVER_RANSOMWARE_CONFIGURE | Node is on. | +| **C2_SERVER_RANSOMWARE_CONFIGURE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| C2_SERVER_TERMINAL_COMMAND | Node is on. | +| **C2_SERVER_TERMINAL_COMMAND** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| C2_SERVER_DATA_EXFILTRATE | Node is on. | +| **C2_SERVER_DATA_EXFILTRATE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| NODE_ACCOUNTS_CHANGE_PASSWORD | Node is on. | +| **NODE_ACCOUNTS_CHANGE_PASSWORD** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| SSH_TO_REMOTE | Node is on. | +| **SSH_TO_REMOTE** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| SESSIONS_REMOTE_LOGOFF | Node is on. | +| **SESSIONS_REMOTE_LOGOFF** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ -| NODE_SEND_REMOTE_COMMAND | Node is on. | +| **NODE_SEND_REMOTE_COMMAND** | Node is on. | +------------------------------------------+---------------------------------------------------------------------+ From f6d793196d2a8130272b46da3eb43925607449df Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Thu, 5 Sep 2024 16:44:29 +0100 Subject: [PATCH 205/206] #2837 - Actioning review comments following second review [skip ci] --- docs/index.rst | 2 ++ .../simulation_components/system/applications/c2_suite.rst | 4 ++-- .../system/common/common_configuration.rst | 2 +- .../notebooks/Command-&-Control-E2E-Demonstration.ipynb | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index ff97f60d..118f7ebf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,8 @@ The ARCD Primary-level AI Training Environment (**PrimAITE**) provides an effect - Modelling background (green) pattern-of-life; - Operates at machine-speed to enable fast training cycles via Reinforcement Learning (RL). +PrimAITE has been designed as an extensible environment and toolkit to support the development, test, training and evaluation of AI-based cyber defensive agents. Whilst PrimAITE ships with a number of example modelled scenarios (a.k.a. Use Cases), it has not been developed to mandate the solving of a single cyber challenge, and instead provides a highly flexible environment application that can be extended and reconfigured by the user to suit their specific cyber defence training and evaluation needs. PrimAITE provides default networks, red agent and green agent behaviour, reward functions, and action / observation space configuration, all of which can be utilised out of the box, but which ultimately can (and in some instances should) be built upon and / or reconfigured to meet the needs of different defensive agent developers. The PrimAITE user guide provides comprehensive instruction on all PrimAITE features, functionality and components, and can be consulted in order to help guide users in any reconfiguration or enhancements they wish to undertake; a library of example Jupyter notebooks are also provided to support such work. + Features ^^^^^^^^ diff --git a/docs/source/simulation_components/system/applications/c2_suite.rst b/docs/source/simulation_components/system/applications/c2_suite.rst index fd9ee546..d045949a 100644 --- a/docs/source/simulation_components/system/applications/c2_suite.rst +++ b/docs/source/simulation_components/system/applications/c2_suite.rst @@ -270,7 +270,7 @@ How often should the C2 Beacon confirm it's connection in timesteps. For example, if the keep alive Frequency is set to one then every single timestep the C2 connection will be confirmed. -It's worth noting that this may be useful option when investigating +It's worth noting that this may be a useful option when investigating network blue agent observation space. This must be a valid integer i.e ``10``. Defaults to ``5``. @@ -283,7 +283,7 @@ The protocol that the C2 Beacon will use to communicate to the C2 Server with. Currently only ``TCP`` and ``UDP`` are valid masquerade protocol options. -It's worth noting that this may be useful option to bypass ACL rules. +It's worth noting that this may be a useful option to bypass ACL rules. This must be a string i.e *UDP*. Defaults to ``TCP``. diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst index 7b32a463..c53ac8b8 100644 --- a/docs/source/simulation_components/system/common/common_configuration.rst +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -20,7 +20,7 @@ The type of software that should be added. To add the required software, this mu options """"""" -The configuration options are the attributes that fall under the options for an application. +The configuration options are the attributes that fall under the options for an application or service. fix_duration """""""""""" diff --git a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb index 97b436cb..45af6c12 100644 --- a/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb +++ b/src/primaite/notebooks/Command-&-Control-E2E-Demonstration.ipynb @@ -1164,7 +1164,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Now we are unable to do so as the C2 Server has lost it's connection to the C2 Beacon:" + "Now we are unable to do so as the C2 Server has lost its connection to the C2 Beacon:" ] }, { From 731982b698db922239d841c68ee02d973f52b5a9 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Fri, 6 Sep 2024 10:05:10 +0100 Subject: [PATCH 206/206] #2837 - Adding some additional wording to the README.md [skip ci] --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 137852f5..c8f644be 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ PrimAITE presents the following features: - Support for multiple agents, each having their own customisable observation space, action space, and reward function definition, and either deterministic or RL-directed behaviour +Whilst PrimAITE ships with a number of example modelled scenarios (a.k.a. Use Cases), it has not been developed to mandate the solving of a single cyber challenge, and instead provides a highly flexible environment application that can be extended and reconfigured by the user to suit their specific cyber defence training and evaluation needs. PrimAITE provides default networks, red agent and green agent behaviour, reward functions, and action / observation space configuration, all of which can be utilised out of the box, but which ultimately can (and in some instances should) be built upon and / or reconfigured to meet the needs of different defensive agent developers. The PrimAITE user guide provides comprehensive instruction on all PrimAITE features, functionality and components, and can be consulted in order to help guide users in any reconfiguration or enhancements they wish to undertake; a library of example Jupyter notebooks are also provided to support such work. + ## Getting Started with PrimAITE ### 💫 Installation