From 33f72db1cb61021bc202d19dbbf0ffcdb8230dfc Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 5 Jan 2024 14:03:06 +0000 Subject: [PATCH 01/37] Updated VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 6da222f2..9414e127 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b3dev +3.0.0b4 From 82cd8780f9467c760dd3cacc8fdf79dcb21ec035 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:03:10 +0000 Subject: [PATCH 02/37] Align Software health state enum with CAOS --- src/primaite/simulator/system/software.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..562c9e0d 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -36,12 +36,12 @@ class SoftwareHealthState(Enum): "Unused state." GOOD = 1 "The software is in a good and healthy condition." - COMPROMISED = 2 - "The software's security has been compromised." - OVERWHELMED = 3 - "he software is overwhelmed and not functioning properly." - PATCHING = 4 + PATCHING = 2 "The software is undergoing patching or updates." + COMPROMISED = 3 + "The software's security has been compromised." + OVERWHELMED = 4 + "he software is overwhelmed and not functioning properly." class SoftwareCriticality(Enum): From 716bd626a5e67715aa9f5360208c5457880250f2 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:29:23 +0000 Subject: [PATCH 03/37] Hide software health state until scan. --- src/primaite/game/agent/observations.py | 5 ++++- src/primaite/simulator/system/software.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 767514b4..eecf4163 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -140,7 +140,10 @@ class ServiceObservation(AbstractObservation): service_state = access_from_nested_dict(state, self.where) if service_state is NOT_PRESENT_IN_STATE: return self.default_observation - return {"operating_status": service_state["operating_state"], "health_status": service_state["health_state"]} + return { + "operating_status": service_state["operating_state"], + "health_status": service_state["health_state_visible"], + } @property def space(self) -> spaces.Space: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..d7c1fa4e 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -145,8 +145,8 @@ class Software(SimComponent): state = super().describe_state() state.update( { - "health_state": self.health_state_actual.value, - "health_state_red_view": self.health_state_visible.value, + "health_state_actual": self.health_state_actual.value, + "health_state_visible": self.health_state_visible.value, "criticality": self.criticality.value, "patching_count": self.patching_count, "scanning_count": self.scanning_count, From f2a496893cc49cbe90b3b11fd67443f030d9c269 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:33:24 +0000 Subject: [PATCH 04/37] Bump VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 9414e127..52f460a5 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b4 +3.0.0b5dev From daa34385e550dc43e984706faa2df024e74d1ad7 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:53:15 +0000 Subject: [PATCH 05/37] Add agent reset for episodes --- src/primaite/game/agent/data_manipulation_bot.py | 9 +++++++++ src/primaite/game/agent/interface.py | 4 ++++ src/primaite/game/game.py | 1 + 3 files changed, 14 insertions(+) diff --git a/src/primaite/game/agent/data_manipulation_bot.py b/src/primaite/game/agent/data_manipulation_bot.py index 8237ce06..3b558087 100644 --- a/src/primaite/game/agent/data_manipulation_bot.py +++ b/src/primaite/game/agent/data_manipulation_bot.py @@ -15,6 +15,7 @@ class DataManipulationAgent(AbstractScriptedAgent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + print("red start step: ", self.agent_settings.start_settings.start_step) self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) @@ -27,6 +28,7 @@ class DataManipulationAgent(AbstractScriptedAgent): -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance ) self.next_execution_timestep = timestep + random_timestep_increment + print("next execution red step: ", self.next_execution_timestep) def get_action(self, obs: ObsType, reward: float = None) -> Tuple[str, Dict]: """Randomly sample an action from the action space. @@ -41,8 +43,15 @@ class DataManipulationAgent(AbstractScriptedAgent): current_timestep = self.action_manager.game.step_counter if current_timestep < self.next_execution_timestep: + print("red agent doing nothing") return "DONOTHING", {"dummy": 0} self._set_next_execution_timestep(current_timestep + self.agent_settings.start_settings.frequency) + print("red agent doing an execute") return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0} + + def reset_agent_for_episode(self) -> None: + """Set the next execution timestep when the episode resets.""" + super().reset_agent_for_episode() + self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index 8657fc45..8b6dd6d4 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -135,6 +135,10 @@ class AbstractAgent(ABC): request = self.action_manager.form_request(action_identifier=action, action_options=options) return request + def reset_agent_for_episode(self) -> None: + """Agent reset logic should go here.""" + pass + class AbstractScriptedAgent(AbstractAgent): """Base class for actors which generate their own behaviour.""" diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 586bca79..08098754 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -162,6 +162,7 @@ class PrimaiteGame: self.simulation.reset_component_for_episode(episode=self.episode_counter) for agent in self.agents: agent.reward_function.total_reward = 0.0 + agent.reset_agent_for_episode() def close(self) -> None: """Close the game, this will close the simulation.""" From e73783f6fa2dc03c5c3274f923ae42460690d561 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 17:10:12 +0000 Subject: [PATCH 06/37] Fixed issue where data manipulation was always executing --- .../services/red_services/data_manipulation_bot.py | 10 ++++++++-- src/primaite/simulator/system/software.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py index 44a56cf1..fcd9a3cc 100644 --- a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py +++ b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py @@ -73,7 +73,7 @@ class DataManipulationBot(DatabaseClient): def _init_request_manager(self) -> RequestManager: rm = super()._init_request_manager() - rm.add_request(name="execute", request_type=RequestType(func=lambda request, context: self.run())) + rm.add_request(name="execute", request_type=RequestType(func=lambda request, context: self.attack())) return rm @@ -169,6 +169,12 @@ class DataManipulationBot(DatabaseClient): Calls the parent classes execute method before starting the application loop. """ super().run() + + def attack(self): + """Perform the attack steps after opening the application.""" + if not self._can_perform_action(): + _LOGGER.debug("Data manipulation application attempted to execute but it cannot perform actions right now.") + self.run() self._application_loop() def _application_loop(self): @@ -199,4 +205,4 @@ class DataManipulationBot(DatabaseClient): :param timestep: The timestep value to update the bot's state. """ - self._application_loop() + pass diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..ca667f46 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -278,7 +278,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state is not NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True From b7cc940e9dd1485b4aada9d0f208bb17b3c4bee4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 09:07:51 +0000 Subject: [PATCH 07/37] Remove temporary print statements --- src/primaite/game/agent/data_manipulation_bot.py | 5 ----- src/primaite/game/agent/rewards.py | 1 - 2 files changed, 6 deletions(-) diff --git a/src/primaite/game/agent/data_manipulation_bot.py b/src/primaite/game/agent/data_manipulation_bot.py index 3b558087..7ad45518 100644 --- a/src/primaite/game/agent/data_manipulation_bot.py +++ b/src/primaite/game/agent/data_manipulation_bot.py @@ -15,8 +15,6 @@ class DataManipulationAgent(AbstractScriptedAgent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - print("red start step: ", self.agent_settings.start_settings.start_step) - self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) def _set_next_execution_timestep(self, timestep: int) -> None: @@ -28,7 +26,6 @@ class DataManipulationAgent(AbstractScriptedAgent): -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance ) self.next_execution_timestep = timestep + random_timestep_increment - print("next execution red step: ", self.next_execution_timestep) def get_action(self, obs: ObsType, reward: float = None) -> Tuple[str, Dict]: """Randomly sample an action from the action space. @@ -43,12 +40,10 @@ class DataManipulationAgent(AbstractScriptedAgent): current_timestep = self.action_manager.game.step_counter if current_timestep < self.next_execution_timestep: - print("red agent doing nothing") return "DONOTHING", {"dummy": 0} self._set_next_execution_timestep(current_timestep + self.agent_settings.start_settings.frequency) - print("red agent doing an execute") return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0} def reset_agent_for_episode(self) -> None: diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 9b3dfb80..cb8f8cb1 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -181,7 +181,6 @@ class WebServer404Penalty(AbstractReward): """ web_service_state = access_from_nested_dict(state, self.location_in_state) if web_service_state is NOT_PRESENT_IN_STATE: - print("error getting web service 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. From b6e414bd705615ea2bad75f1376f6c825d4e7004 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 09:14:07 +0000 Subject: [PATCH 08/37] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c712ef66..7a0ef4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - +- Fixed an issue where the data manipulation attack was triggered at episode start. ### Added - Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have From 66a42ebc6962fde7ebef8166e577716d78a3c392 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 13:06:48 +0000 Subject: [PATCH 09/37] Make database failure based on file status not service status --- .../system/applications/application.py | 2 ++ .../services/database/database_service.py | 20 +++++++++---------- .../system/services/ftp/ftp_service.py | 3 +++ .../_system/_services/test_ftp_client.py | 2 ++ .../_system/_services/test_ftp_server.py | 2 ++ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 898e5917..0ae13228 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -95,6 +95,8 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.health_state_actual = SoftwareHealthState.GOOD def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 61cf1560..89329a17 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from primaite import getLogger from primaite.simulator.file_system.file_system import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus 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 @@ -24,7 +25,7 @@ class DatabaseService(Service): password: Optional[str] = None connections: Dict[str, datetime] = {} - backup_server: IPv4Address = None + backup_server_ip: IPv4Address = None """IP address of the backup server.""" latest_backup_directory: str = None @@ -66,7 +67,7 @@ class DatabaseService(Service): :param: backup_server_ip: The IP address of the backup server """ - self.backup_server = backup_server + self.backup_server_ip = backup_server def backup_database(self) -> bool: """Create a backup of the database to the configured backup server.""" @@ -75,7 +76,7 @@ class DatabaseService(Service): return False # check if the backup server was configured - if self.backup_server is None: + if self.backup_server_ip is None: self.sys_log.error(f"{self.name} - {self.sys_log.hostname}: not configured.") return False @@ -84,7 +85,7 @@ class DatabaseService(Service): # send backup copy of database file to FTP server response = ftp_client_service.send_file( - dest_ip_address=self.backup_server, + dest_ip_address=self.backup_server_ip, src_file_name=self._db_file.name, src_folder_name=self.folder.name, dest_folder_name=str(self.uuid), @@ -112,7 +113,7 @@ class DatabaseService(Service): src_file_name="database.db", dest_folder_name="downloads", dest_file_name="database.db", - dest_ip_address=self.backup_server, + dest_ip_address=self.backup_server_ip, ) if not response: @@ -170,16 +171,13 @@ class DatabaseService(Service): """ self.sys_log.info(f"{self.name}: Running {query}") if query == "SELECT": - if self.health_state_actual == SoftwareHealthState.GOOD: + if self._db_file.health_status == FileSystemItemHealthStatus.GOOD: return {"status_code": 200, "type": "sql", "data": True, "uuid": query_id} else: return {"status_code": 404, "data": False} elif query == "DELETE": - if self.health_state_actual == SoftwareHealthState.GOOD: - self.health_state_actual = SoftwareHealthState.COMPROMISED - return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} - else: - return {"status_code": 404, "data": False} + self._db_file.health_status = FileSystemItemHealthStatus.COMPROMISED + return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} else: # Invalid query return {"status_code": 500, "data": False} diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index f2c01544..8d9bb6fb 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -52,10 +52,12 @@ class FTPServiceABC(Service, ABC): folder_name = payload.ftp_command_args["dest_folder_name"] file_size = payload.ftp_command_args["file_size"] real_file_path = payload.ftp_command_args.get("real_file_path") + health_status = payload.ftp_command_args["health_status"] is_real = real_file_path is not None file = self.file_system.create_file( file_name=file_name, folder_name=folder_name, size=file_size, real=is_real ) + file.health_status = health_status self.sys_log.info( f"{self.name}: Created item in {self.sys_log.hostname}: {payload.ftp_command_args['dest_folder_name']}/" f"{payload.ftp_command_args['dest_file_name']}" @@ -110,6 +112,7 @@ class FTPServiceABC(Service, ABC): "dest_file_name": dest_file_name, "file_size": file.sim_size, "real_file_path": file.sim_path if file.real else None, + "health_status": file.health_status, }, packet_payload_size=file.sim_size, status_code=FTPStatusCode.OK if is_response else None, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py index 134f82bd..941a465e 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py @@ -2,6 +2,7 @@ from ipaddress import IPv4Address import pytest +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.computer import Computer @@ -42,6 +43,7 @@ def test_ftp_client_store_file(ftp_client): "dest_folder_name": "downloads", "dest_file_name": "file.txt", "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, }, packet_payload_size=24, status_code=FTPStatusCode.OK, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py index 2b26c932..137e74d0 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server @@ -41,6 +42,7 @@ def test_ftp_server_store_file(ftp_server): "dest_folder_name": "downloads", "dest_file_name": "file.txt", "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, }, packet_payload_size=24, ) From 1505d087214e724bbcc5661328f79931eb98a1b4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 18:04:48 +0000 Subject: [PATCH 10/37] Fix backup issues and align with Yak --- .../config/_package_data/example_config.yaml | 4 +- src/primaite/game/agent/observations.py | 2 +- src/primaite/session/environment.py | 1 - .../services/database/database_service.py | 44 +++++++++++++------ .../system/services/ftp/ftp_server.py | 3 +- .../red_services/data_manipulation_bot.py | 2 +- .../system/services/web_server/web_server.py | 4 ++ 7 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 2ac23661..ee0eb7ff 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -112,10 +112,8 @@ agents: - service_ref: domain_controller_dns_server - node_ref: web_server services: - - service_ref: web_server_database_client + - service_ref: web_server_web_service - node_ref: database_server - services: - - service_ref: database_service folders: - folder_name: database files: diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index eecf4163..0cb3e8f6 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -555,7 +555,7 @@ class NodeObservation(AbstractObservation): folder_configs = config.get("folders", {}) folders = [ FolderObservation.from_config( - config=c, game=game, parent_where=where, num_files_per_folder=num_files_per_folder + config=c, game=game, parent_where=where + ["file_system"], num_files_per_folder=num_files_per_folder ) for c in folder_configs ] diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 36ab3f58..6701f183 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -23,7 +23,6 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.game: "PrimaiteGame" = game self.agent: ProxyAgent = self.game.rl_agents[0] - self.flatten_obs: bool = False def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: """Perform a step in the environment.""" diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 89329a17..7c665b9a 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from primaite import getLogger from primaite.simulator.file_system.file_system import File from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder 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 @@ -39,7 +40,6 @@ class DatabaseService(Service): kwargs["port"] = Port.POSTGRES_SERVER kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - self._db_file: File self._create_db_file() def set_original_state(self): @@ -49,7 +49,7 @@ class DatabaseService(Service): vals_to_include = { "password", "connections", - "backup_server", + "backup_server_ip", "latest_backup_directory", "latest_backup_file_name", } @@ -86,8 +86,8 @@ class DatabaseService(Service): # send backup copy of database file to FTP server response = ftp_client_service.send_file( dest_ip_address=self.backup_server_ip, - src_file_name=self._db_file.name, - src_folder_name=self.folder.name, + src_file_name=self.db_file.name, + src_folder_name="database", dest_folder_name=str(self.uuid), dest_file_name="database.db", ) @@ -121,13 +121,10 @@ class DatabaseService(Service): return False # replace db file - self.file_system.delete_file(folder_name=self.folder.name, file_name="downloads.db") - self.file_system.copy_file( - src_folder_name="downloads", src_file_name="database.db", dst_folder_name=self.folder.name - ) - self._db_file = self.file_system.get_file(folder_name=self.folder.name, file_name="database.db") + self.file_system.delete_file(folder_name="database", file_name="downloads.db") + self.file_system.copy_file(src_folder_name="downloads", src_file_name="database.db", dst_folder_name="database") - if self._db_file is None: + if self.db_file is None: self.sys_log.error("Copying database backup failed.") return False @@ -137,8 +134,17 @@ class DatabaseService(Service): def _create_db_file(self): """Creates the Simulation File and sqlite file in the file system.""" - self._db_file: File = self.file_system.create_file(folder_name="database", file_name="database.db") - self.folder = self.file_system.get_folder_by_id(self._db_file.folder_id) + self.file_system.create_file(folder_name="database", file_name="database.db") + + @property + def db_file(self) -> File: + """Returns the database file.""" + return self.file_system.get_file(folder_name="database", file_name="database.db") + + @property + def folder(self) -> Folder: + """Returns the database folder.""" + return self.file_system.get_folder_by_id(self.db_file.folder_id) def _process_connect( self, session_id: str, password: Optional[str] = None @@ -171,12 +177,12 @@ class DatabaseService(Service): """ self.sys_log.info(f"{self.name}: Running {query}") if query == "SELECT": - if self._db_file.health_status == FileSystemItemHealthStatus.GOOD: + if self.db_file.health_status == FileSystemItemHealthStatus.GOOD: return {"status_code": 200, "type": "sql", "data": True, "uuid": query_id} else: return {"status_code": 404, "data": False} elif query == "DELETE": - self._db_file.health_status = FileSystemItemHealthStatus.COMPROMISED + self.db_file.health_status = FileSystemItemHealthStatus.COMPROMISED return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} else: # Invalid query @@ -231,3 +237,13 @@ class DatabaseService(Service): software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) return payload["status_code"] == 200 + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep of simulation dynamics to this service. + + Here at the first step, the database backup is created, in addition to normal service update logic. + """ + if timestep == 1: + self.backup_database() + return super().apply_timestep(timestep) diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 0278b616..87f38597 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -106,5 +106,6 @@ class FTPServer(FTPServiceABC): if payload.status_code is not None: return False - self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) + # self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) + self._process_ftp_command(payload=payload, session_id=session_id) return True diff --git a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py index fcd9a3cc..48a05a67 100644 --- a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py +++ b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py @@ -84,7 +84,7 @@ class DataManipulationBot(DatabaseClient): payload: Optional[str] = None, port_scan_p_of_success: float = 0.1, data_manipulation_p_of_success: float = 0.1, - repeat: bool = False, + repeat: bool = True, ): """ Configure the DataManipulatorBot to communicate with a DatabaseService. 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 afd6cb74..eaea6bb1 100644 --- a/src/primaite/simulator/system/services/web_server/web_server.py +++ b/src/primaite/simulator/system/services/web_server/web_server.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.database_client import DatabaseClient from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import SoftwareHealthState _LOGGER = getLogger(__name__) @@ -123,7 +124,10 @@ class WebServer(Service): # get all users if db_client.query("SELECT"): # query succeeded + self.set_health_state(SoftwareHealthState.GOOD) response.status_code = HttpStatusCode.OK + else: + self.set_health_state(SoftwareHealthState.COMPROMISED) return response except Exception: From 2d1041e7b3331c4349a8e906fc715e32899f365a Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 18:38:37 +0000 Subject: [PATCH 11/37] Fix final bugs --- src/primaite/game/agent/observations.py | 11 +++++++---- src/primaite/simulator/network/hardware/base.py | 2 +- .../test_uc2_data_manipulation_scenario.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 0cb3e8f6..e5216e4a 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -205,12 +205,15 @@ class LinkObservation(AbstractObservation): bandwidth = link_state["bandwidth"] load = link_state["current_load"] - utilisation_fraction = load / bandwidth - # 0 is UNUSED, 1 is 0%-10%. 2 is 10%-20%. 3 is 20%-30%. And so on... 10 is exactly 100% - utilisation_category = int(utilisation_fraction * 10) + 1 + if load == 0: + utilisation_category = 0 + else: + utilisation_fraction = load / bandwidth + # 0 is UNUSED, 1 is 0%-10%. 2 is 10%-20%. 3 is 20%-30%. And so on... 10 is exactly 100% + utilisation_category = int(utilisation_fraction * 9) + 1 # TODO: once the links support separte load per protocol, this needs amendment to reflect that. - return {"PROTOCOLS": {"ALL": utilisation_category}} + return {"PROTOCOLS": {"ALL": min(utilisation_category, 10)}} @property def space(self) -> spaces.Space: diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index a310a3f5..f41c1ab6 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1271,8 +1271,8 @@ class Node(SimComponent): self.start_up_countdown = self.start_up_duration if self.start_up_duration <= 0: - self._start_up_actions() self.operating_state = NodeOperatingState.ON + self._start_up_actions() self.sys_log.info("Turned on") for nic in self.nics.values(): if nic._connected_link: diff --git a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py index 0dc2c031..dad6f879 100644 --- a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py +++ b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py @@ -22,7 +22,7 @@ def test_data_manipulation(uc2_network): assert db_client.query("SELECT") # Now we run the DataManipulationBot - db_manipulation_bot.run() + db_manipulation_bot.attack() # Now check that the DB client on the web_server cannot query the users table on the database assert not db_client.query("SELECT") From e57c240b9b1d5fa1fff2e52482e9d3895c97f47d Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 09:55:09 +0000 Subject: [PATCH 12/37] Apply cosmetic changes based on review. --- src/primaite/simulator/system/applications/application.py | 2 +- src/primaite/simulator/system/services/ftp/ftp_server.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 0ae13228..e15b9f1c 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -96,7 +96,7 @@ class Application(IOSoftware): self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING if self.health_state_actual == SoftwareHealthState.UNUSED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 87f38597..f176f58b 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -106,6 +106,5 @@ class FTPServer(FTPServiceABC): if payload.status_code is not None: return False - # self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) self._process_ftp_command(payload=payload, session_id=session_id) return True From d2a2472e5f08f14eaa20e8ff5e24d0a83be2fde6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 10:49:32 +0000 Subject: [PATCH 13/37] Apply bugfix 2151 --- .../system/applications/application.py | 4 +--- .../system/services/ftp/ftp_service.py | 6 ++++- .../simulator/system/services/service.py | 16 ++++--------- src/primaite/simulator/system/software.py | 6 ++--- tests/conftest.py | 5 +++- .../system/test_application_on_node.py | 23 ++++++++++--------- .../system/test_service_on_node.py | 7 +++--- .../_system/_services/test_web_server.py | 7 +++++- 8 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index e15b9f1c..322ac808 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -38,9 +38,6 @@ class Application(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() @@ -95,6 +92,7 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED if self.health_state_actual == SoftwareHealthState.UNUSED: self.set_health_state(SoftwareHealthState.GOOD) diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index 8d9bb6fb..70ba74d7 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -1,7 +1,7 @@ import shutil from abc import ABC from ipaddress import IPv4Address -from typing import Optional +from typing import Dict, Optional from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode @@ -16,6 +16,10 @@ class FTPServiceABC(Service, ABC): Contains shared methods between both classes. """ + def describe_state(self) -> Dict: + """Returns a Dict of the FTPService state.""" + return super().describe_state() + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index e60b7700..f10d8776 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional @@ -77,9 +78,6 @@ class Service(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() @@ -98,6 +96,7 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm + @abstractmethod def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -118,7 +117,6 @@ class Service(IOSoftware): if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.UNUSED def start(self, **kwargs) -> None: """Start the service.""" @@ -129,42 +127,39 @@ class Service(IOSoftware): if self.operating_state == ServiceOperatingState.STOPPED: self.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) def pause(self) -> None: """Pause the service.""" if self.operating_state == ServiceOperatingState.RUNNING: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def resume(self) -> None: """Resume paused service.""" if self.operating_state == ServiceOperatingState.PAUSED: self.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD def restart(self) -> None: """Restart running service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.health_state_actual = SoftwareHealthState.OVERWHELMED self.restart_countdown = self.restart_duration def disable(self) -> None: """Disable the service.""" self.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def enable(self) -> None: """Enable the disabled service.""" if self.operating_state == ServiceOperatingState.DISABLED: self.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def apply_timestep(self, timestep: int) -> None: """ @@ -181,5 +176,4 @@ class Service(IOSoftware): if self.restart_countdown <= 0: _LOGGER.debug(f"Restarting finished for service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD self.restart_countdown -= 1 diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7be270c0..a58e4c48 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -69,9 +69,9 @@ class Software(SimComponent): name: str "The name of the software." - health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_actual: SoftwareHealthState = SoftwareHealthState.UNUSED "The actual health state of the software." - health_state_visible: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_visible: SoftwareHealthState = SoftwareHealthState.UNUSED "The health state of the software visible to the red agent." criticality: SoftwareCriticality = SoftwareCriticality.LOWEST "The criticality level of the software." @@ -278,7 +278,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is not NodeOperatingState.ON: + if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 1ab07dd8..1400f93b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,9 @@ from primaite.simulator.network.hardware.base import Link, Node class TestService(Service): """Test Service class""" + def describe_state(self) -> Dict: + return super().describe_state() + def __init__(self, **kwargs): kwargs["name"] = "TestService" kwargs["port"] = Port.HTTP @@ -60,7 +63,7 @@ class TestApplication(Application): super().__init__(**kwargs) def describe_state(self) -> Dict: - pass + return super().describe_state() @pytest.fixture(scope="function") diff --git a/tests/integration_tests/system/test_application_on_node.py b/tests/integration_tests/system/test_application_on_node.py index 46be5e55..3c9afe43 100644 --- a/tests/integration_tests/system/test_application_on_node.py +++ b/tests/integration_tests/system/test_application_on_node.py @@ -24,8 +24,8 @@ def populated_node(application_class) -> Tuple[Application, Computer]: return app, computer -def test_service_on_offline_node(application_class): - """Test to check that the service cannot be interacted with when node it is on is off.""" +def test_application_on_offline_node(application_class): + """Test to check that the application cannot be interacted with when node it is on is off.""" computer: Computer = Computer( hostname="test_computer", ip_address="192.168.1.2", @@ -49,8 +49,8 @@ def test_service_on_offline_node(application_class): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_off_service(populated_node): - """Check that the service is turned off when the server is turned off""" +def test_server_turns_off_application(populated_node): + """Check that the application is turned off when the server is turned off""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -65,8 +65,8 @@ def test_server_turns_off_service(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_service_cannot_be_turned_on_when_server_is_off(populated_node): - """Check that the service cannot be started when the server is off.""" +def test_application_cannot_be_turned_on_when_server_is_off(populated_node): + """Check that the application cannot be started when the server is off.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -86,8 +86,8 @@ def test_service_cannot_be_turned_on_when_server_is_off(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_on_service(populated_node): - """Check that turning on the server turns on service.""" +def test_server_turns_on_application(populated_node): + """Check that turning on the server turns on application.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -109,13 +109,14 @@ def test_server_turns_on_service(populated_node): assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING - computer.start_up_duration = 0 - computer.shut_down_duration = 0 - computer.power_off() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.OFF assert app.operating_state is ApplicationOperatingState.CLOSED computer.power_on() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py index aab1e4da..9b0084bd 100644 --- a/tests/integration_tests/system/test_service_on_node.py +++ b/tests/integration_tests/system/test_service_on_node.py @@ -117,13 +117,14 @@ def test_server_turns_on_service(populated_node): assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING - server.start_up_duration = 0 - server.shut_down_duration = 0 - server.power_off() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.OFF assert service.operating_state is ServiceOperatingState.STOPPED server.power_on() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py index bbccda27..64277356 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.protocols.http import ( HttpRequestMethod, @@ -15,7 +16,11 @@ from primaite.simulator.system.services.web_server.web_server import WebServer @pytest.fixture(scope="function") def web_server() -> Server: node = Server( - hostname="web_server", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + hostname="web_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + operating_state=NodeOperatingState.ON, ) node.software_manager.install(software_class=WebServer) node.software_manager.software.get("WebServer").start() From b11e4c8ccd06f7638f243f8a2fb506ce4792f6d8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:05:00 +0000 Subject: [PATCH 14/37] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0ef4c7..1c67d16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - Fixed an issue where the data manipulation attack was triggered at episode start. +- Fixed a bug where FTP STOR stored an additional copy on the client machine's filesystem + ### Added - Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have From 4d1c0d268e0b5619851fda92baf6b94f1f0c17a8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:05:55 +0000 Subject: [PATCH 15/37] Fix reward data type --- 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 cb8f8cb1..30baad6f 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -111,9 +111,9 @@ class DatabaseFileIntegrity(AbstractReward): """ database_file_state = access_from_nested_dict(state, self.location_in_state) health_status = database_file_state["health_status"] - if health_status == "corrupted": + if health_status == 3: return -1 - elif health_status == "good": + elif health_status == 1: return 1 else: return 0 From 63b9bc5bc69a8f125f7d1ecad6378de56a833003 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:13:43 +0000 Subject: [PATCH 16/37] 3.0.0b5 --- CHANGELOG.md | 4 ++++ src/primaite/VERSION | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c67d16c..227cec69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ 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. - Fixed an issue where the data manipulation attack was triggered at episode start. - Fixed a bug where FTP STOR stored an additional copy on the client machine's filesystem +- Fixed a bug where the red agent acted to early +- Fixed the order of service health state +- Fixed an issue where starting a node didn't start the services on it + ### Added diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 52f460a5..09fb39d2 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b5dev +3.0.0b5 From ed5591caf8cf11a3727e27028f670dc915687a4c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 14:49:36 +0000 Subject: [PATCH 17/37] Minor fix --- src/primaite/game/agent/observations.py | 2 +- src/primaite/game/agent/rewards.py | 2 +- src/primaite/simulator/file_system/folder.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index e5216e4a..cac5b91e 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -79,7 +79,7 @@ class FileObservation(AbstractObservation): file_state = access_from_nested_dict(state, self.where) if file_state is NOT_PRESENT_IN_STATE: return self.default_observation - return {"health_status": file_state["health_status"]} + return {"health_status": file_state["visible_status"]} @property def space(self) -> spaces.Space: diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 30baad6f..8f064be3 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -111,7 +111,7 @@ class DatabaseFileIntegrity(AbstractReward): """ database_file_state = access_from_nested_dict(state, self.location_in_state) health_status = database_file_state["health_status"] - if health_status == 3: + if health_status == 2: return -1 elif health_status == 1: return 1 diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index d4e72f63..237a6341 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -105,7 +105,7 @@ class Folder(FileSystemItemABC): self._file_request_manager = RequestManager() rm.add_request( name="file", - request_type=RequestType(func=lambda request, context: self._file_request_manager), + request_type=RequestType(func=self._file_request_manager), ) return rm From 842e59f5964612213787e21a459368e136276cf3 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 15:40:37 +0000 Subject: [PATCH 18/37] Database patch --- .../simulator/system/services/database/database_service.py | 5 +++++ src/primaite/simulator/system/software.py | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 7c665b9a..1cdd0390 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -247,3 +247,8 @@ class DatabaseService(Service): if timestep == 1: self.backup_database() return super().apply_timestep(timestep) + + def _update_patch_status(self) -> None: + super()._update_patch_status() + if self._patching_countdown is None: + self.restore_backup() diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index a58e4c48..b7c0bd9b 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -193,8 +193,9 @@ class Software(SimComponent): def patch(self) -> None: """Perform a patch on the software.""" - self._patching_countdown = self.patching_duration - self.set_health_state(SoftwareHealthState.PATCHING) + if self.health_state_actual in (SoftwareHealthState.COMPROMISED, SoftwareHealthState.GOOD): + self._patching_countdown = self.patching_duration + self.set_health_state(SoftwareHealthState.PATCHING) def _update_patch_status(self) -> None: """Update the patch status of the software.""" From e0033de7b671eeaea3d56ab4e0b4898ad85f592c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 12 Jan 2024 14:54:55 +0000 Subject: [PATCH 19/37] Fix folder reset --- src/primaite/simulator/file_system/folder.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index 237a6341..027547bb 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -17,8 +17,6 @@ class Folder(FileSystemItemABC): files: Dict[str, File] = {} "Files stored in the folder." - _files_by_name: Dict[str, File] = {} - "Files by their name as .." deleted_files: Dict[str, File] = {} "Files that have been deleted." @@ -78,7 +76,6 @@ class Folder(FileSystemItemABC): file = self.deleted_files[uuid] self.deleted_files.pop(uuid) self.files[uuid] = file - self._files_by_name[file.name] = file # Clear any other deleted files that aren't original (have been created by agent) self.deleted_files.clear() @@ -89,7 +86,6 @@ class Folder(FileSystemItemABC): if uuid not in original_file_uuids: file = self.files[uuid] self.files.pop(uuid) - self._files_by_name.pop(file.name) # Now reset all remaining files for file in self.files.values(): @@ -219,7 +215,10 @@ class Folder(FileSystemItemABC): :return: The matching File. """ # TODO: Increment read count? - return self._files_by_name.get(file_name) + for file in self.files.values(): + if file.name == file_name: + return file + return None def get_file_by_id(self, file_uuid: str, include_deleted: Optional[bool] = False) -> File: """ @@ -250,15 +249,14 @@ class Folder(FileSystemItemABC): raise Exception(f"Invalid file: {file}") # check if file with id or name already exists in folder - if (force is not True) and file.name in self._files_by_name: + if self.get_file(file.name) is not None and not force: raise Exception(f"File with name {file.name} already exists in folder") - if (force is not True) and file.uuid in self.files: + if (file.uuid in self.files) and not force: raise Exception(f"File with uuid {file.uuid} already exists in folder") # add to list self.files[file.uuid] = file - self._files_by_name[file.name] = file self._file_request_manager.add_request(file.uuid, RequestType(func=file._request_manager)) file.folder = self @@ -275,7 +273,6 @@ class Folder(FileSystemItemABC): if self.files.get(file.uuid): self.files.pop(file.uuid) - self._files_by_name.pop(file.name) self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") @@ -300,7 +297,6 @@ class Folder(FileSystemItemABC): self.deleted_files[file_id] = file self.files = {} - self._files_by_name = {} def restore_file(self, file_uuid: str): """ @@ -316,7 +312,6 @@ class Folder(FileSystemItemABC): file.restore() self.files[file.uuid] = file - self._files_by_name[file.name] = file if file.deleted: self.deleted_files.pop(file_uuid) From 728f80cc2162b5dc1ce7878921dd90e70ca0d740 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 09:48:14 +0000 Subject: [PATCH 20/37] Temporarily disable file delete action --- src/primaite/game/agent/actions.py | 10 ++++++++++ src/primaite/game/agent/rewards.py | 7 +++++++ src/primaite/simulator/file_system/folder.py | 2 +- .../system/services/database/database_service.py | 4 ++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 4c47bfaa..585e2dfa 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -296,6 +296,16 @@ class NodeFileDeleteAction(NodeFileAbstractAction): super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) self.verb: str = "delete" + def form_request(self, node_id: int, folder_id: int, file_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_uuid = self.manager.get_node_uuid_by_idx(node_id) + folder_uuid = self.manager.get_folder_uuid_by_idx(node_idx=node_id, folder_idx=folder_id) + file_uuid = self.manager.get_file_uuid_by_idx(node_idx=node_id, folder_idx=folder_id, file_idx=file_id) + if node_uuid is None or folder_uuid is None or file_uuid is None: + return ["do_nothing"] + return ["do_nothing"] + # return ["network", "node", node_uuid, "file_system", "delete", "file", folder_uuid, file_uuid] + class NodeFileRepairAction(NodeFileAbstractAction): """Action which repairs a file.""" diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 8f064be3..6cee127f 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -110,6 +110,13 @@ class DatabaseFileIntegrity(AbstractReward): :type state: Dict """ database_file_state = access_from_nested_dict(state, self.location_in_state) + if database_file_state is NOT_PRESENT_IN_STATE: + _LOGGER.info( + f"Could not calculate {self.__class__} reward because " + "simulation state did not contain enough information." + ) + return 0.0 + health_status = database_file_state["health_status"] if health_status == 2: return -1 diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index 027547bb..ab862898 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -276,7 +276,7 @@ class Folder(FileSystemItemABC): self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") - self._file_request_manager.remove_request(file.uuid) + # self._file_request_manager.remove_request(file.uuid) else: _LOGGER.debug(f"File with UUID {file.uuid} was not found.") diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 1cdd0390..6fcced25 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -84,6 +84,10 @@ class DatabaseService(Service): ftp_client_service: FTPClient = software_manager.software.get("FTPClient") # send backup copy of database file to FTP server + if not self.db_file: + self.sys_log.error("Attempted to backup database file but it doesn't exist.") + return False + response = ftp_client_service.send_file( dest_ip_address=self.backup_server_ip, src_file_name=self.db_file.name, From edc9772d0a4a452256b10b03dd839496738adda5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 10:10:30 +0000 Subject: [PATCH 21/37] Fix typo in database restore --- .../simulator/system/services/database/database_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 6fcced25..14190dd2 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -125,7 +125,7 @@ class DatabaseService(Service): return False # replace db file - self.file_system.delete_file(folder_name="database", file_name="downloads.db") + self.file_system.delete_file(folder_name="database", file_name="database.db") self.file_system.copy_file(src_folder_name="downloads", src_file_name="database.db", dst_folder_name="database") if self.db_file is None: From 7d218c520115acb611f9de21a55aaea1a1964987 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 10:31:13 +0000 Subject: [PATCH 22/37] bump version --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 09fb39d2..72f12ef8 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b5 +3.0.0b6dev From 42d00e04408ed6a6857cdb67d668478eb027a733 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 21 Jan 2024 16:33:51 +0000 Subject: [PATCH 23/37] Fix issue where file deleted flag wouldn't be reset --- .gitignore | 1 + .../simulator/file_system/file_system.py | 17 +++++++---------- .../file_system/file_system_item_abc.py | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 892751d9..1ce2ca9d 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ benchmark/output # src/primaite/notebooks/scratch.ipynb src/primaite/notebooks/scratch.py sandbox.py +sandbox/ diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index c2eb0d2d..149bf083 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -23,7 +23,6 @@ class FileSystem(SimComponent): "List containing all the folders in the file system." deleted_folders: Dict[str, Folder] = {} "List containing all the folders that have been deleted." - _folders_by_name: Dict[str, Folder] = {} sys_log: SysLog "Instance of SysLog used to create system logs." sim_root: Path @@ -56,7 +55,6 @@ class FileSystem(SimComponent): folder = self.deleted_folders[uuid] self.deleted_folders.pop(uuid) self.folders[uuid] = folder - self._folders_by_name[folder.name] = folder # Clear any other deleted folders that aren't original (have been created by agent) self.deleted_folders.clear() @@ -67,7 +65,6 @@ class FileSystem(SimComponent): if uuid not in original_folder_uuids: folder = self.folders[uuid] self.folders.pop(uuid) - self._folders_by_name.pop(folder.name) # Now reset all remaining folders for folder in self.folders.values(): @@ -173,7 +170,6 @@ class FileSystem(SimComponent): folder = Folder(name=folder_name, sys_log=self.sys_log) self.folders[folder.uuid] = folder - self._folders_by_name[folder.name] = folder self._folder_request_manager.add_request( name=folder.uuid, request_type=RequestType(func=folder._request_manager) ) @@ -188,14 +184,13 @@ class FileSystem(SimComponent): if folder_name == "root": self.sys_log.warning("Cannot delete the root folder.") return - folder = self._folders_by_name.get(folder_name) + folder = self.get_folder(folder_name) if folder: # set folder to deleted state folder.delete() # remove from folder list self.folders.pop(folder.uuid) - self._folders_by_name.pop(folder.name) # add to deleted list folder.remove_all_files() @@ -221,7 +216,10 @@ class FileSystem(SimComponent): :param folder_name: The folder name. :return: The matching Folder. """ - return self._folders_by_name.get(folder_name) + for folder in self.folders.values(): + if folder.name == folder_name: + return folder + return None def get_folder_by_id(self, folder_uuid: str, include_deleted: bool = False) -> Optional[Folder]: """ @@ -261,13 +259,13 @@ class FileSystem(SimComponent): """ if folder_name: # check if file with name already exists - folder = self._folders_by_name.get(folder_name) + folder = self.get_folder(folder_name) # If not then create it if not folder: folder = self.create_folder(folder_name) else: # Use root folder if folder_name not supplied - folder = self._folders_by_name["root"] + folder = self.get_folder("root") # Create the file and add it to the folder file = File( @@ -474,7 +472,6 @@ class FileSystem(SimComponent): folder.restore() self.folders[folder.uuid] = folder - self._folders_by_name[folder.name] = folder if folder.deleted: self.deleted_folders.pop(folder.uuid) diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index 86cd1ee7..c3e1426b 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -87,7 +87,7 @@ class FileSystemItemABC(SimComponent): def set_original_state(self): """Sets the original state.""" - vals_to_keep = {"name", "health_status", "visible_health_status", "previous_hash", "revealed_to_red"} + vals_to_keep = {"name", "health_status", "visible_health_status", "previous_hash", "revealed_to_red", "deleted"} self._original_state = self.model_dump(include=vals_to_keep) def describe_state(self) -> Dict: From 8e19e05f570b57fee370190eb59384bda6adc77f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 21 Jan 2024 17:29:19 +0000 Subject: [PATCH 24/37] Fix acl actions for blue agent. --- .../config/_package_data/example_config.yaml | 60 ++++++++++++------- src/primaite/game/agent/actions.py | 15 ++++- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index ee0eb7ff..7393f5a3 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -304,63 +304,63 @@ agents: action: "NODE_RESET" options: node_id: 5 - 22: + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) action: "NETWORK_ACL_ADDRULE" options: position: 1 permission: 2 - source_ip_id: 7 - dest_ip_id: 1 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL source_port_id: 1 dest_port_id: 1 protocol_id: 1 - 23: + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 2 permission: 2 - source_ip_id: 8 - dest_ip_id: 1 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL source_port_id: 1 dest_port_id: 1 protocol_id: 1 - 24: + 24: # block tcp traffic from client 1 to web app action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 3 permission: 2 - source_ip_id: 7 - dest_ip_id: 3 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server source_port_id: 1 dest_port_id: 1 protocol_id: 3 - 25: + 25: # block tcp traffic from client 2 to web app action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 4 permission: 2 - source_ip_id: 8 - dest_ip_id: 3 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server source_port_id: 1 dest_port_id: 1 protocol_id: 3 26: action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 5 permission: 2 - source_ip_id: 7 - dest_ip_id: 4 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database source_port_id: 1 dest_port_id: 1 protocol_id: 3 27: action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 6 permission: 2 - source_ip_id: 8 - dest_ip_id: 4 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database source_port_id: 1 dest_port_id: 1 protocol_id: 3 @@ -504,6 +504,24 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 + reward_function: reward_components: diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 585e2dfa..6b15c5f8 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -470,13 +470,13 @@ class NetworkACLAddRuleAction(AbstractAction): dst_ip = "ALL" return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: - dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id) + dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 if dest_port_id == 1: dst_port = "ALL" else: - dst_port = self.manager.get_port_by_idx(dest_port_id) + dst_port = self.manager.get_port_by_idx(dest_port_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 return [ @@ -924,6 +924,15 @@ class ActionManager: :return: The constructed ActionManager. :rtype: ActionManager """ + ip_address_order = cfg["options"].pop("ip_address_order", {}) + ip_address_list = [] + for entry in ip_address_order: + node_ref = entry["node_ref"] + nic_num = entry["nic_num"] + node_obj = game.simulation.network.get_node_by_hostname(node_ref) + ip_address = node_obj.ethernet_port[nic_num].ip_address + ip_address_list.append(ip_address) + obj = cls( game=game, actions=cfg["action_list"], @@ -931,7 +940,7 @@ class ActionManager: **cfg["options"], protocols=game.options.protocols, ports=game.options.ports, - ip_address_list=None, + ip_address_list=ip_address_list or None, act_map=cfg.get("action_map"), ) From 88c1d16f1150734fa3db6c5c303ea322b25f94a6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 23 Jan 2024 14:34:05 +0000 Subject: [PATCH 25/37] Fix Router acl not clearing --- src/primaite/game/game.py | 30 +------ .../network/hardware/nodes/router.py | 79 ++++++++++++++++++- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 08098754..159f5bbb 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -13,11 +13,9 @@ from primaite.game.agent.rewards import RewardFunction from primaite.session.io import SessionIO, SessionIOSettings from primaite.simulator.network.hardware.base import NIC, NodeOperatingState from primaite.simulator.network.hardware.nodes.computer import Computer -from primaite.simulator.network.hardware.nodes.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.router import Router from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.hardware.nodes.switch import Switch -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.database_client import DatabaseClient from primaite.simulator.system.applications.web_browser import WebBrowser @@ -227,31 +225,7 @@ class PrimaiteGame: operating_state=NodeOperatingState.ON, ) elif n_type == "router": - new_node = Router( - hostname=node_cfg["hostname"], - num_ports=node_cfg.get("num_ports"), - operating_state=NodeOperatingState.ON, - ) - if "ports" in node_cfg: - for port_num, port_cfg in node_cfg["ports"].items(): - new_node.configure_port( - port=port_num, ip_address=port_cfg["ip_address"], subnet_mask=port_cfg["subnet_mask"] - ) - # new_node.enable_port(port_num) - if "acl" in node_cfg: - for r_num, r_cfg in node_cfg["acl"].items(): - # excuse the uncommon walrus operator ` := `. It's just here as a shorthand, to avoid repeating - # this: 'r_cfg.get('src_port')' - # Port/IPProtocol. TODO Refactor - new_node.acl.add_rule( - action=ACLAction[r_cfg["action"]], - src_port=None if not (p := r_cfg.get("src_port")) else Port[p], - dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], - protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], - src_ip_address=r_cfg.get("ip_address"), - dst_ip_address=r_cfg.get("ip_address"), - position=r_num, - ) + new_node = Router.from_config(node_cfg) else: _LOGGER.warning(f"invalid node type {n_type} in config") if "services" in node_cfg: diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0234934d..bb923d62 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -9,6 +9,7 @@ from prettytable import MARKDOWN, PrettyTable from primaite.simulator.core import RequestManager, RequestType, SimComponent from primaite.simulator.network.hardware.base import ARPCache, ICMP, NIC, Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader @@ -89,6 +90,8 @@ class AccessControlList(SimComponent): implicit_rule: ACLRule max_acl_rules: int = 25 _acl: List[Optional[ACLRule]] = [None] * 24 + _default_config: dict[int, dict] = {} + """Config dict describing how the ACL list should look at episode start""" def __init__(self, **kwargs) -> None: if not kwargs.get("implicit_action"): @@ -110,6 +113,21 @@ class AccessControlList(SimComponent): """Reset the original state of the SimComponent.""" self.implicit_rule.reset_component_for_episode(episode) super().reset_component_for_episode(episode) + self._reset_rules_to_default() + + def _reset_rules_to_default(self) -> None: + """Clear all ACL rules and set them to the default rules config.""" + self._acl = [None] * (self.max_acl_rules - 1) + for r_num, r_cfg in self._default_config.items(): + self.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("ip_address"), + dst_ip_address=r_cfg.get("ip_address"), + position=r_num, + ) def _init_request_manager(self) -> RequestManager: rm = super()._init_request_manager() @@ -391,7 +409,6 @@ class RouteTable(SimComponent): sys_log: SysLog def set_original_state(self): - """Sets the original state.""" """Sets the original state.""" super().set_original_state() self._original_state["routes_orig"] = self.routes @@ -864,3 +881,63 @@ class Router(Node): ] ) print(table) + + @classmethod + def from_config(cls, cfg: dict) -> "Router": + """Create a router based on a config dict. + + Schema: + - hostname (str): unique name for this router. + - num_ports (int, optional): Number of network ports on the router. 8 by default + - ports (dict): Dict with integers from 1 - num_ports as keys. The values should be another dict specifying + ip_address and subnet_mask assigned to that ports (as strings) + - acl (dict): Dict with integers from 1 - max_acl_rules as keys. The key defines the position within the ACL + where the rule will be added (lower number is resolved first). The values should describe valid ACL + Rules as: + - action (str): either PERMIT or DENY + - src_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - dst_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - protocol (str, optional): the named IP protocol such as ICMP, TCP, or UDP + - src_ip_address (str, optional): IP address octet written in base 10 + - dst_ip_address (str, optional): IP address octet written in base 10 + + Example config: + ``` + { + 'hostname': 'router_1', + 'num_ports': 5, + 'ports': { + 1: { + 'ip_address' : '192.168.1.1', + 'subnet_mask' : '255.255.255.0', + } + }, + 'acl' : { + 21: {'action': 'PERMIT', 'src_port': 'HTTP', dst_port: 'HTTP'}, + 22: {'action': 'PERMIT', 'src_port': 'ARP', 'dst_port': 'ARP'}, + 23: {'action': 'PERMIT', 'protocol': 'ICMP'}, + }, + } + ``` + + :param cfg: Router config adhering to schema described in main docstring body + :type cfg: dict + :return: Configured router. + :rtype: Router + """ + new = Router( + hostname=cfg["hostname"], + num_ports=cfg.get("num_ports"), + operating_state=NodeOperatingState.ON, + ) + if "ports" in cfg: + for port_num, port_cfg in cfg["ports"].items(): + new.configure_port( + port=port_num, + ip_address=port_cfg["ip_address"], + subnet_mask=port_cfg["subnet_mask"], + ) + if "acl" in cfg: + new.acl._default_config = cfg["acl"] # save the config to allow resetting + new.acl._reset_rules_to_default() # read the config and apply rules + return new From 0a65f32adfa47cd84c640f3ae9c1b0aed0bc1b94 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 09:27:08 +0000 Subject: [PATCH 26/37] Fix ACL observations --- src/primaite/game/agent/observations.py | 35 +++++++++++++------ .../network/hardware/nodes/router.py | 12 +++---- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index cac5b91e..b7962827 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -1,5 +1,6 @@ """Manages the observation space for the agent.""" from abc import ABC, abstractmethod +from ipaddress import IPv4Address from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from gymnasium import spaces @@ -648,10 +649,13 @@ class AclObservation(AbstractObservation): # TODO: what if the ACL has more rules than num of max rules for obs space obs = {} - for i, rule_state in acl_state.items(): + acl_items = dict(acl_state.items()) + i = 1 # don't show rule 0 for compatibility reasons. + while i < self.num_rules + 1: + rule_state = acl_items[i] if rule_state is None: - obs[i + 1] = { - "position": i, + obs[i] = { + "position": i - 1, "permission": 0, "source_node_id": 0, "source_port": 0, @@ -660,15 +664,26 @@ class AclObservation(AbstractObservation): "protocol": 0, } else: - obs[i + 1] = { - "position": i, + src_ip = rule_state["src_ip_address"] + src_node_id = 1 if src_ip is None else self.node_to_id[IPv4Address(src_ip)] + dst_ip = rule_state["dst_ip_address"] + dst_node_ip = 1 if dst_ip is None else self.node_to_id[IPv4Address(dst_ip)] + src_port = rule_state["src_port"] + src_port_id = 1 if src_port is None else self.port_to_id[src_port] + dst_port = rule_state["dst_port"] + dst_port_id = 1 if dst_port is None else self.port_to_id[dst_port] + protocol = rule_state["protocol"] + protocol_id = 1 if protocol is None else self.protocol_to_id[protocol] + obs[i] = { + "position": i - 1, "permission": rule_state["action"], - "source_node_id": self.node_to_id[rule_state["src_ip_address"]], - "source_port": self.port_to_id[rule_state["src_port"]], - "dest_node_id": self.node_to_id[rule_state["dst_ip_address"]], - "dest_port": self.port_to_id[rule_state["dst_port"]], - "protocol": self.protocol_to_id[rule_state["protocol"]], + "source_node_id": src_node_id, + "source_port": src_port_id, + "dest_node_id": dst_node_ip, + "dest_port": dst_port_id, + "protocol": protocol_id, } + i += 1 return obs @property diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index bb923d62..0c5d0ce9 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -19,8 +19,8 @@ from primaite.simulator.system.core.sys_log import SysLog class ACLAction(Enum): """Enum for defining the ACL action types.""" - DENY = 0 PERMIT = 1 + DENY = 2 class ACLRule(SimComponent): @@ -66,11 +66,11 @@ class ACLRule(SimComponent): """ state = super().describe_state() state["action"] = self.action.value - state["protocol"] = self.protocol.value if self.protocol else None + state["protocol"] = self.protocol.name if self.protocol else None state["src_ip_address"] = str(self.src_ip_address) if self.src_ip_address else None - state["src_port"] = self.src_port.value if self.src_port else None + state["src_port"] = self.src_port.name if self.src_port else None state["dst_ip_address"] = str(self.dst_ip_address) if self.dst_ip_address else None - state["dst_port"] = self.dst_port.value if self.dst_port else None + state["dst_port"] = self.dst_port.name if self.dst_port else None return state @@ -733,8 +733,8 @@ class Router(Node): :return: A dictionary representing the current state. """ state = super().describe_state() - state["num_ports"] = (self.num_ports,) - state["acl"] = (self.acl.describe_state(),) + state["num_ports"] = self.num_ports + state["acl"] = self.acl.describe_state() return state def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: From 28acb5dcaed89364bb920591a8d1bd82f51e6da1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:04:09 +0000 Subject: [PATCH 27/37] Populate step info in environment, and finish notebook --- .../config/_package_data/example_config.yaml | 2 +- .../example_config_2_rl_agents.yaml | 2 +- src/primaite/game/game.py | 5 +- .../notebooks/_package_data/uc2_network.png | Bin 0 -> 70887 bytes src/primaite/notebooks/uc2_demo.ipynb | 1038 +++++++++++++---- src/primaite/session/environment.py | 8 +- .../assets/configs/bad_primaite_session.yaml | 2 +- .../configs/eval_only_primaite_session.yaml | 2 +- tests/assets/configs/multi_agent_session.yaml | 2 +- .../assets/configs/test_primaite_session.yaml | 2 +- .../configs/train_only_primaite_session.yaml | 2 +- 11 files changed, 850 insertions(+), 215 deletions(-) create mode 100644 src/primaite/notebooks/_package_data/uc2_network.png diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 7393f5a3..d8cd0099 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -31,7 +31,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/src/primaite/config/_package_data/example_config_2_rl_agents.yaml b/src/primaite/config/_package_data/example_config_2_rl_agents.yaml index c1e2ea81..6aa54487 100644 --- a/src/primaite/config/_package_data/example_config_2_rl_agents.yaml +++ b/src/primaite/config/_package_data/example_config_2_rl_agents.yaml @@ -25,7 +25,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 159f5bbb..146261f9 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -113,7 +113,7 @@ class PrimaiteGame: self.update_agents(sim_state) # Apply all actions to simulation as requests - self.apply_agent_actions() + agent_actions = self.apply_agent_actions() # noqa # Advance timestep self.advance_timestep() @@ -131,12 +131,15 @@ class PrimaiteGame: def apply_agent_actions(self) -> None: """Apply all actions to simulation as requests.""" + agent_actions = {} for agent in self.agents: obs = agent.observation_manager.current_observation rew = agent.reward_function.current_reward action_choice, options = agent.get_action(obs, rew) + agent_actions[agent.agent_name] = (action_choice, options) request = agent.format_request(action_choice, options) self.simulation.apply_request(request) + return agent_actions def advance_timestep(self) -> None: """Advance timestep.""" diff --git a/src/primaite/notebooks/_package_data/uc2_network.png b/src/primaite/notebooks/_package_data/uc2_network.png new file mode 100644 index 0000000000000000000000000000000000000000..20fa43c996bb7c3bce61778a7aa233ee4b8852f6 GIT binary patch literal 70887 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](_package_data/uc2_network.png)\n", + "\n", + "_(click image to enlarge)_\n", + "\n", + "The red agent deletes the contents of the database. When this happens, the web app cannot fetch data and users navigating to the website get a 404 error.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network\n", + "\n", + "- The web server has:\n", + " - a web service that replies to user HTTP requests\n", + " - a database client that fetches data for the web service\n", + "- The database server has:\n", + " - a POSTGRES database service\n", + " - a database file which is accessed by the database service\n", + " - FTP client used for backing up the data to the backup_server\n", + "- The backup server has:\n", + " - a copy of the database file in a known good state\n", + " - FTP server that can send the backed up file back to the database server\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Green agent\n", + "\n", + "The green agent is logged onto client 2. It sometimes uses the web browser on client 2 to navigate to `http://arcd.com/users`. The web server replies with a status code 200 if the data is available on the database or 404 if not available." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Red agent\n", + "\n", + "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Blue agent\n", + "\n", + "The blue agent can view the entire network, but the health statuses of components are not updated until a scan is performed. The blue agent should restore the database file from backup after it was compromised. It can also prevent further attacks by blocking client 1 from reaching the database server. This can be done by removing client 1's network connection or adding ACL rules on the router to stop the packets from arriving." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Reinforcement learning details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Scripted agents:\n", + "### Red\n", + "The red agent sits on client 1 and uses an application called DataManipulationBot whose sole purpose is to send a DELETE query to the database.\n", + "The red agent can choose one of two action each timestep:\n", + "1. do nothing\n", + "2. execute the data manipulation application\n", + "The schedule for selecting when to execute the application is controlled by three parameters:\n", + "- start time\n", + "- frequency\n", + "- variance\n", + "Attacks start at a random timestep between (start_time - variance) and (start_time + variance). After each attack, another is attempted after a random delay between (frequency - variance) and (frequency + variance) timesteps.\n", + "\n", + "The data manipulation app itself has an element of randomness because the attack has a probability of success. The default is 0.8 to succeed with the port scan step and 0.8 to succeed with the attack itself.\n", + "Upon a successful attack, the database file becomes corrupted which incurs a negative reward for the RL defender.\n", + "\n", + "The red agent does not use information about the state of the network to decide its action.\n", + "\n", + "### Green\n", + "The green agent sits on client 2 and uses the web browser application to send requests to the web server. The schedule of the green agent is currently random, meaning it will request webpage with a 50% probability, and do nothing with a 50% probability.\n", + "\n", + "When the green agent is blocked from accessing the data through the webpage, this incurs a negative reward to the RL defender." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Observation Space\n", + "\n", + "The blue agent's observation space is structured as nested dictionary with the following information:\n", + "```\n", + "\n", + "- NODES\n", + " - \n", + " - SERVICES\n", + " - \n", + " - operating_status\n", + " - health_status\n", + " - FOLDERS\n", + " - \n", + " - health_status\n", + " - FILES\n", + " - \n", + " - health_status\n", + " - NICS\n", + " - \n", + " - nic_status\n", + " - operating_status\n", + "- LINKS\n", + " - \n", + " - PROTOCOLS\n", + " - ALL\n", + " - load\n", + "- ACL\n", + " - \n", + " - position\n", + " - permission\n", + " - source_node_id\n", + " - source_port\n", + " - dest_node_id\n", + " - dest_port\n", + " - protocol\n", + "- ICS\n", + "```\n", + "\n", + "### Mappings\n", + "\n", + "The dict keys for `node_id` are in the following order:\n", + "|node_id|node name|\n", + "|--|--|\n", + "|1|domain_controller|\n", + "|2|web_server|\n", + "|3|database_server|\n", + "|4|backup_server|\n", + "|5|security_suite|\n", + "|6|client_1|\n", + "|7|client_2|\n", + "\n", + "Service 1 on node 2 (web_server) corresponds to the Web Server service. Other services are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "Folder 1 on node 3 corresponds to the database folder. File 1 in that folder corresponds to the database storage file. Other files and folders are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "The dict keys for `link_id` are in the following order:\n", + "|link_id|endpoint_a|endpoint_b|\n", + "|--|--|--|\n", + "|1|router_1|switch_1|\n", + "|1|router_1|switch_2|\n", + "|1|switch_1|domain_controller|\n", + "|1|switch_1|web_server|\n", + "|1|switch_1|database_server|\n", + "|1|switch_1|backup_server|\n", + "|1|switch_1|security_suite|\n", + "|1|switch_2|client_1|\n", + "|1|switch_2|client_2|\n", + "|1|switch_2|security_suite|\n", + "\n", + "The ACL rules in the observation space appear in the same order that they do in the actual ACL. Though, only the first 10 rules are shown, there are default rules lower down that cannot be changed by the agent. The extra rules just allow the network to function normally, by allowing pings, ARP traffic, etc.\n", + "\n", + "Most nodes have only 1 nic, so the observation for those is placed at NIC index 1 in the observation space. Only the security suite has 2 NICs, the second NIC in the observation space is the one that connects the security suite with swtich_2.\n", + "\n", + "The meaning of the services' operating_state is:\n", + "|operating_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|RUNNING|\n", + "|2|STOPPED|\n", + "|3|PAUSED|\n", + "|4|DISABLED|\n", + "|5|INSTALLING|\n", + "|6|RESTARTING|\n", + "\n", + "The meaning of the services' health_state is:\n", + "|health_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|GOOD|\n", + "|2|PATCHING|\n", + "|3|COMPROMISED|\n", + "|4|OVERWHELMED|\n", + "\n", + "The meaning of the files' and folders' health_state is:\n", + "|health_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|GOOD|\n", + "|2|COMPROMISED|\n", + "|3|CORRUPT|\n", + "|4|RESTORING|\n", + "|5|REPAIRING|\n", + "\n", + "The meaning of the NICs' operating_status is:\n", + "|operating_status|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ENABLED|\n", + "|2|DISABLED|\n", + "\n", + "Link load has the following meaning:\n", + "|load|percent utilisation|\n", + "|--|--|\n", + "|0|exactly 0%|\n", + "|1|0-11%|\n", + "|2|11-22%|\n", + "|3|22-33%|\n", + "|4|33-44%|\n", + "|5|44-55%|\n", + "|6|55-66%|\n", + "|7|66-77%|\n", + "|8|77-88%|\n", + "|9|88-99%|\n", + "|10|exactly 100%|\n", + "\n", + "ACL permission has the following meaning:\n", + "|permission|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ALLOW|\n", + "|2|DENY|\n", + "\n", + "ACL source / destination node ids actually correspond to IP addresses (since ACLs work with IP addresses)\n", + "|source / dest node id|ip_address|label|\n", + "|--|--|--|\n", + "|0| | UNUSED|\n", + "|1| |ALL addresses|\n", + "|2| 192.168.1.10 | domain_controller|\n", + "|3| 192.168.1.12 | web_server \n", + "|4| 192.168.1.14 | database_server|\n", + "|5| 192.168.1.16 | backup_server|\n", + "|6| 192.168.1.110 | security_suite (eth-1)|\n", + "|7| 192.168.10.21 | client_1|\n", + "|8| 192.168.10.22 | client_2|\n", + "|9| 192.168.10.110| security_suite (eth-2)|\n", + "\n", + "ACL source / destination port ids have the following encoding:\n", + "|port id|port number| port use |\n", + "|--|--|--|\n", + "|0||UNUSED|\n", + "|1||ALL|\n", + "|2|219|ARP|\n", + "|3|53|DNS|\n", + "|4|80|HTTP|\n", + "|5|5432|POSTGRES_SERVER|\n", + "\n", + "ACL protocol ids have the following encoding:\n", + "|protocol id|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ALL|\n", + "|2|ICMP|\n", + "|3|TCP|\n", + "|4|UDP|\n", + "\n", + "protocol" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Action Space\n", + "\n", + "The blue agent chooses from a list of 54 pre-defined actions. The full list is defined in the `action_map` in the config. The most important ones are explained here:\n", + "\n", + "- `0`: Do nothing\n", + "- `1`: Scan the web service - this refreshes the health status in the observation space\n", + "- `9`: Scan the database file - this refreshes the health status of the database file\n", + "- `13`: Patch the database service - This triggers the database to restore data from the backup server\n", + "- `19`: Shut down client 1\n", + "- `22`: Block outgoing traffic from client 1\n", + "- `26`: Block TCP traffic from client 1 to the database node\n", + "- `28-37`: Remove ACL rules 1-10\n", + "- `42`: Disconnect client 1 from the network\n", + "\n", + "The other actions will either have no effect or will negatively impact the network, so the blue agent should avoid taking other actions, and learn about these actions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reward Function\n", + "\n", + "The blue agent's reward is calculated using two measures:\n", + "1. Whether the database file is in a good state (+1 for good, -1 for corrupted, 0 for any other state)\n", + "2. Whether the green agent's most recent webpage request was successful (+1 for a `200` return code, -1 for a `404` return code and 0 otherwise).\n", + "These two components are averaged to get the final reward.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Demonstration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, load the required modules" + ] + }, { "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n", - "2023-11-26 23:25:47,985\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2023-11-26 23:25:51,213\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2023-11-26 23:25:51,491\tWARNING __init__.py:10 -- PG has/have been moved to `rllib_contrib` and will no longer be maintained by the RLlib team. You can still use it/them normally inside RLlib util Ray 2.8, but from Ray 2.9 on, all `rllib_contrib` algorithms will no longer be part of the core repo, and will therefore have to be installed separately with pinned dependencies for e.g. ray[rllib] and other packages! See https://github.com/ray-project/ray/tree/master/rllib_contrib#rllib-contrib for more information on the RLlib contrib effort.\n" - ] - } - ], + "outputs": [], "source": [ - "from primaite.session.session import PrimaiteSession\n", - "from primaite.game.game import PrimaiteGame\n", - "from primaite.config.load import example_config_path\n", - "\n", - "from primaite.simulator.system.services.database.database_service import DatabaseService\n", - "\n", - "import yaml" + "%load_ext autoreload\n", + "%autoreload 2" ] }, { @@ -36,61 +339,181 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-11-26 23:25:51,579::ERROR::primaite.simulator.network.hardware.base::175::NIC a9:92:0a:5e:1b:e4/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,580::ERROR::primaite.simulator.network.hardware.base::175::NIC ef:03:23:af:3c:19/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,581::ERROR::primaite.simulator.network.hardware.base::175::NIC ae:cf:83:2f:94:17/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,582::ERROR::primaite.simulator.network.hardware.base::175::NIC 4c:b2:99:e2:4a:5d/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,583::ERROR::primaite.simulator.network.hardware.base::175::NIC b9:eb:f9:c2:17:2f/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,590::ERROR::primaite.simulator.network.hardware.base::175::NIC cb:df:ca:54:be:01/192.168.1.10 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,595::ERROR::primaite.simulator.network.hardware.base::175::NIC 6e:32:12:da:4d:0d/192.168.1.12 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,600::ERROR::primaite.simulator.network.hardware.base::175::NIC 58:6e:9b:a7:68:49/192.168.1.14 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,604::ERROR::primaite.simulator.network.hardware.base::175::NIC 33:db:a6:40:dd:a3/192.168.1.16 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,608::ERROR::primaite.simulator.network.hardware.base::175::NIC 72:aa:2b:c0:4c:5f/192.168.1.110 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,610::ERROR::primaite.simulator.network.hardware.base::175::NIC 11:d7:0e:90:d9:a4/192.168.10.110 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,614::ERROR::primaite.simulator.network.hardware.base::175::NIC 86:2b:a4:e5:4d:0f/192.168.10.21 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,631::ERROR::primaite.simulator.network.hardware.base::175::NIC af:ad:8f:84:f1:db/192.168.10.22 cannot be enabled as it is not connected to a Link\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "installing DNSServer on node domain_controller\n", - "installing DatabaseClient on node web_server\n", - "installing WebServer on node web_server\n", - "installing DatabaseService on node database_server\n", - "installing FTPClient on node database_server\n", - "installing FTPServer on node backup_server\n", - "installing DNSClient on node client_1\n", - "installing DNSClient on node client_2\n" + "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "2024-01-25 11:19:29,199\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-25 11:19:31,924\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" ] } ], "source": [ + "# Imports\n", + "from primaite.config.load import example_config_path\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.game.game import PrimaiteGame\n", + "import yaml\n", + "from pprint import pprint\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instantiate the environment. We also disable the agent observation flattening.\n", "\n", - "with open(example_config_path(),'r') as cfgfile:\n", - " cfg = yaml.safe_load(cfgfile)\n", - "game = PrimaiteGame.from_config(cfg)\n", - "net = game.simulation.network\n", - "database_server = net.get_node_by_hostname('database_server')\n", - "web_server = net.get_node_by_hostname('web_server')\n", - "client_1 = net.get_node_by_hostname('client_1')\n", - "\n", - "db_service = database_server.software_manager.software[\"DatabaseService\"]\n", - "db_client = web_server.software_manager.software[\"DatabaseClient\"]\n", - "# db_client.run()\n", - "db_manipulation_bot = client_1.software_manager.software[\"DataManipulationBot\"]\n", - "db_manipulation_bot.port_scan_p_of_success=1.0\n", - "db_manipulation_bot.data_manipulation_p_of_success=1.0\n" + "This cell will print the observation when the network is healthy. You should be able to verify Node file and service statuses against the description above." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Resetting environment, episode 0, avg. reward: 0.0\n", + "env created successfully\n", + "{'ACL': {1: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 0,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 2: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 1,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 3: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 2,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 4: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 3,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 5: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 4,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 6: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 5,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 7: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 6,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 8: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 7,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 9: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 8,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 10: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 9,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0}},\n", + " 'ICS': 0,\n", + " 'LINKS': {1: {'PROTOCOLS': {'ALL': 1}},\n", + " 2: {'PROTOCOLS': {'ALL': 1}},\n", + " 3: {'PROTOCOLS': {'ALL': 1}},\n", + " 4: {'PROTOCOLS': {'ALL': 1}},\n", + " 5: {'PROTOCOLS': {'ALL': 1}},\n", + " 6: {'PROTOCOLS': {'ALL': 1}},\n", + " 7: {'PROTOCOLS': {'ALL': 1}},\n", + " 8: {'PROTOCOLS': {'ALL': 1}},\n", + " 9: {'PROTOCOLS': {'ALL': 1}},\n", + " 10: {'PROTOCOLS': {'ALL': 1}}},\n", + " 'NODES': {1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 1}},\n", + " 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}}\n" + ] + } + ], "source": [ - "db_client.run()" + "# create the env\n", + "with open(example_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "game = PrimaiteGame.from_config(cfg)\n", + "env = PrimaiteGymEnv(game = game)\n", + "# Don't flatten obs as we are not training an agent and we wish to see the dict-formatted observations\n", + "env.agent.flatten_obs = False\n", + "obs, info = env.reset()\n", + "print('env created successfully')\n", + "pprint(obs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The red agent will start attacking at some point between step 20 and 30.\n", + "\n", + "The red agent has a random chance of failing its attack, so you may need run the following cell multiple times until the reward goes from 1.0 to -1.0." ] }, { @@ -99,18 +522,53 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 1, Red action: DONOTHING, Blue reward:1.0\n", + "step: 2, Red action: DONOTHING, Blue reward:1.0\n", + "step: 3, Red action: DONOTHING, Blue reward:1.0\n", + "step: 4, Red action: DONOTHING, Blue reward:1.0\n", + "step: 5, Red action: DONOTHING, Blue reward:1.0\n", + "step: 6, Red action: DONOTHING, Blue reward:1.0\n", + "step: 7, Red action: DONOTHING, Blue reward:1.0\n", + "step: 8, Red action: DONOTHING, Blue reward:1.0\n", + "step: 9, Red action: DONOTHING, Blue reward:1.0\n", + "step: 10, Red action: DONOTHING, Blue reward:1.0\n", + "step: 11, Red action: DONOTHING, Blue reward:1.0\n", + "step: 12, Red action: DONOTHING, Blue reward:1.0\n", + "step: 13, Red action: DONOTHING, Blue reward:1.0\n", + "step: 14, Red action: DONOTHING, Blue reward:1.0\n", + "step: 15, Red action: DONOTHING, Blue reward:1.0\n", + "step: 16, Red action: DONOTHING, Blue reward:1.0\n", + "step: 17, Red action: DONOTHING, Blue reward:1.0\n", + "step: 18, Red action: DONOTHING, Blue reward:1.0\n", + "step: 19, Red action: DONOTHING, Blue reward:1.0\n", + "step: 20, Red action: DONOTHING, Blue reward:1.0\n", + "step: 21, Red action: DONOTHING, Blue reward:1.0\n", + "step: 22, Red action: DONOTHING, Blue reward:1.0\n", + "step: 23, Red action: DONOTHING, Blue reward:1.0\n", + "step: 24, Red action: DONOTHING, Blue reward:1.0\n", + "step: 25, Red action: DONOTHING, Blue reward:1.0\n", + "step: 26, Red action: DONOTHING, Blue reward:1.0\n", + "step: 27, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 28, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 29, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 30, Red action: DONOTHING, Blue reward:-1.0\n" + ] } ], "source": [ - "db_service.backup_database()" + "for step in range(30):\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now the reward is -1, let's have a look at blue agent's observation." ] }, { @@ -119,27 +577,110 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "{1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 1}}, 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}\n" + ] } ], "source": [ - "db_client.query(\"SELECT\")" + "pprint(obs['NODES'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The true statuses of the database file and webapp are not updated. The blue agent needs to perform a scan to see that they have degraded." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 3, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 2}}, 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}\n" + ] + } + ], "source": [ - "db_manipulation_bot.run()" + "obs, reward, terminated, truncated, info = env.step(9) # scan database file\n", + "obs, reward, terminated, truncated, info = env.step(1) # scan webapp service\n", + "pprint(obs['NODES'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now service 1 on node 2 has `health_status = 3`, indicating that the webapp is compromised.\n", + "File 1 in folder 1 on node 3 has `health_status = 2`, indicating that the database file is compromised." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can now patch the database to restore the file to a good health status." ] }, { @@ -148,130 +689,221 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_client.query(\"SELECT\")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_service.restore_backup()" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_client.query(\"SELECT\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "db_manipulation_bot.run()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_1.ping(database_server.ethernet_port[1].ip_address)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "from pydantic import validate_call, BaseModel" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "class A(BaseModel):\n", - " x:int\n", - "\n", - " @validate_call\n", - " def increase_x(self, by:int) -> None:\n", - " self.x += 1" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "my_a = A(x=3)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "ename": "ValidationError", - "evalue": "1 validation error for increase_x\n0\n Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=3.2, input_type=float]\n For further information visit https://errors.pydantic.dev/2.1/v/int_from_float", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValidationError\u001b[0m Traceback (most recent call last)", - "\u001b[1;32m/home/cade/repos/PrimAITE/src/primaite/notebooks/uc2_demo.ipynb Cell 15\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m my_a\u001b[39m.\u001b[39;49mincrease_x(\u001b[39m3.2\u001b[39;49m)\n", - "File \u001b[0;32m~/repos/PrimAITE/venv/lib/python3.10/site-packages/pydantic/_internal/_validate_call.py:91\u001b[0m, in \u001b[0;36mValidateCallWrapper.__call__\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 90\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__call__\u001b[39m(\u001b[39mself\u001b[39m, \u001b[39m*\u001b[39margs: Any, \u001b[39m*\u001b[39m\u001b[39m*\u001b[39mkwargs: Any) \u001b[39m-\u001b[39m\u001b[39m>\u001b[39m Any:\n\u001b[0;32m---> 91\u001b[0m res \u001b[39m=\u001b[39m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49m__pydantic_validator__\u001b[39m.\u001b[39;49mvalidate_python(pydantic_core\u001b[39m.\u001b[39;49mArgsKwargs(args, kwargs))\n\u001b[1;32m 92\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m__return_pydantic_validator__:\n\u001b[1;32m 93\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m__return_pydantic_validator__\u001b[39m.\u001b[39mvalidate_python(res)\n", - "\u001b[0;31mValidationError\u001b[0m: 1 validation error for increase_x\n0\n Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=3.2, input_type=float]\n For further information visit https://errors.pydantic.dev/2.1/v/int_from_float" + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 33\n", + "Red action: DONOTHING\n", + "Green action: DONOTHING\n", + "Blue reward:-1.0\n" ] } ], "source": [ - "my_a.increase_x(3.2)" + "obs, reward, terminated, truncated, info = env.step(13) # patch the database\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user'][0]}\" )\n", + "print(f\"Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The patching takes two steps, so the reward hasn't changed yet. Let's do nothing for another timestep, the reward should improve.\n", + "\n", + "The reward will be 0 as soon as the file finishes restoring. Then, the reward will increase to 1 when the green agent makes a request. (Because the webapp access part of the reward does not update until a successful request is made.)\n", + "\n", + "Run the following cell until the green action is `NODE_APPLICATION_EXECUTE`, then the reward should become 1. If you run it enough times, another red attack will happen and the reward will drop again." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 44\n", + "Red action: DONOTHING\n", + "Green action: NODE_APPLICATION_EXECUTE\n", + "Blue reward:-1.0\n" + ] + } + ], + "source": [ + "obs, reward, terminated, truncated, info = env.step(0) # patch the database\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user'][0]}\" )\n", + "print(f\"Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can prevent attacks by implementing an ACL rule to stop client_1 from sending POSTGRES traffic to the database. (Let's also patch the database file to get the reward back up.)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 107, Red action: DONOTHING, Blue reward:1.0\n", + "step: 108, Red action: DONOTHING, Blue reward:1.0\n", + "step: 109, Red action: DONOTHING, Blue reward:1.0\n", + "step: 110, Red action: DONOTHING, Blue reward:1.0\n", + "step: 111, Red action: DONOTHING, Blue reward:1.0\n", + "step: 112, Red action: DONOTHING, Blue reward:1.0\n", + "step: 113, Red action: DONOTHING, Blue reward:1.0\n", + "step: 114, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 115, Red action: DONOTHING, Blue reward:1.0\n", + "step: 116, Red action: DONOTHING, Blue reward:1.0\n", + "step: 117, Red action: DONOTHING, Blue reward:1.0\n", + "step: 118, Red action: DONOTHING, Blue reward:1.0\n", + "step: 119, Red action: DONOTHING, Blue reward:1.0\n", + "step: 120, Red action: DONOTHING, Blue reward:1.0\n", + "step: 121, Red action: DONOTHING, Blue reward:1.0\n", + "step: 122, Red action: DONOTHING, Blue reward:1.0\n", + "step: 123, Red action: DONOTHING, Blue reward:1.0\n", + "step: 124, Red action: DONOTHING, Blue reward:1.0\n", + "step: 125, Red action: DONOTHING, Blue reward:1.0\n", + "step: 126, Red action: DONOTHING, Blue reward:1.0\n", + "step: 127, Red action: DONOTHING, Blue reward:1.0\n", + "step: 128, Red action: DONOTHING, Blue reward:1.0\n", + "step: 129, Red action: DONOTHING, Blue reward:1.0\n", + "step: 130, Red action: DONOTHING, Blue reward:1.0\n", + "step: 131, Red action: DONOTHING, Blue reward:1.0\n", + "step: 132, Red action: DONOTHING, Blue reward:1.0\n", + "step: 133, Red action: DONOTHING, Blue reward:1.0\n", + "step: 134, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 135, Red action: DONOTHING, Blue reward:1.0\n", + "step: 136, Red action: DONOTHING, Blue reward:1.0\n" + ] + } + ], + "source": [ + "env.step(13) # Patch the database\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )\n", + "\n", + "env.step(26) # Block client 1\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )\n", + "\n", + "for step in range(30):\n", + " obs, reward, terminated, truncated, info = env.step(0) # do nothing\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, even though the red agent executes an attack, the reward stays at 1.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's also have a look at the ACL observation to verify our new ACL rule at position 5." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{1: {'position': 0,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 2: {'position': 1,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 3: {'position': 2,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 4: {'position': 3,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 5: {'position': 4,\n", + " 'permission': 2,\n", + " 'source_node_id': 7,\n", + " 'source_port': 1,\n", + " 'dest_node_id': 4,\n", + " 'dest_port': 1,\n", + " 'protocol': 3},\n", + " 6: {'position': 5,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 7: {'position': 6,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 8: {'position': 7,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 9: {'position': 8,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 10: {'position': 9,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0}}" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "obs['ACL']" ] }, { diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 6701f183..a3831bc1 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -29,7 +29,7 @@ class PrimaiteGymEnv(gymnasium.Env): # make ProxyAgent store the action chosen my the RL policy self.agent.store_action(action) # apply_agent_actions accesses the action we just stored - self.game.apply_agent_actions() + agent_actions = self.game.apply_agent_actions() self.game.advance_timestep() state = self.game.get_sim_state() @@ -39,7 +39,7 @@ class PrimaiteGymEnv(gymnasium.Env): reward = self.agent.reward_function.current_reward terminated = False truncated = self.game.calculate_truncated() - info = {} + info = {"agent_actions": agent_actions} # tell us what all the agents did for convenience. if self.game.save_step_metadata: self._write_step_metadata_json(action, state, reward) return next_obs, reward, terminated, truncated, info @@ -172,7 +172,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv): # 1. Perform actions for agent_name, action in actions.items(): self.agents[agent_name].store_action(action) - self.game.apply_agent_actions() + agent_actions = self.game.apply_agent_actions() # 2. Advance timestep self.game.advance_timestep() @@ -186,7 +186,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv): rewards = {name: agent.reward_function.current_reward for name, agent in self.agents.items()} terminateds = {name: False for name, _ in self.agents.items()} truncateds = {name: self.game.calculate_truncated() for name, _ in self.agents.items()} - infos = {} + infos = {"agent_actions": agent_actions} terminateds["__all__"] = len(self.terminateds) == len(self.agents) truncateds["__all__"] = self.game.calculate_truncated() if self.game.save_step_metadata: diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index 9070f246..e5458670 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -19,7 +19,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index e67f6606..767279ce 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -23,7 +23,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml index 220ca21e..6290fa53 100644 --- a/tests/assets/configs/multi_agent_session.yaml +++ b/tests/assets/configs/multi_agent_session.yaml @@ -29,7 +29,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index d7e94cb6..89b88475 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -27,7 +27,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/train_only_primaite_session.yaml b/tests/assets/configs/train_only_primaite_session.yaml index b89349c0..b9fa1216 100644 --- a/tests/assets/configs/train_only_primaite_session.yaml +++ b/tests/assets/configs/train_only_primaite_session.yaml @@ -23,7 +23,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: From 99723b6578d332d49eb739bb51bda1c0d1a5ef99 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:33:18 +0000 Subject: [PATCH 28/37] Update notebook with more images. --- .../notebooks/_package_data/uc2_attack.png | Bin 0 -> 112286 bytes src/primaite/notebooks/uc2_demo.ipynb | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 src/primaite/notebooks/_package_data/uc2_attack.png diff --git a/src/primaite/notebooks/_package_data/uc2_attack.png b/src/primaite/notebooks/_package_data/uc2_attack.png new file mode 100644 index 0000000000000000000000000000000000000000..8b8df5ce8ddf74ad717c734cd52945164580f0f7 GIT binary patch literal 112286 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

q7w?w|>VNDk-YR_&_h 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 literal 0 HcmV?d00001 diff --git a/src/primaite/notebooks/uc2_demo.ipynb b/src/primaite/notebooks/uc2_demo.ipynb index 7bcfdd29..6a20e3e8 100644 --- a/src/primaite/notebooks/uc2_demo.ipynb +++ b/src/primaite/notebooks/uc2_demo.ipynb @@ -55,7 +55,11 @@ "source": [ "## Red agent\n", "\n", - "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available." + "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available.\n", + "\n", + "[](_package_data/uc2_attack.png)\n", + "\n", + "_(click image to enlarge)_" ] }, { From e7aac754a08772f771c4dc895376eb89c453afe1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:38:28 +0000 Subject: [PATCH 29/37] Update the changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 227cec69..cb2f418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Fixed a bug where ACL rules were not resetting on episode reset. +- Fixed a bug where blue agent's ACL actions were being applied against the wrong IP addresses +- Fixed a bug where deleted files and folders did not reset correctly on episode reset. +- Fixed a bug where service health status was using the actual health state instead of the visible health state +- Fixed a bug where the database file health status was using the incorrect value for negative rewards +- Fixed a bug preventing file actions from reaching their intended file +- Made database patch correctly take 2 timesteps instead of being immediate +- Made database patch only possible when the software is compromised or good, it's no longer possible when the software is OFF or RESETTING +- Temporarily disable the blue agent file delete action due to crashes. This issue is resolved in another branch that will be merged into dev soon. +- Fix a bug where ACLs were not showing up correctly in the observation space. +- Added a notebook which explains Data manipulation scenario, demonstrates the attack, and shows off blue agent's action space, observation space, and reward function. - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - Fixed an issue where the data manipulation attack was triggered at episode start. From 73a75c497b612bbacb352373070c7391205d4c73 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 13:13:50 +0000 Subject: [PATCH 30/37] Fix test --- .../network/hardware/nodes/router.py | 21 ++++++++++-- .../assets/configs/bad_primaite_session.yaml | 17 ++++++++++ .../configs/eval_only_primaite_session.yaml | 17 ++++++++++ tests/assets/configs/multi_agent_session.yaml | 34 +++++++++++++++++++ .../assets/configs/test_primaite_session.yaml | 17 ++++++++++ .../configs/train_only_primaite_session.yaml | 17 ++++++++++ .../_simulator/_network/test_container.py | 24 ++++++++++--- 7 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0c5d0ce9..41c14967 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -90,7 +90,7 @@ class AccessControlList(SimComponent): implicit_rule: ACLRule max_acl_rules: int = 25 _acl: List[Optional[ACLRule]] = [None] * 24 - _default_config: dict[int, dict] = {} + _default_config: Dict[int, dict] = {} """Config dict describing how the ACL list should look at episode start""" def __init__(self, **kwargs) -> None: @@ -109,6 +109,21 @@ class AccessControlList(SimComponent): vals_to_keep = {"implicit_action", "max_acl_rules", "acl"} self._original_state = self.model_dump(include=vals_to_keep, exclude_none=True) + for i, rule in enumerate(self._acl): + if not rule: + continue + self._default_config[i] = {"action": rule.action.name} + if rule.src_ip_address: + self._default_config[i]["src_ip"] = str(rule.src_ip_address) + if rule.dst_ip_address: + self._default_config[i]["dst_ip"] = str(rule.dst_ip_address) + if rule.src_port: + self._default_config[i]["src_port"] = rule.src_port.name + if rule.dst_port: + self._default_config[i]["dst_port"] = rule.dst_port.name + if rule.protocol: + self._default_config[i]["protocol"] = rule.protocol.name + def reset_component_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" self.implicit_rule.reset_component_for_episode(episode) @@ -124,8 +139,8 @@ class AccessControlList(SimComponent): src_port=None if not (p := r_cfg.get("src_port")) else Port[p], dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], - src_ip_address=r_cfg.get("ip_address"), - dst_ip_address=r_cfg.get("ip_address"), + src_ip_address=r_cfg.get("src_ip"), + dst_ip_address=r_cfg.get("dst_ip"), position=r_num, ) diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index e5458670..4a1fc275 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -491,6 +491,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index 767279ce..c8ffa23f 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -502,6 +502,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml index 6290fa53..6cd22694 100644 --- a/tests/assets/configs/multi_agent_session.yaml +++ b/tests/assets/configs/multi_agent_session.yaml @@ -509,6 +509,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: @@ -940,6 +957,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index 89b88475..99087798 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -507,6 +507,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/train_only_primaite_session.yaml b/tests/assets/configs/train_only_primaite_session.yaml index b9fa1216..c2842a06 100644 --- a/tests/assets/configs/train_only_primaite_session.yaml +++ b/tests/assets/configs/train_only_primaite_session.yaml @@ -503,6 +503,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index e348838e..7667a59f 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -10,6 +10,22 @@ from primaite.simulator.system.applications.database_client import DatabaseClien from primaite.simulator.system.services.database.database_service import DatabaseService +def filter_keys_nested_item(data, keys): + stack = [(data, {})] + while stack: + current, filtered = stack.pop() + if isinstance(current, dict): + for k, v in current.items(): + if k in keys: + filtered[k] = filter_keys_nested_item(v, keys) + elif isinstance(v, (dict, list)): + stack.append((v, {})) + elif isinstance(current, list): + for item in current: + stack.append((item, {})) + return filtered + + @pytest.fixture(scope="function") def network(example_network) -> Network: assert len(example_network.routers) is 1 @@ -59,10 +75,10 @@ def test_reset_network(network): assert client_1.operating_state is NodeOperatingState.ON assert server_1.operating_state is NodeOperatingState.ON - - assert json.dumps(network.describe_state(), sort_keys=True, indent=2) == json.dumps( - state_before, sort_keys=True, indent=2 - ) + # don't worry if UUIDs change + a = filter_keys_nested_item(json.dumps(network.describe_state(), sort_keys=True, indent=2), ["uuid"]) + b = filter_keys_nested_item(json.dumps(state_before, sort_keys=True, indent=2), ["uuid"]) + assert a == b def test_creating_container(): From 4b98c1f630feccfde1eb12f5b3cebffef8170345 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:43:49 +0000 Subject: [PATCH 31/37] Update uc2 notebook --- src/primaite/notebooks/uc2_demo.ipynb | 115 ++++++++++++++------------ 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/src/primaite/notebooks/uc2_demo.ipynb b/src/primaite/notebooks/uc2_demo.ipynb index 6a20e3e8..679e8226 100644 --- a/src/primaite/notebooks/uc2_demo.ipynb +++ b/src/primaite/notebooks/uc2_demo.ipynb @@ -345,8 +345,8 @@ "text": [ "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", " from .autonotebook import tqdm as notebook_tqdm\n", - "2024-01-25 11:19:29,199\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2024-01-25 11:19:31,924\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" + "2024-01-25 14:43:32,056\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-25 14:43:35,213\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" ] } ], @@ -502,6 +502,9 @@ "# create the env\n", "with open(example_config_path(), 'r') as f:\n", " cfg = yaml.safe_load(f)\n", + " # set success probability to 1.0 to avoid rerunning cells.\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['data_manipulation_p_of_success'] = 1.0\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['port_scan_p_of_success'] = 1.0\n", "game = PrimaiteGame.from_config(cfg)\n", "env = PrimaiteGymEnv(game = game)\n", "# Don't flatten obs as we are not training an agent and we wish to see the dict-formatted observations\n", @@ -515,9 +518,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The red agent will start attacking at some point between step 20 and 30.\n", - "\n", - "The red agent has a random chance of failing its attack, so you may need run the following cell multiple times until the reward goes from 1.0 to -1.0." + "The red agent will start attacking at some point between step 20 and 30. When this happens, the reward will go from 1.0 to 0.0, and to -1.0 when the green agent tries to access the webpage." ] }, { @@ -529,10 +530,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "step: 1, Red action: DONOTHING, Blue reward:1.0\n", - "step: 2, Red action: DONOTHING, Blue reward:1.0\n", - "step: 3, Red action: DONOTHING, Blue reward:1.0\n", - "step: 4, Red action: DONOTHING, Blue reward:1.0\n", + "step: 1, Red action: DONOTHING, Blue reward:0.5\n", + "step: 2, Red action: DONOTHING, Blue reward:0.5\n", + "step: 3, Red action: DONOTHING, Blue reward:0.5\n", + "step: 4, Red action: DONOTHING, Blue reward:0.5\n", "step: 5, Red action: DONOTHING, Blue reward:1.0\n", "step: 6, Red action: DONOTHING, Blue reward:1.0\n", "step: 7, Red action: DONOTHING, Blue reward:1.0\n", @@ -550,20 +551,22 @@ "step: 19, Red action: DONOTHING, Blue reward:1.0\n", "step: 20, Red action: DONOTHING, Blue reward:1.0\n", "step: 21, Red action: DONOTHING, Blue reward:1.0\n", - "step: 22, Red action: DONOTHING, Blue reward:1.0\n", - "step: 23, Red action: DONOTHING, Blue reward:1.0\n", - "step: 24, Red action: DONOTHING, Blue reward:1.0\n", - "step: 25, Red action: DONOTHING, Blue reward:1.0\n", - "step: 26, Red action: DONOTHING, Blue reward:1.0\n", - "step: 27, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 22, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 23, Red action: DONOTHING, Blue reward:0.0\n", + "step: 24, Red action: DONOTHING, Blue reward:0.0\n", + "step: 25, Red action: DONOTHING, Blue reward:0.0\n", + "step: 26, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 27, Red action: DONOTHING, Blue reward:-1.0\n", "step: 28, Red action: DONOTHING, Blue reward:-1.0\n", "step: 29, Red action: DONOTHING, Blue reward:-1.0\n", - "step: 30, Red action: DONOTHING, Blue reward:-1.0\n" + "step: 30, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 31, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 32, Red action: DONOTHING, Blue reward:-1.0\n" ] } ], "source": [ - "for step in range(30):\n", + "for step in range(32):\n", " obs, reward, terminated, truncated, info = env.step(0)\n", " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" ] @@ -696,9 +699,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "step: 33\n", + "step: 35\n", "Red action: DONOTHING\n", - "Green action: DONOTHING\n", + "Green action: NODE_APPLICATION_EXECUTE\n", "Blue reward:-1.0\n" ] } @@ -724,17 +727,17 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "step: 44\n", + "step: 36\n", "Red action: DONOTHING\n", "Green action: NODE_APPLICATION_EXECUTE\n", - "Blue reward:-1.0\n" + "Blue reward:0.0\n" ] } ], @@ -755,43 +758,45 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "step: 107, Red action: DONOTHING, Blue reward:1.0\n", - "step: 108, Red action: DONOTHING, Blue reward:1.0\n", - "step: 109, Red action: DONOTHING, Blue reward:1.0\n", - "step: 110, Red action: DONOTHING, Blue reward:1.0\n", - "step: 111, Red action: DONOTHING, Blue reward:1.0\n", - "step: 112, Red action: DONOTHING, Blue reward:1.0\n", - "step: 113, Red action: DONOTHING, Blue reward:1.0\n", - "step: 114, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", - "step: 115, Red action: DONOTHING, Blue reward:1.0\n", - "step: 116, Red action: DONOTHING, Blue reward:1.0\n", - "step: 117, Red action: DONOTHING, Blue reward:1.0\n", - "step: 118, Red action: DONOTHING, Blue reward:1.0\n", - "step: 119, Red action: DONOTHING, Blue reward:1.0\n", - "step: 120, Red action: DONOTHING, Blue reward:1.0\n", - "step: 121, Red action: DONOTHING, Blue reward:1.0\n", - "step: 122, Red action: DONOTHING, Blue reward:1.0\n", - "step: 123, Red action: DONOTHING, Blue reward:1.0\n", - "step: 124, Red action: DONOTHING, Blue reward:1.0\n", - "step: 125, Red action: DONOTHING, Blue reward:1.0\n", - "step: 126, Red action: DONOTHING, Blue reward:1.0\n", - "step: 127, Red action: DONOTHING, Blue reward:1.0\n", - "step: 128, Red action: DONOTHING, Blue reward:1.0\n", - "step: 129, Red action: DONOTHING, Blue reward:1.0\n", - "step: 130, Red action: DONOTHING, Blue reward:1.0\n", - "step: 131, Red action: DONOTHING, Blue reward:1.0\n", - "step: 132, Red action: DONOTHING, Blue reward:1.0\n", - "step: 133, Red action: DONOTHING, Blue reward:1.0\n", - "step: 134, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", - "step: 135, Red action: DONOTHING, Blue reward:1.0\n", - "step: 136, Red action: DONOTHING, Blue reward:1.0\n" + "step: 37, Red action: DONOTHING, Blue reward:0.0\n", + "step: 38, Red action: DONOTHING, Blue reward:0.0\n", + "step: 39, Red action: DONOTHING, Blue reward:1.0\n", + "step: 40, Red action: DONOTHING, Blue reward:1.0\n", + "step: 41, Red action: DONOTHING, Blue reward:1.0\n", + "step: 42, Red action: DONOTHING, Blue reward:1.0\n", + "step: 43, Red action: DONOTHING, Blue reward:1.0\n", + "step: 44, Red action: DONOTHING, Blue reward:1.0\n", + "step: 45, Red action: DONOTHING, Blue reward:1.0\n", + "step: 46, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 47, Red action: DONOTHING, Blue reward:1.0\n", + "step: 48, Red action: DONOTHING, Blue reward:1.0\n", + "step: 49, Red action: DONOTHING, Blue reward:1.0\n", + "step: 50, Red action: DONOTHING, Blue reward:1.0\n", + "step: 51, Red action: DONOTHING, Blue reward:1.0\n", + "step: 52, Red action: DONOTHING, Blue reward:1.0\n", + "step: 53, Red action: DONOTHING, Blue reward:1.0\n", + "step: 54, Red action: DONOTHING, Blue reward:1.0\n", + "step: 55, Red action: DONOTHING, Blue reward:1.0\n", + "step: 56, Red action: DONOTHING, Blue reward:1.0\n", + "step: 57, Red action: DONOTHING, Blue reward:1.0\n", + "step: 58, Red action: DONOTHING, Blue reward:1.0\n", + "step: 59, Red action: DONOTHING, Blue reward:1.0\n", + "step: 60, Red action: DONOTHING, Blue reward:1.0\n", + "step: 61, Red action: DONOTHING, Blue reward:1.0\n", + "step: 62, Red action: DONOTHING, Blue reward:1.0\n", + "step: 63, Red action: DONOTHING, Blue reward:1.0\n", + "step: 64, Red action: DONOTHING, Blue reward:1.0\n", + "step: 65, Red action: DONOTHING, Blue reward:1.0\n", + "step: 66, Red action: DONOTHING, Blue reward:1.0\n", + "step: 67, Red action: DONOTHING, Blue reward:1.0\n", + "step: 68, Red action: DONOTHING, Blue reward:1.0\n" ] } ], @@ -823,7 +828,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -901,7 +906,7 @@ " 'protocol': 0}}" ] }, - "execution_count": 24, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } From 7f996ca16acf57c028991af52d79944efde0a2e5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:52:48 +0000 Subject: [PATCH 32/37] Make sure notebook images get copied --- src/primaite/setup/reset_demo_notebooks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/primaite/setup/reset_demo_notebooks.py b/src/primaite/setup/reset_demo_notebooks.py index a4ee4c4d..bcf89b6a 100644 --- a/src/primaite/setup/reset_demo_notebooks.py +++ b/src/primaite/setup/reset_demo_notebooks.py @@ -44,3 +44,12 @@ def run(overwrite_existing: bool = True) -> None: print(dst_fp) shutil.copy2(src_fp, dst_fp) _LOGGER.info(f"Reset example notebook: {dst_fp}") + + for src_fp in primaite_root.glob("notebooks/_package_data/*"): + dst_fp = example_notebooks_user_dir / "_package_data" / src_fp.name + if should_copy_file(src_fp, dst_fp, overwrite_existing): + if not Path.exists(example_notebooks_user_dir / "_package_data/"): + Path.mkdir(example_notebooks_user_dir / "_package_data/") + print(dst_fp) + shutil.copy2(src_fp, dst_fp) + _LOGGER.info(f"Copied notebook resource to: {dst_fp}") From 5a9eaeb1851c83d42bc69de240d34a137815edba Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:59:00 +0000 Subject: [PATCH 33/37] Update the readme --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec335108..416bd0ec 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Currently, the PrimAITE wheel can only be installed from GitHub. This may change #### Windows (PowerShell) **Prerequisites:** -* Manual install of Python >= 3.8 < 3.11 +* Manual install of Python >= 3.9 < 3.12 **Install:** @@ -56,7 +56,7 @@ primaite session #### Unix **Prerequisites:** -* Manual install of Python >= 3.8 < 3.11 +* Manual install of Python >= 3.9 < 3.12 ``` bash sudo add-apt-repository ppa:deadsnakes/ppa @@ -82,6 +82,7 @@ primaite session ``` + ### Developer Install from Source To make your own changes to PrimAITE, perform the install from source (developer install) @@ -138,3 +139,7 @@ make html cd docs .\make.bat html ``` + + +## Example notebooks +Check out the example notebooks to learn more about how PrimAITE works and how you can use it to train agents. They are automatically copied to your primaite installation directory when you run `primaite setup`. From 0056bfddeea4544bf0b783dfdac1c68e9fd248b8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:59:24 +0000 Subject: [PATCH 34/37] Bump version to 3.0.0b6 --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 72f12ef8..43662e8c 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b6dev +3.0.0b6 From 2ba05e73484c5af75b833a17fb4c1b3a9db23339 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 15:17:09 +0000 Subject: [PATCH 35/37] Fixed being unable to specify all addresses in acl rule --- src/primaite/game/agent/actions.py | 18 ++++++++++++------ .../simulator/network/hardware/nodes/router.py | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 6b15c5f8..fa85dbf7 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -453,27 +453,33 @@ class NetworkACLAddRuleAction(AbstractAction): protocol = self.manager.get_internet_protocol_by_idx(protocol_id - 2) # subtract 2 to account for UNUSED=0 and ALL=1. - if source_ip_id in [0, 1]: + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif source_ip_id == 1: src_ip = "ALL" - return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: src_ip = self.manager.get_ip_address_by_idx(source_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if source_port_id == 1: + if source_port_id == 0: + return ["do_nothing"] # invalid formulation + elif source_port_id == 1: src_port = "ALL" else: src_port = self.manager.get_port_by_idx(source_port_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if dest_ip_id in (0, 1): + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_ip_id == 1: dst_ip = "ALL" - return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if dest_port_id == 1: + if dest_port_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_port_id == 1: dst_port = "ALL" else: dst_port = self.manager.get_port_by_idx(dest_port_id - 2) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 41c14967..a17d2ebf 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -162,9 +162,9 @@ class AccessControlList(SimComponent): func=lambda request, context: self.add_rule( ACLAction[request[0]], None if request[1] == "ALL" else IPProtocol[request[1]], - IPv4Address(request[2]), + None if request[2] == "ALL" else IPv4Address(request[2]), None if request[3] == "ALL" else Port[request[3]], - IPv4Address(request[4]), + None if request[4] == "ALL" else IPv4Address(request[4]), None if request[5] == "ALL" else Port[request[5]], int(request[6]), ) From bea72aa6a99cf146bdcd35caf359ee38354fdd26 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 29 Jan 2024 12:28:44 +0000 Subject: [PATCH 36/37] Fix ftp client connection list --- src/primaite/simulator/system/services/ftp/ftp_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 7faa5d32..39bc57f0 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -89,6 +89,7 @@ class FTPClient(FTPServiceABC): f"{self.name}: Successfully connected to FTP Server " f"{dest_ip_address} via port {payload.ftp_command_args.value}" ) + self.add_connection(connection_id="server_connection", session_id=session_id) return True else: if is_reattempt: From def52f94e3a708898ac815c80d2133a98dc16d4b Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 30 Jan 2024 09:56:16 +0000 Subject: [PATCH 37/37] Add docstrings and update typos --- README.md | 4 ++-- src/primaite/simulator/file_system/folder.py | 1 - .../system/services/database/database_service.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 416bd0ec..7dfe15bd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Currently, the PrimAITE wheel can only be installed from GitHub. This may change #### Windows (PowerShell) **Prerequisites:** -* Manual install of Python >= 3.9 < 3.12 +* Manual install of Python >= 3.8 < 3.12 **Install:** @@ -56,7 +56,7 @@ primaite session #### Unix **Prerequisites:** -* Manual install of Python >= 3.9 < 3.12 +* Manual install of Python >= 3.8 < 3.12 ``` bash sudo add-apt-repository ppa:deadsnakes/ppa diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index ab862898..dae32cd5 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -276,7 +276,6 @@ class Folder(FileSystemItemABC): self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") - # self._file_request_manager.remove_request(file.uuid) else: _LOGGER.debug(f"File with UUID {file.uuid} was not found.") diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index a7c2bb69..c9c4d6fa 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -151,6 +151,15 @@ class DatabaseService(Service): def _process_connect( self, connection_id: str, password: Optional[str] = None ) -> Dict[str, Union[int, Dict[str, bool]]]: + """Process an incoming connection request. + + :param connection_id: A unique identifier for the connection + :type connection_id: str + :param password: Supplied password. It must match self.password for connection success, defaults to None + :type password: Optional[str], optional + :return: Response to connection request containing success info. + :rtype: Dict[str, Union[int, Dict[str, bool]]] + """ status_code = 500 # Default internal server error if self.operating_state == ServiceOperatingState.RUNNING: status_code = 503 # service unavailable @@ -279,6 +288,7 @@ class DatabaseService(Service): return super().apply_timestep(timestep) def _update_patch_status(self) -> None: + """Perform a database restore when the patching countdown is finished.""" super()._update_patch_status() if self._patching_countdown is None: self.restore_backup()