diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index 01111290..2375a391 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -102,9 +102,7 @@ stages: version: '2.1.x' - script: | - coverage run -m --source=primaite pytest -v -o junit_family=xunit2 --junitxml=junit/test-results.xml --cov-fail-under=80 - coverage xml -o coverage.xml -i - coverage html -d htmlcov -i + python run_test_and_coverage.py displayName: 'Run tests and code coverage' # Run the notebooks diff --git a/CHANGELOG.md b/CHANGELOG.md index 42519cdf..adf24fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,258 +6,178 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### Added - -- **show_bandwidth_load Function**: Displays current bandwidth load for each frequency in the airspace. -- **Bandwidth Tracking**: Tracks data transmission across each frequency. -- **New Tests**: Added to validate the respect of bandwidth capacities and the correct parsing of airspace configurations from YAML files. -- **New Logging**: Added a new agent behaviour log which are more human friendly than agent history. These Logs are found in session log directory and can be enabled in the I/O settings in a yaml configuration file. ### Changed +- Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. -- **NetworkInterface Speed Type**: The `speed` attribute of `NetworkInterface` has been changed from `int` to `float`. -- **Transmission Feasibility Check**: Updated `_can_transmit` function in `Link` to account for current load and total bandwidth capacity, ensuring transmissions do not exceed limits. -- **Frame Size Details**: Frame `size` attribute now includes both core size and payload size in bytes. -- **Transmission Blocking**: Enhanced `AirSpace` logic to block transmissions that would exceed the available capacity. -- **Software (un)install refactored**: Removed the install/uninstall methods in the node class and made the software manager install/uninstall handle all of their functionality. + +## [3.2.0] - 2024-07-18 + +### Added +- Action penalty is a reward component that applies a negative reward for doing any action other than DONOTHING +- Application configuration actions for RansomwareScript, DatabaseClient, and DoSBot applications +- Ability to configure how long it takes to apply the service fix action +- Terminal service using SSH +- Airspaces now track the amount of data being transmitted, viewable using the `show_bandwidth_load` method +- Tests to verify that airspace bandwidth is applied correctly and can be configured via YAML +- Agent logging for agents' internal decision logic +- Action masking in all PrimAITE environments + +### Changed +- Application registry was moved to the `Application` class and now updates automatically when Application is subclassed +- Databases can no longer respond to request while performing a backup +- Application install no longer accepts an `ip_address` parameter +- Application install action can now be used on all applications +- Actions have additional logic for checking validity +- Frame `size` attribute now includes both core size and payload size in bytes +- The `speed` attribute of `NetworkInterface` has been changed from `int` to `float` +- Tidied up CHANGELOG +- Enhanced `AirSpace` logic to block transmissions that would exceed the available capacity. +- Updated `_can_transmit` function in `Link` to account for current load and total bandwidth capacity, ensuring transmissions do not exceed limits. ### Fixed - -- **Transmission Permission Logic**: Corrected the logic in `can_transmit_frame` to accurately prevent overloads by checking if the transmission of a frame stays within allowable bandwidth limits after considering current load. +- Links and airspaces can no longer transmit data if this would exceed their bandwidth -[//]: # (This file needs tidying up between 2.0.0 and this line as it hasn't been segmented into 3.0.0 and 3.1.0 and isn't compliant with https://keepachangelog.com/en/1.1.0/) - -## 3.0.0b9 -- Removed deprecated `PrimaiteSession` class. -- Added ability to set log levels via configuration. -- Upgraded pydantic to version 2.7.0 -- Upgraded Ray to version >= 2.9 -- Added ipywidgets to the dependencies -- Added ability to define scenarios that change depending on the episode number. -- Standardised Environment API by renaming the config parameter of `PrimaiteGymEnv` from `game_config` to `env_config` -- Database Connection ID's are now created/issued by DatabaseService and not DatabaseClient -- Updated DatabaseClient so that it can now have a single native DatabaseClientConnection along with a collection of DatabaseClientConnection's. -- Implemented the uninstall functionality for DatabaseClient so that all connections are terminated at the DatabaseService. -- Added the ability for a DatabaseService to terminate a connection. -- Added active_connection to DatabaseClientConnection so that if the connection is terminated active_connection is set to False and the object can no longer be used. -- Added additional show functions to enable connection inspection. -- Updates to agent logging, to include the reward both per step and per episode. -- Introduced Developer CLI tools to assist with developing/debugging PrimAITE - - Can be enabled via `primaite dev-mode enable` - - Activating dev-mode will change the location where the sessions will be output - by default will output where the PrimAITE repository is located -- Refactored all air-space usage to that a new instance of AirSpace is created for each instance of Network. This 1:1 relationship between network and airspace will allow parallelization. -- Added notebook to demonstrate use of SubprocVecEnv from SB3 to vectorise environments to speed up training. - -## [Unreleased] -- Made requests fail to reach their target if the node is off -- Added responses to requests -- Made environment reset completely recreate the game object. -- Changed the red agent in the data manipulation scenario to randomly choose client 1 or client 2 to start its attack. -- Changed the data manipulation scenario to include a second green agent on client 1. -- Refactored actions and observations to be configurable via object name, instead of UUID. -- 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 -- 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. -- Added support for SQL INSERT command. -- Added ability to log each agent's action choices in each step to a JSON file. -- Removal of Link bandwidth hardcoding. This can now be configured via the network configuration yaml. Will default to 100 if not present. -- Added NMAP application to all host and layer-3 network nodes. -- Added Terminal Class for HostNode components. - -### Bug Fixes - -- ACL rules were not resetting on episode reset. -- ACLs were not showing up correctly in the observation space. -- Blue agent's ACL actions were being applied against the wrong IP addresses -- Deleted files and folders did not reset correctly on episode reset. -- Service health status was using the actual health state instead of the visible health state -- Database file health status was using the incorrect value for negative rewards -- Preventing file actions from reaching their intended file -- The data manipulation attack was triggered at episode start. -- FTP STOR stored an additional copy on the client machine's filesystem -- The red agent acted to early -- Order of service health state -- Starting a node didn't start the services on it -- Fixed an issue where the services were still able to run even though the node the service is installed on is turned off -- The use of NODE_FILE_CHECKHASH and NODE_FOLDER_CHECKHASH in the current release is marked as 'Not Implemented'. - +## [3.1.0] - 2024-06-25 ### Added -- Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have -fundamental services like ARP, ICMP, and PCAP running them by default. -- Network Transmission - Modelled OSI Model layers 1 through to 5 with various classes for creating network frames and -transmitting them from a Service/Application, down through the layers, over the wire, and back up through the layers to -a Service/Application another machine. -- Introduced `Router` and `Switch` classes to manage networking routes more effectively. - - Added `ACLRule` and `RouteTableEntry` classes as part of the `Router`. -- New `.show()` methods in all network component classes to inspect the state in either plain text or markdown formats. -- Added `Computer` and `Server` class to better differentiate types of network nodes. -- Integrated a new Use Case 2 network into the system. -- New unit tests to verify routing between different subnets using `.ping()`. -- system - Added the core structure of Application, Services, and Components. Also added a SoftwareManager and -SessionManager. -- Permission System - each action can define criteria that will be used to permit or deny agent actions. -- File System - ability to emulate a node's file system during a simulation -- Example notebooks - There are 5 jupyter notebook which walk through using PrimAITE - 1. Training a Stable Baselines 3 agent - 2. Training a single agent system using Ray RLLib - 3. Training a multi-agent system Ray RLLib - 4. Data manipulation end to end demonstration - 5. Data manipulation scenario with customised red agents -- Database: - - `DatabaseClient` and `DatabaseService` created to allow emulation of database actions - - Ability for `DatabaseService` to backup its data to another server via FTP and restore data from backup -- Red Agent Services: - - Data Manipulator Bot - A red agent service which sends a payload to a target machine. (By default this payload is a SQL query that breaks a database). The attack runs in stages with a random, configurable probability of succeeding. - - `DataManipulationAgent` runs the Data Manipulator Bot according to a configured start step, frequency and variance. -- DNS Services: `DNSClient` and `DNSServer` -- FTP Services: `FTPClient` and `FTPServer` -- HTTP Services: `WebBrowser` to simulate a web client and `WebServer` -- NTP Services: `NTPClient` and `NTPServer` -- **RouterNIC Class**: Introduced a new class `RouterNIC`, extending the standard `NIC` functionality. This class is specifically designed for router operations, optimizing the processing and routing of network traffic. - - **Custom Layer-3 Processing**: The `RouterNIC` class includes custom handling for network frames, bypassing standard Node NIC's Layer 3 broadcast/unicast checks. This allows for more efficient routing behavior in network scenarios where router-specific frame processing is required. - - **Enhanced Frame Reception**: The `receive_frame` method in `RouterNIC` is tailored to handle frames based on Layer 2 (Ethernet) checks, focusing on MAC address-based routing and broadcast frame acceptance. -- **Subnet-Wide Broadcasting for Services and Applications**: Implemented the ability for services and applications to conduct broadcasts across an entire IPv4 subnet within the network simulation framework. -- Introduced the `NetworkInterface` abstract class to provide a common interface for all network interfaces. Subclasses are divided into two main categories: `WiredNetworkInterface` and `WirelessNetworkInterface`, each serving as an abstract base class (ABC) for more specific interface types. Under `WiredNetworkInterface`, the subclasses `NIC` and `SwitchPort` were added. For wireless interfaces, `WirelessNIC` and `WirelessAccessPoint` are the subclasses under `WirelessNetworkInterface`. -- Added `Layer3Interface` as an abstract base class for networking functionalities at layer 3, including IP addressing and routing capabilities. This class is inherited by `NIC`, `WirelessNIC`, and `WirelessAccessPoint` to provide them with layer 3 capabilities, facilitating their role in both wired and wireless networking contexts with IP-based communication. -- Created the `ARP` and `ICMP` service classes to handle Address Resolution Protocol operations and Internet Control Message Protocol messages, respectively, with `RouterARP` and `RouterICMP` for router-specific implementations. -- Created `HostNode` as a subclass of `Node`, extending its functionality with host-specific services and applications. This class is designed to represent end-user devices like computers or servers that can initiate and respond to network communications. -- Introduced a new `IPV4Address` type in the Pydantic model for enhanced validation and auto-conversion of IPv4 addresses from strings using an `ipv4_validator`. -- Comprehensive documentation for the Node and its network interfaces, detailing the operational workflow from frame reception to application-level processing. -- Detailed descriptions of the Session Manager and Software Manager functionalities, including their roles in managing sessions, software services, and applications within the simulation. -- Documentation for the Packet Capture (PCAP) service and SysLog functionality, highlighting their importance in logging network frames and system events, respectively. -- Expanded documentation on network devices such as Routers, Switches, Computers, and Switch Nodes, explaining their specific processing logic and protocol support. -- **Firewall Node**: Introduced the `Firewall` class extending the functionality of the existing `Router` class. The `Firewall` class incorporates advanced features to scrutinize, direct, and filter traffic between various network zones, guided by predefined security rules and policies. Key functionalities include: - - Access Control Lists (ACLs) for traffic filtering based on IP addresses, protocols, and port numbers. - - Network zone segmentation for managing traffic across external, internal, and DMZ (De-Militarized Zone) networks. - - Interface configuration to establish connectivity and define network parameters for external, internal, and DMZ interfaces. - - Protocol and service management to oversee traffic and enforce security policies. - - Dynamic traffic processing and filtering to ensure network security and integrity. -- `AirSpace` class to simulate wireless communications, managing wireless interfaces and facilitating the transmission of frames within specified frequencies. -- `AirSpaceFrequency` enum for defining standard wireless frequencies, including 2.4 GHz and 5 GHz bands, to support realistic wireless network simulations. -- `WirelessRouter` class, extending the `Router` class, to incorporate wireless networking capabilities alongside traditional wired connections. This class allows the configuration of wireless access points with specific IP settings and operating frequencies. -- Documentation Updates: - - Examples include how to set up PrimAITE session via config - - Examples include how to create nodes and install software via config - - Examples include how to set up PrimAITE session via Python - - Examples include how to create nodes and install software via Python - - Added missing ``DoSBot`` documentation page - - Added diagrams where needed to make understanding some things easier - - Templated parts of the documentation to prevent unnecessary repetition and for easier maintaining of documentation - - Separated documentation pages of some items i.e. client and server software were on the same pages - which may make things confusing - - Configuration section at the bottom of the software pages specifying the configuration options available (and which ones are optional) -- Ability to add ``Firewall`` node via config -- Ability to add ``Router`` routes via config -- Ability to add ``Router``/``Firewall`` ``ACLRule`` via config -- NMNE capturing capabilities to `NetworkInterface` class for detecting and logging Malicious Network Events. -- New `nmne_config` settings in the simulation configuration to enable NMNE capturing and specify keywords such as "DELETE". -- Router-specific SessionManager Implementation: Introduced a specialized version of the SessionManager tailored for router operations. This enhancement enables the SessionManager to determine the routing path by consulting the route table. +- Observations for traffic amounts on host network interfaces +- NMAP application network discovery, including ping scan and port scan +- NMAP actions +- Automated adding copyright notices to source files +- More file types +- `show` method to files +- `model_dump` methods to network enums to enable better logging ### Changed -- Integrated the RouteTable into the Routers frame processing. -- Frames are now dropped when their TTL reaches 0 -- **NIC Functionality Update**: Updated the Network Interface Card (`NIC`) functionality to support Layer 3 (L3) broadcasts. - - **Layer 3 Broadcast Handling**: Enhanced the existing `NIC` classes to correctly process and handle Layer 3 broadcasts. This update allows devices using standard NICs to effectively participate in network activities that involve L3 broadcasting. - - **Improved Frame Reception Logic**: The `receive_frame` method of the `NIC` class has been updated to include additional checks and handling for L3 broadcasts, ensuring proper frame processing in a wider range of network scenarios. -- Standardised the way network interfaces are accessed across all `Node` subclasses (`HostNode`, `Router`, `Switch`) by maintaining a comprehensive `network_interface` attribute. This attribute captures all network interfaces by their port number, streamlining the management and interaction with network interfaces across different types of nodes. -- Refactored all tests to utilise new `Node` subclasses (`Computer`, `Server`, `Router`, `Switch`) instead of creating generic `Node` instances and manually adding network interfaces. This change aligns test setups more closely with the intended use cases and hierarchies within the network simulation framework. -- Updated all tests to employ the `Network()` class for managing nodes and their connections, ensuring a consistent and structured approach to setting up network topologies in testing scenarios. -- **ACLRule Wildcard Masking**: Updated the `ACLRule` class to support IP ranges using wildcard masking. This enhancement allows for more flexible and granular control over traffic filtering, enabling the specification of broader or more specific IP address ranges in ACL rules. -- Updated `NetworkInterface` documentation to reflect the new NMNE capturing features and how to use them. -- Integration of NMNE capturing functionality within the `NICObservation` class. -- Changed blue action set to enable applying node scan, reset, start, and shutdown to every host in data manipulation scenario +- Updated file system actions to stop failures when creating duplicate files +- Improved parsing of ACL add rule actions to make some parameters optional + +### Fixed +- Fixed database client uninstall failing due to persistent connections +- Fixed packet storm when pinging broadcast addresses + + +## [3.0.0] - 2024-06-10 + +### Added +- New simulation module +- Multi agent reinforcement learning support +- File system class to manage files and folders +- Software for nodes that can have its own behaviour +- Software classes to model FTP, Postgres databases, web traffic, NTP +- Much more detailed network simulation including packets, links, and network interfaces +- More node types: host, computer, server, router, switch, wireless router, and firewalls +- Network Hardware - NIC, SwitchPort, Node, and Link. Nodes have fundamental services like ARP, ICMP, and PCAP running them by default. +- Malicious network event detection +- New `game` module for managing agents +- ACL rule wildcard masking +- Network broadcasting +- Wireless transmission +- More detailed documentation +- Example jupyter notebooks to demonstrate new functionality +- More reward components +- Packet capture logs +- Node system logs +- Per-step full simulation state log +- Attack randomisation with respect to timing and attack source +- Ability to set log level via CLI +- Ability to vary the YAML configuration per-episode +- Developer CLI tools for enhanced debugging (with `primaite dev-mode enable`) +- `show` function to many simulation objects to inspect their current state + +### Changed +- Decoupled the environment from the simulation by adding the `game` interface layer +- Made agents share a common base class +- Added more actions +- Made all agents use CAOS actions, including red and green agents +- Reworked YAML configuration file schema +- Reworked the reward system to be component-based +- Changed agent logs to create a JSON output instead of CSV with more detailed action information +- Made observation space flattening optional +- Made all logging optional +- Agent actions now provide responses with a success code ### Removed -- Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` -- Removed legacy training modules -- Removed tests for legacy code +- Legacy simulation modules +- Legacy training modules +- Tests for legacy code +- Hardcoded IERs and PoL, traffic generation is now handled by agents and software +- Inbuilt agent training scripts -### Fixed -- Addressed network transmission issues that previously allowed ARP requests to be incorrectly routed and repeated across different subnets. This fix ensures ARP requests are correctly managed and confined to their appropriate network segments. -- Resolved problems in `Node` and its subclasses where the default gateway configuration was not properly utilized for communications across different subnets. This correction ensures that nodes effectively use their configured default gateways for outbound communications to other network segments, thereby enhancing the network's routing functionality and reliability. -- Network Interface Port name/num being set properly for sys log and PCAP output. ## [2.0.0] - 2023-07-26 ### Added -- Command Line Interface (CLI) for easy access and streamlined usage of PrimAITE. -- Application Directories to enable PrimAITE as a Python package with predefined directories for storage. -- Support for Ray Rllib, allowing training of PPO and A2C agents using Stable Baselines3 and Ray RLlib. -- Random Red Agent to train the blue agent against, with options for randomised Red Agent `POL` and `IER`. -- Repeatability of sessions through seed settings, and deterministic or stochastic evaluation options. -- Session loading to revisit previously run sessions for SB3 Agents. -- Agent Session Classes (`AgentSessionABC` and `HardCodedAgentSessionABC`) to standardise agent training with a common interface. -- Standardised Session Output in a structured format in the user's app sessions directory, providing four types of outputs: - 1. Session Metadata - 2. Results - 3. Diagrams - 4. Saved agents (training checkpoints and a final trained agent). -- Configurable Observation Space managed by the `ObservationHandler` class for a more flexible observation space setup. -- Benchmarking of PrimAITE performance, showcasing session and step durations for reference. -- Documentation overhaul, including automatic API and test documentation with recursive Sphinx auto-summary, using the Furo theme for responsive light/dark theme, and enhanced navigation with `sphinx-code-tabs` and `sphinx-copybutton`. +- Command Line Interface (CLI) for easy access and streamlined usage of PrimAITE. +- Application Directories to enable PrimAITE as a Python package with predefined directories for storage. +- Support for Ray Rllib, allowing training of PPO and A2C agents using Stable Baselines3 and Ray RLlib. +- Random Red Agent to train the blue agent against, with options for randomised Red Agent `POL` and `IER`. +- Repeatability of sessions through seed settings, and deterministic or stochastic evaluation options. +- Session loading to revisit previously run sessions for SB3 Agents. +- Agent Session Classes (`AgentSessionABC` and `HardCodedAgentSessionABC`) to standardise agent training with a common interface. +- Standardised Session Output in a structured format in the user's app sessions directory, providing four types of outputs: Session Metadata, Results, Diagrams, Trained agents. +- Configurable Observation Space managed by the `ObservationHandler` class for a more flexible observation space setup. +- Benchmarking of PrimAITE performance, showcasing session and step durations for reference. +- Documentation overhaul, including automatic API and test documentation with recursive Sphinx auto-summary, using the Furo theme for responsive light/dark theme, and enhanced navigation with `sphinx-code-tabs` and `sphinx-copybutton`. ### Changed -- Action Space updated to discrete spaces, introducing a new `ANY` action space option for combined `NODE` and `ACL` actions. -- Improved `Node` attribute naming convention for consistency, now adhering to `Pascal Case`. -- Package Structure has been refactored for better build, distribution, and installation, with all source code now in the `src/` directory, and the `PRIMAITE` Python package renamed to `primaite` to adhere to PEP-8 Package & Module Names. -- Docs and Tests now sit outside the `src/` directory. -- Non-python files (example config files, Jupyter notebooks, etc.) now sit inside a `*/_package_data/` directory in their respective sub-packages. -- All dependencies are now defined in the `pyproject.toml` file. -- Introduced individual configuration for the number of episodes and time steps for training and evaluation sessions, with separate config values for each. -- Decoupled the lay down config file from the training config, allowing more flexibility in configuration management. -- Updated `Transactions` to only report pre-action observation, improving the CSV header and providing more human-readable descriptions for columns relating to observations. -- Changes to `AccessControlList`, where the `acl` dictionary is now a list to accommodate changes to ACL action space and positioning of `ACLRules` inside the list to signal their level of priority. +- Action Space updated to discrete spaces, introducing a new `ANY` action space option for combined `NODE` and `ACL` actions. +- Improved `Node` attribute naming convention for consistency, now adhering to `Pascal Case`. +- Package Structure has been refactored for better build, distribution, and installation, with all source code now in the `src/` directory, and the `PRIMAITE` Python package renamed to `primaite` to adhere to PEP-8 Package & Module Names. +- Docs and Tests now sit outside the `src/` directory. +- Non-python files (example config files, Jupyter notebooks, etc.) now sit inside a `*/_package_data/` directory in their respective sub-packages. +- All dependencies are now defined in the `pyproject.toml` file. +- Introduced individual configuration for the number of episodes and time steps for training and evaluation sessions, with separate config values for each. +- Decoupled the lay down config file from the training config, allowing more flexibility in configuration management. +- Updated `Transactions` to only report pre-action observation, improving the CSV header and providing more human-readable descriptions for columns relating to observations. +- Changes to `AccessControlList`, where the `acl` dictionary is now a list to accommodate changes to ACL action space and positioning of `ACLRules` inside the list to signal their level of priority. ### Fixed -- Various bug fixes, including Green IERs separation, correct clearing of links in the reference environment, and proper reward calculation. -- Logic to check if a node is OFF before executing actions on the node by the blue agent, preventing erroneous state changes. -- Improved functionality of Resetting a Node, adding "SHUTTING DOWN" and "BOOTING" operating states for more reliable reset commands. -- Corrected the order of actions in the `Primaite` env to ensure the blue agent uses the current state for decision-making. +- Various bug fixes, including Green IERs separation, correct clearing of links in the reference environment, and proper reward calculation. +- Logic to check if a node is OFF before executing actions on the node by the blue agent, preventing erroneous state changes. +- Improved functionality of Resetting a Node, adding "SHUTTING DOWN" and "BOOTING" operating states for more reliable reset commands. +- Corrected the order of actions in the `Primaite` env to ensure the blue agent uses the current state for decision-making. + ## [1.1.1] - 2023-06-27 -### Bug Fixes -* Fixed bug whereby 'reference' environment links reach bandwidth capacity and are never cleared due to green & red IERs being applied to them. This bug had a knock-on effect that meant IERs were being blocked based on the full capacity of links on the reference environment which was not correct; they should only be based on the link capacity of the 'live' environment. This fix has been addressed by: - * Implementing a reference copy of all green IERs (`self.green_iers_reference`). - * Clearing the traffic on reference IERs at the same time as the live IERs. - * Passing the `green_iers_reference` to the `apply_iers` function at the reference stage. - * Passing the `green_iers_reference` as an additional argument to `calculate_reward_function`. - * Updating the green IERs section of the `calculate_reward_function` to now take into account both the green reference IERs and live IERs. The `green_ier_blocked` reward is only applied if the IER is blocked in the live environment but is running in the reference environment. - * Re-ordering the actions taken as part of the step function to ensure the blue action happens first before other changes. - * Removing the unnecessary "Reapply PoL and IERs" action from the step function. - * Moving the deep-copy of nodes and links to below the "Implement blue action" stage of the step function. +### Fixed +- Fixed bug whereby 'reference' environment links reach bandwidth capacity and are never cleared due to green & red IERs being applied to them. This bug had a knock-on effect that meant IERs were being blocked based on the full capacity of links on the reference environment which was not correct; they should only be based on the link capacity of the 'live' environment. This fix has been addressed by: + - Implementing a reference copy of all green IERs (`self.green_iers_reference`). + - Clearing the traffic on reference IERs at the same time as the live IERs. + - Passing the `green_iers_reference` to the `apply_iers` function at the reference stage. + - Passing the `green_iers_reference` as an additional argument to `calculate_reward_function`. + - Updating the green IERs section of the `calculate_reward_function` to now take into account both the green reference IERs and live IERs. The `green_ier_blocked` reward is only applied if the IER is blocked in the live environment but is running in the reference environment. + - Re-ordering the actions taken as part of the step function to ensure the blue action happens first before other changes. + - Removing the unnecessary "Reapply PoL and IERs" action from the step function. + - Moving the deep-copy of nodes and links to below the "Implement blue action" stage of the step function. + ## [1.1.0] - 2023-03-13 ### Added -* The user can now initiate either a TRAINING session or an EVALUATION (test) session with the Stable Baselines 3 (SB3) agents via the config_main.yaml file. During evaluation/testing, the agent policy will be fixed (no longer learning) and subjected to the SB3 `evaluate_policy()` function. -* The user can choose whether a saved agent is loaded into the session (with reference to a URL) via the `config_main.yaml` file. They specify a Boolean true/false indicating whether a saved agent should be loaded, and specify the URL and file name. -* Active and Service nodes now possess a new "File System State" attribute. This attribute is permitted to have the states GOOD, CORRUPT, DESTROYED, REPAIRING, and RESTORING. This new feature affects the following components: - * Blue agent observation space; - * Blue agent action space; - * Reward function; - * Node pattern-of-life. -* The Red Agent node pattern-of-life has been enhanced so that node PoL is triggered by an 'initiator'. The initiator is either DIRECT (state change is applied to the node without any conditions), IER (state change is applied to the node based on IER entry condition), or SERVICE (state change is applied to the node based on a service state condition on the same node or a different node within the network). -* New default config named "config_5_DATA_MANIPULATION.yaml" and associated Training Use Case Profile. -* NodeStateInstruction has been split into `NodeStateInstructionGreen` and `NodeStateInstructionRed` to reflect the changes within the red agent pattern-of-life capability. -* The reward function has been enhanced so that node attribute states of resetting, patching, repairing, and restarting contribute to the overall reward value. -* The User Guide has been updated to reflect all the above changes. +- The user can now initiate either a TRAINING session or an EVALUATION (test) session with the Stable Baselines 3 (SB3) agents via the config_main.yaml file. During evaluation/testing, the agent policy will be fixed (no longer learning) and subjected to the SB3 `evaluate_policy()` function. +- The user can choose whether a saved agent is loaded into the session (with reference to a URL) via the `config_main.yaml` file. They specify a Boolean true/false indicating whether a saved agent should be loaded, and specify the URL and file name. +- Active and Service nodes now possess a new "File System State" attribute. This attribute is permitted to have the states GOOD, CORRUPT, DESTROYED, REPAIRING, and RESTORING. This new feature affects the following components: + - Blue agent observation space; + - Blue agent action space; + - Reward function; + - Node pattern-of-life. +- The Red Agent node pattern-of-life has been enhanced so that node PoL is triggered by an 'initiator'. The initiator is either DIRECT (state change is applied to the node without any conditions), IER (state change is applied to the node based on IER entry condition), or SERVICE (state change is applied to the node based on a service state condition on the same node or a different node within the network). +- New default config named "config_5_DATA_MANIPULATION.yaml" and associated Training Use Case Profile. +- NodeStateInstruction has been split into `NodeStateInstructionGreen` and `NodeStateInstructionRed` to reflect the changes within the red agent pattern-of-life capability. +- The reward function has been enhanced so that node attribute states of resetting, patching, repairing, and restarting contribute to the overall reward value. +- The User Guide has been updated to reflect all the above changes. ### Changed -* "config_1_DDOS_BASIC.yaml" modified to make it more simplistic to aid evaluation testing. -* "config_2_DDOS_BASIC.yaml" updated to reflect the addition of the File System State and the Red Agent node pattern-of-life enhancement. -* "config_3_DOS_VERY_BASIC.yaml" updated to reflect the addition of the File System State and the Red Agent node pattern-of-life enhancement. -* "config_UNIT_TEST.yaml" is a copy of the new "config_5_DATA_MANIPULATION.yaml" file. -* Updates to Transactions. +- "config_1_DDOS_BASIC.yaml" modified to make it more simplistic to aid evaluation testing. +- "config_2_DDOS_BASIC.yaml" updated to reflect the addition of the File System State and the Red Agent node pattern-of-life enhancement. +- "config_3_DOS_VERY_BASIC.yaml" updated to reflect the addition of the File System State and the Red Agent node pattern-of-life enhancement. +- "config_UNIT_TEST.yaml" is a copy of the new "config_5_DATA_MANIPULATION.yaml" file. +- Updates to Transactions. ### Fixed -* Fixed "config_2_DDOS_BASIC.yaml" by adding another ACL rule to allow traffic to flow from Node 9 to Node 3. Previously, there was no rule, so one of the green IERs could not flow by default. - - - -[unreleased]: https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/compare/v2.0.0...HEAD -[2.0.0]: https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/releases/tag/v2.0.0 +- Fixed "config_2_DDOS_BASIC.yaml" by adding another ACL rule to allow traffic to flow from Node 9 to Node 3. Previously, there was no rule, so one of the green IERs could not flow by default. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf5e75e4..dc10edbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,6 @@ * [Fork the repository](https://github.com/{todo:fill in URL}/PrimAITE/fork). * Install the pre-commit hook with `pre-commit install`. * Implement the bug fix. -* Update documentation where applicable. -* Update the **UNRELEASED** section of the [CHANGELOG.md](CHANGELOG.md) file -* Write a suitable test/tests. * Commit the bug fix to the dev branch on your fork. If the bug has an open issue under [Issues](https://github.com/{todo:fill in URL}/PrimAITE/issues), reference the issue in the commit message (e.g. #1 references issue 1). * Submit a pull request from your dev branch to the {todo:fill in URL}/PrimAITE dev branch. Again, if the bug has an open issue under [Issues](https://github.com/{todo:fill in URL}/PrimAITE/issues), reference the issue in the pull request description. diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000..b4654829 --- /dev/null +++ b/_config.yml @@ -0,0 +1,3 @@ +# Used by nbmake to change build pipeline notebook timeout +execute: + timeout: 600 diff --git a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst index e648e4a1..7cf11eb4 100644 --- a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst +++ b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst @@ -53,3 +53,30 @@ The number of time steps required to occur in order for the node to cycle from ` Optional. Default value is ``3``. The number of time steps required to occur in order for the node to cycle from ``ON`` to ``SHUTTING_DOWN`` and then finally ``OFF``. + +``users`` +--------- + +The list of pre-existing users that are additional to the default admin user (``username=admin``, ``password=admin``). +Additional users are configured as an array nd must contain a ``username``, ``password``, and can contain an optional +boolean ``is_admin``. + +Example of adding two additional users to a node: + +.. code-block:: yaml + + simulation: + network: + nodes: + - hostname: client_1 + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + users: + - username: jane.doe + password: '1234' + is_admin: true + - username: john.doe + password: password_1 + is_admin: false diff --git a/docs/source/simulation_components/network/base_hardware.rst b/docs/source/simulation_components/network/base_hardware.rst index 9e42b1de..ce1e5c74 100644 --- a/docs/source/simulation_components/network/base_hardware.rst +++ b/docs/source/simulation_components/network/base_hardware.rst @@ -97,8 +97,8 @@ Node Behaviours/Functions - **receive_frame()**: Handles the processing of incoming network frames. - **apply_timestep()**: Advances the state of the node according to the simulation timestep. - **power_on()**: Initiates the node, enabling all connected Network Interfaces and starting all Services and - Applications, taking into account the `start_up_duration`. -- **power_off()**: Stops the node's operations, adhering to the `shut_down_duration`. + Applications, taking into account the ``start_up_duration``. +- **power_off()**: Stops the node's operations, adhering to the ``shut_down_duration``. - **ping()**: Sends ICMP echo requests to a specified IP address to test connectivity. - **has_enabled_network_interface()**: Checks if the node has any network interfaces enabled, facilitating network communication. @@ -109,3 +109,205 @@ Node Behaviours/Functions The Node class handles installation of system software, network connectivity, frame processing, system logging, and power states. It establishes baseline functionality while allowing subclassing to model specific node types like hosts, routers, firewalls etc. The flexible architecture enables composing complex network topologies. + +User, UserManager, and UserSessionManager +========================================= + +The ``base.py`` module also includes essential classes for managing users and their sessions within the PrimAITE +simulation. These are the ``User``, ``UserManager``, and ``UserSessionManager`` classes. The base ``Node`` class comes +with ``UserManager``, and ``UserSessionManager`` classes pre-installed. + +User Class +---------- + +The ``User`` class represents a user in the system. It includes attributes such as ``username``, ``password``, +``disabled``, and ``is_admin`` to define the user's credentials and status. + +Example Usage +^^^^^^^^^^^^^ + +Creating a user: + .. code-block:: python + + user = User(username="john_doe", password="12345") + +UserManager Class +----------------- + +The ``UserManager`` class handles user management tasks such as creating users, authenticating them, changing passwords, +and enabling or disabling user accounts. It maintains a dictionary of users and provides methods to manage them +effectively. + +Example Usage +^^^^^^^^^^^^^ + +Creating a ``UserManager`` instance and adding a user: + .. code-block:: python + + user_manager = UserManager() + user_manager.add_user(username="john_doe", password="12345") + +Authenticating a user: + .. code-block:: python + + user = user_manager.authenticate_user(username="john_doe", password="12345") + +UserSessionManager Class +------------------------ + +The ``UserSessionManager`` class manages user sessions, including local and remote sessions. It handles session creation, +timeouts, and provides methods for logging users in and out. + +Example Usage +^^^^^^^^^^^^^ + +Creating a ``UserSessionManager`` instance and logging a user in locally: + .. code-block:: python + + session_manager = UserSessionManager() + session_id = session_manager.local_login(username="john_doe", password="12345") + +Logging a user out: + .. code-block:: python + + session_manager.local_logout() + +Practical Examples +------------------ + +Below are unit tests which act as practical examples illustrating how to use the ``User``, ``UserManager``, and +``UserSessionManager`` classes within the context of a client-server network simulation. + +Setting up a Client-Server Network +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from typing import Tuple + from uuid import uuid4 + + import pytest + + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.network.hardware.nodes.host.server import Server + + @pytest.fixture(scope="function") + def client_server_network() -> Tuple[Computer, Server, Network]: + network = Network() + + client = Computer( + hostname="client", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client.power_on() + + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + network.connect(client.network_interface[1], server.network_interface[1]) + + return client, server, network + +Local Login Success +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + +Local Login Failure +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_local_login_failure(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert not client.user_session_manager.local_user_logged_in + +Adding a New User and Successful Local Login +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_user_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_manager.add_user(username="jane.doe", password="12345") + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + +Clearing Previous Login on New Local Login +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_local_login_clears_previous_login(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + client.user_manager.add_user(username="jane.doe", password="12345") + + new_session_id = client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "jane.doe" + + assert new_session_id != current_session_id + +Persistent Login for the Same User +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def test_new_local_login_attempt_same_uses_persists(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + new_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + assert new_session_id == current_session_id diff --git a/run_test_and_coverage.py b/run_test_and_coverage.py new file mode 100644 index 00000000..3bd9072d --- /dev/null +++ b/run_test_and_coverage.py @@ -0,0 +1,22 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import subprocess +import sys +from typing import Any + + +def run_command(command: Any): + """Runs a command and returns the exit code.""" + result = subprocess.run(command, shell=True) + if result.returncode != 0: + sys.exit(result.returncode) + + +# Run pytest with coverage +run_command( + "coverage run -m --source=primaite pytest -v -o junit_family=xunit2 " + "--junitxml=junit/test-results.xml --cov-fail-under=80" +) + +# Generate coverage reports if tests passed +run_command("coverage xml -o coverage.xml -i") +run_command("coverage html -d htmlcov -i") diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 9a5fedc9..7263cfc1 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -294,7 +294,7 @@ class ConfigureDoSBotAction(AbstractAction): """Action which sets config parameters for a DoS bot on a node.""" class _Opts(BaseModel): - """Schema for options that can be passed to this option.""" + """Schema for options that can be passed to this action.""" model_config = ConfigDict(extra="forbid") target_ip_address: Optional[str] = None diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 6e29c2ce..c197a8de 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -18,7 +18,7 @@ from primaite.game.agent.scripted_agents.tap001 import TAP001 from primaite.game.science import graph_has_cycle, topological_sort from primaite.simulator import SIM_OUTPUT from primaite.simulator.network.airspace import AirSpaceFrequency -from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState +from primaite.simulator.network.hardware.base import NetworkInterface, NodeOperatingState, UserManager from primaite.simulator.network.hardware.nodes.host.computer import Computer from primaite.simulator.network.hardware.nodes.host.host_node import NIC from primaite.simulator.network.hardware.nodes.host.server import Printer, Server @@ -271,6 +271,7 @@ class PrimaiteGame: for node_cfg in nodes_cfg: n_type = node_cfg["type"] + new_node = None if n_type == "computer": new_node = Computer( hostname=node_cfg["hostname"], @@ -320,6 +321,11 @@ class PrimaiteGame: msg = f"invalid node type {n_type} in config" _LOGGER.error(msg) raise ValueError(msg) + + if "users" in node_cfg and new_node.software_manager.software.get("UserManager"): + user_manager: UserManager = new_node.software_manager.software["UserManager"] # noqa + for user_cfg in node_cfg["users"]: + user_manager.add_user(**user_cfg, bypass_can_perform_action=True) if "services" in node_cfg: for service_cfg in node_cfg["services"]: new_service = None diff --git a/src/primaite/notebooks/Action-masking.ipynb b/src/primaite/notebooks/Action-masking.ipynb index 8811bb15..ba70f2b4 100644 --- a/src/primaite/notebooks/Action-masking.ipynb +++ b/src/primaite/notebooks/Action-masking.ipynb @@ -101,7 +101,6 @@ "from primaite.session.ray_envs import PrimaiteRayEnv\n", "from ray.rllib.algorithms.ppo import PPOConfig\n", "import yaml\n", - "from ray import air, tune\n", "from ray.rllib.examples.rl_modules.classes.action_masking_rlm import ActionMaskingTorchRLModule\n", "from ray.rllib.core.rl_module.rl_module import SingleAgentRLModuleSpec\n" ] @@ -135,8 +134,7 @@ " .training(train_batch_size=128)\n", ")\n", "algo = config.build()\n", - "for i in range(2):\n", - " results = algo.train()" + "results = algo.train()" ] }, { @@ -191,8 +189,7 @@ " .training(train_batch_size=128)\n", ")\n", "algo = config.build()\n", - "for i in range(2):\n", - " results = algo.train()" + "results = algo.train()" ] } ], @@ -212,7 +209,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.8" + "version": "3.10.12" } }, "nbformat": 4, diff --git a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb index c185b8b5..28f08edd 100644 --- a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb +++ b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb @@ -24,14 +24,11 @@ "metadata": {}, "outputs": [], "source": [ - "from primaite.game.game import PrimaiteGame\n", "import yaml\n", "\n", - "from primaite.session.ray_envs import PrimaiteRayEnv\n", "from primaite import PRIMAITE_PATHS\n", "\n", "import ray\n", - "from ray import air, tune\n", "from ray.rllib.algorithms.ppo import PPOConfig\n", "from primaite.session.ray_envs import PrimaiteRayMARLEnv\n", "\n", @@ -72,7 +69,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "#### Set training parameters and start the training\n", + "#### Start the training\n", "This example will save outputs to a default Ray directory and use mostly default settings." ] }, @@ -82,13 +79,8 @@ "metadata": {}, "outputs": [], "source": [ - "tune.Tuner(\n", - " \"PPO\",\n", - " run_config=air.RunConfig(\n", - " stop={\"timesteps_total\": 5 * 128},\n", - " ),\n", - " param_space=config\n", - ").fit()" + "algo = config.build()\n", + "results = algo.train()" ] } ], diff --git a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb index bdd60f36..9d870192 100644 --- a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb +++ b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb @@ -17,12 +17,10 @@ "metadata": {}, "outputs": [], "source": [ - "from primaite.game.game import PrimaiteGame\n", "import yaml\n", "from primaite.config.load import data_manipulation_config_path\n", "\n", "from primaite.session.ray_envs import PrimaiteRayEnv\n", - "from ray import air, tune\n", "import ray\n", "from ray.rllib.algorithms.ppo import PPOConfig\n", "\n", @@ -64,7 +62,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "#### Set training parameters and start the training" + "#### Start the training" ] }, { @@ -73,13 +71,8 @@ "metadata": {}, "outputs": [], "source": [ - "tune.Tuner(\n", - " \"PPO\",\n", - " run_config=air.RunConfig(\n", - " stop={\"timesteps_total\": 512}\n", - " ),\n", - " param_space=config\n", - ").fit()\n" + "algo = config.build()\n", + "results = algo.train()\n" ] } ], diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 98f47cc3..4994e7d3 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,10 +6,10 @@ import secrets from abc import ABC, abstractmethod from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, ClassVar, Dict, Optional, TypeVar, Union +from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union from prettytable import MARKDOWN, PrettyTable -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validate_call from primaite import getLogger from primaite.exceptions import NetworkError @@ -22,6 +22,7 @@ from primaite.simulator.network.hardware.node_operating_state import NodeOperati from primaite.simulator.network.nmne import NMNEConfig from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application from primaite.simulator.system.core.packet_capture import PacketCapture from primaite.simulator.system.core.session_manager import SessionManager @@ -29,7 +30,7 @@ from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog from primaite.simulator.system.processes.process import Process from primaite.simulator.system.services.service import Service -from primaite.simulator.system.software import IOSoftware +from primaite.simulator.system.software import IOSoftware, Software from primaite.utils.converters import convert_dict_enum_keys_to_enum_values from primaite.utils.validators import IPV4Address @@ -788,6 +789,650 @@ class Link(SimComponent): self.current_load = 0.0 +class User(SimComponent): + """ + Represents a user in the PrimAITE system. + + :ivar username: The username of the user + :ivar password: The password of the user + :ivar disabled: Boolean flag indicating whether the user is disabled + :ivar is_admin: Boolean flag indicating whether the user has admin privileges + """ + + username: str + """The username of the user""" + + password: str + """The password of the user""" + + disabled: bool = False + """Boolean flag indicating whether the user is disabled""" + + is_admin: bool = False + """Boolean flag indicating whether the user has admin privileges""" + + num_of_logins: int = 0 + """Counts the number of the User has logged in""" + + def describe_state(self) -> Dict: + """ + Returns a dictionary representing the current state of the user. + + :return: A dict containing the state of the user + """ + return self.model_dump() + + +class UserManager(Service): + """ + Manages users within the PrimAITE system, handling creation, authentication, and administration. + + :param users: A dictionary of all users by their usernames + :param admins: A dictionary of admin users by their usernames + :param disabled_admins: A dictionary of currently disabled admin users by their usernames + """ + + users: Dict[str, User] = {} + + def __init__(self, **kwargs): + """ + Initializes a UserManager instanc. + + :param username: The username for the default admin user + :param password: The password for the default admin user + """ + kwargs["name"] = "UserManager" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) + + self.start() + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + # todo add doc about requeest schemas + rm.add_request( + "change_password", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.change_user_password(username=request[0], current_password=request[1], new_password=request[2]) + ) + ), + ) + return rm + + def describe_state(self) -> Dict: + """ + Returns the state of the UserManager along with the number of users and admins. + + :return: A dict containing detailed state information + """ + state = super().describe_state() + state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)}) + state["users"] = {k: v.describe_state() for k, v in self.users.items()} + return state + + def show(self, markdown: bool = False): + """ + Display the Users. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["Username", "Admin", "Disabled"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} User Manager" + for user in self.users.values(): + table.add_row([user.username, user.is_admin, user.disabled]) + print(table.get_string(sortby="Username")) + + @property + def non_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all enabled non-admin users. + + :return: A dictionary with usernames as keys and User objects as values for non-admin, non-disabled users. + """ + return {k: v for k, v in self.users.items() if not v.is_admin and not v.disabled} + + @property + def disabled_non_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all disabled non-admin users. + + :return: A dictionary with usernames as keys and User objects as values for non-admin, disabled users. + """ + return {k: v for k, v in self.users.items() if not v.is_admin and v.disabled} + + @property + def admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all enabled admin users. + + :return: A dictionary with usernames as keys and User objects as values for admin, non-disabled users. + """ + return {k: v for k, v in self.users.items() if v.is_admin and not v.disabled} + + @property + def disabled_admins(self) -> Dict[str, User]: + """ + Returns a dictionary of all disabled admin users. + + :return: A dictionary with usernames as keys and User objects as values for admin, disabled users. + """ + return {k: v for k, v in self.users.items() if v.is_admin and v.disabled} + + def install(self) -> None: + """Setup default user during first-time installation.""" + self.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True) + + def _is_last_admin(self, username: str) -> bool: + return username in self.admins and len(self.admins) == 1 + + def add_user( + self, username: str, password: str, is_admin: bool = False, bypass_can_perform_action: bool = False + ) -> bool: + """ + Adds a new user to the system. + + :param username: The username for the new user + :param password: The password for the new user + :param is_admin: Flag indicating if the new user is an admin + :return: True if user was successfully added, False otherwise + """ + if not bypass_can_perform_action and not self._can_perform_action(): + return False + if username in self.users: + self.sys_log.info(f"{self.name}: Failed to create new user {username} as this user name already exists") + return False + user = User(username=username, password=password, is_admin=is_admin) + self.users[username] = user + self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}") + return True + + def authenticate_user(self, username: str, password: str) -> Optional[User]: + """ + Authenticates a user's login attempt. + + :param username: The username of the user trying to log in + :param password: The password provided by the user + :return: The User object if authentication is successful, None otherwise + """ + if not self._can_perform_action(): + return None + user = self.users.get(username) + if user and not user.disabled and user.password == password: + self.sys_log.info(f"{self.name}: User authenticated: {username}") + return user + self.sys_log.info(f"{self.name}: Authentication failed for: {username}") + return None + + def change_user_password(self, username: str, current_password: str, new_password: str) -> bool: + """ + Changes a user's password. + + :param username: The username of the user changing their password + :param current_password: The current password of the user + :param new_password: The new password for the user + :return: True if the password was changed successfully, False otherwise + """ + if not self._can_perform_action(): + return False + user = self.users.get(username) + if user and user.password == current_password: + user.password = new_password + self.sys_log.info(f"{self.name}: Password changed for {username}") + return True + self.sys_log.info(f"{self.name}: Password change failed for {username}") + return False + + def disable_user(self, username: str) -> bool: + """ + Disables a user account, preventing them from logging in. + + :param username: The username of the user to disable + :return: True if the user was disabled successfully, False otherwise + """ + if not self._can_perform_action(): + return False + if username in self.users and not self.users[username].disabled: + if self._is_last_admin(username): + self.sys_log.info(f"{self.name}: Cannot disable User {username} as they are the only enabled admin") + return False + self.users[username].disabled = True + self.sys_log.info(f"{self.name}: User disabled: {username}") + return True + self.sys_log.info(f"{self.name}: Failed to disable user: {username}") + return False + + def enable_user(self, username: str) -> bool: + """ + Enables a previously disabled user account. + + :param username: The username of the user to enable + :return: True if the user was enabled successfully, False otherwise + """ + if username in self.users and self.users[username].disabled: + self.users[username].disabled = False + self.sys_log.info(f"{self.name}: User enabled: {username}") + return True + self.sys_log.info(f"{self.name}: Failed to enable user: {username}") + return False + + +class UserSession(SimComponent): + """ + Represents a user session on the Node. + + This class manages the state of a user session, including the user, session start, last active step, + and end step. It also indicates whether the session is local. + + :ivar user: The user associated with this session. + :ivar start_step: The timestep when the session was started. + :ivar last_active_step: The last timestep when the session was active. + :ivar end_step: The timestep when the session ended, if applicable. + :ivar local: Indicates if the session is local. Defaults to True. + """ + + user: User + """The user associated with this session.""" + + start_step: int + """The timestep when the session was started.""" + + last_active_step: int + """The last timestep when the session was active.""" + + end_step: Optional[int] = None + """The timestep when the session ended, if applicable.""" + + local: bool = True + """Indicates if the session is a local session or a remote session. Defaults to True as a local session.""" + + @classmethod + def create(cls, user: User, timestep: int) -> UserSession: + """ + Creates a new instance of UserSession. + + This class method initialises a user session with the given user and timestep. + + :param user: The user associated with this session. + :param timestep: The timestep when the session is created. + :return: An instance of UserSession. + """ + user.num_of_logins += 1 + return UserSession(user=user, start_step=timestep, last_active_step=timestep) + + def describe_state(self) -> Dict: + """ + Describes the current state of the user session. + + :return: A dictionary representing the state of the user session. + """ + return self.model_dump() + + +class RemoteUserSession(UserSession): + """ + Represents a remote user session on the Node. + + This class extends the UserSession class to include additional attributes and methods specific to remote sessions. + + :ivar remote_ip_address: The IP address of the remote user. + :ivar local: Indicates that this is not a local session. Always set to False. + """ + + remote_ip_address: IPV4Address + """The IP address of the remote user.""" + + local: bool = False + """Indicates that this is not a local session. Always set to False.""" + + @classmethod + def create(cls, user: User, timestep: int, remote_ip_address: IPV4Address) -> RemoteUserSession: # noqa + """ + Creates a new instance of RemoteUserSession. + + This class method initialises a remote user session with the given user, timestep, and remote IP address. + + :param user: The user associated with this session. + :param timestep: The timestep when the session is created. + :param remote_ip_address: The IP address of the remote user. + :return: An instance of RemoteUserSession. + """ + return RemoteUserSession( + user=user, start_step=timestep, last_active_step=timestep, remote_ip_address=remote_ip_address + ) + + def describe_state(self) -> Dict: + """ + Describes the current state of the remote user session. + + This method extends the base describe_state method to include the remote IP address. + + :return: A dictionary representing the state of the remote user session. + """ + state = super().describe_state() + state["remote_ip_address"] = str(self.remote_ip_address) + return state + + +class UserSessionManager(Service): + """ + Manages user sessions on a Node, including local and remote sessions. + + This class handles authentication, session management, and session timeouts for users interacting with the Node. + """ + + local_session: Optional[UserSession] = None + """The current local user session, if any.""" + + remote_sessions: Dict[str, RemoteUserSession] = {} + """A dictionary of active remote user sessions.""" + + historic_sessions: List[UserSession] = Field(default_factory=list) + """A list of historic user sessions.""" + + local_session_timeout_steps: int = 30 + """The number of steps before a local session times out due to inactivity.""" + + remote_session_timeout_steps: int = 5 + """The number of steps before a remote session times out due to inactivity.""" + + max_remote_sessions: int = 3 + """The maximum number of concurrent remote sessions allowed.""" + + current_timestep: int = 0 + """The current timestep in the simulation.""" + + def __init__(self, **kwargs): + """ + Initializes a UserSessionManager instance. + + :param username: The username for the default admin user + :param password: The password for the default admin user + """ + kwargs["name"] = "UserSessionManager" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + super().__init__(**kwargs) + self.start() + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + # todo add doc about requeest schemas + rm.add_request( + "remote_login", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.remote_login(username=request[0], password=request[1], remote_ip_address=request[2]) + ) + ), + ) + + rm.add_request( + "remote_logout", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.remote_logout(remote_session_id=request[0]) + ) + ), + ) + return rm + + def show(self, markdown: bool = False, include_session_id: bool = False, include_historic: bool = False): + """ + Displays a table of the user sessions on the Node. + + :param markdown: Whether to display the table in markdown format. + :param include_session_id: Whether to include session IDs in the table. + :param include_historic: Whether to include historic sessions in the table. + """ + headers = ["Session ID", "Username", "Type", "Remote IP", "Start Step", "Step Last Active", "End Step"] + + if not include_session_id: + headers = headers[1:] + + table = PrettyTable(headers) + + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.parent.hostname} User Sessions" + + def _add_session_to_table(user_session: UserSession): + """ + Adds a user session to the table for display. + + This helper function determines whether the session is local or remote and formats the session data + accordingly. It then adds the session data to the table. + + :param user_session: The user session to add to the table. + """ + session_type = "local" + remote_ip = "" + if isinstance(user_session, RemoteUserSession): + session_type = "remote" + remote_ip = str(user_session.remote_ip_address) + data = [ + user_session.uuid, + user_session.user.username, + session_type, + remote_ip, + user_session.start_step, + user_session.last_active_step, + user_session.end_step if user_session.end_step else "", + ] + if not include_session_id: + data = data[1:] + table.add_row(data) + + if self.local_session is not None: + _add_session_to_table(self.local_session) + + for user_session in self.remote_sessions.values(): + _add_session_to_table(user_session) + + if include_historic: + for user_session in self.historic_sessions: + _add_session_to_table(user_session) + + print(table.get_string(sortby="Step Last Active", reversesort=True)) + + def describe_state(self) -> Dict: + """ + Describes the current state of the UserSessionManager. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + state["active_remote_logins"] = len(self.remote_sessions) + return state + + @property + def _user_manager(self) -> UserManager: + """ + Returns the UserManager instance. + + :return: The UserManager instance. + """ + return self.software_manager.software["UserManager"] # noqa + + def pre_timestep(self, timestep: int) -> None: + """Apply any pre-timestep logic that helps make sure we have the correct observations.""" + self.current_timestep = timestep + if self.local_session: + if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep: + self._timeout_session(self.local_session) + + def _timeout_session(self, session: UserSession) -> None: + """ + Handles session timeout logic. + + :param session: The session to be timed out. + """ + session.end_step = self.current_timestep + session_identity = session.user.username + if session.local: + self.local_session = None + session_type = "Local" + else: + self.remote_sessions.pop(session.uuid) + session_type = "Remote" + session_identity = f"{session_identity} {session.remote_ip_address}" + + self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity") + + @property + def remote_session_limit_reached(self) -> bool: + """ + Checks if the maximum number of remote sessions has been reached. + + :return: True if the limit is reached, otherwise False. + """ + return len(self.remote_sessions) >= self.max_remote_sessions + + def validate_remote_session_uuid(self, remote_session_id: str) -> bool: + """ + Validates if a given remote session ID exists. + + :param remote_session_id: The remote session ID to validate. + :return: True if the session ID exists, otherwise False. + """ + return remote_session_id in self.remote_sessions + + def _login( + self, username: str, password: str, local: bool = True, remote_ip_address: Optional[IPv4Address] = None + ) -> Optional[str]: + """ + Logs a user in either locally or remotely. + + :param username: The username of the account. + :param password: The password of the account. + :param local: Whether the login is local or remote. + :param remote_ip_address: The remote IP address for remote login. + :return: The session ID if login is successful, otherwise None. + """ + if not self._can_perform_action(): + return None + + user = self._user_manager.authenticate_user(username=username, password=password) + + if not user: + self.sys_log.info(f"{self.name}: Incorrect username or password") + return None + + session_id = None + if local: + create_new_session = True + if self.local_session: + if self.local_session.user != user: + # logout the current user + self.local_logout() + else: + # not required as existing logged-in user attempting to re-login + create_new_session = False + + if create_new_session: + self.local_session = UserSession.create(user=user, timestep=self.current_timestep) + + session_id = self.local_session.uuid + else: + if not self.remote_session_limit_reached: + remote_session = RemoteUserSession.create( + user=user, timestep=self.current_timestep, remote_ip_address=remote_ip_address + ) + session_id = remote_session.uuid + self.remote_sessions[session_id] = remote_session + self.sys_log.info(f"{self.name}: User {user.username} logged in") + return session_id + + def local_login(self, username: str, password: str) -> Optional[str]: + """ + Logs a user in locally. + + :param username: The username of the account. + :param password: The password of the account. + :return: The session ID if login is successful, otherwise None. + """ + return self._login(username=username, password=password, local=True) + + @validate_call() + def remote_login(self, username: str, password: str, remote_ip_address: IPV4Address) -> Optional[str]: + """ + Logs a user in remotely. + + :param username: The username of the account. + :param password: The password of the account. + :param remote_ip_address: The remote IP address for the remote login. + :return: The session ID if login is successful, otherwise None. + """ + return self._login(username=username, password=password, local=False, remote_ip_address=remote_ip_address) + + def _logout(self, local: bool = True, remote_session_id: Optional[str] = None) -> bool: + """ + Logs a user out either locally or remotely. + + :param local: Whether the logout is local or remote. + :param remote_session_id: The remote session ID for remote logout. + :return: True if logout successful, otherwise False. + """ + if not self._can_perform_action(): + return False + session = None + if local and self.local_session: + session = self.local_session + session.end_step = self.current_timestep + self.local_session = None + + if not local and remote_session_id: + session = self.remote_sessions.pop(remote_session_id) + if session: + self.historic_sessions.append(session) + self.sys_log.info(f"{self.name}: User {session.user.username} logged out") + return True + return False + + def local_logout(self) -> bool: + """ + Logs out the current local user. + + :return: True if logout successful, otherwise False. + """ + return self._logout(local=True) + + def remote_logout(self, remote_session_id: str) -> bool: + """ + Logs out a remote user by session ID. + + :param remote_session_id: The remote session ID. + :return: True if logout successful, otherwise False. + """ + return self._logout(local=False, remote_session_id=remote_session_id) + + @property + def local_user_logged_in(self) -> bool: + """ + Checks if a local user is currently logged in. + + :return: True if a local user is logged in, otherwise False. + """ + return self.local_session is not None + + class Node(SimComponent): """ A basic Node class that represents a node on the network. @@ -855,11 +1500,14 @@ class Node(SimComponent): red_scan_countdown: int = 0 "Time steps until reveal to red scan is complete." + SYSTEM_SOFTWARE: ClassVar[Dict[str, Type[Software]]] = {} + "Base system software that must be preinstalled." + def __init__(self, **kwargs): """ Initialize the Node with various components and managers. - This method initializes the ARP cache, ICMP handler, session manager, and software manager if they are not + This method initialises the ARP cache, ICMP handler, session manager, and software manager if they are not provided. """ if not kwargs.get("sys_log"): @@ -879,9 +1527,40 @@ class Node(SimComponent): dns_server=kwargs.get("dns_server"), ) super().__init__(**kwargs) + self._install_system_software() self.session_manager.node = self self.session_manager.software_manager = self.software_manager - self._install_system_software() + + @property + def user_manager(self) -> Optional[UserManager]: + """The Nodes User Manager.""" + return self.software_manager.software.get("UserManager") # noqa + + @property + def user_session_manager(self) -> Optional[UserSessionManager]: + """The Nodes User Session Manager.""" + return self.software_manager.software.get("UserSessionManager") # noqa + + def local_login(self, username: str, password: str) -> Optional[str]: + """ + Attempt to log in to the node uas a local user. + + This method attempts to authenticate a local user with the given username and password. If successful, it + returns a session token. If authentication fails, it returns None. + + :param username: The username of the account attempting to log in. + :param password: The password of the account attempting to log in. + :return: A session token if the login is successful, otherwise None. + """ + return self.user_session_manager.local_login(username, password) + + def local_logout(self) -> None: + """ + Log out the current local user from the node. + + This method ends the current local user's session and invalidates the session token. + """ + return self.user_session_manager.local_logout() def ip_is_network_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: """ @@ -936,7 +1615,7 @@ class Node(SimComponent): @property def fail_message(self) -> str: """Message that is reported when a request is rejected by this validator.""" - return f"Cannot perform request on node '{self.node.hostname}' because it is not turned on." + return f"Cannot perform request on node '{self.node.hostname}' because it is not powered on." class _NodeIsOffValidator(RequestPermissionValidator): """ @@ -978,7 +1657,7 @@ class Node(SimComponent): application_name = request[0] if self.software_manager.software.get(application_name): self.sys_log.warning(f"Can't install {application_name}. It's already installed.") - return RequestResponse.from_bool(False) + return RequestResponse(status="success", data={"reason": "already installed"}) application_class = Application._application_registry[application_name] self.software_manager.install(application_class) application_instance = self.software_manager.software.get(application_name) @@ -1085,10 +1764,6 @@ class Node(SimComponent): return rm - def _install_system_software(self): - """Install System Software - software that is usually provided with the OS.""" - pass - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -1477,6 +2152,11 @@ class Node(SimComponent): # for process_id in self.processes: # self.processes[process_id] + def _install_system_software(self) -> None: + """Preinstall required software.""" + for _, software_class in self.SYSTEM_SOFTWARE.items(): + self.software_manager.install(software_class) + def __contains__(self, item: Any) -> bool: if isinstance(item, Service): return item.uuid in self.services diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index 1fb936cd..7393490b 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -5,7 +5,13 @@ from ipaddress import IPv4Address from typing import Any, ClassVar, Dict, Optional from primaite import getLogger -from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, Link, Node +from primaite.simulator.network.hardware.base import ( + IPWiredNetworkInterface, + Link, + Node, + UserManager, + UserSessionManager, +) from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.transmission.data_link_layer import Frame from primaite.simulator.system.applications.application import ApplicationOperatingState @@ -309,6 +315,8 @@ class HostNode(Node): "WebBrowser": WebBrowser, "NMAP": NMAP, "Terminal": Terminal, + "UserSessionManager": UserSessionManager, + "UserManager": UserManager, } """List of system software that is automatically installed on nodes.""" @@ -341,18 +349,6 @@ class HostNode(Node): """ return self.software_manager.software.get("ARP") - def _install_system_software(self): - """ - Installs the system software and network services typically found on an operating system. - - This method equips the host with essential network services and applications, preparing it for various - network-related tasks and operations. - """ - for _, software_class in self.SYSTEM_SOFTWARE.items(): - self.software_manager.install(software_class) - - super()._install_system_software() - def default_gateway_hello(self): """ Sends a hello message to the default gateway to establish connectivity and resolve the gateway's MAC address. diff --git a/src/primaite/simulator/network/hardware/nodes/network/router.py b/src/primaite/simulator/network/hardware/nodes/network/router.py index 61b7b96a..42821120 100644 --- a/src/primaite/simulator/network/hardware/nodes/network/router.py +++ b/src/primaite/simulator/network/hardware/nodes/network/router.py @@ -4,14 +4,14 @@ from __future__ import annotations import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from prettytable import MARKDOWN, PrettyTable from pydantic import validate_call from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType, SimComponent -from primaite.simulator.network.hardware.base import IPWiredNetworkInterface +from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, UserManager, UserSessionManager from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.network.network_node import NetworkNode from primaite.simulator.network.protocols.arp import ARPPacket @@ -1200,6 +1200,11 @@ class Router(NetworkNode): RouteTable, RouterARP, and RouterICMP services. """ + SYSTEM_SOFTWARE: ClassVar[Dict] = { + "UserSessionManager": UserSessionManager, + "UserManager": UserManager, + } + num_ports: int network_interfaces: Dict[str, RouterInterface] = {} "The Router Interfaces on the node." @@ -1235,6 +1240,7 @@ class Router(NetworkNode): resolution within the network. These services are crucial for the router's operation, enabling it to manage network traffic efficiently. """ + super()._install_system_software() self.software_manager.install(RouterICMP) icmp: RouterICMP = self.software_manager.icmp # noqa icmp.router = self diff --git a/src/primaite/simulator/network/hardware/nodes/network/switch.py b/src/primaite/simulator/network/hardware/nodes/network/switch.py index 1a7da2e7..4324ac94 100644 --- a/src/primaite/simulator/network/hardware/nodes/network/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/network/switch.py @@ -108,6 +108,9 @@ class Switch(NetworkNode): for i in range(1, self.num_ports + 1): self.connect_nic(SwitchPort()) + def _install_system_software(self): + pass + def show(self, markdown: bool = False): """ Prints a table of the SwitchPorts on the Switch. diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 9c4d7cf6..e00afba6 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -103,7 +103,7 @@ class SoftwareManager: return True return False - def install(self, software_class: Type[IOSoftware]): + def install(self, software_class: Type[IOSoftware], **install_kwargs): """ Install an Application or Service. @@ -113,7 +113,11 @@ class SoftwareManager: self.sys_log.warning(f"Cannot install {software_class} as it is already installed") return software = software_class( - software_manager=self, sys_log=self.sys_log, file_system=self.file_system, dns_server=self.dns_server + software_manager=self, + sys_log=self.sys_log, + file_system=self.file_system, + dns_server=self.dns_server, + **install_kwargs, ) software.parent = self.node if isinstance(software, Application): diff --git a/src/primaite/simulator/system/services/access/__init__.py b/src/primaite/simulator/system/services/access/__init__.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/access/__init__.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/services/access/user_manager.py b/src/primaite/simulator/system/services/access/user_manager.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/access/user_manager.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/services/access/user_session_manager.py b/src/primaite/simulator/system/services/access/user_session_manager.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/access/user_session_manager.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7ea67dcd..7c27534a 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -291,7 +291,7 @@ class IOSoftware(Software): """ if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: self.software_manager.node.sys_log.error( - f"{self.name} Error: {self.software_manager.node.hostname} is not online." + f"{self.name} Error: {self.software_manager.node.hostname} is not powered on." ) return False return True diff --git a/tests/assets/configs/basic_node_with_users.yaml b/tests/assets/configs/basic_node_with_users.yaml new file mode 100644 index 00000000..064519dd --- /dev/null +++ b/tests/assets/configs/basic_node_with_users.yaml @@ -0,0 +1,34 @@ +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + sys_log_level: WARNING + agent_log_level: INFO + save_agent_logs: true + write_agent_log_to_terminal: True + + +game: + max_episode_length: 256 + ports: + - ARP + protocols: + - ICMP + - UDP + + +simulation: + network: + nodes: + - hostname: client_1 + type: computer + ip_address: 192.168.10.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + users: + - username: jane.doe + password: '1234' + is_admin: true + - username: john.doe + password: password_1 + is_admin: false diff --git a/tests/integration_tests/configuration_file_parsing/test_software_fix_duration.py b/tests/integration_tests/configuration_file_parsing/test_software_fix_duration.py index ae4825ff..dd38fafd 100644 --- a/tests/integration_tests/configuration_file_parsing/test_software_fix_duration.py +++ b/tests/integration_tests/configuration_file_parsing/test_software_fix_duration.py @@ -45,7 +45,7 @@ def test_fix_duration_set_from_config(): client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") # in config - services take 3 timesteps to fix - for service in SERVICE_TYPES_MAPPING: + for service in ["DNSClient", "DNSServer", "DatabaseService", "WebServer", "FTPClient", "FTPServer", "NTPServer"]: assert client_1.software_manager.software.get(service) is not None assert client_1.software_manager.software.get(service).fixing_duration == 3 @@ -53,7 +53,7 @@ def test_fix_duration_set_from_config(): # remove test applications from list applications = set(Application._application_registry) - set(TestApplications) - for application in applications: + for application in ["RansomwareScript", "WebBrowser", "DataManipulationBot", "DoSBot", "DatabaseClient"]: assert client_1.software_manager.software.get(application) is not None assert client_1.software_manager.software.get(application).fixing_duration == 1 @@ -64,17 +64,13 @@ def test_fix_duration_for_one_item(): client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") # in config - services take 3 timesteps to fix - services = copy.copy(SERVICE_TYPES_MAPPING) - services.pop("DatabaseService") - for service in services: + for service in ["DNSClient", "DNSServer", "WebServer", "FTPClient", "FTPServer", "NTPServer"]: assert client_1.software_manager.software.get(service) is not None assert client_1.software_manager.software.get(service).fixing_duration == 2 # in config - applications take 1 timestep to fix # remove test applications from list - applications = set(Application._application_registry) - set(TestApplications) - applications.remove("DatabaseClient") - for applications in applications: + for applications in ["RansomwareScript", "WebBrowser", "DataManipulationBot", "DoSBot"]: assert client_1.software_manager.software.get(applications) is not None assert client_1.software_manager.software.get(applications).fixing_duration == 2 diff --git a/tests/integration_tests/network/test_users_creation_from_config.py b/tests/integration_tests/network/test_users_creation_from_config.py new file mode 100644 index 00000000..8cd3b037 --- /dev/null +++ b/tests/integration_tests/network/test_users_creation_from_config.py @@ -0,0 +1,26 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import yaml + +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.hardware.base import UserManager +from tests import TEST_ASSETS_ROOT + + +def test_users_from_config(): + config_path = TEST_ASSETS_ROOT / "configs" / "basic_node_with_users.yaml" + + with open(config_path, "r") as f: + config_dict = yaml.safe_load(f) + network = PrimaiteGame.from_config(cfg=config_dict).simulation.network + + client_1 = network.get_node_by_hostname("client_1") + + user_manager: UserManager = client_1.software_manager.software["UserManager"] + + assert len(user_manager.users) == 3 + + assert user_manager.users["jane.doe"].password == "1234" + assert user_manager.users["jane.doe"].is_admin + + assert user_manager.users["john.doe"].password == "password_1" + assert not user_manager.users["john.doe"].is_admin diff --git a/tests/integration_tests/system/test_user_session_manager_logins.py b/tests/integration_tests/system/test_user_session_manager_logins.py new file mode 100644 index 00000000..4318530c --- /dev/null +++ b/tests/integration_tests/system/test_user_session_manager_logins.py @@ -0,0 +1,274 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Tuple +from uuid import uuid4 + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import User +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server + + +@pytest.fixture(scope="function") +def client_server_network() -> Tuple[Computer, Server, Network]: + network = Network() + + client = Computer( + hostname="client", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client.power_on() + + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + network.connect(client.network_interface[1], server.network_interface[1]) + + return client, server, network + + +def test_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + +def test_login_count_increases(client_server_network): + client, server, network = client_server_network + + admin_user: User = client.user_manager.users["admin"] + + assert admin_user.num_of_logins == 0 + + client.user_session_manager.local_login(username="admin", password="admin") + + assert admin_user.num_of_logins == 1 + + client.user_session_manager.local_login(username="admin", password="admin") + + # shouldn't change as user is already logged in + assert admin_user.num_of_logins == 1 + + client.user_session_manager.local_logout() + + client.user_session_manager.local_login(username="admin", password="admin") + + assert admin_user.num_of_logins == 2 + + +def test_local_login_failure(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert not client.user_session_manager.local_user_logged_in + + +def test_new_user_local_login_success(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + client.user_manager.add_user(username="jane.doe", password="12345") + + client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + +def test_new_local_login_clears_previous_login(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + client.user_manager.add_user(username="jane.doe", password="12345") + + new_session_id = client.user_session_manager.local_login(username="jane.doe", password="12345") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "jane.doe" + + assert new_session_id != current_session_id + + +def test_new_local_login_attempt_same_uses_persists(client_server_network): + client, server, network = client_server_network + + assert not client.user_session_manager.local_user_logged_in + + current_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + new_session_id = client.user_session_manager.local_login(username="admin", password="admin") + + assert client.user_session_manager.local_user_logged_in + + assert client.user_session_manager.local_session.user.username == "admin" + + assert new_session_id == current_session_id + + +def test_remote_login_success(client_server_network): + # partial test for now until we get the terminal application in so that amn actual remote connection can be made + client, server, network = client_server_network + + assert not server.user_session_manager.remote_sessions + + remote_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + assert server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + server.user_session_manager.remote_logout(remote_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_remote_login_failure(client_server_network): + # partial test for now until we get the terminal application in so that amn actual remote connection can be made + client, server, network = client_server_network + + assert not server.user_session_manager.remote_sessions + + remote_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_new_user_remote_login_success(client_server_network): + client, server, network = client_server_network + + server.user_manager.add_user(username="jane.doe", password="12345") + + remote_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + assert server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + server.user_session_manager.remote_logout(remote_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(remote_session_id) + + +def test_max_remote_sessions_same_user(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [ + server.user_session_manager.remote_login(username="admin", password="admin", remote_ip_address="192.168.1.10") + for _ in range(server.user_session_manager.max_remote_sessions) + ] + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_max_remote_sessions_different_users(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [] + + for i in range(server.user_session_manager.max_remote_sessions): + username = str(uuid4()) + password = "12345" + server.user_manager.add_user(username=username, password=password) + + remote_session_ids.append( + server.user_session_manager.remote_login( + username=username, password=password, remote_ip_address="192.168.1.10" + ) + ) + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_max_remote_sessions_limit_reached(client_server_network): + client, server, network = client_server_network + + remote_session_ids = [ + server.user_session_manager.remote_login(username="admin", password="admin", remote_ip_address="192.168.1.10") + for _ in range(server.user_session_manager.max_remote_sessions) + ] + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + assert len(server.user_session_manager.remote_sessions) == server.user_session_manager.max_remote_sessions + + fourth_attempt_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + assert not server.user_session_manager.validate_remote_session_uuid(fourth_attempt_session_id) + + assert all([server.user_session_manager.validate_remote_session_uuid(id) for id in remote_session_ids]) + + +def test_single_remote_logout_others_persist(client_server_network): + client, server, network = client_server_network + + server.user_manager.add_user(username="jane.doe", password="12345") + server.user_manager.add_user(username="john.doe", password="12345") + + admin_session_id = server.user_session_manager.remote_login( + username="admin", password="admin", remote_ip_address="192.168.1.10" + ) + + jane_session_id = server.user_session_manager.remote_login( + username="jane.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + john_session_id = server.user_session_manager.remote_login( + username="john.doe", password="12345", remote_ip_address="192.168.1.10" + ) + + server.user_session_manager.remote_logout(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(john_session_id) + + server.user_session_manager.remote_logout(jane_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert server.user_session_manager.validate_remote_session_uuid(john_session_id) + + server.user_session_manager.remote_logout(john_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(admin_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(jane_session_id) + + assert not server.user_session_manager.validate_remote_session_uuid(john_session_id)