diff --git a/.azure/azure-benchmark-pipeline.yaml b/.azure/azure-benchmark-pipeline.yaml new file mode 100644 index 00000000..44010cac --- /dev/null +++ b/.azure/azure-benchmark-pipeline.yaml @@ -0,0 +1,108 @@ +trigger: +- release/* + +schedules: +- cron: "0 2 * * 1-5" # Run at 2 AM every weekday + displayName: "Weekday Schedule" + branches: + include: + - 'refs/heads/dev' +variables: + VERSION: '' + MAJOR_VERSION: '' + +jobs: +- job: PrimAITE_Benchmark + timeoutInMinutes: 360 # 6-hour maximum + pool: + name: 'Imaginary Yak Pool' + workspace: + clean: all + + steps: + - checkout: self + persistCredentials: true + + - script: | + python3.10 -m venv venv + displayName: 'Create venv' + + - script: | + VERSION=$(cat src/primaite/VERSION | tr -d '\n') + if [[ "$(Build.SourceBranch)" == "refs/heads/dev" ]]; then + DATE=$(date +%Y%m%d) + echo "${VERSION}+dev.${DATE}" > src/primaite/VERSION + fi + displayName: 'Update VERSION file for Dev Benchmark' + + - script: | + VERSION=$(cat src/primaite/VERSION | tr -d '\n') + MAJOR_VERSION=$(echo $VERSION | cut -d. -f1) + echo "##vso[task.setvariable variable=VERSION]$VERSION" + echo "##vso[task.setvariable variable=MAJOR_VERSION]$MAJOR_VERSION" + displayName: 'Set Version Variables' + + - script: | + source venv/bin/activate + pip install --upgrade pip + pip install -e .[dev,rl] + primaite setup + displayName: 'Install Dependencies' + + - script: | + set -e + source venv/bin/activate + cd benchmark + python primaite_benchmark.py + cd .. + displayName: 'Run Benchmarking Script' + + - script: | + tar czf primaite_v$(VERSION)_benchmark.tar.gz benchmark/results/v$(MAJOR_VERSION)/v$(VERSION) + displayName: 'Prepare Artifacts for Publishing' + + - task: PublishPipelineArtifact@1 + inputs: + targetPath: primaite_v$(VERSION)_benchmark.tar.gz + artifactName: 'benchmark-zip-output' + publishLocation: 'pipeline' + displayName: 'Publish Benchmark Output zip as Artifact' + + - script: | + git config --global user.email "oss@dstl.gov.uk" + git config --global user.name "Defence Science and Technology Laboratory UK" + workingDirectory: $(System.DefaultWorkingDirectory) + displayName: 'Configure Git' + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/heads/release')) + + - script: | + echo "Fetching all branches..." + git fetch --all --prune + + echo "Stashing files..." + git stash push -u + + echo "Resolving branch name..." + # Extracting just the branch name from the full ref path + branch_name=$(echo "$(Build.SourceBranch)" | sed 's|refs/heads/||') + echo "Branch Name: $branch_name" + + echo "Checking out branch $branch_name..." + git checkout $branch_name + + echo "Popping stash..." + git stash pop + + echo "Adding benchmark results..." + git add benchmark/results/v$(MAJOR_VERSION)/v$(VERSION)/* + + echo "Committing changes..." + git commit -m "Automated benchmark output commit for version $(VERSION) [skip ci]" + + echo "Pushing to remote..." + git push origin $branch_name + displayName: 'Commit and Push Benchmark Results' + workingDirectory: $(System.DefaultWorkingDirectory) + env: + GIT_CREDENTIALS: $(System.AccessToken) + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/heads/release')) diff --git a/.azure/azure-build-deploy-docs-pipeline.yml b/.azure/azure-build-deploy-docs-pipeline.yml index 0f44b0c8..8ebfe4d6 100644 --- a/.azure/azure-build-deploy-docs-pipeline.yml +++ b/.azure/azure-build-deploy-docs-pipeline.yml @@ -26,8 +26,12 @@ jobs: displayName: 'Install build dependencies' - script: | - pip install -e .[dev] - displayName: 'Install Yawning-Titan for docs autosummary' + pip install -e .[dev,rl] + displayName: 'Install PrimAITE for docs autosummary' + + - script: | + apt-get install pandoc + displayName: 'Install Pandoc' - script: | primaite setup diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index 0bb03594..aea94807 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -6,45 +6,55 @@ trigger: - bugfix/* - release/* +pr: + autoCancel: true + drafts: false parameters: # https://stackoverflow.com/a/70046417 - name: matrix type: object default: - - job_name: 'UbuntuPython38' - py: '3.8' - img: 'ubuntu-latest' - every_time: false - - job_name: 'UbuntuPython310' - py: '3.10' + # - job_name: 'UbuntuPython38' + # py: '3.8' + # img: 'ubuntu-latest' + # every_time: false + # publish_coverage: false + - job_name: 'UbuntuPython311' + py: '3.11' img: 'ubuntu-latest' every_time: true - - job_name: 'WindowsPython38' - py: '3.8' + publish_coverage: true + # - job_name: 'WindowsPython38' + # py: '3.8' + # img: 'windows-latest' + # every_time: false + # publish_coverage: false + - job_name: 'WindowsPython311' + py: '3.11' img: 'windows-latest' every_time: false - - job_name: 'WindowsPython310' - py: '3.10' - img: 'windows-latest' - every_time: false - - job_name: 'MacOSPython38' - py: '3.8' - img: 'macOS-latest' - every_time: false - - job_name: 'MacOSPython310' - py: '3.10' + publish_coverage: false + # - job_name: 'MacOSPython38' + # py: '3.8' + # img: 'macOS-latest' + # every_time: false + # publish_coverage: false + - job_name: 'MacOSPython311' + py: '3.11' img: 'macOS-latest' every_time: false + publish_coverage: false stages: - stage: Test jobs: - ${{ each item in parameters.matrix }}: - job: ${{ item.job_name }} + timeoutInMinutes: 90 + cancelTimeoutInMinutes: 1 pool: vmImage: ${{ item.img }} - - condition: or( eq(variables['Build.Reason'], 'PullRequest'), ${{ item.every_time }} ) + condition: and(succeeded(), or( eq(variables['Build.Reason'], 'PullRequest'), ${{ item.every_time }} )) steps: - task: UsePythonVersion@0 @@ -72,12 +82,12 @@ stages: - script: | PRIMAITE_WHEEL=$(ls ./dist/primaite*.whl) - python -m pip install $PRIMAITE_WHEEL[dev] + python -m pip install $PRIMAITE_WHEEL[dev,rl] displayName: 'Install PrimAITE' condition: or(eq( variables['Agent.OS'], 'Linux' ), eq( variables['Agent.OS'], 'Darwin' )) - script: | - forfiles /p dist\ /m *.whl /c "cmd /c python -m pip install @file[dev]" + forfiles /p dist\ /m *.whl /c "cmd /c python -m pip install @file[dev,rl]" displayName: 'Install PrimAITE' condition: eq( variables['Agent.OS'], 'Windows_NT' ) @@ -85,6 +95,34 @@ stages: primaite setup displayName: 'Perform PrimAITE Setup' + - task: UseDotNet@2 + displayName: 'Install dotnet dependencies' + inputs: + packageType: 'sdk' + version: '2.1.x' + - script: | - pytest -n 4 - displayName: 'Run tests' + 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 + displayName: 'Run tests and code coverage' + + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testRunner: JUnit + testResultsFiles: 'junit/**.xml' + testRunTitle: 'Publish test results' + failTaskOnFailedTests: true + + - publish: $(System.DefaultWorkingDirectory)/htmlcov/ + # publish the html report - so we can debug the coverage if needed + condition: ${{ item.publish_coverage }} # should only be run once + artifact: coverage_report + + - task: PublishCodeCoverageResults@2 + # publish the code coverage so it can be viewed in the run coverage page + condition: ${{ item.publish_coverage }} # should only be run once + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' diff --git a/.azuredevops/pull_request_template.md b/.azuredevops/pull_request_template.md index 538baf5c..bd25cdc1 100644 --- a/.azuredevops/pull_request_template.md +++ b/.azuredevops/pull_request_template.md @@ -1,12 +1,16 @@ -## Summary -*Replace this text with an explanation of what the changes are and how you implemented them. Can this impact any other parts of the codebase that we should keep in mind?* - -## Test process -*How have you tested this (if applicable)?* - -## Checklist -- [ ] This PR is linked to a **work item** -- [ ] I have performed **self-review** of the code -- [ ] I have written **tests** for any new functionality added with this PR -- [ ] I have updated the **documentation** if this PR changes or adds functionality -- [ ] I have run **pre-commit** checks for code style +## Summary +*Replace this text with an explanation of what the changes are and how you implemented them. Can this impact any other parts of the codebase that we should keep in mind?* + +## Test process +*How have you tested this (if applicable)?* + +## Checklist +- [ ] PR is linked to a **work item** +- [ ] **acceptance criteria** of linked ticket are met +- [ ] performed **self-review** of the code +- [ ] written **tests** for any new functionality added with this PR +- [ ] updated the **documentation** if this PR changes or adds functionality +- [ ] written/updated **design docs** if this PR implements new functionality +- [ ] updated the **change log** +- [ ] ran **pre-commit** checks for code style +- [ ] attended to any **TO-DOs** left in the code diff --git a/.flake8 b/.flake8 index 398d14fb..c2d9e4bb 100644 --- a/.flake8 +++ b/.flake8 @@ -9,5 +9,12 @@ extend-ignore = E712 D401 F811 + ANN002 + ANN003 + ANN101 + ANN102 exclude = docs/source/* + tests/* +suppress-none-returning=True +suppress-dummy-args=True diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..1ee05bfa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] - " +labels: bug +assignees: '' + +--- + +### Describe the bug: + +A clear and concise description of what the bug is. + +### To Reproduce: + +Steps to reproduce the behaviour: + +1. Import '...' +2. Instantiate '....' +3. Pass to '....' +4. Run '....' +5. See error + +### Expected behaviour + +A clear and concise description of what you expected to happen. + +### Screenshots/Outputs + +If applicable, add screenshots to help explain your problem. + +### Environment (please complete the following information) + + - **OS:** [e.g. Ubuntu 22.04] + - **Python:** [e.g. 3.10.11] + - **PrimAITE Version:** [e.g. v2.0.0] + - **Software:** [e.g. cli, Jupyter, PyCharm, VSCode etc.] + +### Additional context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..c0a20642 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[REQUEST] - " +labels: feature_request +assignees: '' + +--- + +### Is your feature request related to a problem? + +If so, please give a concise description of what the problem is. Ex. I'm always frustrated when [...] + +### Describe the solution you'd like: + +A clear and concise description of what you want to happen. + +### Describe alternatives you've considered: + +A clear and concise description of any alternative solutions or features you've considered. + +### Additional context: + +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build-sphinx.yml b/.github/workflows/build-sphinx.yml new file mode 100644 index 00000000..da20fbd3 --- /dev/null +++ b/.github/workflows/build-sphinx.yml @@ -0,0 +1,60 @@ +name: build-sphinx-to-github-pages + +env: + GITHUB_ACTOR: Autonomous-Resilient-Cyber-Defence + GITHUB_REPOSITORY: Autonomous-Resilient-Cyber-Defence/PrimAITE + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN}} + +on: + push: + branches: [main] + + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install python dev + run: | + set -x + sudo apt-get update + sudo add-apt-repository ppa:deadsnakes/ppa -y + sudo apt install python${{ matrix.python-version}}-dev -y + + - name: Install Git + run: | + set -x + sudo apt-get install -y git + shell: bash + + - name: Set pip, wheel, setuptools versions + run: | + python -m pip install --upgrade pip==23.0.1 + pip install wheel==0.38.4 --upgrade + pip install setuptools==66 --upgrade + pip install build + + - name: Install PrimAITE for docs autosummary + run: | + set -x + python -m pip install -e .[dev,rl] + + - name: Run build script for Sphinx pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + run: | + set -x + bash $PWD/docs/build-sphinx-docs-to-github-pages.sh diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 00000000..1b85f4be --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,66 @@ +name: Python package + +on: + push: + branches: + - main + - dev + - dev-gui + - 'release/**' + pull_request: + branches: + - main + - dev + - dev-gui + - 'release/**' +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install python dev + run: | + sudo apt update + sudo add-apt-repository ppa:deadsnakes/ppa -y + sudo apt install python${{ matrix.python-version}}-dev -y + + - name: Install Build Dependencies + run: | + python -m pip install --upgrade pip==23.0.1 + pip install wheel==0.38.4 --upgrade + pip install setuptools==66 --upgrade + pip install build + + - name: Build PrimAITE + run: | + python -m build + + - name: Install PrimAITE + run: | + PRIMAITE_WHEEL=$(ls ./dist/primaite*.whl) + python -m pip install $PRIMAITE_WHEEL[dev,rl] + + - name: Perform PrimAITE Setup + run: | + primaite setup + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + + - name: Run tests + run: | + pytest tests/ diff --git a/.gitignore b/.gitignore index 60f5f54c..2ba8d4a7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +junit/ htmlcov/ .tox/ .nox/ @@ -81,6 +82,10 @@ target/ # Jupyter Notebook .ipynb_checkpoints +PPO_UC2/ +# ignore everything but the executed notebooks rst in the docs/source/notebooks directory +!docs/source/notebooks/executed_notebooks.rst +docs/source/notebooks/**/* # IPython profile_default/ @@ -144,9 +149,22 @@ cython_debug/ # IDE .idea/ docs/source/primaite-dependencies.rst +.vscode/ # outputs src/primaite/outputs/ +simulation_output/ +sessions/ +PrimAITE-PPO-example-agent.zip # benchmark session outputs benchmark/output +# src/primaite/notebooks/scratch.ipynb +src/primaite/notebooks/scratch.py +sandbox.py +sandbox/ +sandbox.ipynb + +# benchmarking +**/benchmark/sessions/ +**/benchmark/output/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e435bee..91230171 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,10 +3,11 @@ repos: rev: v4.4.0 hooks: - id: check-yaml + exclude: scenario_with_placeholders/ - id: end-of-file-fixer - id: trailing-whitespace - id: check-added-large-files - args: ['--maxkb=1000'] + args: ['--maxkb=5000'] - id: mixed-line-ending - id: requirements-txt-fixer - repo: http://github.com/psf/black @@ -27,3 +28,8 @@ repos: - id: flake8 additional_dependencies: - flake8-docstrings + - flake8-annotations + - repo: https://github.com/kynan/nbstripout + rev: 0.7.1 + hooks: + - id: nbstripout diff --git a/CHANGELOG.md b/CHANGELOG.md index d66257b5..227cf729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,157 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 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 configuraiton yaml. Will default to 100 if not present. + +### 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'. + + +### 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. + +### 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 + +### Removed +- Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` +- Removed legacy training modules +- Removed tests for legacy code + +### 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4d70ebb3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# How to contribute to PrimAITE? + + +### **Did you find a bug?** + + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/issues). +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=%5BBUG%5D+-+%3Cbug+title+goes+here%3E). Be sure to follow our bug report template with the headers **Describe the bug**, **To Reproduce**, **Expected behaviour**, **Screenshots/Outputs**, **Environment**, and **Additional context** + + +### **Do you have a solution to fix the bug?** + +* [Fork the repository](https://github.com/Autonomous-Resilient-Cyber-Defence/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/Autonomous-Resilient-Cyber-Defence/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 Autonomous-Resilient-Cyber-Defence/PrimAITE dev branch. Again, if the bug has an open issue under [Issues](https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/issues), reference the issue in the pull request description. + +### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of PrimAITE will generally not be accepted. + +### **Do you intend to add a new feature or change an existing one?** + +* Submit a [feature request issue](https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/issues/new?assignees=&labels=feature_request&projects=&template=feature_request.md&title=%5BREQUEST%5D+-+%3Crequest+title+goes+here%3E). +* Know how to implement the new feature or change? Follow the same steps in the bug fix section above to fork, build, document, test, commit, and submit a pull request. + +### **Do you have questions about the source code?** + +Ask any question about how to use PrimAITE in our discussions section. + +### **Do you want to contribute to the PrimAITE documentation?** + +Please follow the "Do you intend to add a new feature or change an existing one?" section above and tag your feature request issue and pull request with the documentation tag. + +Thank you from the PrimAITE dev team! 🙌 diff --git a/MANIFEST.in b/MANIFEST.in index 51ae4ddf..2ac7b306 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include src/primaite/setup/_package_data/primaite_config.yaml include src/primaite/config/_package_data/*.yaml +include src/primaite/simulator/_package_data/*.ipynb diff --git a/PrimAITE_logo_transparent.png b/PrimAITE_logo_transparent.png new file mode 100644 index 00000000..3e12f643 Binary files /dev/null and b/PrimAITE_logo_transparent.png differ diff --git a/README.md b/README.md index f7c6efd7..68a8488b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,161 @@ # PrimAITE +![image](./PrimAITE_logo_transparent.png) + +The ARCD Primary-level AI Training Environment (**PrimAITE**) provides an effective simulation capability for the purposes of training and evaluating AI in a cyber-defensive role. It incorporates the functionality required of a primary-level ARCD environment, which includes: + +- The ability to model a relevant platform / system context; + +- The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems and services; + +- Operates at machine-speed to enable fast training cycles. + +PrimAITE presents the following features: + +- Highly configurable (via YAML files) to provide the means to model a variety of platform / system laydowns and adversarial attack scenarios; + +- A Reinforcement Learning (RL) reward function based on (a) the ability to counter the specific modelled adversarial cyber-attack, and (b) the ability to ensure success; + +- Provision of logging to support AI evaluation and metrics gathering; + +- Realistic network traffic simulation, including address and sending packets via internet protocols like TCP, UDP, ICMP, and others + +- Routers with traffic routing and firewall capabilities + +- Support for multiple agents, each having their own customisable observation space, action space, and reward function definition, and either deterministic or RL-directed behaviour + ## Getting Started with PrimAITE -### Pre-Requisites - -In order to get **PrimAITE** installed, you will need to have the following installed: - -- `python3.8+` -- `python3-pip` -- `virtualenv` - +### 💫 Installation **PrimAITE** is designed to be OS-agnostic, and thus should work on most variations/distros of Linux, Windows, and MacOS. +Currently, the PrimAITE wheel can only be installed from GitHub. This may change in the future with release to PyPi. -### Installation from source -#### 1. Navigate to the PrimAITE folder and create a new python virtual environment (venv) +#### Windows (PowerShell) -```unix -python3 -m venv +**Prerequisites:** +* Manual install of Python >= 3.8 < 3.12 + +**Install:** + +``` powershell +mkdir ~\primaite +cd ~\primaite +python3 -m venv .venv +attrib +h .venv /s /d # Hides the .venv directory +.\.venv\Scripts\activate +pip install primaite-3.0.0-py3-none-any.whl[rl] +primaite setup ``` -#### 2. Activate the venv + +#### Unix + +**Prerequisites:** +* Manual install of Python >= 3.8 < 3.12 + +``` bash +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt install python3.10 +sudo apt-get install python3-pip +sudo apt-get install python3-venv +``` +**Install:** + +``` bash +mkdir ~/primaite +cd ~/primaite +python3 -m venv .venv +source .venv/bin/activate +pip install primaite-3.0.0-py3-none-any.whl[rl] +primaite setup +``` + + + +### Developer Install from Source +To make your own changes to PrimAITE, perform the install from source (developer install) + +#### 1. Clone the PrimAITE repository +``` unix +git clone git@github.com:Autonomous-Resilient-Cyber-Defence/PrimAITE.git +``` + +#### 2. CD into the repo directory +``` unix +cd PrimAITE +``` +#### 3. Create a new python virtual environment (venv) + +```unix +python3 -m venv venv +``` + +#### 4. Activate the venv ##### Unix ```bash -source /bin/activate +source venv/bin/activate ``` -##### Windows +##### Windows (Powershell) ```powershell -.\\Scripts\activate +.\venv\Scripts\activate ``` -#### 3. Install `primaite` into the venv along with all of it's dependencies +#### 5. Install `primaite` with the dev extra into the venv along with all of it's dependencies ```bash -python3 -m pip install -e . +python3 -m pip install -e .[dev,rl] ``` -### Development Installation -To install the development dependencies, postfix the command in step 3 above with the `[dev]` extra. Example: +#### 6. Perform the PrimAITE setup: ```bash -python3 -m pip install -e .[dev] +primaite setup ``` -## Building documentation +#### Note +*It is possible to install PrimAITE without Ray RLLib, StableBaselines3, or any deep learning libraries by omitting the `rl` flag in the pip install command.* + +### Running PrimAITE + +Use the provided jupyter notebooks as a starting point to try running PrimAITE. They are automatically copied to your PrimAITE notebook folder when you run `primaite setup`. + +#### 1. Activate the virtual environment + +##### Windows (Powershell) +```powershell +.\venv\Scripts\activate +``` + +##### Unix +```bash +source venv/bin/activate +``` + +#### 2. Open jupyter notebook + +```bash +python -m jupyter notebook +``` +Then, click the URL provided by the jupyter command to open the jupyter application in your browser. You can also open notebooks in your IDE if supported. + +## 📚 Documentation + +### Pre requisites + +Building the documentation requires the installation of Pandoc + +##### Unix +```bash +sudo apt-get install pandoc +``` + +##### Other operating systems +Follow the steps in https://pandoc.org/installing.html + +### Building the documentation + The PrimAITE documentation can be built with the following commands: ##### Unix @@ -53,12 +164,12 @@ cd docs make html ``` -##### Windows +##### Windows (Powershell) ```powershell cd docs .\make.bat html ``` -This will build the documentation as a collection of HTML files which uses the Read The Docs sphinx theme. Other build -options are available but may require additional dependencies such as LaTeX and PDF. Please refer to the Sphinx documentation -for your specific output requirements. + +## Example notebooks +Check out the example notebooks to learn more about how PrimAITE works and how you can use it to train agents. They are automatically copied to your primaite installation directory when you run `primaite setup`. diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py new file mode 100644 index 00000000..4ad398b9 --- /dev/null +++ b/benchmark/benchmark.py @@ -0,0 +1,22 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from typing import Any, Dict, Optional, Tuple + +from gymnasium.core import ObsType + +from primaite.session.environment import PrimaiteGymEnv + + +class BenchmarkPrimaiteGymEnv(PrimaiteGymEnv): + """ + Class that extends the PrimaiteGymEnv. + + The reset method is extended so that the average rewards per episode are recorded. + """ + + total_time_steps: int = 0 + + def reset(self, seed: Optional[int] = None) -> Tuple[ObsType, Dict[str, Any]]: + """Overrides the PrimAITEGymEnv reset so that the total timesteps is saved.""" + self.total_time_steps += self.game.step_counter + + return super().reset(seed=seed) diff --git a/benchmark/primaite_benchmark.py b/benchmark/primaite_benchmark.py index ead5723b..0e6c2acc 100644 --- a/benchmark/primaite_benchmark.py +++ b/benchmark/primaite_benchmark.py @@ -1,206 +1,93 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK import json -import platform import shutil -import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict, Final, Optional, Tuple, Union -from unittest.mock import patch +from typing import Any, Dict, Final, Tuple -import GPUtil -import plotly.graph_objects as go -import polars as pl -import psutil -import yaml -from plotly.graph_objs import Figure -from pylatex import Command, Document -from pylatex import Figure as LatexFigure -from pylatex import Section, Subsection, Tabular -from pylatex.utils import bold +from report import build_benchmark_md_report +from stable_baselines3 import PPO import primaite -from primaite.config.lay_down_config import data_manipulation_config_path -from primaite.data_viz.session_plots import get_plotly_config -from primaite.environment.primaite_env import Primaite -from primaite.primaite_session import PrimaiteSession +from benchmark import BenchmarkPrimaiteGymEnv +from primaite.config.load import data_manipulation_config_path _LOGGER = primaite.getLogger(__name__) +_MAJOR_V = primaite.__version__.split(".")[0] + _BENCHMARK_ROOT = Path(__file__).parent -_RESULTS_ROOT: Final[Path] = _BENCHMARK_ROOT / "results" -_RESULTS_ROOT.mkdir(exist_ok=True, parents=True) +_RESULTS_ROOT: Final[Path] = _BENCHMARK_ROOT / "results" / f"v{_MAJOR_V}" +_VERSION_ROOT: Final[Path] = _RESULTS_ROOT / f"v{primaite.__version__}" +_SESSION_METADATA_ROOT: Final[Path] = _VERSION_ROOT / "session_metadata" -_OUTPUT_ROOT: Final[Path] = _BENCHMARK_ROOT / "output" -# Clear and recreate the output directory -if _OUTPUT_ROOT.exists(): - shutil.rmtree(_OUTPUT_ROOT) -_OUTPUT_ROOT.mkdir() - -_TRAINING_CONFIG_PATH = _BENCHMARK_ROOT / "config" / "benchmark_training_config.yaml" -_LAY_DOWN_CONFIG_PATH = data_manipulation_config_path() +_SESSION_METADATA_ROOT.mkdir(parents=True, exist_ok=True) -def get_size(size_bytes: int): - """ - Scale bytes to its proper format. +class BenchmarkSession: + """Benchmark Session class.""" - e.g: - 1253656 => '1.20MB' - 1253656678 => '1.17GB' + gym_env: BenchmarkPrimaiteGymEnv + """Gym environment used by the session to train.""" - : - """ - factor = 1024 - for unit in ["", "K", "M", "G", "T", "P"]: - if size_bytes < factor: - return f"{size_bytes:.2f}{unit}B" - size_bytes /= factor + num_episodes: int + """Number of episodes to run the training session.""" + episode_len: int + """The number of steps per episode.""" -def _get_system_info() -> Dict: - """Builds and returns a dict containing system info.""" - uname = platform.uname() - cpu_freq = psutil.cpu_freq() - virtual_mem = psutil.virtual_memory() - swap_mem = psutil.swap_memory() - gpus = GPUtil.getGPUs() - return { - "System": { - "OS": uname.system, - "OS Version": uname.version, - "Machine": uname.machine, - "Processor": uname.processor, - }, - "CPU": { - "Physical Cores": psutil.cpu_count(logical=False), - "Total Cores": psutil.cpu_count(logical=True), - "Max Frequency": f"{cpu_freq.max:.2f}Mhz", - }, - "Memory": {"Total": get_size(virtual_mem.total), "Swap Total": get_size(swap_mem.total)}, - "GPU": [{"Name": gpu.name, "Total Memory": f"{gpu.memoryTotal}MB"} for gpu in gpus], - } + total_steps: int + """Number of steps to run the training session.""" + batch_size: int + """Number of steps for each episode.""" -def _build_benchmark_latex_report( - benchmark_metadata_dict: Dict, this_version_plot_path: Path, all_version_plot_path: Path -): - geometry_options = {"tmargin": "2.5cm", "rmargin": "2.5cm", "bmargin": "2.5cm", "lmargin": "2.5cm"} - data = benchmark_metadata_dict - primaite_version = data["primaite_version"] + learning_rate: float + """Learning rate for the model.""" - # Create a new document - doc = Document("report", geometry_options=geometry_options) - # Title - doc.preamble.append(Command("title", f"PrimAITE {primaite_version} Learning Benchmark")) - doc.preamble.append(Command("author", "PrimAITE Dev Team")) - doc.preamble.append(Command("date", datetime.now().date())) - doc.append(Command("maketitle")) + start_time: datetime + """Start time for the session.""" - sessions = data["total_sessions"] - episodes = data["training_config"]["num_train_episodes"] - steps = data["training_config"]["num_train_steps"] - - # Body - with doc.create(Section("Introduction")): - doc.append( - f"PrimAITE v{primaite_version} was benchmarked automatically upon release. Learning rate metrics " - f"were captured to be referenced during system-level testing and user acceptance testing (UAT)." - ) - doc.append( - f"\nThe benchmarking process consists of running {sessions} training session using the same " - f"training and lay down config files. Each session trains an agent for {episodes} episodes, " - f"with each episode consisting of {steps} steps." - ) - doc.append( - f"\nThe mean reward per episode from each session is captured. This is then used to calculate a " - f"combined average reward per episode from the {sessions} individual sessions for smoothing. " - f"Finally, a 25-widow rolling average of the combined average reward per session is calculated for " - f"further smoothing." - ) - - with doc.create(Section("System Information")): - with doc.create(Subsection("Python")): - with doc.create(Tabular("|l|l|")) as table: - table.add_hline() - table.add_row((bold("Version"), sys.version)) - table.add_hline() - for section, section_data in data["system_info"].items(): - if section_data: - with doc.create(Subsection(section)): - if isinstance(section_data, dict): - with doc.create(Tabular("|l|l|")) as table: - table.add_hline() - for key, value in section_data.items(): - table.add_row((bold(key), value)) - table.add_hline() - elif isinstance(section_data, list): - headers = section_data[0].keys() - tabs_str = "|".join(["l" for _ in range(len(headers))]) - tabs_str = f"|{tabs_str}|" - with doc.create(Tabular(tabs_str)) as table: - table.add_hline() - table.add_row([bold(h) for h in headers]) - table.add_hline() - for item in section_data: - table.add_row(item.values()) - table.add_hline() - - headers_map = { - "total_sessions": "Total Sessions", - "total_episodes": "Total Episodes", - "total_time_steps": "Total Steps", - "av_s_per_session": "Av Session Duration (s)", - "av_s_per_step": "Av Step Duration (s)", - "av_s_per_100_steps_10_nodes": "Av Duration per 100 Steps per 10 Nodes (s)", - } - with doc.create(Section("Stats")): - with doc.create(Subsection("Benchmark Results")): - with doc.create(Tabular("|l|l|")) as table: - table.add_hline() - for section, header in headers_map.items(): - if section.startswith("av_"): - table.add_row((bold(header), f"{data[section]:.4f}")) - else: - table.add_row((bold(header), data[section])) - table.add_hline() - - with doc.create(Section("Graphs")): - with doc.create(Subsection(f"PrimAITE {primaite_version} Learning Benchmark Plot")): - with doc.create(LatexFigure(position="h!")) as pic: - pic.add_image(str(this_version_plot_path)) - pic.add_caption(f"PrimAITE {primaite_version} Learning Benchmark Plot") - - with doc.create(Subsection("PrimAITE All Versions Learning Benchmark Plot")): - with doc.create(LatexFigure(position="h!")) as pic: - pic.add_image(str(all_version_plot_path)) - pic.add_caption("PrimAITE All Versions Learning Benchmark Plot") - - doc.generate_pdf(str(this_version_plot_path).replace(".png", ""), clean_tex=True) - - -class BenchmarkPrimaiteSession(PrimaiteSession): - """A benchmarking primaite session.""" + end_time: datetime + """End time for the session.""" def __init__( self, - training_config_path: Union[str, Path], - lay_down_config_path: Union[str, Path], + gym_env: BenchmarkPrimaiteGymEnv, + episode_len: int, + num_episodes: int, + n_steps: int, + batch_size: int, + learning_rate: float, ): - super().__init__(training_config_path, lay_down_config_path) - self.setup() + """Initialise the BenchmarkSession.""" + self.gym_env = gym_env + self.episode_len = episode_len + self.n_steps = n_steps + self.num_episodes = num_episodes + self.total_steps = self.num_episodes * self.episode_len + self.batch_size = batch_size + self.learning_rate = learning_rate - @property - def env(self) -> Primaite: - """Direct access to the env for ease of testing.""" - return self._agent_session._env # noqa + def train(self): + """Run the training session.""" + # start timer for session + self.start_time = datetime.now() + model = PPO( + policy="MlpPolicy", + env=self.gym_env, + learning_rate=self.learning_rate, + n_steps=self.n_steps, + batch_size=self.batch_size, + verbose=0, + tensorboard_log="./PPO_UC2/", + ) + model.learn(total_timesteps=self.total_steps) - def __enter__(self): - return self + # end timer for session + self.end_time = datetime.now() - def __exit__(self, type, value, tb): - shutil.rmtree(self.session_path) - _LOGGER.debug(f"Deleted benchmark session directory: {self.session_path}") + self.session_metadata = self.generate_learn_metadata_dict() def _learn_benchmark_durations(self) -> Tuple[float, float, float]: """ @@ -214,235 +101,99 @@ class BenchmarkPrimaiteSession(PrimaiteSession): :return: The learning benchmark durations as a Tuple of three floats: Tuple[total_s, s_per_step, s_per_100_steps_10_nodes]. """ - data = self.metadata_file_as_dict() - start_dt = datetime.fromisoformat(data["start_datetime"]) - end_dt = datetime.fromisoformat(data["end_datetime"]) - delta = end_dt - start_dt + delta = self.end_time - self.start_time total_s = delta.total_seconds() - total_steps = data["learning"]["total_time_steps"] + total_steps = self.batch_size * self.num_episodes s_per_step = total_s / total_steps - num_nodes = self.env.num_nodes + num_nodes = len(self.gym_env.game.simulation.network.nodes) num_intervals = total_steps / 100 av_interval_time = total_s / num_intervals s_per_100_steps_10_nodes = av_interval_time / (num_nodes / 10) return total_s, s_per_step, s_per_100_steps_10_nodes - def learn_metadata_dict(self) -> Dict[str, Any]: + def generate_learn_metadata_dict(self) -> Dict[str, Any]: """Metadata specific to the learning session.""" total_s, s_per_step, s_per_100_steps_10_nodes = self._learn_benchmark_durations() + self.gym_env.total_reward_per_episode.pop(0) # remove episode 0 return { - "total_episodes": self.env.actual_episode_count, - "total_time_steps": self.env.total_step_count, + "total_episodes": self.gym_env.episode_counter, + "total_time_steps": self.gym_env.total_time_steps, "total_s": total_s, "s_per_step": s_per_step, "s_per_100_steps_10_nodes": s_per_100_steps_10_nodes, - "av_reward_per_episode": self.learn_av_reward_per_episode_dict(), + "total_reward_per_episode": self.gym_env.total_reward_per_episode, } -def _get_benchmark_session_path(session_timestamp: datetime) -> Path: - return _OUTPUT_ROOT / session_timestamp.strftime("%Y-%m-%d_%H-%M-%S") - - -def _get_benchmark_primaite_session() -> BenchmarkPrimaiteSession: - with patch("primaite.agents.agent_abc.get_session_path", _get_benchmark_session_path) as mck: - mck.session_timestamp = datetime.now() - return BenchmarkPrimaiteSession(_TRAINING_CONFIG_PATH, _LAY_DOWN_CONFIG_PATH) - - -def _build_benchmark_results_dict(start_datetime: datetime, metadata_dict: Dict) -> dict: - n = len(metadata_dict) - with open(_TRAINING_CONFIG_PATH, "r") as file: - training_config_dict = yaml.safe_load(file) - with open(_LAY_DOWN_CONFIG_PATH, "r") as file: - lay_down_config_dict = yaml.safe_load(file) - averaged_data = { - "start_timestamp": start_datetime.isoformat(), - "end_datetime": datetime.now().isoformat(), - "primaite_version": primaite.__version__, - "system_info": _get_system_info(), - "total_sessions": n, - "total_episodes": sum(d["total_episodes"] for d in metadata_dict.values()), - "total_time_steps": sum(d["total_time_steps"] for d in metadata_dict.values()), - "av_s_per_session": sum(d["total_s"] for d in metadata_dict.values()) / n, - "av_s_per_step": sum(d["s_per_step"] for d in metadata_dict.values()) / n, - "av_s_per_100_steps_10_nodes": sum(d["s_per_100_steps_10_nodes"] for d in metadata_dict.values()) / n, - "combined_av_reward_per_episode": {}, - "session_av_reward_per_episode": {k: v["av_reward_per_episode"] for k, v in metadata_dict.items()}, - "training_config": training_config_dict, - "lay_down_config": lay_down_config_dict, - } - - episodes = metadata_dict[1]["av_reward_per_episode"].keys() - - for episode in episodes: - combined_av_reward = sum(metadata_dict[k]["av_reward_per_episode"][episode] for k in metadata_dict.keys()) / n - averaged_data["combined_av_reward_per_episode"][episode] = combined_av_reward - - return averaged_data - - -def _get_df_from_episode_av_reward_dict(data: Dict): - data: Dict = {"episode": data.keys(), "av_reward": data.values()} - - return ( - pl.from_dict(data) - .with_columns(rolling_mean=pl.col("av_reward").rolling_mean(window_size=25)) - .rename({"rolling_mean": "rolling_av_reward"}) - ) - - -def _plot_benchmark_metadata( - benchmark_metadata_dict: Dict, - title: Optional[str] = None, - subtitle: Optional[str] = None, -) -> Figure: - if title: - if subtitle: - title = f"{title}
{subtitle}" - else: - if subtitle: - title = subtitle - - config = get_plotly_config() - layout = go.Layout( - autosize=config["size"]["auto_size"], - width=config["size"]["width"], - height=config["size"]["height"], - ) - # Create the line graph with a colored line - fig = go.Figure(layout=layout) - fig.update_layout(template=config["template"]) - - for session, av_reward_dict in benchmark_metadata_dict["session_av_reward_per_episode"].items(): - df = _get_df_from_episode_av_reward_dict(av_reward_dict) - fig.add_trace( - go.Scatter( - x=df["episode"], - y=df["av_reward"], - mode="lines", - name=f"Session {session}", - opacity=0.25, - line={"color": "#a6a6a6"}, - ) - ) - - df = _get_df_from_episode_av_reward_dict(benchmark_metadata_dict["combined_av_reward_per_episode"]) - fig.add_trace( - go.Scatter( - x=df["episode"], y=df["av_reward"], mode="lines", name="Combined Session Av", line={"color": "#FF0000"} - ) - ) - - fig.add_trace( - go.Scatter( - x=df["episode"], - y=df["rolling_av_reward"], - mode="lines", - name="Rolling Av (Combined Session Av)", - line={"color": "#4CBB17"}, - ) - ) - - # Set the layout of the graph - fig.update_layout( - xaxis={ - "title": "Episode", - "type": "linear", - }, - yaxis={"title": "Average Reward"}, - title=title, - ) - - return fig - - -def _plot_all_benchmarks_combined_session_av(): +def _get_benchmark_primaite_environment() -> BenchmarkPrimaiteGymEnv: """ - Plot the Benchmark results for each released version of PrimAITE. + Create an instance of the BenchmarkPrimaiteGymEnv. - Does this by iterating over the ``benchmark/results`` directory and - extracting the benchmark metadata json for each version that has been - benchmarked. The combined_av_reward_per_episode is extracted from each, - converted into a polars dataframe, and plotted as a scatter line in plotly. + This environment will be used to train the agents on. """ - title = "PrimAITE Versions Learning Benchmark" - subtitle = "Rolling Av (Combined Session Av)" - if title: - if subtitle: - title = f"{title}
{subtitle}" - else: - if subtitle: - title = subtitle - config = get_plotly_config() - layout = go.Layout( - autosize=config["size"]["auto_size"], - width=config["size"]["width"], - height=config["size"]["height"], - ) - # Create the line graph with a colored line - fig = go.Figure(layout=layout) - fig.update_layout(template=config["template"]) - - for dir in _RESULTS_ROOT.iterdir(): - if dir.is_dir(): - metadata_file = dir / f"{dir.name}_benchmark_metadata.json" - with open(metadata_file, "r") as file: - metadata_dict = json.load(file) - df = _get_df_from_episode_av_reward_dict(metadata_dict["combined_av_reward_per_episode"]) - - fig.add_trace(go.Scatter(x=df["episode"], y=df["rolling_av_reward"], mode="lines", name=dir.name)) - - # Set the layout of the graph - fig.update_layout( - xaxis={ - "title": "Episode", - "type": "linear", - }, - yaxis={"title": "Average Reward"}, - title=title, - ) - fig["data"][0]["showlegend"] = True - - return fig + env = BenchmarkPrimaiteGymEnv(env_config=data_manipulation_config_path()) + return env -def run(): +def _prepare_session_directory(): + """Prepare the session directory so that it is easier to clean up after the benchmarking is done.""" + # override session path + session_path = _BENCHMARK_ROOT / "sessions" + + if session_path.is_dir(): + shutil.rmtree(session_path) + + primaite.PRIMAITE_PATHS.user_sessions_path = session_path + primaite.PRIMAITE_PATHS.user_sessions_path.mkdir(exist_ok=True, parents=True) + + +def run( + number_of_sessions: int = 5, + num_episodes: int = 1000, + episode_len: int = 128, + n_steps: int = 1280, + batch_size: int = 32, + learning_rate: float = 3e-4, +) -> None: """Run the PrimAITE benchmark.""" - start_datetime = datetime.now() - av_reward_per_episode_dicts = {} - for i in range(1, 11): + benchmark_start_time = datetime.now() + + session_metadata_dict = {} + + _prepare_session_directory() + + # run training + for i in range(1, number_of_sessions + 1): print(f"Starting Benchmark Session: {i}") - with _get_benchmark_primaite_session() as session: - session.learn() - av_reward_per_episode_dicts[i] = session.learn_metadata_dict() - benchmark_metadata = _build_benchmark_results_dict( - start_datetime=start_datetime, metadata_dict=av_reward_per_episode_dicts + with _get_benchmark_primaite_environment() as gym_env: + session = BenchmarkSession( + gym_env=gym_env, + num_episodes=num_episodes, + n_steps=n_steps, + episode_len=episode_len, + batch_size=batch_size, + learning_rate=learning_rate, + ) + session.train() + + # Dump the session metadata so that we're not holding it in memory as it's large + with open(_SESSION_METADATA_ROOT / f"{i}.json", "w") as file: + json.dump(session.session_metadata, file, indent=4) + + for i in range(1, number_of_sessions + 1): + with open(_SESSION_METADATA_ROOT / f"{i}.json", "r") as file: + session_metadata_dict[i] = json.load(file) + # generate report + build_benchmark_md_report( + benchmark_start_time=benchmark_start_time, + session_metadata=session_metadata_dict, + config_path=data_manipulation_config_path(), + results_root_path=_RESULTS_ROOT, ) - v_str = f"v{primaite.__version__}" - - version_result_dir = _RESULTS_ROOT / v_str - if version_result_dir.exists(): - shutil.rmtree(version_result_dir) - version_result_dir.mkdir(exist_ok=True, parents=True) - - with open(version_result_dir / f"{v_str}_benchmark_metadata.json", "w") as file: - json.dump(benchmark_metadata, file, indent=4) - title = f"PrimAITE v{primaite.__version__.strip()} Learning Benchmark" - fig = _plot_benchmark_metadata(benchmark_metadata, title=title) - this_version_plot_path = version_result_dir / f"{title}.png" - fig.write_image(this_version_plot_path) - - fig = _plot_all_benchmarks_combined_session_av() - - all_version_plot_path = _RESULTS_ROOT / "PrimAITE Versions Learning Benchmark.png" - fig.write_image(all_version_plot_path) - - _build_benchmark_latex_report(benchmark_metadata, this_version_plot_path, all_version_plot_path) if __name__ == "__main__": diff --git a/benchmark/report.py b/benchmark/report.py new file mode 100644 index 00000000..9c576562 --- /dev/null +++ b/benchmark/report.py @@ -0,0 +1,426 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +import plotly.graph_objects as go +import polars as pl +import yaml +from plotly.graph_objs import Figure +from utils import _get_system_info + +import primaite + +PLOT_CONFIG = { + "size": {"auto_size": False, "width": 1500, "height": 900}, + "template": "plotly_white", + "range_slider": False, + "av_s_per_100_steps_10_nodes_benchmark_threshold": 5, + "benchmark_line_color": "grey", +} + + +def _build_benchmark_results_dict(start_datetime: datetime, metadata_dict: Dict, config: Dict) -> dict: + """ + Constructs a dictionary aggregating benchmark results from multiple sessions. + + :param start_datetime: The datetime when the benchmarking started. + :param metadata_dict: Dictionary containing metadata for each session. + :param config: Configuration settings used during the benchmarking. + :return: A dictionary containing aggregated data and metadata from the benchmarking sessions. + """ + num_sessions = len(metadata_dict) # number of sessions + + averaged_data = { + "start_timestamp": start_datetime.isoformat(), + "end_datetime": datetime.now().isoformat(), + "primaite_version": primaite.__version__, + "system_info": _get_system_info(), + "total_sessions": num_sessions, + "total_episodes": sum(d["total_episodes"] for d in metadata_dict.values()), + "total_time_steps": sum(d["total_time_steps"] for d in metadata_dict.values()), + "av_s_per_session": sum(d["total_s"] for d in metadata_dict.values()) / num_sessions, + "av_s_per_step": sum(d["s_per_step"] for d in metadata_dict.values()) / num_sessions, + "av_s_per_100_steps_10_nodes": sum(d["s_per_100_steps_10_nodes"] for d in metadata_dict.values()) + / num_sessions, + "combined_total_reward_per_episode": {}, + "session_total_reward_per_episode": {k: v["total_reward_per_episode"] for k, v in metadata_dict.items()}, + "config": config, + } + + # find the average of each episode across all sessions + episodes = metadata_dict[1]["total_reward_per_episode"].keys() + + for episode in episodes: + combined_av_reward = ( + sum(metadata_dict[k]["total_reward_per_episode"][episode] for k in metadata_dict.keys()) / num_sessions + ) + averaged_data["combined_total_reward_per_episode"][episode] = combined_av_reward + + return averaged_data + + +def _get_df_from_episode_av_reward_dict(data: Dict) -> pl.DataFrame: + """ + Converts a dictionary of episode average rewards into a Polars DataFrame. + + :param data: Dictionary with episodes as keys and average rewards as values. + :return: Polars DataFrame with episodes and average rewards, including a rolling average. + """ + data: Dict = {"episode": data.keys(), "av_reward": data.values()} + + return ( + pl.from_dict(data) + .with_columns(rolling_mean=pl.col("av_reward").rolling_mean(window_size=25)) + .rename({"rolling_mean": "rolling_av_reward"}) + ) + + +def _plot_benchmark_metadata( + benchmark_metadata_dict: Dict, + title: Optional[str] = None, + subtitle: Optional[str] = None, +) -> Figure: + """ + Plots benchmark metadata as a line graph using Plotly. + + :param benchmark_metadata_dict: Dictionary containing the total reward per episode and session. + :param title: Optional title for the graph. + :param subtitle: Optional subtitle for the graph. + :return: Plotly figure object representing the benchmark metadata plot. + """ + if title: + if subtitle: + title = f"{title}
{subtitle}" + else: + if subtitle: + title = subtitle + + layout = go.Layout( + autosize=PLOT_CONFIG["size"]["auto_size"], + width=PLOT_CONFIG["size"]["width"], + height=PLOT_CONFIG["size"]["height"], + ) + # Create the line graph with a colored line + fig = go.Figure(layout=layout) + fig.update_layout(template=PLOT_CONFIG["template"]) + + for session, av_reward_dict in benchmark_metadata_dict["session_total_reward_per_episode"].items(): + df = _get_df_from_episode_av_reward_dict(av_reward_dict) + fig.add_trace( + go.Scatter( + x=df["episode"], + y=df["av_reward"], + mode="lines", + name=f"Session {session}", + opacity=0.25, + line={"color": "#a6a6a6"}, + ) + ) + + df = _get_df_from_episode_av_reward_dict(benchmark_metadata_dict["combined_total_reward_per_episode"]) + fig.add_trace( + go.Scatter( + x=df["episode"], y=df["av_reward"], mode="lines", name="Combined Session Av", line={"color": "#FF0000"} + ) + ) + + fig.add_trace( + go.Scatter( + x=df["episode"], + y=df["rolling_av_reward"], + mode="lines", + name="Rolling Av (Combined Session Av)", + line={"color": "#4CBB17"}, + ) + ) + + # Set the layout of the graph + fig.update_layout( + xaxis={ + "title": "Episode", + "type": "linear", + }, + yaxis={"title": "Total Reward"}, + title=title, + ) + + return fig + + +def _plot_all_benchmarks_combined_session_av(results_directory: Path) -> Figure: + """ + Plot the Benchmark results for each released version of PrimAITE. + + Does this by iterating over the ``benchmark/results`` directory and + extracting the benchmark metadata json for each version that has been + benchmarked. The combined_total_reward_per_episode is extracted from each, + converted into a polars dataframe, and plotted as a scatter line in plotly. + """ + major_v = primaite.__version__.split(".")[0] + title = f"Learning Benchmark of Minor and Bugfix Releases for Major Version {major_v}" + subtitle = "Rolling Av (Combined Session Av)" + if title: + if subtitle: + title = f"{title}
{subtitle}" + else: + if subtitle: + title = subtitle + layout = go.Layout( + autosize=PLOT_CONFIG["size"]["auto_size"], + width=PLOT_CONFIG["size"]["width"], + height=PLOT_CONFIG["size"]["height"], + ) + # Create the line graph with a colored line + fig = go.Figure(layout=layout) + fig.update_layout(template=PLOT_CONFIG["template"]) + + for dir in results_directory.iterdir(): + if dir.is_dir(): + metadata_file = dir / f"{dir.name}_benchmark_metadata.json" + with open(metadata_file, "r") as file: + metadata_dict = json.load(file) + df = _get_df_from_episode_av_reward_dict(metadata_dict["combined_total_reward_per_episode"]) + + fig.add_trace(go.Scatter(x=df["episode"], y=df["rolling_av_reward"], mode="lines", name=dir.name)) + + # Set the layout of the graph + fig.update_layout( + xaxis={ + "title": "Episode", + "type": "linear", + }, + yaxis={"title": "Total Reward"}, + title=title, + ) + fig["data"][0]["showlegend"] = True + + return fig + + +def _get_performance_benchmark_for_all_version_dict(results_directory: Path) -> Dict[str, float]: + """ + Gathers performance benchmarks for all versions of the software stored in a specified directory. + + This function iterates through each directory within the specified results directory, + extracts the av_s_per_100_steps_10_nodes from the benchmark_metadata.json files, and aggregates it into a + dictionary. + + :param results_directory: The directory containing subdirectories for each version's benchmark data. + :return: A dictionary with version numbers as keys and their corresponding average performance benchmark + (average time per 100 steps on 10 nodes) as values. + """ + performance_benchmark_dict = {} + for dir in results_directory.iterdir(): + if dir.is_dir(): + metadata_file = dir / f"{dir.name}_benchmark_metadata.json" + with open(metadata_file, "r") as file: + metadata_dict = json.load(file) + version = metadata_dict["primaite_version"] + performance_benchmark_dict[version] = metadata_dict["av_s_per_100_steps_10_nodes"] + return performance_benchmark_dict + + +def _plot_av_s_per_100_steps_10_nodes( + version_times_dict: Dict[str, float], +) -> Figure: + """ + Creates a bar chart visualising the performance of each version of PrimAITE. + + Performance is based on the average training time per 100 steps on 10 nodes. The function also includes a benchmark + line indicating the target maximum time. + + Versions that perform under this time are marked in green, and those over are marked in red. + + :param version_times_dict: A dictionary with software versions as keys and average times as values. + :return: A Plotly figure object representing the bar chart of the performance metrics. + """ + major_v = primaite.__version__.split(".")[0] + title = f"Performance of Minor and Bugfix Releases for Major Version {major_v}" + subtitle = ( + f"Average Training Time per 100 Steps on 10 Nodes " + f"(target: <= {PLOT_CONFIG['av_s_per_100_steps_10_nodes_benchmark_threshold']} seconds)" + ) + title = f"{title}
{subtitle}" + + layout = go.Layout( + autosize=PLOT_CONFIG["size"]["auto_size"], + width=PLOT_CONFIG["size"]["width"], + height=PLOT_CONFIG["size"]["height"], + ) + fig = go.Figure(layout=layout) + fig.update_layout(template=PLOT_CONFIG["template"]) + + versions = sorted(list(version_times_dict.keys())) + times = [version_times_dict[version] for version in versions] + av_s_per_100_steps_10_nodes_benchmark_threshold = PLOT_CONFIG["av_s_per_100_steps_10_nodes_benchmark_threshold"] + benchmark_line_color = PLOT_CONFIG["benchmark_line_color"] + + # Calculate the appropriate maximum y-axis value + max_y_axis_value = max(max(times), av_s_per_100_steps_10_nodes_benchmark_threshold) + 1 + + fig.add_trace( + go.Bar( + x=versions, + y=times, + marker_color=[ + "green" if time < av_s_per_100_steps_10_nodes_benchmark_threshold else "red" for time in times + ], + text=times, + textposition="auto", + ) + ) + + # Add a horizontal line for the benchmark + fig.add_shape( + type="line", + x0=-0.5, # start slightly before the first bar + x1=len(versions) - 0.5, # end slightly after the last bar + y0=av_s_per_100_steps_10_nodes_benchmark_threshold, + y1=av_s_per_100_steps_10_nodes_benchmark_threshold, + line=dict( + color=benchmark_line_color, + width=2, + dash="dot", + ), + ) + + fig.update_layout( + xaxis_title="PrimAITE Version", + yaxis_title="Avg Time per 100 Steps on 10 Nodes (seconds)", + yaxis=dict(range=[0, max_y_axis_value]), + title=title, + ) + + return fig + + +def build_benchmark_md_report( + benchmark_start_time: datetime, session_metadata: Dict, config_path: Path, results_root_path: Path +) -> None: + """ + Generates a Markdown report for a benchmarking session, documenting performance metrics and graphs. + + This function orchestrates the creation of several graphs depicting various performance benchmarks and aggregates + them into a markdown document that includes comprehensive system and benchmark information. + + :param benchmark_start_time: The datetime object representing when the benchmarking process was initiated. + :param session_metadata: A dictionary containing metadata for each benchmarking session. + :param config_path: A pathlib.Path object pointing to the configuration file used for the benchmark sessions. + :param results_root_path: A pathlib.Path object pointing to the directory where the results and graphs should be + saved. + """ + # generate report folder + v_str = f"v{primaite.__version__}" + + version_result_dir = results_root_path / v_str + version_result_dir.mkdir(exist_ok=True, parents=True) + + # load the config file as dict + with open(config_path, "r") as f: + cfg_data = yaml.safe_load(f) + + # generate the benchmark metadata dict + benchmark_metadata_dict = _build_benchmark_results_dict( + start_datetime=benchmark_start_time, metadata_dict=session_metadata, config=cfg_data + ) + major_v = primaite.__version__.split(".")[0] + with open(version_result_dir / f"{v_str}_benchmark_metadata.json", "w") as file: + json.dump(benchmark_metadata_dict, file, indent=4) + title = f"PrimAITE v{primaite.__version__.strip()} Learning Benchmark" + fig = _plot_benchmark_metadata(benchmark_metadata_dict, title=title) + this_version_plot_path = version_result_dir / f"{title}.png" + fig.write_image(this_version_plot_path) + + fig = _plot_all_benchmarks_combined_session_av(results_directory=results_root_path) + + filename = f"PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version {major_v}.png" + + all_version_plot_path = version_result_dir / filename + fig.write_image(all_version_plot_path) + + performance_benchmark_dict = _get_performance_benchmark_for_all_version_dict(results_directory=results_root_path) + fig = _plot_av_s_per_100_steps_10_nodes(performance_benchmark_dict) + filename = f"PrimAITE Performance of Minor and Bugfix Releases for Major Version {major_v}.png" + performance_benchmark_plot_path = version_result_dir / filename + fig.write_image(performance_benchmark_plot_path) + + data = benchmark_metadata_dict + primaite_version = data["primaite_version"] + + with open(version_result_dir / f"PrimAITE v{primaite_version} Benchmark Report.md", "w") as file: + # Title + file.write(f"# PrimAITE v{primaite_version} Learning Benchmark\n") + file.write("## PrimAITE Dev Team\n") + file.write(f"### {datetime.now().date()}\n") + file.write("\n---\n") + + sessions = data["total_sessions"] + episodes = session_metadata[1]["total_episodes"] - 1 + steps = data["config"]["game"]["max_episode_length"] + + # Body + file.write("## 1 Introduction\n") + file.write( + f"PrimAITE v{primaite_version} was benchmarked automatically upon release. Learning rate metrics " + f"were captured to be referenced during system-level testing and user acceptance testing (UAT).\n" + ) + file.write( + f"The benchmarking process consists of running {sessions} training session using the same " + f"config file. Each session trains an agent for {episodes} episodes, " + f"with each episode consisting of {steps} steps.\n" + ) + file.write( + f"The total reward per episode from each session is captured. This is then used to calculate an " + f"caverage total reward per episode from the {sessions} individual sessions for smoothing. " + f"Finally, a 25-widow rolling average of the average total reward per session is calculated for " + f"further smoothing.\n" + ) + + file.write("## 2 System Information\n") + i = 1 + file.write(f"### 2.{i} Python\n") + file.write(f"**Version:** {sys.version}\n") + + for section, section_data in data["system_info"].items(): + i += 1 + if section_data: + file.write(f"### 2.{i} {section}\n") + if isinstance(section_data, dict): + for key, value in section_data.items(): + file.write(f"- **{key}:** {value}\n") + + headers_map = { + "total_sessions": "Total Sessions", + "total_episodes": "Total Episodes", + "total_time_steps": "Total Steps", + "av_s_per_session": "Av Session Duration (s)", + "av_s_per_step": "Av Step Duration (s)", + "av_s_per_100_steps_10_nodes": "Av Duration per 100 Steps per 10 Nodes (s)", + } + + file.write("## 3 Stats\n") + for section, header in headers_map.items(): + if section.startswith("av_"): + file.write(f"- **{header}:** {data[section]:.4f}\n") + else: + file.write(f"- **{header}:** {data[section]}\n") + + file.write("## 4 Graphs\n") + + file.write(f"### 4.1 v{primaite_version} Learning Benchmark Plot\n") + file.write(f"![PrimAITE {primaite_version} Learning Benchmark Plot]({this_version_plot_path.name})\n") + + file.write(f"### 4.2 Learning Benchmark of Minor and Bugfix Releases for Major Version {major_v}\n") + file.write( + f"![Learning Benchmark of Minor and Bugfix Releases for Major Version {major_v}]" + f"({all_version_plot_path.name})\n" + ) + + file.write(f"### 4.3 Performance of Minor and Bugfix Releases for Major Version {major_v}\n") + file.write( + f"![Performance of Minor and Bugfix Releases for Major Version {major_v}]" + f"({performance_benchmark_plot_path.name})\n" + ) diff --git a/benchmark/results/PrimAITE Versions Learning Benchmark.png b/benchmark/results/v2/PrimAITE Versions Learning Benchmark.png similarity index 100% rename from benchmark/results/PrimAITE Versions Learning Benchmark.png rename to benchmark/results/v2/PrimAITE Versions Learning Benchmark.png diff --git a/benchmark/results/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.pdf b/benchmark/results/v2/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.pdf similarity index 100% rename from benchmark/results/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.pdf rename to benchmark/results/v2/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.pdf diff --git a/benchmark/results/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.png b/benchmark/results/v2/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.png similarity index 100% rename from benchmark/results/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.png rename to benchmark/results/v2/v2.0.0/PrimAITE v2.0.0 Learning Benchmark.png diff --git a/benchmark/results/v2.0.0/v2.0.0_benchmark_metadata.json b/benchmark/results/v2/v2.0.0/v2.0.0_benchmark_metadata.json similarity index 100% rename from benchmark/results/v2.0.0/v2.0.0_benchmark_metadata.json rename to benchmark/results/v2/v2.0.0/v2.0.0_benchmark_metadata.json diff --git a/benchmark/results/v3/v3.0.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png b/benchmark/results/v3/v3.0.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png new file mode 100644 index 00000000..542f8f56 Binary files /dev/null and b/benchmark/results/v3/v3.0.0/PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png differ diff --git a/benchmark/results/v3/v3.0.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png b/benchmark/results/v3/v3.0.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png new file mode 100644 index 00000000..05fa4f15 Binary files /dev/null and b/benchmark/results/v3/v3.0.0/PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png differ diff --git a/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Benchmark Report.md b/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Benchmark Report.md new file mode 100644 index 00000000..c2cd6e78 --- /dev/null +++ b/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Benchmark Report.md @@ -0,0 +1,38 @@ +# PrimAITE v3.0.0 Learning Benchmark +## PrimAITE Dev Team +### 2024-07-20 + +--- +## 1 Introduction +PrimAITE v3.0.0 was benchmarked automatically upon release. Learning rate metrics were captured to be referenced during system-level testing and user acceptance testing (UAT). +The benchmarking process consists of running 5 training session using the same config file. Each session trains an agent for 1000 episodes, with each episode consisting of 128 steps. +The total reward per episode from each session is captured. This is then used to calculate an caverage total reward per episode from the 5 individual sessions for smoothing. Finally, a 25-widow rolling average of the average total reward per session is calculated for further smoothing. +## 2 System Information +### 2.1 Python +**Version:** 3.10.14 (main, Apr 6 2024, 18:45:05) [GCC 9.4.0] +### 2.2 System +- **OS:** Linux +- **OS Version:** #76~20.04.1-Ubuntu SMP Thu Jun 13 18:00:23 UTC 2024 +- **Machine:** x86_64 +- **Processor:** x86_64 +### 2.3 CPU +- **Physical Cores:** 2 +- **Total Cores:** 4 +- **Max Frequency:** 0.00Mhz +### 2.4 Memory +- **Total:** 15.62GB +- **Swap Total:** 0.00B +## 3 Stats +- **Total Sessions:** 5 +- **Total Episodes:** 5005 +- **Total Steps:** 640000 +- **Av Session Duration (s):** 1452.5910 +- **Av Step Duration (s):** 0.0454 +- **Av Duration per 100 Steps per 10 Nodes (s):** 4.5393 +## 4 Graphs +### 4.1 v3.0.0 Learning Benchmark Plot +![PrimAITE 3.0.0 Learning Benchmark Plot](PrimAITE v3.0.0 Learning Benchmark.png) +### 4.2 Learning Benchmark of Minor and Bugfix Releases for Major Version 3 +![Learning Benchmark of Minor and Bugfix Releases for Major Version 3](PrimAITE Learning Benchmark of Minor and Bugfix Releases for Major Version 3.png) +### 4.3 Performance of Minor and Bugfix Releases for Major Version 3 +![Performance of Minor and Bugfix Releases for Major Version 3](PrimAITE Performance of Minor and Bugfix Releases for Major Version 3.png) diff --git a/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Learning Benchmark.png b/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Learning Benchmark.png new file mode 100644 index 00000000..b61c706a Binary files /dev/null and b/benchmark/results/v3/v3.0.0/PrimAITE v3.0.0 Learning Benchmark.png differ diff --git a/benchmark/results/v3/v3.0.0/session_metadata/1.json b/benchmark/results/v3/v3.0.0/session_metadata/1.json new file mode 100644 index 00000000..5301d60a --- /dev/null +++ b/benchmark/results/v3/v3.0.0/session_metadata/1.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1257.254153, + "s_per_step": 0.03928919228125, + "s_per_100_steps_10_nodes": 3.9289192281250003, + "total_reward_per_episode": { + "1": -18.999999999999964, + "2": -51.100000000000165, + "3": -7.849999999999995, + "4": -23.34999999999995, + "5": -50.10000000000007, + "6": -41.15, + "7": -16.999999999999975, + "8": -45.250000000000064, + "9": -27.049999999999986, + "10": -37.85000000000004, + "11": -48.40000000000007, + "12": -96.4, + "13": -17.09999999999997, + "14": -31.49999999999999, + "15": -96.75, + "16": -18.84999999999997, + "17": -60.40000000000009, + "18": -11.350000000000007, + "19": -71.55000000000004, + "20": -20.449999999999957, + "21": -12.799999999999983, + "22": -92.35, + "23": -16.049999999999976, + "24": -14.79999999999998, + "25": -33.05000000000003, + "26": -45.10000000000004, + "27": -19.99999999999996, + "28": -68.00000000000007, + "29": -19.749999999999964, + "30": -33.2, + "31": -21.09999999999996, + "32": -16.849999999999973, + "33": -18.1, + "34": -89.9, + "35": -15.59999999999998, + "36": -43.25000000000004, + "37": -22.099999999999955, + "38": -34.05000000000005, + "39": -24.24999999999996, + "40": -18.099999999999977, + "41": -35.55000000000005, + "42": -10.649999999999993, + "43": -96.10000000000002, + "44": -8.549999999999992, + "45": -24.099999999999948, + "46": -15.099999999999982, + "47": -21.799999999999944, + "48": -49.500000000000064, + "49": -14.349999999999996, + "50": -17.49999999999997, + "51": -5.599999999999979, + "52": -22.79999999999995, + "53": -61.899999999999984, + "54": -8.099999999999993, + "55": -16.199999999999985, + "56": -15.59999999999998, + "57": -12.049999999999988, + "58": -16.399999999999974, + "59": -61.3000000000001, + "60": -9.799999999999986, + "61": -17.199999999999974, + "62": -9.549999999999997, + "63": -25.250000000000018, + "64": -12.199999999999989, + "65": 0.9499999999999997, + "66": -17.69999999999997, + "67": -54.95000000000009, + "68": -6.600000000000015, + "69": -10.449999999999987, + "70": -18.04999999999997, + "71": -21.549999999999955, + "72": -44.850000000000065, + "73": -28.199999999999985, + "74": 4.100000000000025, + "75": -23.89999999999995, + "76": -0.8999999999999659, + "77": -18.19999999999997, + "78": -100.85, + "79": 3.900000000000005, + "80": -34.20000000000005, + "81": -24.849999999999987, + "82": -12.349999999999993, + "83": -23.24999999999995, + "84": 7.750000000000062, + "85": -12.44999999999999, + "86": -23.100000000000012, + "87": -59.5500000000001, + "88": -62.2000000000001, + "89": -4.899999999999981, + "90": -14.54999999999998, + "91": -17.299999999999972, + "92": -12.049999999999986, + "93": -15.899999999999979, + "94": -12.049999999999985, + "95": -17.349999999999973, + "96": -87.89999999999999, + "97": -6.54999999999999, + "98": -13.349999999999994, + "99": -8.850000000000003, + "100": -16.649999999999977, + "101": -88.25, + "102": -15.799999999999983, + "103": -18.299999999999965, + "104": -82.85000000000001, + "105": -17.949999999999967, + "106": -1.7499999999999967, + "107": -80.55000000000001, + "108": -34.20000000000003, + "109": -56.15000000000009, + "110": -15.499999999999977, + "111": -68.54999999999995, + "112": -12.649999999999999, + "113": -62.950000000000074, + "114": -78.54999999999995, + "115": -32.60000000000003, + "116": 3.800000000000053, + "117": -83.70000000000002, + "118": 8.499999999999964, + "119": -95.20000000000002, + "120": -2.699999999999963, + "121": -7.099999999999998, + "122": -3.9999999999999885, + "123": -11.84999999999999, + "124": -9.999999999999986, + "125": -14.449999999999992, + "126": -3.5499999999999767, + "127": -8.399999999999997, + "128": 5.849999999999995, + "129": -3.500000000000015, + "130": -6.7499999999999885, + "131": -12.35, + "132": -6.749999999999994, + "133": -16.899999999999974, + "134": -86.55000000000001, + "135": -9.99999999999999, + "136": -16.199999999999974, + "137": -71.25000000000001, + "138": 33.64999999999992, + "139": -15.749999999999979, + "140": -18.09999999999997, + "141": -10.750000000000004, + "142": -13.99999999999999, + "143": -15.54999999999998, + "144": 23.499999999999954, + "145": -56.0, + "146": -61.80000000000003, + "147": -3.8999999999999977, + "148": -54.250000000000014, + "149": -94.49999999999997, + "150": -12.849999999999987, + "151": -93.05000000000001, + "152": -82.3, + "153": -61.35000000000011, + "154": -3.8999999999999835, + "155": -11.449999999999992, + "156": -2.199999999999969, + "157": -6.999999999999982, + "158": 5.600000000000004, + "159": -25.250000000000004, + "160": 3.3000000000000367, + "161": -7.6499999999999915, + "162": 6.250000000000031, + "163": -84.85, + "164": -14.299999999999978, + "165": -80.85, + "166": -29.749999999999964, + "167": -65.79999999999997, + "168": -41.150000000000006, + "169": -7.949999999999995, + "170": 1.950000000000028, + "171": -84.70000000000002, + "172": -42.10000000000005, + "173": 9.75000000000001, + "174": -13.299999999999986, + "175": -1.9499999999999789, + "176": -87.30000000000001, + "177": -10.100000000000009, + "178": -6.999999999999991, + "179": -14.099999999999984, + "180": -55.64999999999999, + "181": -9.449999999999989, + "182": -68.75, + "183": -75.59999999999997, + "184": -2.8500000000000014, + "185": -78.75, + "186": 15.549999999999908, + "187": -82.10000000000005, + "188": -1.849999999999981, + "189": -27.999999999999996, + "190": -46.00000000000002, + "191": -12.399999999999986, + "192": 6.749999999999887, + "193": -12.84999999999998, + "194": 28.649999999999963, + "195": -60.85, + "196": -64.89999999999995, + "197": -8.049999999999994, + "198": -73.94999999999999, + "199": -16.74999999999998, + "200": 3.949999999999995, + "201": -87.19999999999996, + "202": -17.69999999999997, + "203": -7.549999999999987, + "204": 12.849999999999966, + "205": -21.099999999999973, + "206": -78.04999999999998, + "207": -24.59999999999996, + "208": -66.15, + "209": 3.550000000000021, + "210": -93.45, + "211": 1.8000000000000427, + "212": 7.950000000000027, + "213": 7.799999999999999, + "214": -7.450000000000004, + "215": 4.950000000000019, + "216": -16.849999999999973, + "217": -4.299999999999982, + "218": -11.999999999999972, + "219": 2.8499999999999686, + "220": -5.14999999999998, + "221": -43.94999999999999, + "222": -18.54999999999998, + "223": -4.300000000000009, + "224": -63.85000000000003, + "225": 54.59999999999989, + "226": 14.89999999999996, + "227": -25.550000000000036, + "228": -0.5000000000000246, + "229": -62.5, + "230": -63.249999999999986, + "231": -71.29999999999994, + "232": 3.0999999999999472, + "233": -83.00000000000003, + "234": 17.05000000000001, + "235": 4.299999999999985, + "236": -19.299999999999965, + "237": -5.749999999999991, + "238": -91.55000000000001, + "239": -74.99999999999999, + "240": 9.349999999999978, + "241": -56.800000000000026, + "242": 17.50000000000001, + "243": -9.29999999999998, + "244": -11.39999999999999, + "245": -27.800000000000033, + "246": 4.0000000000000355, + "247": 15.650000000000034, + "248": 20.499999999999964, + "249": 3.200000000000017, + "250": 1.2000000000000304, + "251": 29.84999999999982, + "252": -76.45000000000002, + "253": -33.4, + "254": 12.400000000000002, + "255": -85.39999999999999, + "256": 15.400000000000006, + "257": -60.75000000000001, + "258": -17.70000000000003, + "259": 18.899999999999974, + "260": 22.949999999999967, + "261": 60.849999999999866, + "262": 74.09999999999994, + "263": -0.5000000000000068, + "264": -59.099999999999966, + "265": 118.15000000000012, + "266": -39.700000000000024, + "267": 35.74999999999982, + "268": 85.40000000000002, + "269": -21.949999999999985, + "270": -67.09999999999998, + "271": 7.049999999999962, + "272": 48.09999999999986, + "273": 11.950000000000035, + "274": -49.849999999999994, + "275": 28.25000000000002, + "276": 35.89999999999993, + "277": 18.799999999999997, + "278": -55.80000000000001, + "279": -83.0, + "280": 24.65000000000007, + "281": -4.999999999999982, + "282": 52.74999999999988, + "283": 48.600000000000016, + "284": -18.200000000000003, + "285": 33.6, + "286": 9.099999999999987, + "287": 77.00000000000017, + "288": 75.80000000000005, + "289": 25.20000000000003, + "290": 29.500000000000014, + "291": -83.45, + "292": -21.39999999999998, + "293": -17.099999999999973, + "294": 70.30000000000008, + "295": 62.99999999999985, + "296": -37.64999999999999, + "297": 85.60000000000004, + "298": 101.40000000000009, + "299": 2.950000000000074, + "300": 18.24999999999999, + "301": 8.749999999999993, + "302": -0.1499999999999917, + "303": 82.70000000000026, + "304": 45.54999999999996, + "305": -27.5, + "306": 93.30000000000004, + "307": 7.949999999999961, + "308": 15.650000000000022, + "309": -19.54999999999996, + "310": 75.75000000000011, + "311": 108.15000000000018, + "312": 69.94999999999995, + "313": 102.44999999999993, + "314": 16.300000000000022, + "315": 34.449999999999825, + "316": 75.55000000000005, + "317": 46.649999999999885, + "318": -19.099999999999955, + "319": 51.69999999999991, + "320": 63.150000000000034, + "321": 105.5, + "322": 85.25000000000013, + "323": 49.799999999999876, + "324": -5.49999999999998, + "325": 59.550000000000054, + "326": 5.100000000000001, + "327": 0.44999999999997975, + "328": 75.45000000000005, + "329": 76.00000000000006, + "330": -28.449999999999974, + "331": 102.0000000000001, + "332": 33.04999999999992, + "333": 94.40000000000016, + "334": 74.00000000000004, + "335": 49.94999999999999, + "336": -3.900000000000001, + "337": 55.400000000000006, + "338": 77.74999999999999, + "339": 44.09999999999987, + "340": 101.89999999999996, + "341": -74.85, + "342": 118.60000000000012, + "343": 68.39999999999993, + "344": 70.65000000000002, + "345": 83.30000000000001, + "346": 63.80000000000007, + "347": 98.84999999999997, + "348": 41.599999999999866, + "349": 43.19999999999984, + "350": 109.35000000000018, + "351": 107.00000000000003, + "352": 101.20000000000003, + "353": 80.54999999999998, + "354": 66.00000000000007, + "355": 37.699999999999946, + "356": 44.999999999999964, + "357": 54.49999999999995, + "358": 90.44999999999993, + "359": 51.399999999999835, + "360": 78.20000000000012, + "361": 31.84999999999985, + "362": 88.20000000000006, + "363": 66.60000000000008, + "364": 55.399999999999935, + "365": 76.65000000000013, + "366": 56.09999999999996, + "367": 83.7000000000001, + "368": 117.89999999999998, + "369": 35.75000000000002, + "370": 82.85000000000002, + "371": 84.95, + "372": 83.14999999999992, + "373": 92.05000000000001, + "374": 76.90000000000003, + "375": 109.25000000000006, + "376": 97.85000000000004, + "377": 47.35000000000002, + "378": 52.94999999999992, + "379": 103.00000000000007, + "380": 79.40000000000008, + "381": 77.89999999999995, + "382": 79.00000000000017, + "383": 59.74999999999991, + "384": 107.20000000000007, + "385": 81.05000000000005, + "386": 66.00000000000001, + "387": 89.10000000000002, + "388": 51.34999999999988, + "389": 84.05000000000003, + "390": 70.80000000000007, + "391": 70.55000000000001, + "392": 104.20000000000009, + "393": 119.50000000000009, + "394": 84.20000000000013, + "395": 88.25000000000007, + "396": 18.15000000000001, + "397": 72.09999999999997, + "398": 83.89999999999999, + "399": 94.95000000000016, + "400": 82.95000000000013, + "401": -2.1500000000000004, + "402": 102.05000000000007, + "403": 73.44999999999997, + "404": 112.45000000000006, + "405": 111.25, + "406": 110.14999999999995, + "407": 114.35000000000001, + "408": 111.10000000000001, + "409": 97.65000000000003, + "410": 92.50000000000004, + "411": 92.85000000000012, + "412": 109.45000000000002, + "413": 113.70000000000003, + "414": 117.20000000000007, + "415": 59.89999999999992, + "416": 112.60000000000002, + "417": 117.8500000000002, + "418": 118.50000000000004, + "419": 113.5000000000001, + "420": 93.45, + "421": 100.20000000000014, + "422": 113.60000000000008, + "423": 85.89999999999996, + "424": 95.64999999999999, + "425": 110.55000000000008, + "426": 104.25000000000016, + "427": 90.44999999999997, + "428": 102.2, + "429": 106.14999999999999, + "430": 94.55000000000001, + "431": 109.7000000000001, + "432": 108.60000000000012, + "433": 111.2500000000001, + "434": 89.04999999999998, + "435": 98.60000000000012, + "436": 97.8, + "437": 113.3500000000001, + "438": 119.34999999999998, + "439": 109.60000000000018, + "440": 110.45000000000024, + "441": 112.75000000000007, + "442": 115.19999999999996, + "443": 115.30000000000003, + "444": 109.45000000000005, + "445": 112.44999999999996, + "446": 99.19999999999996, + "447": 111.09999999999998, + "448": 106.05000000000003, + "449": -2.6499999999999924, + "450": 113.49999999999999, + "451": 106.60000000000004, + "452": 94.85000000000004, + "453": 51.45000000000001, + "454": 118.2, + "455": 118.85000000000011, + "456": 106.45000000000005, + "457": 100.50000000000013, + "458": 111.04999999999994, + "459": 110.14999999999996, + "460": 10.750000000000002, + "461": 117.75000000000007, + "462": 108.15000000000008, + "463": 108.05000000000003, + "464": 112.05000000000011, + "465": 116.80000000000004, + "466": 103.54999999999981, + "467": 104.50000000000011, + "468": 68.85000000000007, + "469": 119.80000000000015, + "470": 89.2499999999999, + "471": 103.39999999999998, + "472": 107.90000000000003, + "473": 113.80000000000007, + "474": 105.8000000000001, + "475": 114.20000000000009, + "476": 104.04999999999995, + "477": 105.10000000000008, + "478": 104.85000000000004, + "479": 117.95000000000014, + "480": 114.40000000000003, + "481": 51.699999999999804, + "482": 109.44999999999999, + "483": 113.25000000000009, + "484": 91.44999999999997, + "485": -30.94999999999996, + "486": 110.60000000000012, + "487": 109.95000000000009, + "488": 119.00000000000006, + "489": 113.85000000000004, + "490": 106.35000000000016, + "491": 111.00000000000001, + "492": 114.35000000000001, + "493": 105.55000000000011, + "494": 102.30000000000003, + "495": 100.5500000000002, + "496": 102.55000000000003, + "497": 111.0000000000002, + "498": 95.39999999999999, + "499": 112.00000000000009, + "500": 119.35000000000007, + "501": 114.75, + "502": 95.80000000000007, + "503": 42.44999999999984, + "504": 56.749999999999844, + "505": 113.30000000000004, + "506": 113.59999999999995, + "507": 105.20000000000019, + "508": 117.4000000000001, + "509": 117.7500000000001, + "510": 92.7000000000001, + "511": 107.35000000000011, + "512": 110.55000000000007, + "513": 100.40000000000015, + "514": 108.00000000000004, + "515": 106.85000000000001, + "516": 114.90000000000015, + "517": 108.4000000000002, + "518": 95.15000000000002, + "519": 106.8, + "520": 106.70000000000002, + "521": 111.40000000000009, + "522": 109.99999999999996, + "523": 62.34999999999983, + "524": 109.39999999999993, + "525": 110.45000000000016, + "526": 111.65000000000005, + "527": 108.55000000000014, + "528": 116.19999999999995, + "529": 107.70000000000013, + "530": 110.50000000000014, + "531": 112.15000000000008, + "532": 108.3000000000002, + "533": 101.14999999999992, + "534": 108.70000000000012, + "535": 106.7500000000001, + "536": 110.40000000000008, + "537": 109.74999999999996, + "538": 110.49999999999993, + "539": 103.30000000000011, + "540": 100.3500000000001, + "541": 98.99999999999997, + "542": 111.4, + "543": 110.20000000000019, + "544": 114.64999999999993, + "545": 102.15000000000019, + "546": 112.9000000000002, + "547": 116.49999999999997, + "548": 103.35000000000012, + "549": 106.70000000000009, + "550": 99.40000000000002, + "551": 103.85000000000004, + "552": 113.35000000000008, + "553": 98.45000000000013, + "554": 106.00000000000014, + "555": 114.0500000000001, + "556": 117.69999999999997, + "557": 112.59999999999992, + "558": 110.90000000000013, + "559": 112.54999999999997, + "560": 106.30000000000013, + "561": 114.10000000000002, + "562": 111.45000000000007, + "563": 92.95, + "564": 113.30000000000013, + "565": 100.14999999999999, + "566": 119.65000000000016, + "567": 102.55000000000003, + "568": 96.00000000000001, + "569": 109.44999999999992, + "570": 115.10000000000014, + "571": 112.00000000000004, + "572": 93.85000000000014, + "573": 109.00000000000011, + "574": 106.0500000000001, + "575": 100.99999999999996, + "576": 105.25000000000017, + "577": 117.99999999999993, + "578": 109.75, + "579": 110.75000000000009, + "580": 116.25000000000018, + "581": 107.49999999999991, + "582": 111.00000000000011, + "583": 112.2500000000001, + "584": 118.15000000000008, + "585": 113.45000000000017, + "586": 111.35000000000014, + "587": 115.20000000000007, + "588": 110.30000000000005, + "589": 107.04999999999988, + "590": 112.00000000000004, + "591": 113.50000000000018, + "592": 112.75000000000016, + "593": 110.35000000000008, + "594": 116.00000000000011, + "595": 116.95, + "596": 105.90000000000008, + "597": 112.2500000000001, + "598": 107.65000000000005, + "599": 112.15000000000006, + "600": 116.35000000000008, + "601": 107.55000000000004, + "602": 103.45000000000012, + "603": 100.49999999999991, + "604": 110.85000000000026, + "605": 110.20000000000017, + "606": 94.10000000000015, + "607": 112.15000000000008, + "608": 113.55000000000018, + "609": 65.40000000000003, + "610": 111.3000000000001, + "611": 118.9500000000001, + "612": 109.85000000000001, + "613": 99.89999999999999, + "614": 107.0500000000001, + "615": 108.70000000000005, + "616": 115.30000000000013, + "617": 111.85000000000025, + "618": 106.40000000000002, + "619": 118.45, + "620": 112.50000000000007, + "621": 108.5500000000001, + "622": 114.60000000000004, + "623": 120.44999999999999, + "624": 105.20000000000002, + "625": 105.85000000000001, + "626": 111.55000000000003, + "627": 113.80000000000005, + "628": 110.45000000000014, + "629": 77.50000000000017, + "630": 117.00000000000023, + "631": 113.55000000000007, + "632": 105.75000000000007, + "633": 115.45000000000012, + "634": 111.2999999999999, + "635": 106.05000000000005, + "636": 98.19999999999989, + "637": 109.55000000000014, + "638": 114.10000000000011, + "639": 115.75000000000006, + "640": 105.3, + "641": 96.00000000000003, + "642": 79.89999999999988, + "643": 111.0000000000002, + "644": 109.30000000000007, + "645": 109.2000000000001, + "646": 106.20000000000014, + "647": 113.50000000000004, + "648": 114.9000000000001, + "649": 122.45000000000005, + "650": 114.7500000000001, + "651": 111.05000000000021, + "652": 114.65000000000009, + "653": 107.99999999999996, + "654": 29.49999999999987, + "655": 112.89999999999998, + "656": 105.24999999999996, + "657": 108.99999999999999, + "658": 89.20000000000006, + "659": 111.30000000000022, + "660": 114.95000000000024, + "661": 108.29999999999997, + "662": 112.94999999999999, + "663": 120.25000000000016, + "664": 114.14999999999996, + "665": 104.40000000000006, + "666": 113.75000000000009, + "667": 91.65000000000002, + "668": 114.10000000000008, + "669": 113.8000000000001, + "670": 115.30000000000017, + "671": 107.2500000000002, + "672": 101.45000000000002, + "673": 115.35000000000021, + "674": 101.14999999999996, + "675": 115.40000000000003, + "676": 111.49999999999997, + "677": 113.20000000000016, + "678": 117.85000000000008, + "679": 97.0, + "680": 106.75000000000013, + "681": 113.55000000000001, + "682": 112.55000000000011, + "683": 114.40000000000025, + "684": 118.49999999999996, + "685": 110.0500000000001, + "686": 105.05000000000004, + "687": 115.95000000000014, + "688": 114.4000000000001, + "689": 108.65000000000005, + "690": 22.099999999999937, + "691": 114.60000000000005, + "692": 110.60000000000012, + "693": 116.55000000000007, + "694": 116.30000000000005, + "695": 99.20000000000003, + "696": 118.50000000000006, + "697": 106.60000000000011, + "698": 117.20000000000016, + "699": 98.05000000000008, + "700": 116.15000000000009, + "701": 104.95, + "702": 83.25, + "703": 2.000000000000016, + "704": 11.150000000000055, + "705": -17.599999999999977, + "706": 84.84999999999995, + "707": 50.84999999999998, + "708": 25.94999999999985, + "709": 33.54999999999979, + "710": 93.45000000000005, + "711": 42.399999999999764, + "712": -33.65000000000001, + "713": 92.69999999999995, + "714": 25.75000000000005, + "715": 32.399999999999764, + "716": 67.45000000000002, + "717": 25.9499999999999, + "718": 111.29999999999998, + "719": 55.89999999999982, + "720": -1.24999999999999, + "721": 24.899999999999935, + "722": 110.24999999999996, + "723": 70.19999999999989, + "724": 117.85000000000005, + "725": 11.500000000000071, + "726": 45.84999999999982, + "727": -47.69999999999998, + "728": -0.7499999999999503, + "729": 14.00000000000006, + "730": 85.85000000000004, + "731": 46.39999999999977, + "732": 111.70000000000013, + "733": 55.499999999999844, + "734": 112.75, + "735": 94.60000000000018, + "736": 98.30000000000018, + "737": 106.95000000000003, + "738": 72.04999999999993, + "739": 99.15000000000005, + "740": 101.8000000000001, + "741": 103.45000000000013, + "742": 3.45000000000001, + "743": 100.90000000000013, + "744": 94.24999999999991, + "745": 112.50000000000006, + "746": 99.7, + "747": 94.60000000000025, + "748": 100.64999999999995, + "749": 106.90000000000006, + "750": 48.049999999999855, + "751": 5.7999999999999226, + "752": 74.14999999999979, + "753": 117.15000000000005, + "754": -9.0, + "755": 109.99999999999996, + "756": 82.25000000000001, + "757": 111.7500000000001, + "758": 76.84999999999987, + "759": 103.25000000000007, + "760": 107.65000000000016, + "761": 115.55000000000001, + "762": 110.10000000000012, + "763": 101.04999999999997, + "764": 100.90000000000006, + "765": 108.45000000000017, + "766": 100.95000000000003, + "767": 106.95000000000014, + "768": 115.70000000000005, + "769": 102.5000000000001, + "770": 112.05000000000014, + "771": 82.84999999999988, + "772": 108.40000000000006, + "773": 107.50000000000011, + "774": 117.85000000000007, + "775": 106.9499999999999, + "776": 116.70000000000017, + "777": 118.30000000000007, + "778": 39.80000000000001, + "779": 108.20000000000013, + "780": 112.50000000000004, + "781": 108.89999999999999, + "782": 96.34999999999995, + "783": 120.60000000000001, + "784": 114.95000000000006, + "785": 105.99999999999989, + "786": 108.2, + "787": 119.60000000000005, + "788": 99.15000000000009, + "789": 113.60000000000005, + "790": 76.94999999999997, + "791": 112.7000000000001, + "792": 106.00000000000013, + "793": 109.60000000000004, + "794": 101.00000000000006, + "795": 105.60000000000011, + "796": 114.1000000000001, + "797": 113.30000000000008, + "798": 115.25000000000007, + "799": 113.90000000000002, + "800": 112.25000000000014, + "801": 103.35000000000001, + "802": 111.4, + "803": 113.50000000000006, + "804": 113.80000000000013, + "805": 115.15000000000018, + "806": 116.8, + "807": 113.20000000000007, + "808": 109.70000000000002, + "809": 108.70000000000014, + "810": 112.80000000000014, + "811": 112.60000000000015, + "812": 108.64999999999998, + "813": 114.25000000000007, + "814": 116.70000000000006, + "815": 103.8, + "816": 114.30000000000015, + "817": 117.04999999999995, + "818": 115.65000000000018, + "819": 107.19999999999999, + "820": 112.15000000000015, + "821": 113.15000000000003, + "822": 105.40000000000002, + "823": 109.2, + "824": 115.0500000000002, + "825": 106.95000000000003, + "826": 117.65000000000012, + "827": 109.50000000000023, + "828": 115.60000000000007, + "829": 92.49999999999999, + "830": 115.85000000000002, + "831": 106.0, + "832": 100.70000000000009, + "833": 117.15000000000015, + "834": 120.40000000000005, + "835": 118.90000000000015, + "836": 114.15000000000005, + "837": 104.40000000000003, + "838": 111.3000000000001, + "839": 112.20000000000014, + "840": 113.00000000000017, + "841": 108.75000000000011, + "842": 108.15000000000009, + "843": 111.6000000000001, + "844": 108.60000000000014, + "845": 116.00000000000003, + "846": 107.40000000000019, + "847": 116.55000000000021, + "848": 98.95000000000003, + "849": 109.45000000000002, + "850": 109.80000000000011, + "851": 94.15000000000015, + "852": 108.55000000000007, + "853": 114.50000000000014, + "854": 108.30000000000013, + "855": 119.20000000000006, + "856": 92.49999999999997, + "857": 116.60000000000012, + "858": 115.80000000000001, + "859": 113.10000000000014, + "860": 106.4000000000001, + "861": 110.40000000000009, + "862": 117.70000000000006, + "863": 116.7, + "864": 100.59999999999995, + "865": 113.45000000000022, + "866": 112.80000000000003, + "867": 101.84999999999997, + "868": 105.55000000000018, + "869": 108.40000000000005, + "870": 113.84999999999988, + "871": 114.55000000000008, + "872": 107.2, + "873": 116.45000000000013, + "874": 112.69999999999996, + "875": 114.65, + "876": 108.7500000000001, + "877": 110.10000000000002, + "878": 107.05000000000017, + "879": 114.25000000000011, + "880": 87.70000000000009, + "881": 110.60000000000002, + "882": 107.00000000000009, + "883": 104.70000000000007, + "884": 107.15000000000013, + "885": 105.45000000000002, + "886": 113.90000000000003, + "887": 112.10000000000001, + "888": 115.05000000000018, + "889": 104.44999999999983, + "890": 106.69999999999996, + "891": 109.30000000000003, + "892": 113.59999999999995, + "893": 106.45000000000006, + "894": 115.75000000000006, + "895": 109.89999999999996, + "896": 112.80000000000014, + "897": 108.30000000000011, + "898": 104.35000000000011, + "899": 113.2000000000001, + "900": 110.85000000000012, + "901": 109.10000000000004, + "902": 113.8500000000001, + "903": 102.39999999999985, + "904": 105.0500000000001, + "905": 104.35000000000014, + "906": 108.64999999999999, + "907": 106.05000000000013, + "908": 116.60000000000004, + "909": 111.20000000000009, + "910": 114.95000000000006, + "911": 109.34999999999995, + "912": 103.95000000000002, + "913": 108.90000000000003, + "914": 110.15000000000016, + "915": 112.40000000000003, + "916": 117.40000000000002, + "917": 112.05000000000007, + "918": 119.5, + "919": 104.55000000000015, + "920": 118.6, + "921": 110.9500000000002, + "922": 109.30000000000008, + "923": 112.35000000000011, + "924": 109.95000000000007, + "925": 115.6000000000001, + "926": 110.75000000000017, + "927": 113.45000000000003, + "928": 117.35000000000011, + "929": 110.90000000000009, + "930": 114.90000000000005, + "931": 105.6499999999999, + "932": 115.69999999999999, + "933": 114.70000000000012, + "934": 113.94999999999997, + "935": 118.25, + "936": 114.70000000000024, + "937": 110.75000000000003, + "938": 113.40000000000006, + "939": 108.70000000000023, + "940": 112.2000000000001, + "941": 107.10000000000005, + "942": 107.20000000000007, + "943": 103.60000000000005, + "944": 111.65000000000002, + "945": 112.4500000000001, + "946": 108.55000000000013, + "947": 110.15000000000013, + "948": 107.00000000000006, + "949": 105.80000000000013, + "950": 113.45000000000014, + "951": 110.95000000000005, + "952": 96.89999999999999, + "953": 108.45000000000009, + "954": 110.25000000000001, + "955": 115.65000000000018, + "956": 117.55000000000014, + "957": 112.2500000000001, + "958": 113.55000000000015, + "959": 116.0000000000002, + "960": 116.39999999999999, + "961": 106.9500000000001, + "962": 114.04999999999993, + "963": 106.64999999999992, + "964": 105.25000000000009, + "965": 110.0000000000002, + "966": 113.20000000000007, + "967": 104.90000000000008, + "968": 112.8500000000001, + "969": 107.0500000000001, + "970": 109.85000000000015, + "971": 110.85000000000008, + "972": 112.4500000000001, + "973": 116.90000000000002, + "974": 109.70000000000009, + "975": 122.25000000000009, + "976": 115.50000000000004, + "977": 112.00000000000004, + "978": 119.35000000000014, + "979": 110.95000000000007, + "980": 121.30000000000014, + "981": 111.70000000000007, + "982": 119.40000000000009, + "983": 106.6500000000001, + "984": 102.64999999999998, + "985": 108.2, + "986": 103.8499999999998, + "987": 113.80000000000004, + "988": 104.8500000000001, + "989": 110.9500000000001, + "990": 111.00000000000009, + "991": 114.00000000000009, + "992": 113.30000000000003, + "993": 115.75000000000009, + "994": 114.55000000000014, + "995": 111.60000000000005, + "996": 107.95000000000009, + "997": 117.1000000000001, + "998": 117.35000000000008, + "999": 112.49999999999994, + "1000": 115.2500000000002 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.0.0/session_metadata/2.json b/benchmark/results/v3/v3.0.0/session_metadata/2.json new file mode 100644 index 00000000..e10a9ee5 --- /dev/null +++ b/benchmark/results/v3/v3.0.0/session_metadata/2.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1258.014987, + "s_per_step": 0.03931296834375, + "s_per_100_steps_10_nodes": 3.931296834375, + "total_reward_per_episode": { + "1": -58.850000000000094, + "2": -25.849999999999984, + "3": -10.799999999999988, + "4": -34.04999999999999, + "5": -59.84999999999999, + "6": -62.05000000000015, + "7": -38.200000000000024, + "8": -62.300000000000104, + "9": -4.099999999999996, + "10": -61.1000000000001, + "11": -19.849999999999984, + "12": -5.4, + "13": -35.65000000000015, + "14": -2.19999999999996, + "15": -25.000000000000004, + "16": -17.549999999999972, + "17": -20.44999999999996, + "18": -72.25000000000003, + "19": -35.24999999999999, + "20": -9.849999999999994, + "21": -22.149999999999952, + "22": -18.599999999999984, + "23": -43.350000000000044, + "24": -34.60000000000001, + "25": -46.50000000000011, + "26": -90.3, + "27": -18.199999999999946, + "28": -36.00000000000005, + "29": -11.899999999999991, + "30": -20.999999999999986, + "31": -50.600000000000136, + "32": -32.9, + "33": -22.249999999999954, + "34": -60.299999999999955, + "35": -56.40000000000012, + "36": -32.79999999999999, + "37": -74.35000000000001, + "38": -56.950000000000166, + "39": -10.649999999999986, + "40": -20.149999999999977, + "41": -4.350000000000009, + "42": -8.600000000000021, + "43": -17.099999999999973, + "44": -2.1499999999999755, + "45": -27.749999999999936, + "46": -21.59999999999996, + "47": -48.09999999999996, + "48": -11.149999999999988, + "49": -21.99999999999997, + "50": -55.30000000000012, + "51": -36.65000000000003, + "52": -6.649999999999991, + "53": -16.349999999999977, + "54": -36.250000000000036, + "55": -97.7000000000001, + "56": -36.3000000000001, + "57": -29.04999999999997, + "58": -19.549999999999965, + "59": -95.80000000000003, + "60": -26.399999999999974, + "61": -17.349999999999987, + "62": -84.75000000000001, + "63": -19.89999999999998, + "64": -12.99999999999999, + "65": -16.54999999999998, + "66": -69.7500000000001, + "67": -75.79999999999998, + "68": -22.549999999999955, + "69": -18.899999999999974, + "70": -11.549999999999994, + "71": -14.099999999999982, + "72": -15.999999999999977, + "73": -14.049999999999983, + "74": -3.5499999999999936, + "75": -29.64999999999995, + "76": -23.04999999999995, + "77": -0.04999999999997784, + "78": -20.999999999999996, + "79": -86.64999999999998, + "80": -19.299999999999965, + "81": -20.149999999999963, + "82": -20.44999999999996, + "83": -69.74999999999999, + "84": -8.350000000000001, + "85": -15.799999999999978, + "86": -12.69999999999999, + "87": -44.849999999999994, + "88": -11.89999999999999, + "89": -22.199999999999953, + "90": -13.299999999999988, + "91": -46.250000000000185, + "92": -55.65000000000007, + "93": -21.549999999999958, + "94": -16.44999999999996, + "95": -20.59999999999996, + "96": -2.099999999999987, + "97": -18.399999999999967, + "98": -88.7, + "99": -11.3, + "100": -23.34999999999995, + "101": -1.999999999999969, + "102": -19.99999999999996, + "103": 5.700000000000022, + "104": -15.899999999999983, + "105": 0.5000000000000284, + "106": -21.14999999999996, + "107": -16.499999999999975, + "108": -92.55, + "109": -21.09999999999996, + "110": -17.64999999999997, + "111": -88.55000000000001, + "112": -55.54999999999995, + "113": -85.79999999999998, + "114": -22.199999999999953, + "115": -65.70000000000002, + "116": 29.349999999999866, + "117": -12.249999999999998, + "118": -21.04999999999996, + "119": -13.89999999999998, + "120": -4.3999999999999835, + "121": -1.6499999999999633, + "122": 2.350000000000039, + "123": -65.85000000000002, + "124": -92.55000000000001, + "125": -3.2999999999999705, + "126": -14.099999999999993, + "127": -16.899999999999974, + "128": -76.1, + "129": 13.950000000000042, + "130": -3.3499999999999766, + "131": -8.599999999999936, + "132": -10.049999999999999, + "133": -13.199999999999989, + "134": -3.7499999999999902, + "135": -3.1999999999999877, + "136": -17.24999999999997, + "137": -50.70000000000007, + "138": -88.2, + "139": -4.649999999999982, + "140": -18.14999999999997, + "141": 4.150000000000027, + "142": 4.100000000000055, + "143": 3.9000000000000012, + "144": -62.29999999999999, + "145": 4.550000000000031, + "146": -12.399999999999979, + "147": -32.749999999999964, + "148": -9.549999999999994, + "149": -13.849999999999984, + "150": -54.849999999999994, + "151": 9.599999999999946, + "152": 6.1999999999999655, + "153": 1.250000000000031, + "154": -16.549999999999983, + "155": 4.150000000000016, + "156": -19.599999999999966, + "157": -12.699999999999976, + "158": -7.349999999999994, + "159": -16.799999999999976, + "160": -7.799999999999999, + "161": 17.699999999999918, + "162": -4.649999999999981, + "163": -98.60000000000001, + "164": -2.7999999999999767, + "165": -37.800000000000004, + "166": -11.499999999999995, + "167": -11.049999999999988, + "168": -25.54999999999995, + "169": -17.999999999999968, + "170": -1.9499999999999753, + "171": -23.899999999999977, + "172": -93.6, + "173": 0.05000000000000293, + "174": -20.49999999999996, + "175": -8.450000000000005, + "176": -36.95, + "177": -7.6499999999999915, + "178": -74.44999999999999, + "179": 3.300000000000047, + "180": -93.9, + "181": -2.4499999999999815, + "182": -15.349999999999984, + "183": -63.5, + "184": -4.349999999999986, + "185": 11.249999999999979, + "186": 42.749999999999844, + "187": 50.89999999999979, + "188": -11.349999999999982, + "189": -5.399999999999989, + "190": -26.849999999999966, + "191": 44.24999999999982, + "192": -80.20000000000002, + "193": 4.249999999999983, + "194": 11.900000000000038, + "195": 18.649999999999974, + "196": -40.149999999999984, + "197": -95.75, + "198": -83.65000000000002, + "199": -12.099999999999993, + "200": 2.650000000000005, + "201": -2.549999999999976, + "202": 38.49999999999992, + "203": -78.05000000000001, + "204": -41.50000000000005, + "205": -16.099999999999984, + "206": -7.050000000000021, + "207": -4.449999999999988, + "208": -16.149999999999952, + "209": -5.249999999999993, + "210": 10.20000000000006, + "211": 6.400000000000064, + "212": 20.300000000000022, + "213": -57.3, + "214": -4.2999999999999785, + "215": -14.349999999999989, + "216": 22.099999999999845, + "217": -6.649999999999991, + "218": -17.14999999999998, + "219": 42.349999999999966, + "220": 35.64999999999999, + "221": 18.70000000000002, + "222": 38.300000000000004, + "223": 49.39999999999988, + "224": -33.04999999999997, + "225": 11.34999999999993, + "226": 82.79999999999977, + "227": 3.7500000000000338, + "228": 70.3999999999998, + "229": -16.9, + "230": -73.69999999999997, + "231": -11.400000000000013, + "232": -79.19999999999993, + "233": 18.500000000000046, + "234": 25.44999999999993, + "235": -21.34999999999996, + "236": 0.4000000000000128, + "237": 43.99999999999978, + "238": 4.25000000000005, + "239": 58.64999999999986, + "240": -86.34999999999994, + "241": -51.24999999999997, + "242": 54.54999999999993, + "243": -18.349999999999973, + "244": -23.65000000000004, + "245": -1.9499999999999744, + "246": 18.050000000000004, + "247": 40.39999999999983, + "248": 53.04999999999985, + "249": 30.499999999999773, + "250": -32.54999999999997, + "251": -15.699999999999978, + "252": 27.199999999999985, + "253": -70.10000000000001, + "254": 5.550000000000015, + "255": 55.349999999999746, + "256": 65.24999999999991, + "257": 66.99999999999989, + "258": 18.749999999999947, + "259": 29.649999999999864, + "260": -14.350000000000012, + "261": 75.24999999999979, + "262": 10.700000000000056, + "263": 85.04999999999974, + "264": 37.849999999999994, + "265": 16.850000000000065, + "266": -39.34999999999999, + "267": 9.250000000000014, + "268": 7.3499999999999455, + "269": 16.249999999999968, + "270": 24.049999999999883, + "271": 78.70000000000013, + "272": 54.59999999999981, + "273": -43.0000000000001, + "274": 88.34999999999975, + "275": -27.049999999999958, + "276": 72.64999999999975, + "277": -16.19999999999997, + "278": 92.9999999999998, + "279": 18.699999999999996, + "280": 33.499999999999986, + "281": 80.99999999999993, + "282": -8.10000000000001, + "283": 18.00000000000008, + "284": 94.74999999999977, + "285": 97.3499999999998, + "286": -69.25000000000001, + "287": 11.299999999999997, + "288": 62.749999999999744, + "289": 56.44999999999974, + "290": 60.299999999999756, + "291": -19.100000000000044, + "292": -56.30000000000004, + "293": 71.39999999999975, + "294": 17.150000000000023, + "295": 100.74999999999991, + "296": 46.94999999999996, + "297": 35.94999999999979, + "298": 39.94999999999981, + "299": -36.4, + "300": 52.99999999999981, + "301": 86.74999999999976, + "302": -77.25, + "303": 61.39999999999972, + "304": 73.19999999999976, + "305": 74.94999999999979, + "306": 110.94999999999979, + "307": 67.14999999999978, + "308": 69.8999999999998, + "309": 25.899999999999995, + "310": 66.14999999999975, + "311": -74.30000000000001, + "312": -49.09999999999999, + "313": 7.850000000000069, + "314": 84.89999999999979, + "315": 75.15000000000009, + "316": 20.299999999999976, + "317": 50.69999999999973, + "318": -31.049999999999994, + "319": 41.49999999999982, + "320": 35.850000000000016, + "321": 19.70000000000003, + "322": 96.54999999999977, + "323": 69.04999999999974, + "324": 59.0999999999999, + "325": 51.59999999999997, + "326": 37.39999999999993, + "327": -36.40000000000002, + "328": 102.99999999999976, + "329": 99.04999999999977, + "330": -29.69999999999999, + "331": 22.19999999999998, + "332": 51.599999999999845, + "333": 59.799999999999834, + "334": 104.24999999999979, + "335": 78.04999999999977, + "336": 100.94999999999973, + "337": 80.19999999999978, + "338": 16.700000000000006, + "339": -19.250000000000018, + "340": 90.05000000000025, + "341": -49.09999999999999, + "342": 36.550000000000004, + "343": 103.19999999999973, + "344": 94.34999999999972, + "345": 1.7000000000000282, + "346": 83.39999999999984, + "347": 87.54999999999974, + "348": -56.44999999999997, + "349": 83.64999999999984, + "350": 72.04999999999976, + "351": 67.59999999999977, + "352": 50.64999999999994, + "353": 65.54999999999991, + "354": 74.59999999999985, + "355": -0.5999999999999834, + "356": 25.699999999999985, + "357": 97.29999999999973, + "358": 116.75000000000027, + "359": 37.39999999999974, + "360": 35.84999999999976, + "361": 92.1999999999998, + "362": 99.79999999999974, + "363": 62.2499999999999, + "364": 42.74999999999992, + "365": 63.74999999999973, + "366": -7.69999999999999, + "367": 104.24999999999974, + "368": 95.24999999999977, + "369": 107.99999999999976, + "370": 93.7499999999998, + "371": 82.69999999999989, + "372": 49.04999999999986, + "373": -41.95000000000013, + "374": 105.84999999999981, + "375": 105.74999999999973, + "376": 95.44999999999973, + "377": 61.349999999999724, + "378": 107.49999999999979, + "379": 39.949999999999854, + "380": 68.99999999999977, + "381": 86.99999999999977, + "382": 99.04999999999981, + "383": 105.39999999999974, + "384": 101.54999999999984, + "385": 76.94999999999983, + "386": 111.40000000000006, + "387": 35.899999999999814, + "388": 95.09999999999974, + "389": 89.89999999999975, + "390": 93.79999999999976, + "391": 18.500000000000075, + "392": -44.19999999999999, + "393": 102.94999999999978, + "394": 103.69999999999976, + "395": 107.24999999999976, + "396": 103.74999999999973, + "397": -15.649999999999956, + "398": 75.39999999999989, + "399": 93.35000000000011, + "400": 98.69999999999973, + "401": 83.54999999999974, + "402": 87.79999999999977, + "403": 111.39999999999978, + "404": 98.99999999999977, + "405": 51.399999999999785, + "406": 101.49999999999974, + "407": 84.34999999999974, + "408": 101.84999999999977, + "409": 113.85000000000004, + "410": 10.150000000000077, + "411": -36.30000000000009, + "412": 48.799999999999756, + "413": 99.69999999999979, + "414": 73.04999999999986, + "415": 97.49999999999974, + "416": -73.19999999999999, + "417": -3.1500000000000887, + "418": -20.69999999999998, + "419": 53.149999999999814, + "420": 102.59999999999977, + "421": 109.89999999999975, + "422": 38.549999999999905, + "423": 103.59999999999977, + "424": 112.64999999999992, + "425": 100.74999999999976, + "426": 53.34999999999994, + "427": -7.649999999999986, + "428": 43.69999999999996, + "429": 110.14999999999998, + "430": -20.749999999999986, + "431": 103.94999999999976, + "432": 59.949999999999896, + "433": 63.79999999999992, + "434": 106.69999999999975, + "435": 110.0999999999998, + "436": 90.54999999999978, + "437": 107.59999999999984, + "438": 105.5499999999998, + "439": 66.44999999999989, + "440": 77.79999999999991, + "441": 104.69999999999975, + "442": 74.04999999999983, + "443": 106.04999999999993, + "444": 49.849999999999824, + "445": 82.59999999999975, + "446": 74.6999999999998, + "447": 17.000000000000064, + "448": 106.79999999999995, + "449": 43.24999999999994, + "450": 102.59999999999994, + "451": 115.60000000000025, + "452": 101.04999999999976, + "453": 100.39999999999974, + "454": 107.19999999999973, + "455": 102.34999999999977, + "456": 8.949999999999843, + "457": 14.450000000000003, + "458": 105.44999999999975, + "459": 109.64999999999985, + "460": 107.64999999999975, + "461": 97.74999999999977, + "462": -20.599999999999955, + "463": 46.49999999999996, + "464": 106.34999999999974, + "465": 100.44999999999976, + "466": 7.100000000000035, + "467": 64.54999999999984, + "468": 76.14999999999972, + "469": 103.29999999999977, + "470": 4.349999999999993, + "471": 98.89999999999978, + "472": -8.999999999999998, + "473": 101.49999999999976, + "474": 106.74999999999972, + "475": 80.79999999999988, + "476": 77.59999999999987, + "477": 85.14999999999985, + "478": 101.69999999999975, + "479": 105.94999999999972, + "480": 100.14999999999976, + "481": 58.79999999999992, + "482": 97.54999999999978, + "483": 71.34999999999985, + "484": 106.44999999999972, + "485": 80.74999999999976, + "486": 24.850000000000016, + "487": 72.74999999999987, + "488": 98.04999999999973, + "489": 71.59999999999978, + "490": 105.09999999999991, + "491": 87.9999999999999, + "492": 97.74999999999973, + "493": 96.84999999999972, + "494": 104.69999999999976, + "495": 96.44999999999973, + "496": 46.19999999999994, + "497": 106.35000000000008, + "498": 99.94999999999978, + "499": 89.49999999999993, + "500": 105.34999999999985, + "501": 52.39999999999977, + "502": 104.64999999999972, + "503": 102.39999999999975, + "504": 62.39999999999976, + "505": 94.24999999999974, + "506": 72.14999999999975, + "507": 105.4999999999999, + "508": 73.8499999999999, + "509": 103.44999999999973, + "510": 89.5499999999998, + "511": 45.59999999999995, + "512": 87.04999999999981, + "513": 104.54999999999977, + "514": 100.09999999999977, + "515": 105.79999999999974, + "516": 99.69999999999976, + "517": 108.39999999999974, + "518": 103.64999999999976, + "519": -65.25, + "520": 63.79999999999973, + "521": 94.39999999999974, + "522": 105.79999999999974, + "523": 97.64999999999974, + "524": 49.84999999999987, + "525": 93.74999999999976, + "526": 79.54999999999973, + "527": 105.59999999999974, + "528": 107.19999999999975, + "529": 73.29999999999973, + "530": 93.19999999999973, + "531": 107.34999999999971, + "532": 111.24999999999982, + "533": -82.9, + "534": 103.94999999999976, + "535": 102.19999999999978, + "536": 102.09999999999991, + "537": 101.74999999999977, + "538": 67.74999999999977, + "539": 60.69999999999973, + "540": 82.4999999999998, + "541": 93.59999999999997, + "542": 101.79999999999977, + "543": 97.54999999999977, + "544": 107.59999999999974, + "545": 105.2999999999998, + "546": 97.44999999999976, + "547": 106.89999999999974, + "548": 107.6999999999999, + "549": 92.54999999999976, + "550": 104.24999999999974, + "551": 99.39999999999992, + "552": -10.450000000000088, + "553": 104.44999999999976, + "554": 79.59999999999985, + "555": 102.84999999999977, + "556": 76.54999999999976, + "557": 103.34999999999977, + "558": 98.34999999999974, + "559": 95.59999999999981, + "560": 103.49999999999976, + "561": 106.99999999999974, + "562": 66.34999999999982, + "563": 103.34999999999972, + "564": 102.74999999999977, + "565": 106.84999999999995, + "566": 111.79999999999994, + "567": 103.79999999999976, + "568": 105.24999999999976, + "569": 101.84999999999974, + "570": 103.34999999999977, + "571": 108.49999999999976, + "572": 36.10000000000002, + "573": -23.599999999999945, + "574": 104.04999999999976, + "575": 107.34999999999972, + "576": 109.79999999999974, + "577": 93.49999999999974, + "578": 21.60000000000007, + "579": 91.05000000000007, + "580": 95.69999999999976, + "581": 110.35000000000018, + "582": 101.14999999999979, + "583": 101.64999999999976, + "584": 103.94999999999976, + "585": 107.99999999999973, + "586": 105.49999999999976, + "587": 107.99999999999976, + "588": 91.29999999999976, + "589": 103.34999999999974, + "590": 106.34999999999977, + "591": 107.9000000000002, + "592": 83.94999999999982, + "593": 98.49999999999974, + "594": 104.49999999999976, + "595": 39.49999999999973, + "596": 104.19999999999976, + "597": 27.300000000000022, + "598": 109.44999999999976, + "599": 51.299999999999955, + "600": 108.99999999999973, + "601": 91.44999999999982, + "602": 47.79999999999975, + "603": 104.49999999999974, + "604": 101.44999999999972, + "605": 110.79999999999986, + "606": 95.39999999999975, + "607": 98.14999999999975, + "608": 108.14999999999972, + "609": 116.00000000000027, + "610": 88.94999999999983, + "611": 104.8499999999998, + "612": 101.09999999999977, + "613": 105.24999999999974, + "614": 100.39999999999978, + "615": 110.19999999999982, + "616": 108.49999999999973, + "617": 74.99999999999989, + "618": 108.79999999999977, + "619": 100.14999999999976, + "620": 98.19999999999975, + "621": 77.24999999999982, + "622": 114.45, + "623": 109.64999999999982, + "624": 64.84999999999972, + "625": 42.49999999999976, + "626": 103.44999999999976, + "627": 36.24999999999999, + "628": 82.29999999999974, + "629": 109.74999999999989, + "630": 85.79999999999978, + "631": 46.49999999999997, + "632": 75.49999999999983, + "633": 110.19999999999976, + "634": 107.20000000000019, + "635": 102.84999999999977, + "636": 98.84999999999998, + "637": 118.70000000000034, + "638": 106.84999999999974, + "639": 105.54999999999976, + "640": 86.09999999999981, + "641": 93.2999999999998, + "642": 103.74999999999976, + "643": 64.94999999999975, + "644": 105.44999999999973, + "645": 103.74999999999977, + "646": 105.19999999999972, + "647": 106.39999999999974, + "648": 103.94999999999976, + "649": 81.25000000000007, + "650": 106.29999999999984, + "651": 108.04999999999978, + "652": 96.14999999999985, + "653": 102.99999999999983, + "654": 88.3499999999998, + "655": 96.7499999999999, + "656": 106.99999999999976, + "657": 95.34999999999991, + "658": 104.64999999999975, + "659": 101.44999999999993, + "660": 104.34999999999977, + "661": 104.59999999999978, + "662": 110.95000000000022, + "663": 49.09999999999995, + "664": 44.999999999999766, + "665": 108.4499999999998, + "666": 111.59999999999978, + "667": 80.59999999999985, + "668": 95.39999999999978, + "669": 105.09999999999974, + "670": 107.54999999999974, + "671": 102.09999999999981, + "672": 103.24999999999976, + "673": 87.1999999999998, + "674": 109.84999999999977, + "675": 79.44999999999975, + "676": 105.04999999999976, + "677": 96.89999999999978, + "678": 104.04999999999977, + "679": 103.29999999999991, + "680": 103.59999999999977, + "681": 103.69999999999976, + "682": 75.94999999999978, + "683": 99.54999999999994, + "684": 75.24999999999983, + "685": 70.44999999999973, + "686": 100.94999999999972, + "687": 119.65000000000033, + "688": 89.7499999999998, + "689": 104.54999999999976, + "690": 103.39999999999976, + "691": 106.19999999999976, + "692": 100.09999999999975, + "693": 102.99999999999977, + "694": 103.39999999999976, + "695": 103.19999999999976, + "696": 106.79999999999973, + "697": 105.59999999999974, + "698": 60.54999999999978, + "699": 106.79999999999974, + "700": 106.09999999999975, + "701": 115.7, + "702": 82.7499999999999, + "703": 111.04999999999978, + "704": 102.74999999999986, + "705": 107.69999999999986, + "706": 106.14999999999982, + "707": 104.14999999999974, + "708": 111.19999999999983, + "709": 111.29999999999994, + "710": 105.94999999999973, + "711": 109.84999999999978, + "712": 113.25, + "713": 107.3499999999999, + "714": 116.50000000000028, + "715": 110.99999999999983, + "716": 104.49999999999974, + "717": 110.05000000000003, + "718": 113.60000000000015, + "719": 106.5999999999998, + "720": 115.65000000000005, + "721": -14.500000000000004, + "722": 52.2, + "723": 77.9499999999998, + "724": 65.49999999999986, + "725": 24.64999999999987, + "726": 35.84999999999999, + "727": -5.549999999999993, + "728": 81.45000000000014, + "729": 33.29999999999991, + "730": 95.80000000000011, + "731": 88.10000000000002, + "732": 107.30000000000015, + "733": 26.40000000000004, + "734": 64.95000000000006, + "735": 58.999999999999986, + "736": -6.700000000000006, + "737": 17.850000000000026, + "738": 112.10000000000016, + "739": 28.849999999999884, + "740": 113.75000000000009, + "741": 115.05000000000015, + "742": 77.34999999999981, + "743": 35.5999999999998, + "744": 110.7500000000001, + "745": 53.14999999999979, + "746": -16.699999999999978, + "747": 20.79999999999998, + "748": 87.6, + "749": 42.59999999999981, + "750": 96.65000000000008, + "751": 83.55000000000001, + "752": 93.3500000000001, + "753": 72.99999999999997, + "754": 6.749999999999982, + "755": 102.00000000000011, + "756": 91.90000000000009, + "757": 101.29999999999997, + "758": 25.499999999999986, + "759": 72.54999999999987, + "760": 106.75000000000001, + "761": 86.64999999999996, + "762": 99.80000000000001, + "763": 112.45000000000019, + "764": 101.25000000000016, + "765": 43.14999999999978, + "766": 39.59999999999978, + "767": 49.399999999999935, + "768": 114.65000000000002, + "769": 110.2, + "770": 47.59999999999976, + "771": 94.65000000000002, + "772": 116.85000000000007, + "773": 45.99999999999977, + "774": 116.15000000000015, + "775": 91.10000000000008, + "776": 49.949999999999854, + "777": 110.55000000000007, + "778": 78.29999999999991, + "779": 103.95000000000002, + "780": 97.85000000000005, + "781": 109.80000000000007, + "782": 115.00000000000011, + "783": 65.5999999999999, + "784": 90.05000000000005, + "785": 104.04999999999997, + "786": 77.24999999999979, + "787": 111.80000000000003, + "788": 49.24999999999975, + "789": 109.74999999999991, + "790": 118.65000000000003, + "791": 116.10000000000011, + "792": 94.09999999999985, + "793": 96.94999999999992, + "794": 105.30000000000007, + "795": 123.45000000000005, + "796": 112.60000000000015, + "797": 116.70000000000002, + "798": 102.50000000000011, + "799": 9.550000000000004, + "800": 111.30000000000004, + "801": 114.05, + "802": 108.40000000000005, + "803": 111.15000000000016, + "804": 114.65000000000015, + "805": -66.09999999999992, + "806": 81.25000000000009, + "807": 83.5499999999998, + "808": 109.6500000000001, + "809": 101.25000000000009, + "810": 114.20000000000007, + "811": 99.25000000000013, + "812": 19.199999999999907, + "813": 121.75000000000009, + "814": 92.5999999999998, + "815": 115.30000000000024, + "816": 110.10000000000005, + "817": 105.1000000000001, + "818": 101.0500000000002, + "819": 95.04999999999991, + "820": 100.0, + "821": 109.85000000000004, + "822": 113.70000000000014, + "823": 104.20000000000006, + "824": 111.3000000000002, + "825": 113.85, + "826": 101.55000000000008, + "827": 112.70000000000003, + "828": 106.90000000000003, + "829": 86.35000000000005, + "830": 88.59999999999997, + "831": 113.00000000000004, + "832": 108.15000000000016, + "833": 114.55000000000013, + "834": 102.64999999999998, + "835": 110.64999999999999, + "836": 104.94999999999996, + "837": 77.49999999999999, + "838": 110.1, + "839": 100.89999999999996, + "840": 112.40000000000006, + "841": 102.50000000000006, + "842": 90.5, + "843": 61.999999999999936, + "844": 90.45000000000014, + "845": 115.40000000000012, + "846": 109.20000000000007, + "847": 92.95000000000002, + "848": 70.1999999999999, + "849": 112.85000000000001, + "850": 88.29999999999986, + "851": 116.45000000000014, + "852": 115.0, + "853": 115.5000000000001, + "854": 99.90000000000006, + "855": 115.10000000000007, + "856": 110.1000000000001, + "857": 113.25000000000013, + "858": 114.45000000000003, + "859": 108.00000000000016, + "860": 101.10000000000014, + "861": 118.95000000000005, + "862": 101.75000000000013, + "863": 101.15000000000013, + "864": 106.15000000000012, + "865": 108.2000000000001, + "866": 116.35000000000008, + "867": 116.10000000000016, + "868": 116.50000000000026, + "869": 111.30000000000007, + "870": 110.69999999999999, + "871": 101.80000000000014, + "872": 114.65000000000019, + "873": 108.2000000000002, + "874": 112.0500000000002, + "875": 111.70000000000016, + "876": 101.60000000000011, + "877": 103.40000000000013, + "878": 109.45000000000014, + "879": 111.80000000000021, + "880": 113.0500000000001, + "881": 108.55000000000003, + "882": 108.74999999999999, + "883": 115.20000000000024, + "884": 78.14999999999986, + "885": 111.70000000000029, + "886": 99.8, + "887": 108.85000000000008, + "888": 112.44999999999997, + "889": 110.90000000000009, + "890": 113.75000000000007, + "891": 114.44999999999997, + "892": 114.84999999999998, + "893": 107.20000000000006, + "894": 115.35000000000005, + "895": 117.05000000000007, + "896": 112.2000000000001, + "897": 106.80000000000004, + "898": 98.10000000000008, + "899": 108.80000000000015, + "900": 101.35000000000004, + "901": 99.54999999999994, + "902": 107.10000000000018, + "903": 121.0000000000002, + "904": 111.70000000000023, + "905": 113.30000000000018, + "906": 112.35, + "907": 111.85000000000011, + "908": 110.55000000000013, + "909": 108.25000000000014, + "910": 106.90000000000005, + "911": 110.05, + "912": 112.55000000000001, + "913": 95.15000000000003, + "914": 109.50000000000003, + "915": 120.75000000000007, + "916": 117.44999999999996, + "917": 112.15000000000015, + "918": 110.80000000000008, + "919": 107.65000000000008, + "920": 114.05000000000007, + "921": 109.25000000000018, + "922": 100.70000000000002, + "923": 86.40000000000006, + "924": 118.70000000000002, + "925": 105.5500000000002, + "926": 111.20000000000003, + "927": 113.30000000000007, + "928": 115.39999999999999, + "929": 112.4999999999999, + "930": 106.55000000000017, + "931": 95.95000000000005, + "932": 5.500000000000049, + "933": 98.2, + "934": 104.50000000000001, + "935": 114.20000000000007, + "936": 114.25000000000006, + "937": 109.05000000000024, + "938": 113.75000000000034, + "939": 94.05000000000003, + "940": 113.95000000000023, + "941": 115.40000000000003, + "942": 113.00000000000007, + "943": 112.45000000000006, + "944": 103.8500000000001, + "945": 113.64999999999999, + "946": 108.45000000000005, + "947": 117.10000000000012, + "948": 113.05000000000004, + "949": 106.9500000000001, + "950": 110.0500000000001, + "951": 117.85000000000014, + "952": 98.50000000000003, + "953": 113.50000000000001, + "954": 108.40000000000028, + "955": 115.30000000000008, + "956": 109.75000000000013, + "957": 107.55000000000018, + "958": 108.75000000000011, + "959": 115.85000000000002, + "960": 116.70000000000005, + "961": 108.00000000000009, + "962": 114.30000000000013, + "963": 115.75000000000007, + "964": 117.10000000000012, + "965": 114.05000000000003, + "966": 109.20000000000003, + "967": 106.00000000000006, + "968": 100.50000000000006, + "969": 118.4000000000002, + "970": 108.9000000000002, + "971": 108.10000000000015, + "972": 110.49999999999993, + "973": 103.09999999999994, + "974": 112.35000000000014, + "975": 110.75000000000013, + "976": 67.94999999999979, + "977": 112.10000000000007, + "978": 115.25000000000017, + "979": 104.9500000000001, + "980": 111.00000000000026, + "981": 117.60000000000007, + "982": 106.60000000000008, + "983": 103.45000000000007, + "984": 114.45000000000003, + "985": 107.6, + "986": 114.60000000000001, + "987": 102.75000000000009, + "988": 107.30000000000007, + "989": 109.84999999999997, + "990": 112.40000000000009, + "991": 116.50000000000024, + "992": 117.70000000000006, + "993": 106.50000000000016, + "994": 108.10000000000004, + "995": 114.1, + "996": 106.90000000000006, + "997": 115.40000000000018, + "998": 120.85000000000002, + "999": 103.49999999999994, + "1000": 117.9500000000001 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.0.0/session_metadata/3.json b/benchmark/results/v3/v3.0.0/session_metadata/3.json new file mode 100644 index 00000000..78ddf66a --- /dev/null +++ b/benchmark/results/v3/v3.0.0/session_metadata/3.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1488.85671, + "s_per_step": 0.0465267721875, + "s_per_100_steps_10_nodes": 4.65267721875, + "total_reward_per_episode": { + "1": -50.79999999999999, + "2": -38.25000000000007, + "3": -8.149999999999988, + "4": -10.699999999999996, + "5": -59.6500000000001, + "6": -69.40000000000005, + "7": -14.749999999999977, + "8": -48.35000000000015, + "9": -54.25000000000016, + "10": -66.30000000000008, + "11": -23.199999999999967, + "12": -25.649999999999967, + "13": -52.40000000000007, + "14": -46.000000000000206, + "15": -18.699999999999978, + "16": -25.59999999999997, + "17": -15.249999999999984, + "18": -61.25000000000002, + "19": -60.05000000000017, + "20": -21.09999999999996, + "21": -13.899999999999983, + "22": 15.549999999999972, + "23": -11.499999999999982, + "24": -16.899999999999974, + "25": -14.699999999999983, + "26": -10.999999999999986, + "27": -17.74999999999997, + "28": -21.549999999999958, + "29": -29.949999999999957, + "30": -69.20000000000006, + "31": -11.95, + "32": -38.54999999999997, + "33": -63.650000000000105, + "34": -71.49999999999999, + "35": -15.49999999999998, + "36": -47.250000000000064, + "37": -20.49999999999996, + "38": -32.80000000000004, + "39": -20.04999999999996, + "40": -12.549999999999978, + "41": -39.750000000000085, + "42": -16.74999999999998, + "43": -5.899999999999994, + "44": -15.349999999999982, + "45": -6.1499999999999915, + "46": 1.1000000000000034, + "47": -36.90000000000008, + "48": -16.549999999999976, + "49": -34.55000000000002, + "50": -92.05000000000005, + "51": -35.15000000000004, + "52": -16.049999999999976, + "53": -17.950000000000017, + "54": -17.89999999999998, + "55": -39.05000000000005, + "56": -14.599999999999977, + "57": -21.199999999999953, + "58": -49.400000000000105, + "59": -9.949999999999989, + "60": -18.39999999999997, + "61": -89.54999999999998, + "62": -90.5, + "63": -29.70000000000003, + "64": -15.749999999999975, + "65": -51.24999999999993, + "66": -36.64999999999998, + "67": -40.00000000000004, + "68": -5.199999999999986, + "69": -21.499999999999975, + "70": -53.00000000000009, + "71": -5.399999999999988, + "72": -14.249999999999975, + "73": -7.899999999999993, + "74": -25.39999999999996, + "75": -18.499999999999968, + "76": -15.599999999999977, + "77": -86.79999999999998, + "78": -38.20000000000007, + "79": -88.2, + "80": -72.1, + "81": -62.749999999999986, + "82": -100.25, + "83": -27.0, + "84": -18.999999999999968, + "85": -61.150000000000105, + "86": -10.049999999999988, + "87": -14.94999999999998, + "88": -40.85000000000006, + "89": -7.149999999999989, + "90": -15.649999999999975, + "91": -14.599999999999977, + "92": -16.100000000000005, + "93": -6.349999999999994, + "94": -93.95, + "95": -63.3, + "96": -9.9, + "97": -23.999999999999968, + "98": -17.399999999999984, + "99": -18.84999999999997, + "100": -1.6999999999999829, + "101": -20.59999999999996, + "102": 0.6000000000000287, + "103": -4.899999999999991, + "104": -36.05000000000001, + "105": -29.29999999999998, + "106": -36.750000000000014, + "107": -20.39999999999996, + "108": -41.79999999999998, + "109": -77.25, + "110": -20.54999999999996, + "111": -13.049999999999986, + "112": -19.299999999999965, + "113": -11.899999999999995, + "114": -17.999999999999968, + "115": -12.299999999999986, + "116": -15.69999999999998, + "117": -21.949999999999957, + "118": -13.499999999999988, + "119": -14.099999999999989, + "120": -19.24999999999996, + "121": -11.199999999999987, + "122": -19.34999999999997, + "123": -20.39999999999996, + "124": -14.999999999999979, + "125": -18.19999999999997, + "126": -21.049999999999958, + "127": -7.699999999999999, + "128": -19.14999999999996, + "129": -13.099999999999982, + "130": -20.34999999999996, + "131": -8.74999999999999, + "132": 4.950000000000038, + "133": -73.29999999999998, + "134": -7.100000000000002, + "135": 11.60000000000001, + "136": -85.99999999999999, + "137": -38.84999999999997, + "138": -88.25, + "139": -6.149999999999993, + "140": -13.549999999999992, + "141": 1.6500000000000292, + "142": -4.699999999999991, + "143": 6.600000000000034, + "144": -75.79999999999997, + "145": -82.05000000000008, + "146": -15.09999999999998, + "147": 10.150000000000034, + "148": -19.249999999999964, + "149": -1.1499999999999808, + "150": -15.74999999999998, + "151": 6.1000000000000565, + "152": 30.449999999999996, + "153": -18.699999999999967, + "154": -4.999999999999984, + "155": -34.55000000000001, + "156": -20.29999999999996, + "157": 8.799999999999995, + "158": -18.500000000000007, + "159": -17.099999999999973, + "160": -9.949999999999983, + "161": -6.399999999999987, + "162": -5.549999999999992, + "163": -49.30000000000001, + "164": -35.95, + "165": -8.550000000000018, + "166": -10.449999999999989, + "167": 7.000000000000046, + "168": -13.64999999999998, + "169": 21.899999999999974, + "170": -16.29999999999998, + "171": -8.25000000000002, + "172": -31.249999999999986, + "173": 27.399999999999892, + "174": -88.5, + "175": -8.249999999999988, + "176": 1.900000000000028, + "177": -24.100000000000016, + "178": -24.300000000000026, + "179": 4.3500000000000165, + "180": -12.39999999999999, + "181": 29.599999999999966, + "182": -10.74999999999998, + "183": 8.149999999999975, + "184": 18.04999999999998, + "185": 23.19999999999998, + "186": -15.749999999999995, + "187": -24.64999999999999, + "188": -12.349999999999978, + "189": 22.99999999999998, + "190": 37.84999999999985, + "191": -42.650000000000034, + "192": -26.199999999999992, + "193": -3.3999999999999932, + "194": -28.69999999999999, + "195": -6.250000000000017, + "196": -17.29999999999997, + "197": -77.60000000000001, + "198": -15.749999999999979, + "199": -20.450000000000053, + "200": -22.950000000000003, + "201": 36.14999999999997, + "202": -2.8000000000000194, + "203": -14.699999999999967, + "204": 58.749999999999936, + "205": -9.949999999999994, + "206": 13.24999999999998, + "207": -46.900000000000034, + "208": -15.049999999999985, + "209": -0.9499999999999613, + "210": 18.64999999999998, + "211": -1.1999999999999655, + "212": 58.00000000000003, + "213": -3.1500000000000035, + "214": 27.150000000000045, + "215": 61.249999999999964, + "216": -14.90000000000002, + "217": -3.05, + "218": 20.750000000000025, + "219": 21.549999999999994, + "220": 58.00000000000004, + "221": 9.399999999999977, + "222": 28.44999999999994, + "223": 29.599999999999994, + "224": 46.24999999999982, + "225": 6.200000000000018, + "226": 6.049999999999981, + "227": 40.59999999999994, + "228": 63.44999999999995, + "229": 10.700000000000006, + "230": 16.200000000000017, + "231": 82.85, + "232": 85.05, + "233": 50.49999999999994, + "234": 90.89999999999998, + "235": -12.39999999999997, + "236": 59.79999999999999, + "237": 30.44999999999977, + "238": 73.40000000000003, + "239": 45.49999999999998, + "240": 47.94999999999995, + "241": 55.000000000000064, + "242": -2.849999999999966, + "243": 57.69999999999994, + "244": 80.35, + "245": 30.300000000000033, + "246": 12.04999999999998, + "247": 44.2999999999999, + "248": 53.29999999999999, + "249": 78.35000000000002, + "250": 55.9, + "251": 66.39999999999992, + "252": -64.89999999999995, + "253": 10.349999999999966, + "254": 71.35000000000002, + "255": 57.9500000000001, + "256": 11.050000000000011, + "257": -7.800000000000005, + "258": 74.35, + "259": 108.15000000000002, + "260": 70.10000000000002, + "261": 92.70000000000013, + "262": 44.44999999999986, + "263": 111.7000000000001, + "264": 69.84999999999994, + "265": 103.10000000000018, + "266": 76.45000000000014, + "267": 35.000000000000064, + "268": 58.799999999999926, + "269": 91.40000000000005, + "270": 72.39999999999996, + "271": -24.95000000000001, + "272": 86.84999999999997, + "273": 48.449999999999996, + "274": -6.149999999999984, + "275": 85.30000000000004, + "276": 12.199999999999974, + "277": 55.04999999999999, + "278": 23.399999999999935, + "279": 56.20000000000001, + "280": 111.95000000000013, + "281": 84.15000000000015, + "282": 79.74999999999997, + "283": -36.95000000000001, + "284": 84.85, + "285": 105.80000000000003, + "286": 119.20000000000012, + "287": 100.70000000000005, + "288": -1.9499999999999644, + "289": 104.10000000000004, + "290": 69.2499999999999, + "291": 100.35000000000008, + "292": 100.89999999999999, + "293": 84.64999999999995, + "294": 101.95000000000019, + "295": 87.90000000000009, + "296": 91.35000000000004, + "297": 109.35000000000014, + "298": 100.44999999999999, + "299": 99.10000000000004, + "300": 41.699999999999925, + "301": 97.65, + "302": 118.2000000000001, + "303": 38.59999999999999, + "304": 58.79999999999992, + "305": 101.15000000000009, + "306": -6.5499999999999545, + "307": 71.65000000000005, + "308": 60.049999999999926, + "309": 89.05000000000018, + "310": -54.099999999999945, + "311": 84.70000000000012, + "312": 72.10000000000004, + "313": 111.0000000000002, + "314": 95.00000000000001, + "315": 95.95000000000006, + "316": 107.69999999999999, + "317": 89.75000000000003, + "318": 79.69999999999995, + "319": 104.10000000000001, + "320": 89.40000000000009, + "321": 80.85000000000018, + "322": 110.4, + "323": 114.65000000000006, + "324": 107.09999999999995, + "325": 92.45000000000005, + "326": 107.20000000000009, + "327": 90.60000000000016, + "328": 95.25000000000016, + "329": 109.14999999999999, + "330": 80.04999999999998, + "331": 79.90000000000002, + "332": 93.15000000000005, + "333": 107.30000000000001, + "334": 85.40000000000002, + "335": 118.90000000000018, + "336": 37.84999999999998, + "337": 104.6500000000002, + "338": 111.45000000000005, + "339": 109.60000000000012, + "340": 91.54999999999997, + "341": 31.299999999999958, + "342": 94.00000000000006, + "343": 107.1, + "344": 103.60000000000014, + "345": 114.25000000000004, + "346": 116.25000000000006, + "347": 104.1000000000001, + "348": 105.1500000000001, + "349": 111.05000000000005, + "350": 101.40000000000013, + "351": 99.20000000000019, + "352": 108.64999999999998, + "353": 28.499999999999908, + "354": 48.900000000000006, + "355": 98.40000000000006, + "356": 103.85000000000015, + "357": 105.50000000000014, + "358": 105.2000000000001, + "359": 119.40000000000018, + "360": 109.9500000000002, + "361": 57.89999999999987, + "362": 118.80000000000004, + "363": 93.30000000000004, + "364": 120.35000000000005, + "365": 113.25000000000013, + "366": 114.8, + "367": 107.39999999999999, + "368": 103.55000000000004, + "369": 96.50000000000007, + "370": 103.00000000000014, + "371": 104.90000000000003, + "372": 112.64999999999999, + "373": 97.55000000000007, + "374": 111.25000000000011, + "375": 109.45000000000007, + "376": 117.60000000000011, + "377": 110.60000000000012, + "378": 119.54999999999995, + "379": 99.40000000000015, + "380": 111.14999999999998, + "381": 113.50000000000014, + "382": 67.44999999999986, + "383": 114.95000000000005, + "384": 118.10000000000018, + "385": 111.5, + "386": 91.79999999999994, + "387": 108.75000000000023, + "388": 91.85000000000001, + "389": 105.6, + "390": 114.9500000000002, + "391": -64.99999999999993, + "392": 112.1500000000001, + "393": 109.60000000000007, + "394": 111.90000000000006, + "395": 104.59999999999988, + "396": 113.20000000000009, + "397": 106.4000000000001, + "398": 117.30000000000003, + "399": 114.65000000000019, + "400": 107.80000000000005, + "401": 108.75000000000007, + "402": 113.40000000000005, + "403": 107.30000000000005, + "404": 112.8500000000002, + "405": 114.50000000000003, + "406": 106.24999999999993, + "407": 116.10000000000007, + "408": 81.89999999999995, + "409": 96.50000000000009, + "410": 116.55000000000024, + "411": 110.45000000000013, + "412": 100.55000000000011, + "413": 113.35000000000001, + "414": 117.25000000000014, + "415": 106.75000000000011, + "416": 121.40000000000012, + "417": 110.60000000000004, + "418": 106.24999999999997, + "419": 60.24999999999998, + "420": 98.30000000000015, + "421": 109.70000000000013, + "422": 90.35000000000001, + "423": 109.69999999999999, + "424": 117.3000000000001, + "425": 111.3500000000001, + "426": 100.79999999999997, + "427": 109.95, + "428": 106.75000000000006, + "429": 101.50000000000001, + "430": 92.30000000000011, + "431": 114.60000000000007, + "432": 103.3499999999999, + "433": 115.90000000000008, + "434": 103.70000000000014, + "435": 115.14999999999999, + "436": 115.25000000000016, + "437": 101.75000000000004, + "438": 109.40000000000013, + "439": 110.65000000000003, + "440": 105.4, + "441": 111.25000000000007, + "442": 109.00000000000009, + "443": 108.1000000000001, + "444": 110.60000000000004, + "445": 116.10000000000014, + "446": 97.30000000000007, + "447": 115.64999999999996, + "448": 111.74999999999997, + "449": 119.64999999999998, + "450": 112.40000000000005, + "451": 105.09999999999998, + "452": 118.5499999999999, + "453": 113.9500000000001, + "454": 114.10000000000005, + "455": 110.40000000000009, + "456": 107.8, + "457": 115.90000000000002, + "458": 114.25000000000009, + "459": 113.70000000000017, + "460": 114.05000000000015, + "461": 42.69999999999999, + "462": 112.90000000000008, + "463": 109.30000000000013, + "464": 113.70000000000006, + "465": 110.54999999999993, + "466": 107.59999999999997, + "467": 122.49999999999997, + "468": 107.19999999999999, + "469": 106.45000000000019, + "470": 111.20000000000006, + "471": 111.85000000000012, + "472": 99.55000000000003, + "473": 92.7999999999999, + "474": 113.00000000000024, + "475": 117.4500000000002, + "476": 113.95000000000013, + "477": 116.95000000000009, + "478": 114.35000000000012, + "479": 110.00000000000018, + "480": 104.10000000000004, + "481": 117.85000000000018, + "482": 115.90000000000018, + "483": 109.35000000000014, + "484": 50.14999999999981, + "485": 115.39999999999998, + "486": 116.6500000000003, + "487": 111.40000000000005, + "488": 74.29999999999984, + "489": 103.90000000000012, + "490": 106.79999999999995, + "491": 114.95000000000003, + "492": 111.45000000000006, + "493": 10.299999999999963, + "494": 115.3500000000001, + "495": 124.69999999999996, + "496": 114.25000000000014, + "497": 100.75000000000011, + "498": 115.10000000000007, + "499": 110.80000000000015, + "500": 107.25000000000006, + "501": 117.00000000000011, + "502": 108.9000000000001, + "503": 113.9500000000002, + "504": 113.8, + "505": 110.49999999999991, + "506": 107.44999999999999, + "507": 112.9000000000001, + "508": 109.15000000000013, + "509": 109.7, + "510": 114.30000000000003, + "511": 119.90000000000005, + "512": 101.09999999999995, + "513": 105.44999999999987, + "514": 95.25000000000003, + "515": 75.25000000000003, + "516": 112.44999999999999, + "517": 109.79999999999987, + "518": 106.40000000000002, + "519": 112.9500000000001, + "520": 108.45000000000019, + "521": 109.45000000000007, + "522": 119.39999999999999, + "523": 120.40000000000008, + "524": 106.05000000000011, + "525": 116.00000000000011, + "526": 117.60000000000016, + "527": 111.15000000000018, + "528": 100.5000000000001, + "529": 110.95000000000013, + "530": 118.55000000000027, + "531": 115.59999999999991, + "532": 105.10000000000011, + "533": 113.0500000000002, + "534": 105.00000000000011, + "535": 115.60000000000007, + "536": 110.60000000000005, + "537": 113.9000000000002, + "538": 106.90000000000003, + "539": 114.40000000000009, + "540": 118.45000000000005, + "541": 115.9000000000002, + "542": 118.65000000000019, + "543": 116.90000000000006, + "544": 110.00000000000014, + "545": 111.30000000000013, + "546": 111.34999999999997, + "547": 107.75000000000014, + "548": 107.84999999999985, + "549": 115.10000000000008, + "550": 115.85000000000007, + "551": 114.80000000000007, + "552": 110.05000000000003, + "553": 116.00000000000014, + "554": 111.50000000000009, + "555": 105.6000000000001, + "556": 112.95000000000003, + "557": 109.5500000000002, + "558": 112.39999999999999, + "559": 103.84999999999985, + "560": 105.35000000000005, + "561": 102.25000000000007, + "562": 111.65000000000006, + "563": 109.2500000000001, + "564": 115.70000000000009, + "565": 116.70000000000009, + "566": 115.05000000000011, + "567": 107.80000000000021, + "568": 109.19999999999987, + "569": 117.90000000000013, + "570": 102.59999999999991, + "571": 108.14999999999995, + "572": 115.00000000000023, + "573": 108.5500000000002, + "574": 107.30000000000024, + "575": 105.84999999999997, + "576": 112.90000000000015, + "577": 115.25000000000009, + "578": 101.75000000000018, + "579": 109.99999999999994, + "580": 98.8500000000001, + "581": 115.50000000000018, + "582": 115.15000000000006, + "583": 108.55000000000001, + "584": 110.55000000000022, + "585": 118.60000000000007, + "586": 104.8, + "587": 109.15000000000009, + "588": 108.55000000000008, + "589": 112.20000000000022, + "590": 96.3000000000001, + "591": 112.35000000000008, + "592": 110.15000000000009, + "593": 107.4500000000001, + "594": 108.60000000000012, + "595": 106.95000000000007, + "596": 111.55000000000007, + "597": 115.40000000000012, + "598": 114.85000000000007, + "599": 117.00000000000016, + "600": 109.65000000000008, + "601": 109.5500000000001, + "602": 114.20000000000009, + "603": 108.15000000000008, + "604": 104.05000000000014, + "605": 117.0500000000001, + "606": 94.19999999999993, + "607": 115.84999999999998, + "608": 101.30000000000014, + "609": 109.00000000000013, + "610": 113.65000000000013, + "611": 105.70000000000003, + "612": 109.39999999999999, + "613": 119.05000000000015, + "614": 113.05000000000005, + "615": 116.30000000000018, + "616": 115.25000000000006, + "617": 105.64999999999996, + "618": 114.40000000000003, + "619": 102.7499999999999, + "620": 117.05000000000007, + "621": 104.3000000000002, + "622": 116.90000000000012, + "623": 114.60000000000008, + "624": 117.25000000000017, + "625": 112.8000000000001, + "626": 113.99999999999997, + "627": 109.44999999999996, + "628": 109.70000000000006, + "629": 113.50000000000006, + "630": 110.90000000000008, + "631": 106.09999999999987, + "632": 108.55000000000005, + "633": 113.50000000000017, + "634": 113.60000000000011, + "635": 103.90000000000002, + "636": 111.7, + "637": 105.8000000000001, + "638": 106.50000000000017, + "639": 114.80000000000001, + "640": 114.30000000000014, + "641": 105.40000000000012, + "642": 106.19999999999999, + "643": 113.34999999999998, + "644": 110.05000000000015, + "645": 110.40000000000008, + "646": 118.5000000000001, + "647": 109.7000000000001, + "648": 111.10000000000005, + "649": 115.90000000000003, + "650": 113.80000000000007, + "651": 108.20000000000006, + "652": 108.50000000000006, + "653": -26.25000000000002, + "654": 110.85000000000002, + "655": 93.54999999999993, + "656": 111.45000000000022, + "657": 116.25000000000016, + "658": 107.19999999999986, + "659": 109.84999999999998, + "660": 114.50000000000004, + "661": 116.24999999999997, + "662": 110.5500000000002, + "663": 109.15000000000015, + "664": 86.7499999999999, + "665": 109.60000000000024, + "666": 101.30000000000005, + "667": 111.2500000000002, + "668": 119.85000000000014, + "669": 115.95000000000017, + "670": 112.40000000000023, + "671": 108.74999999999993, + "672": 116.05000000000024, + "673": 100.14999999999995, + "674": 109.85000000000001, + "675": 111.6500000000001, + "676": 109.65000000000003, + "677": 116.60000000000016, + "678": 112.15000000000006, + "679": 118.20000000000019, + "680": 114.90000000000013, + "681": 103.65000000000003, + "682": 117.30000000000005, + "683": 107.0, + "684": 110.65000000000019, + "685": 116.95000000000002, + "686": 108.00000000000001, + "687": 113.90000000000016, + "688": 54.79999999999992, + "689": 101.7, + "690": 9.650000000000045, + "691": 107.25000000000003, + "692": 72.65000000000013, + "693": 114.65000000000006, + "694": 116.7000000000001, + "695": 108.75000000000013, + "696": 112.25000000000004, + "697": 109.40000000000019, + "698": 109.00000000000003, + "699": 111.60000000000011, + "700": 108.70000000000003, + "701": 74.7999999999999, + "702": 107.24999999999996, + "703": 112.40000000000005, + "704": 114.79999999999997, + "705": 107.20000000000013, + "706": 87.4500000000001, + "707": 104.95000000000012, + "708": 119.8000000000001, + "709": 108.24999999999999, + "710": 115.30000000000003, + "711": 119.75000000000023, + "712": 109.99999999999999, + "713": 116.1000000000003, + "714": 109.14999999999995, + "715": 107.55000000000001, + "716": 112.70000000000009, + "717": 115.25, + "718": 110.05000000000022, + "719": 107.45000000000009, + "720": 110.40000000000018, + "721": 113.14999999999999, + "722": 114.05000000000005, + "723": 105.25000000000004, + "724": 115.40000000000015, + "725": 112.15000000000013, + "726": 115.3500000000002, + "727": 113.60000000000012, + "728": 111.04999999999998, + "729": 110.90000000000015, + "730": 110.50000000000014, + "731": 78.55000000000001, + "732": 109.0500000000001, + "733": 74.99999999999994, + "734": 113.40000000000019, + "735": 111.64999999999999, + "736": 110.15, + "737": 117.25000000000014, + "738": 101.09999999999982, + "739": 111.3500000000001, + "740": 110.40000000000013, + "741": 114.35000000000004, + "742": 104.35000000000004, + "743": 107.95000000000007, + "744": 116.55000000000011, + "745": 108.45, + "746": 105.35, + "747": 54.24999999999995, + "748": 116.40000000000018, + "749": 112.95000000000017, + "750": 110.35000000000012, + "751": 105.95000000000009, + "752": 108.65000000000005, + "753": 110.25000000000006, + "754": 107.80000000000004, + "755": 106.65000000000003, + "756": 106.25000000000011, + "757": 114.45000000000003, + "758": 121.20000000000007, + "759": 91.79999999999978, + "760": 113.40000000000019, + "761": 104.49999999999997, + "762": 112.15000000000003, + "763": 109.85000000000015, + "764": 108.30000000000003, + "765": 110.75000000000003, + "766": 105.04999999999998, + "767": 117.65000000000013, + "768": 112.24999999999993, + "769": 113.60000000000021, + "770": 115.05000000000014, + "771": 108.60000000000012, + "772": 105.85000000000005, + "773": 115.10000000000007, + "774": 111.65000000000002, + "775": 97.55000000000003, + "776": 92.50000000000007, + "777": 101.74999999999983, + "778": 113.20000000000019, + "779": 107.25000000000006, + "780": 109.00000000000009, + "781": 106.19999999999989, + "782": 79.05000000000007, + "783": 113.30000000000007, + "784": 117.85000000000015, + "785": 113.20000000000002, + "786": 109.65000000000008, + "787": 112.70000000000007, + "788": 112.24999999999997, + "789": 114.6000000000002, + "790": 108.45000000000005, + "791": 111.15000000000003, + "792": 103.70000000000013, + "793": 110.29999999999993, + "794": 116.54999999999991, + "795": 107.60000000000007, + "796": 117.3500000000001, + "797": 112.59999999999997, + "798": 105.14999999999998, + "799": 106.00000000000007, + "800": 114.95000000000019, + "801": 106.29999999999986, + "802": 103.70000000000006, + "803": 112.45000000000005, + "804": 114.1000000000002, + "805": 115.15000000000003, + "806": 122.00000000000018, + "807": 108.64999999999999, + "808": 111.60000000000005, + "809": 118.15000000000009, + "810": 115.90000000000013, + "811": 116.65000000000012, + "812": 96.24999999999991, + "813": 104.8, + "814": 111.35000000000015, + "815": 98.3, + "816": 110.69999999999997, + "817": 104.30000000000005, + "818": 115.40000000000008, + "819": 111.75000000000003, + "820": 107.8500000000001, + "821": 117.7000000000001, + "822": 111.50000000000011, + "823": 112.70000000000017, + "824": 110.05000000000013, + "825": 100.15000000000006, + "826": 107.95000000000013, + "827": 113.70000000000023, + "828": 111.65000000000008, + "829": 114.15, + "830": 110.39999999999996, + "831": 105.49999999999999, + "832": 105.85000000000016, + "833": 113.84999999999997, + "834": 114.9000000000001, + "835": 110.9500000000002, + "836": 109.20000000000009, + "837": 111.80000000000007, + "838": 110.35000000000008, + "839": 110.70000000000009, + "840": 117.75000000000009, + "841": 108.70000000000007, + "842": 105.19999999999996, + "843": 107.85000000000011, + "844": 115.00000000000006, + "845": 113.20000000000002, + "846": 109.95000000000003, + "847": 116.35000000000005, + "848": 114.90000000000016, + "849": 112.05000000000004, + "850": 114.85000000000011, + "851": 111.00000000000009, + "852": 109.40000000000019, + "853": 116.29999999999995, + "854": -23.59999999999998, + "855": 115.90000000000025, + "856": 110.05000000000017, + "857": 114.60000000000002, + "858": 113.20000000000006, + "859": 118.30000000000001, + "860": 109.24999999999999, + "861": 119.70000000000002, + "862": 110.85000000000005, + "863": 111.2500000000002, + "864": 108.65000000000013, + "865": 102.40000000000003, + "866": 118.40000000000009, + "867": 106.15000000000009, + "868": 111.30000000000013, + "869": 111.95000000000009, + "870": 105.50000000000007, + "871": 108.14999999999998, + "872": 110.05000000000014, + "873": 111.00000000000016, + "874": 115.45000000000006, + "875": 117.80000000000015, + "876": 116.49999999999991, + "877": 108.89999999999989, + "878": 109.00000000000007, + "879": 117.5000000000001, + "880": 109.35000000000002, + "881": 116.75000000000023, + "882": 103.85000000000002, + "883": 118.45000000000006, + "884": 107.9000000000001, + "885": 101.85000000000008, + "886": 106.80000000000004, + "887": 106.79999999999997, + "888": 104.99999999999989, + "889": 112.64999999999998, + "890": 114.15000000000016, + "891": 115.55000000000018, + "892": 15.79999999999994, + "893": 111.59999999999995, + "894": 110.85000000000007, + "895": 108.09999999999991, + "896": 114.55000000000014, + "897": 121.05000000000008, + "898": 113.35000000000004, + "899": 112.60000000000002, + "900": 109.6500000000002, + "901": 108.60000000000005, + "902": 112.15000000000018, + "903": 102.4999999999999, + "904": 115.89999999999998, + "905": 102.14999999999984, + "906": 110.39999999999992, + "907": 98.54999999999995, + "908": 110.70000000000007, + "909": 113.55000000000005, + "910": 112.55000000000014, + "911": 117.6, + "912": 74.50000000000006, + "913": 107.85000000000002, + "914": 107.40000000000005, + "915": 114.2500000000001, + "916": 101.24999999999999, + "917": 116.70000000000026, + "918": 108.30000000000017, + "919": 93.95000000000005, + "920": 112.49999999999999, + "921": -16.800000000000008, + "922": 111.75000000000016, + "923": 115.69999999999997, + "924": 108.85000000000004, + "925": 106.85000000000015, + "926": 111.30000000000005, + "927": 111.0000000000001, + "928": 107.5000000000001, + "929": 108.10000000000012, + "930": 115.9000000000002, + "931": 112.25000000000007, + "932": 113.10000000000004, + "933": 111.10000000000016, + "934": 115.75000000000003, + "935": 110.9500000000002, + "936": 107.39999999999999, + "937": 116.90000000000002, + "938": 113.00000000000006, + "939": 69.69999999999995, + "940": 48.39999999999991, + "941": 102.85, + "942": 107.6000000000001, + "943": 103.00000000000014, + "944": 109.15000000000002, + "945": 110.54999999999998, + "946": 103.19999999999997, + "947": 116.2500000000002, + "948": 113.00000000000004, + "949": 108.75000000000009, + "950": 102.20000000000012, + "951": 113.20000000000022, + "952": 109.90000000000019, + "953": 108.45000000000006, + "954": 108.64999999999999, + "955": 100.95, + "956": 107.65000000000003, + "957": 114.84999999999997, + "958": 113.40000000000003, + "959": 113.35000000000008, + "960": 114.80000000000005, + "961": 108.15, + "962": 113.85000000000018, + "963": 97.74999999999996, + "964": 116.2500000000001, + "965": 109.50000000000018, + "966": 114.7500000000002, + "967": 104.39999999999992, + "968": 110.1, + "969": 110.5000000000001, + "970": 111.10000000000012, + "971": 103.60000000000001, + "972": 103.50000000000003, + "973": 108.40000000000006, + "974": 103.99999999999989, + "975": 106.05000000000005, + "976": 119.15000000000008, + "977": 111.15, + "978": 113.1500000000001, + "979": 113.80000000000004, + "980": 114.80000000000005, + "981": 116.00000000000006, + "982": 110.79999999999988, + "983": 123.1000000000001, + "984": 107.94999999999987, + "985": 109.90000000000002, + "986": 111.05000000000014, + "987": 111.50000000000014, + "988": 114.70000000000017, + "989": 105.15, + "990": 108.6500000000001, + "991": 104.05000000000001, + "992": 118.20000000000009, + "993": 103.90000000000003, + "994": 105.44999999999992, + "995": 106.40000000000008, + "996": 110.6000000000002, + "997": 115.90000000000009, + "998": 114.95000000000002, + "999": 112.00000000000017, + "1000": 106.10000000000002 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.0.0/session_metadata/4.json b/benchmark/results/v3/v3.0.0/session_metadata/4.json new file mode 100644 index 00000000..c714cacb --- /dev/null +++ b/benchmark/results/v3/v3.0.0/session_metadata/4.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1535.29994, + "s_per_step": 0.047978123125, + "s_per_100_steps_10_nodes": 4.7978123125000005, + "total_reward_per_episode": { + "1": -21.899999999999956, + "2": -14.449999999999976, + "3": -89.60000000000001, + "4": -18.14999999999997, + "5": -17.949999999999967, + "6": -26.049999999999937, + "7": -58.00000000000009, + "8": -63.20000000000011, + "9": -26.699999999999953, + "10": -101.65, + "11": -44.30000000000007, + "12": -24.65, + "13": -48.90000000000007, + "14": -16.099999999999973, + "15": -71.40000000000003, + "16": -87.9, + "17": -77.4, + "18": -62.2500000000001, + "19": -93.1, + "20": -20.64999999999996, + "21": -33.400000000000034, + "22": -43.05000000000006, + "23": -100.29999999999998, + "24": -21.849999999999955, + "25": -15.299999999999983, + "26": -96.05, + "27": -24.25000000000001, + "28": 1.9500000000000421, + "29": -18.999999999999964, + "30": -3.2499999999999916, + "31": -11.349999999999993, + "32": -7.550000000000001, + "33": -36.099999999999966, + "34": -14.599999999999982, + "35": -20.149999999999963, + "36": -30.150000000000023, + "37": -11.049999999999983, + "38": -64.3000000000001, + "39": -3.4499999999999904, + "40": -12.34999999999999, + "41": -8.84999999999998, + "42": -9.499999999999998, + "43": -16.74999999999998, + "44": -14.94999999999998, + "45": -18.999999999999964, + "46": -19.099999999999962, + "47": -40.65000000000004, + "48": -95.59999999999994, + "49": -12.899999999999988, + "50": -86.35000000000001, + "51": -35.35000000000001, + "52": -13.70000000000001, + "53": -8.049999999999988, + "54": -39.20000000000013, + "55": -16.54999999999998, + "56": -21.399999999999967, + "57": -19.699999999999964, + "58": -54.45000000000004, + "59": -51.80000000000008, + "60": -9.100000000000001, + "61": -21.649999999999956, + "62": 0.6000000000000145, + "63": -11.49999999999999, + "64": -17.899999999999974, + "65": -65.75, + "66": -17.34999999999997, + "67": -16.44999999999997, + "68": -70.20000000000005, + "69": -82.55000000000004, + "70": -13.099999999999987, + "71": -10.19999999999999, + "72": -6.29999999999999, + "73": -20.84999999999996, + "74": -9.949999999999994, + "75": -9.100000000000001, + "76": -16.799999999999972, + "77": -13.199999999999957, + "78": -10.199999999999987, + "79": -37.85000000000008, + "80": -13.549999999999985, + "81": -89.95000000000007, + "82": -12.099999999999993, + "83": -15.449999999999974, + "84": -15.699999999999978, + "85": -10.999999999999993, + "86": -5.149999999999979, + "87": -13.699999999999989, + "88": -20.800000000000033, + "89": -15.999999999999975, + "90": -19.449999999999964, + "91": -22.700000000000003, + "92": -83.7, + "93": 13.600000000000007, + "94": -9.399999999999991, + "95": -7.149999999999995, + "96": -5.849999999999979, + "97": 1.299999999999975, + "98": -5.949999999999986, + "99": -22.150000000000016, + "100": -5.899999999999991, + "101": -12.64999999999997, + "102": -18.74999999999999, + "103": -21.049999999999958, + "104": -25.900000000000055, + "105": -14.699999999999978, + "106": -18.049999999999972, + "107": -1.6499999999999966, + "108": -88.90000000000002, + "109": -58.999999999999986, + "110": -63.24999999999991, + "111": 18.100000000000033, + "112": 9.549999999999974, + "113": -70.44999999999999, + "114": -74.35000000000001, + "115": -58.55, + "116": 5.950000000000025, + "117": 7.650000000000025, + "118": -61.75, + "119": -34.69999999999998, + "120": 5.500000000000035, + "121": -72.44999999999999, + "122": -69.9, + "123": -21.000000000000007, + "124": -86.55000000000004, + "125": 4.750000000000042, + "126": -9.099999999999996, + "127": 41.24999999999982, + "128": -53.200000000000045, + "129": -11.949999999999982, + "130": -61.350000000000094, + "131": 16.500000000000014, + "132": -18.54999999999997, + "133": -12.9, + "134": -41.70000000000005, + "135": -28.949999999999996, + "136": -20.149999999999977, + "137": 1.5000000000000069, + "138": -13.599999999999982, + "139": -17.549999999999976, + "140": -67.69999999999999, + "141": -64.64999999999998, + "142": -14.24999999999995, + "143": -2.9999999999999973, + "144": -13.399999999999984, + "145": 22.999999999999954, + "146": -2.549999999999967, + "147": 4.35000000000002, + "148": -7.999999999999975, + "149": 21.70000000000003, + "150": -27.849999999999973, + "151": 20.650000000000052, + "152": -86.39999999999996, + "153": 2.3500000000000476, + "154": -35.550000000000026, + "155": -21.0, + "156": -29.149999999999956, + "157": -65.14999999999993, + "158": -86.30000000000001, + "159": 3.8500000000000907, + "160": -9.950000000000001, + "161": 3.500000000000022, + "162": -74.8, + "163": -64.35, + "164": 30.199999999999825, + "165": -72.04999999999995, + "166": 17.050000000000026, + "167": -18.400000000000063, + "168": -17.799999999999972, + "169": 29.900000000000016, + "170": -2.6499999999999506, + "171": 3.7500000000000444, + "172": -78.69999999999999, + "173": -76.1, + "174": -35.35000000000005, + "175": -61.400000000000006, + "176": 14.449999999999928, + "177": -69.69999999999996, + "178": -40.70000000000012, + "179": -62.44999999999999, + "180": -0.15000000000000413, + "181": 24.049999999999887, + "182": -12.199999999999978, + "183": -98.3, + "184": -78.4, + "185": -13.24999999999999, + "186": -80.25000000000001, + "187": -90.85000000000004, + "188": -14.44999999999998, + "189": -97.10000000000001, + "190": -14.999999999999991, + "191": -7.799999999999985, + "192": 7.950000000000008, + "193": 32.499999999999886, + "194": -7.950000000000016, + "195": -18.599999999999966, + "196": 43.69999999999983, + "197": 8.600000000000033, + "198": 23.850000000000012, + "199": -8.049999999999994, + "200": -92.75, + "201": -84.60000000000001, + "202": -31.74999999999995, + "203": 7.600000000000028, + "204": -42.70000000000002, + "205": -15.84999999999996, + "206": -13.499999999999996, + "207": -54.35000000000009, + "208": 29.59999999999977, + "209": 19.14999999999997, + "210": -76.5, + "211": -58.35000000000006, + "212": -54.90000000000009, + "213": 2.650000000000049, + "214": -57.70000000000002, + "215": -13.799999999999972, + "216": 6.9000000000000075, + "217": 23.850000000000062, + "218": -2.250000000000001, + "219": 1.100000000000045, + "220": -4.499999999999976, + "221": -74.0999999999999, + "222": -12.199999999999992, + "223": -81.44999999999999, + "224": -6.95000000000001, + "225": -94.8, + "226": -66.89999999999998, + "227": -57.70000000000001, + "228": 1.8000000000000302, + "229": -69.30000000000001, + "230": -64.95, + "231": -16.299999999999983, + "232": 0.3499999999999919, + "233": -67.3, + "234": 11.399999999999995, + "235": -82.9, + "236": -15.549999999999981, + "237": -68.49999999999994, + "238": -62.8, + "239": 19.249999999999936, + "240": 19.549999999999965, + "241": 28.00000000000002, + "242": -70.75000000000001, + "243": -90.80000000000001, + "244": 33.7999999999998, + "245": 8.999999999999932, + "246": -33.200000000000045, + "247": -9.899999999999999, + "248": -17.749999999999975, + "249": -4.550000000000008, + "250": -85.44999999999993, + "251": -84.39999999999998, + "252": 12.150000000000034, + "253": -16.499999999999975, + "254": 9.650000000000073, + "255": 5.7000000000000615, + "256": 53.74999999999985, + "257": 6.950000000000031, + "258": 104.10000000000024, + "259": -18.600000000000037, + "260": -77.9, + "261": 6.750000000000051, + "262": -29.65000000000004, + "263": -79.60000000000005, + "264": -43.55000000000008, + "265": -46.89999999999998, + "266": 5.900000000000046, + "267": -78.99999999999996, + "268": -67.34999999999995, + "269": -45.80000000000001, + "270": 64.94999999999975, + "271": -47.74999999999999, + "272": -9.45000000000001, + "273": 64.54999999999995, + "274": -27.74999999999997, + "275": 7.650000000000018, + "276": -90.35000000000005, + "277": -62.999999999999986, + "278": 62.44999999999993, + "279": -80.7, + "280": 59.099999999999895, + "281": -77.8, + "282": 1.7500000000000502, + "283": 25.399999999999885, + "284": -79.95000000000002, + "285": 27.349999999999973, + "286": 19.350000000000037, + "287": -73.6, + "288": 41.04999999999987, + "289": -76.6999999999999, + "290": 1.749999999999981, + "291": -5.649999999999985, + "292": 28.050000000000043, + "293": 9.100000000000009, + "294": 20.149999999999935, + "295": 40.85000000000008, + "296": -7.099999999999973, + "297": 0.849999999999985, + "298": 59.94999999999987, + "299": 21.499999999999982, + "300": 30.54999999999982, + "301": -45.20000000000001, + "302": 44.84999999999991, + "303": 28.249999999999996, + "304": -39.00000000000002, + "305": -23.05000000000001, + "306": 7.649999999999961, + "307": -40.19999999999996, + "308": 11.95000000000007, + "309": 3.450000000000032, + "310": -80.85000000000005, + "311": -16.14999999999998, + "312": 36.19999999999994, + "313": 6.299999999999992, + "314": -13.199999999999982, + "315": 28.449999999999964, + "316": -17.19999999999999, + "317": -39.30000000000007, + "318": 26.55000000000001, + "319": 42.899999999999885, + "320": 33.74999999999984, + "321": -17.64999999999996, + "322": 80.70000000000024, + "323": 22.09999999999993, + "324": -13.249999999999988, + "325": 42.04999999999978, + "326": -16.549999999999976, + "327": 74.69999999999999, + "328": 67.95000000000022, + "329": -10.050000000000011, + "330": 67.2999999999999, + "331": 25.349999999999845, + "332": 72.29999999999998, + "333": 64.24999999999989, + "334": -68.25, + "335": -41.05000000000011, + "336": 50.24999999999977, + "337": -30.600000000000016, + "338": -79.80000000000001, + "339": 24.800000000000022, + "340": -27.549999999999958, + "341": -4.599999999999999, + "342": -65.09999999999992, + "343": -8.44999999999994, + "344": -20.099999999999984, + "345": -29.149999999999995, + "346": 18.150000000000055, + "347": -48.7000000000001, + "348": -41.54999999999997, + "349": 47.75000000000002, + "350": 52.999999999999716, + "351": 64.39999999999993, + "352": 62.64999999999979, + "353": 62.849999999999795, + "354": -63.50000000000006, + "355": 87.05000000000021, + "356": 37.69999999999998, + "357": -1.1999999999999889, + "358": -11.400000000000006, + "359": -4.499999999999992, + "360": 104.00000000000014, + "361": 98.2000000000001, + "362": -15.199999999999976, + "363": 27.600000000000076, + "364": 27.999999999999897, + "365": -20.00000000000003, + "366": -77.25000000000003, + "367": -15.550000000000013, + "368": 108.55000000000021, + "369": 42.89999999999982, + "370": -48.45000000000012, + "371": -5.949999999999975, + "372": 32.19999999999976, + "373": 81.95000000000005, + "374": 86.45000000000005, + "375": -56.34999999999995, + "376": -18.499999999999947, + "377": 48.099999999999945, + "378": 42.74999999999983, + "379": -75.3, + "380": -4.349999999999977, + "381": 88.84999999999981, + "382": 19.40000000000005, + "383": 38.54999999999984, + "384": 113.05000000000024, + "385": 27.70000000000006, + "386": -14.099999999999971, + "387": -76.55000000000004, + "388": -14.000000000000036, + "389": 107.70000000000014, + "390": 34.54999999999983, + "391": 55.349999999999795, + "392": 106.30000000000018, + "393": 47.24999999999993, + "394": 8.00000000000006, + "395": 12.749999999999899, + "396": 31.799999999999805, + "397": -5.29999999999999, + "398": 86.74999999999979, + "399": -7.500000000000018, + "400": 49.79999999999988, + "401": 18.20000000000002, + "402": -12.949999999999964, + "403": -36.199999999999996, + "404": 119.30000000000032, + "405": 83.69999999999987, + "406": -39.40000000000001, + "407": -12.449999999999998, + "408": 102.95000000000009, + "409": 39.64999999999997, + "410": 48.1, + "411": 78.39999999999996, + "412": 0.349999999999976, + "413": 31.099999999999834, + "414": 76.8999999999998, + "415": 33.10000000000002, + "416": -65.14999999999998, + "417": 65.70000000000002, + "418": 41.449999999999946, + "419": -7.399999999999974, + "420": 34.849999999999994, + "421": 85.45000000000012, + "422": 67.74999999999994, + "423": 106.20000000000014, + "424": -5.250000000000014, + "425": 95.80000000000001, + "426": 114.70000000000029, + "427": 83.49999999999999, + "428": 69.75, + "429": -57.19999999999999, + "430": 20.600000000000016, + "431": 113.40000000000018, + "432": 34.59999999999992, + "433": -49.04999999999999, + "434": 67.94999999999975, + "435": 24.84999999999996, + "436": -79.94999999999999, + "437": 55.29999999999992, + "438": 33.949999999999996, + "439": 108.60000000000026, + "440": 65.99999999999976, + "441": 71.99999999999977, + "442": 104.50000000000018, + "443": 107.19999999999993, + "444": 79.55000000000003, + "445": 36.89999999999998, + "446": -42.80000000000002, + "447": 84.49999999999986, + "448": 101.05, + "449": 82.10000000000007, + "450": 116.55000000000028, + "451": 35.499999999999936, + "452": 95.19999999999975, + "453": -10.750000000000044, + "454": -25.950000000000045, + "455": 67.79999999999978, + "456": 57.34999999999985, + "457": 86.49999999999993, + "458": 28.849999999999945, + "459": 40.09999999999998, + "460": 89.8500000000001, + "461": 57.99999999999997, + "462": 104.3499999999999, + "463": 106.04999999999973, + "464": 8.349999999999994, + "465": 80.80000000000011, + "466": 78.9, + "467": 63.59999999999994, + "468": 41.59999999999998, + "469": 55.70000000000001, + "470": 0.8000000000000071, + "471": 51.94999999999991, + "472": 70.54999999999998, + "473": 115.05000000000017, + "474": 85.49999999999996, + "475": 62.699999999999896, + "476": 97.20000000000014, + "477": 79.84999999999994, + "478": 77.55000000000003, + "479": 47.99999999999988, + "480": 64.8999999999999, + "481": 57.10000000000004, + "482": 115.2000000000002, + "483": 111.60000000000005, + "484": 85.69999999999999, + "485": 104.75000000000026, + "486": 92.5500000000001, + "487": 99.6999999999998, + "488": 84.1999999999999, + "489": 115.2500000000002, + "490": 98.49999999999993, + "491": 98.05000000000013, + "492": 116.45000000000026, + "493": 106.0000000000002, + "494": 109.25000000000021, + "495": 94.04999999999997, + "496": 75.15, + "497": 88.85000000000005, + "498": 74.65000000000023, + "499": 58.39999999999983, + "500": 45.549999999999805, + "501": 109.15000000000023, + "502": 95.19999999999996, + "503": 78.89999999999999, + "504": 110.2500000000002, + "505": 83.35000000000001, + "506": 100.25000000000007, + "507": 72.49999999999997, + "508": 107.1, + "509": 105.95000000000013, + "510": 76.85, + "511": 75.94999999999997, + "512": 101.85000000000011, + "513": 115.55000000000025, + "514": 109.50000000000013, + "515": 25.099999999999838, + "516": 60.749999999999936, + "517": 97.29999999999991, + "518": 115.65000000000009, + "519": 93.05000000000003, + "520": 56.799999999999905, + "521": -0.40000000000003405, + "522": 58.649999999999956, + "523": 70.39999999999989, + "524": 108.65, + "525": 120.55000000000024, + "526": 53.649999999999935, + "527": 36.49999999999998, + "528": 109.05000000000018, + "529": 66.00000000000013, + "530": 115.00000000000024, + "531": 100.84999999999995, + "532": 118.60000000000024, + "533": 111.2000000000001, + "534": 47.499999999999844, + "535": 105.55000000000017, + "536": 91.64999999999985, + "537": 112.54999999999997, + "538": 68.59999999999982, + "539": 112.85000000000014, + "540": 88.80000000000005, + "541": 108.89999999999979, + "542": 101.75000000000017, + "543": 38.24999999999989, + "544": 93.29999999999991, + "545": 106.70000000000007, + "546": 47.29999999999987, + "547": 92.60000000000001, + "548": 118.55000000000024, + "549": 95.10000000000014, + "550": 105.19999999999996, + "551": 114.55000000000004, + "552": 82.89999999999986, + "553": 99.8, + "554": 105.00000000000007, + "555": 35.099999999999945, + "556": 48.349999999999945, + "557": 88.90000000000006, + "558": 112.3499999999999, + "559": 58.89999999999984, + "560": 91.10000000000015, + "561": 86.59999999999984, + "562": 114.90000000000018, + "563": 116.55000000000022, + "564": 77.94999999999987, + "565": 105.00000000000006, + "566": 97.29999999999981, + "567": 113.64999999999999, + "568": 103.75000000000007, + "569": 81.10000000000004, + "570": 110.14999999999998, + "571": 93.15000000000003, + "572": 99.95000000000013, + "573": 85.59999999999992, + "574": 82.00000000000013, + "575": 115.50000000000003, + "576": 89.89999999999986, + "577": 80.69999999999993, + "578": 112.90000000000006, + "579": 70.7499999999998, + "580": 85.95000000000005, + "581": 107.05000000000004, + "582": 108.25000000000007, + "583": 73.89999999999996, + "584": 103.44999999999995, + "585": 103.35000000000011, + "586": 89.49999999999987, + "587": 102.25000000000014, + "588": 109.20000000000023, + "589": 96.60000000000005, + "590": 105.84999999999997, + "591": 102.45000000000017, + "592": 58.84999999999985, + "593": 102.4, + "594": 113.10000000000014, + "595": 110.15000000000012, + "596": 108.80000000000011, + "597": 109.5000000000002, + "598": 121.54999999999998, + "599": 109.10000000000024, + "600": 113.25000000000014, + "601": 106.30000000000013, + "602": 99.8500000000001, + "603": 88.15, + "604": 114.20000000000023, + "605": 93.80000000000003, + "606": 103.50000000000007, + "607": 96.7500000000002, + "608": -4.299999999999987, + "609": 104.35000000000005, + "610": 103.45000000000019, + "611": 112.25000000000004, + "612": 108.95000000000013, + "613": 114.45000000000002, + "614": 106.10000000000002, + "615": 109.99999999999991, + "616": 51.349999999999845, + "617": 115.44999999999999, + "618": 102.00000000000004, + "619": 115.05000000000007, + "620": 111.7000000000001, + "621": 104.00000000000003, + "622": 109.14999999999998, + "623": 113.99999999999999, + "624": 104.44999999999992, + "625": 108.40000000000009, + "626": 66.24999999999989, + "627": 108.50000000000001, + "628": 111.0000000000001, + "629": 114.85000000000015, + "630": 111.0500000000001, + "631": 109.39999999999998, + "632": 99.24999999999999, + "633": 106.89999999999996, + "634": 112.10000000000018, + "635": 114.55000000000005, + "636": 106.80000000000004, + "637": 106.0000000000001, + "638": 109.15000000000006, + "639": 115.30000000000004, + "640": 106.05000000000004, + "641": 113.15000000000009, + "642": 112.35000000000012, + "643": 74.55000000000004, + "644": 107.55000000000014, + "645": 105.2, + "646": 104.10000000000022, + "647": 107.49999999999997, + "648": 109.6500000000001, + "649": 110.20000000000005, + "650": 114.45000000000017, + "651": 107.8, + "652": 111.05000000000013, + "653": 110.04999999999998, + "654": 109.60000000000011, + "655": 107.40000000000016, + "656": 110.50000000000007, + "657": 113.75000000000009, + "658": 104.59999999999998, + "659": 117.5500000000001, + "660": 107.70000000000014, + "661": 100.85000000000011, + "662": 114.35000000000007, + "663": 104.40000000000006, + "664": 113.20000000000014, + "665": 111.80000000000004, + "666": 100.25000000000004, + "667": 102.1500000000001, + "668": 106.2000000000001, + "669": 114.80000000000004, + "670": 117.80000000000017, + "671": 114.25000000000013, + "672": 104.1, + "673": 104.90000000000006, + "674": 116.60000000000021, + "675": 113.24999999999996, + "676": 119.45000000000009, + "677": 117.60000000000011, + "678": 108.64999999999995, + "679": 110.40000000000013, + "680": 113.25000000000014, + "681": 118.90000000000006, + "682": 110.00000000000007, + "683": 105.95000000000016, + "684": 115.25000000000018, + "685": 102.89999999999998, + "686": 108.39999999999993, + "687": 110.10000000000002, + "688": 113.5500000000001, + "689": 112.55000000000007, + "690": 109.65000000000005, + "691": 110.05000000000013, + "692": 102.75000000000003, + "693": 108.69999999999993, + "694": 112.70000000000022, + "695": 113.9, + "696": 109.10000000000007, + "697": 114.4500000000001, + "698": 110.95000000000005, + "699": 112.39999999999999, + "700": 105.35000000000008, + "701": 105.80000000000003, + "702": 98.10000000000016, + "703": 109.95000000000014, + "704": 2.150000000000006, + "705": 109.00000000000009, + "706": 118.39999999999999, + "707": 106.10000000000008, + "708": 96.80000000000011, + "709": 111.10000000000015, + "710": 118.80000000000022, + "711": 114.49999999999997, + "712": 109.15000000000003, + "713": 113.10000000000007, + "714": 113.30000000000007, + "715": 105.35000000000014, + "716": 108.90000000000009, + "717": 116.00000000000003, + "718": 103.50000000000009, + "719": 112.80000000000015, + "720": 107.70000000000006, + "721": 108.80000000000004, + "722": 52.89999999999999, + "723": 108.40000000000018, + "724": 106.35000000000007, + "725": 113.35000000000015, + "726": 114.30000000000013, + "727": 116.55000000000013, + "728": 105.3999999999999, + "729": 117.40000000000015, + "730": -62.29999999999995, + "731": 111.5500000000002, + "732": 114.90000000000013, + "733": 112.65000000000019, + "734": 108.00000000000001, + "735": 117.20000000000019, + "736": 110.55000000000001, + "737": 115.60000000000014, + "738": 99.3000000000001, + "739": 112.75000000000007, + "740": 118.10000000000014, + "741": 76.54999999999994, + "742": 109.55000000000013, + "743": 114.2500000000002, + "744": 80.4500000000001, + "745": 108.55000000000001, + "746": 111.85000000000026, + "747": 115.45000000000014, + "748": 111.69999999999992, + "749": 109.30000000000001, + "750": 118.15000000000005, + "751": 115.95000000000006, + "752": 105.49999999999996, + "753": 113.50000000000017, + "754": 109.60000000000014, + "755": 116.45000000000013, + "756": 112.65000000000012, + "757": 117.45000000000007, + "758": 106.80000000000005, + "759": 116.85000000000011, + "760": 108.50000000000018, + "761": 110.25000000000016, + "762": 101.25000000000009, + "763": 105.10000000000018, + "764": 112.40000000000015, + "765": 101.75000000000009, + "766": 111.30000000000011, + "767": 114.20000000000007, + "768": 102.3500000000001, + "769": 91.34999999999994, + "770": 109.00000000000023, + "771": 108.75000000000006, + "772": 103.70000000000016, + "773": 116.10000000000001, + "774": 103.85000000000007, + "775": 112.75000000000003, + "776": 113.20000000000013, + "777": 116.75000000000023, + "778": 112.55, + "779": 112.50000000000016, + "780": 98.65, + "781": 109.20000000000003, + "782": 101.6000000000001, + "783": 109.49999999999997, + "784": 117.75000000000007, + "785": 106.15000000000005, + "786": 116.95000000000006, + "787": 100.1000000000001, + "788": 104.50000000000006, + "789": 97.49999999999993, + "790": 106.30000000000013, + "791": 66.89999999999992, + "792": 105.1000000000001, + "793": 118.55000000000015, + "794": 113.79999999999997, + "795": 108.70000000000003, + "796": 107.2000000000001, + "797": 118.15000000000003, + "798": 111.95000000000013, + "799": 116.10000000000007, + "800": 102.25000000000011, + "801": 68.44999999999996, + "802": 112.49999999999999, + "803": 116.45000000000014, + "804": 111.60000000000008, + "805": 109.9, + "806": 38.74999999999991, + "807": 119.40000000000009, + "808": 107.60000000000014, + "809": 106.79999999999995, + "810": 112.00000000000006, + "811": 106.90000000000005, + "812": 109.55000000000001, + "813": 114.90000000000016, + "814": 109.8500000000002, + "815": 105.50000000000007, + "816": 112.45000000000023, + "817": 105.39999999999998, + "818": 109.80000000000011, + "819": 95.69999999999997, + "820": 112.25000000000013, + "821": 118.10000000000001, + "822": 114.35000000000012, + "823": 118.10000000000014, + "824": 106.69999999999989, + "825": 118.80000000000001, + "826": 106.09999999999994, + "827": 106.55000000000008, + "828": 73.90000000000008, + "829": 102.80000000000008, + "830": 113.85000000000004, + "831": 112.15, + "832": 111.80000000000022, + "833": 112.4000000000001, + "834": 55.84999999999986, + "835": 106.75000000000013, + "836": 107.10000000000022, + "837": 67.3499999999999, + "838": 98.2, + "839": 107.6500000000001, + "840": 98.40000000000008, + "841": 111.45000000000017, + "842": 112.65000000000012, + "843": 84.30000000000003, + "844": 111.15000000000018, + "845": 122.74999999999997, + "846": 106.30000000000018, + "847": 103.00000000000003, + "848": 100.15000000000005, + "849": 114.25, + "850": 109.70000000000006, + "851": 108.10000000000005, + "852": 115.60000000000004, + "853": 107.4, + "854": 110.64999999999995, + "855": 111.00000000000007, + "856": 116.05000000000005, + "857": 102.25000000000017, + "858": 109.70000000000009, + "859": 103.05000000000001, + "860": 95.90000000000002, + "861": 33.499999999999986, + "862": 48.39999999999995, + "863": 109.5500000000001, + "864": 108.75000000000014, + "865": 110.30000000000003, + "866": 112.90000000000023, + "867": 107.65000000000008, + "868": 115.15000000000009, + "869": 110.2500000000002, + "870": 117.7, + "871": 104.30000000000001, + "872": 101.29999999999997, + "873": 114.05000000000015, + "874": 108.5999999999999, + "875": 110.85000000000016, + "876": 97.90000000000006, + "877": 100.7, + "878": 115.20000000000012, + "879": 114.05, + "880": 117.75000000000001, + "881": 108.80000000000004, + "882": 107.05000000000004, + "883": 91.55000000000005, + "884": 109.50000000000001, + "885": 105.70000000000012, + "886": 113.00000000000006, + "887": 76.25000000000009, + "888": 106.2, + "889": 110.85000000000008, + "890": 111.70000000000017, + "891": 92.35000000000007, + "892": 115.40000000000012, + "893": 108.2999999999999, + "894": 86.8000000000001, + "895": 107.40000000000012, + "896": 106.25000000000016, + "897": 111.9, + "898": 112.35000000000015, + "899": 114.60000000000002, + "900": 113.29999999999995, + "901": 102.15000000000015, + "902": 112.90000000000022, + "903": -14.099999999999989, + "904": 97.89999999999999, + "905": 102.95000000000005, + "906": 118.5000000000001, + "907": 112.05000000000013, + "908": 117.80000000000014, + "909": 107.40000000000019, + "910": 111.69999999999997, + "911": 108.34999999999994, + "912": 117.50000000000001, + "913": 117.15000000000008, + "914": 115.15000000000013, + "915": 107.00000000000001, + "916": 23.45, + "917": 109.35000000000007, + "918": 99.90000000000009, + "919": 99.80000000000011, + "920": 106.30000000000007, + "921": 119.00000000000009, + "922": 110.10000000000008, + "923": 105.39999999999998, + "924": 121.20000000000016, + "925": 118.1500000000001, + "926": 109.90000000000009, + "927": 114.90000000000003, + "928": 113.85, + "929": 100.49999999999996, + "930": 109.90000000000009, + "931": 105.15000000000016, + "932": 99.3, + "933": 101.35000000000007, + "934": 117.70000000000022, + "935": 104.95000000000012, + "936": 71.04999999999987, + "937": 106.85000000000005, + "938": 108.05000000000004, + "939": 112.55000000000004, + "940": 114.00000000000009, + "941": 110.40000000000002, + "942": 24.69999999999992, + "943": 60.749999999999915, + "944": 111.70000000000007, + "945": 119.75000000000007, + "946": 111.10000000000005, + "947": 117.2500000000001, + "948": 105.60000000000007, + "949": 112.55000000000008, + "950": 97.40000000000002, + "951": 111.85000000000016, + "952": 116.60000000000008, + "953": 110.2500000000001, + "954": 92.85000000000018, + "955": 99.45000000000006, + "956": 111.45000000000023, + "957": 110.10000000000001, + "958": 114.80000000000003, + "959": 116.90000000000012, + "960": 110.25000000000006, + "961": -33.6, + "962": 107.20000000000003, + "963": 112.25000000000003, + "964": 108.10000000000014, + "965": 104.05000000000003, + "966": 116.55000000000013, + "967": 111.4, + "968": 104.05000000000007, + "969": 113.80000000000007, + "970": 112.50000000000027, + "971": 104.6500000000001, + "972": 112.19999999999995, + "973": 111.25000000000004, + "974": 114.50000000000011, + "975": 97.4500000000001, + "976": 110.60000000000011, + "977": 111.35000000000001, + "978": 106.25000000000003, + "979": 114.75000000000004, + "980": 108.99999999999999, + "981": 110.65000000000006, + "982": 117.25000000000011, + "983": 95.55000000000007, + "984": 116.75000000000006, + "985": 110.3000000000001, + "986": 117.55000000000004, + "987": 109.90000000000006, + "988": 105.50000000000006, + "989": 101.75000000000006, + "990": 109.54999999999995, + "991": 115.40000000000005, + "992": 15.650000000000086, + "993": 108.15000000000006, + "994": 106.05000000000008, + "995": 112.50000000000003, + "996": 110.55000000000003, + "997": 108.40000000000005, + "998": 113.25000000000003, + "999": 100.75000000000009, + "1000": 110.70000000000007 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.0.0/session_metadata/5.json b/benchmark/results/v3/v3.0.0/session_metadata/5.json new file mode 100644 index 00000000..e7cd6d72 --- /dev/null +++ b/benchmark/results/v3/v3.0.0/session_metadata/5.json @@ -0,0 +1,1009 @@ +{ + "total_episodes": 1001, + "total_time_steps": 128000, + "total_s": 1723.529247, + "s_per_step": 0.053860288968749996, + "s_per_100_steps_10_nodes": 5.386028896875, + "total_reward_per_episode": { + "1": -46.70000000000007, + "2": -19.849999999999977, + "3": -22.549999999999955, + "4": -17.399999999999967, + "5": -38.950000000000024, + "6": -19.099999999999966, + "7": -15.049999999999992, + "8": -76.30000000000003, + "9": -61.25000000000008, + "10": -23.89999999999997, + "11": -14.549999999999985, + "12": -19.049999999999965, + "13": -12.849999999999985, + "14": -19.99999999999996, + "15": -15.199999999999969, + "16": -17.549999999999972, + "17": -4.5, + "18": -42.550000000000054, + "19": -19.54999999999998, + "20": -17.14999999999997, + "21": -94.15, + "22": -18.14999999999997, + "23": -21.34999999999996, + "24": -21.65000000000001, + "25": -31.449999999999953, + "26": -93.85, + "27": -15.899999999999979, + "28": -50.40000000000008, + "29": -26.399999999999988, + "30": -51.199999999999996, + "31": -20.099999999999984, + "32": -16.99999999999997, + "33": -51.15000000000022, + "34": -21.849999999999955, + "35": -28.550000000000065, + "36": -37.80000000000004, + "37": -15.849999999999975, + "38": -18.899999999999967, + "39": -21.649999999999956, + "40": -92.55000000000014, + "41": -6.149999999999979, + "42": -36.15000000000002, + "43": -21.949999999999957, + "44": -25.949999999999964, + "45": -21.599999999999955, + "46": -27.64999999999993, + "47": -15.899999999999977, + "48": -24.949999999999996, + "49": -11.599999999999989, + "50": -22.349999999999955, + "51": -17.149999999999984, + "52": -65.65000000000009, + "53": -44.75000000000001, + "54": -13.949999999999992, + "55": -20.04999999999996, + "56": -93.80000000000004, + "57": -50.70000000000007, + "58": -36.85000000000007, + "59": -14.14999999999998, + "60": -88.05, + "61": -96.75, + "62": -12.94999999999999, + "63": -15.49999999999998, + "64": -29.750000000000043, + "65": -7.700000000000012, + "66": -60.4500000000001, + "67": -17.79999999999997, + "68": -14.54999999999997, + "69": -27.04999999999995, + "70": -28.500000000000007, + "71": 7.650000000000068, + "72": -18.449999999999967, + "73": -11.949999999999987, + "74": -16.899999999999977, + "75": -22.90000000000001, + "76": -82.24999999999994, + "77": -0.7999999999999745, + "78": -19.649999999999963, + "79": -72.94999999999999, + "80": -35.50000000000009, + "81": -64.35, + "82": -21.599999999999955, + "83": -21.399999999999956, + "84": -15.74999999999996, + "85": -48.349999999999994, + "86": -23.799999999999947, + "87": -12.599999999999987, + "88": -11.749999999999993, + "89": -8.599999999999996, + "90": -77.65000000000006, + "91": -21.34999999999996, + "92": -14.39999999999998, + "93": -13.049999999999985, + "94": -16.599999999999973, + "95": -14.349999999999985, + "96": -14.299999999999986, + "97": -14.049999999999978, + "98": -35.05, + "99": -5.999999999999961, + "100": 2.2000000000000473, + "101": -10.299999999999992, + "102": -23.849999999999948, + "103": -16.349999999999977, + "104": -25.449999999999942, + "105": -45.4, + "106": -8.899999999999991, + "107": -41.65000000000016, + "108": -0.7500000000000457, + "109": -15.349999999999975, + "110": -25.59999999999995, + "111": -19.749999999999964, + "112": -13.099999999999984, + "113": 3.2000000000000064, + "114": -15.04999999999998, + "115": -11.04999999999999, + "116": -47.99999999999994, + "117": -21.449999999999957, + "118": -17.599999999999977, + "119": 6.4000000000000306, + "120": 0.7000000000000142, + "121": -21.899999999999956, + "122": -14.349999999999985, + "123": -19.699999999999964, + "124": -14.299999999999985, + "125": -57.04999999999998, + "126": -6.250000000000001, + "127": -17.74999999999997, + "128": -1.5999999999999779, + "129": 9.800000000000068, + "130": -16.549999999999972, + "131": -14.64999999999998, + "132": -20.249999999999996, + "133": -21.699999999999953, + "134": 8.500000000000052, + "135": 0.10000000000005338, + "136": -15.049999999999992, + "137": -52.000000000000085, + "138": -42.250000000000014, + "139": -5.049999999999991, + "140": -1.2000000000000004, + "141": -16.249999999999964, + "142": -21.04999999999997, + "143": -16.49999999999997, + "144": -14.899999999999975, + "145": -15.299999999999974, + "146": 2.050000000000013, + "147": -16.899999999999967, + "148": -85.69999999999999, + "149": -16.0, + "150": 22.79999999999995, + "151": 22.04999999999997, + "152": -13.499999999999982, + "153": -70.14999999999995, + "154": -71.75000000000007, + "155": -34.94999999999997, + "156": 5.199999999999951, + "157": -4.999999999999977, + "158": 3.000000000000022, + "159": -0.0999999999999821, + "160": -14.299999999999985, + "161": 9.25000000000001, + "162": -6.749999999999983, + "163": 5.750000000000025, + "164": -7.699999999999989, + "165": -5.649999999999977, + "166": -5.500000000000006, + "167": -11.749999999999984, + "168": -17.699999999999985, + "169": 7.750000000000034, + "170": -14.199999999999987, + "171": -32.79999999999996, + "172": -40.19999999999999, + "173": 21.84999999999983, + "174": -11.799999999999994, + "175": -4.44999999999998, + "176": 1.5000000000000318, + "177": -2.799999999999975, + "178": -80.40000000000005, + "179": -29.499999999999993, + "180": -40.29999999999997, + "181": -93.54999999999997, + "182": -12.699999999999985, + "183": 8.10000000000004, + "184": 24.199999999999925, + "185": -7.949999999999988, + "186": -38.40000000000002, + "187": -12.899999999999983, + "188": 15.100000000000033, + "189": -9.549999999999995, + "190": 10.150000000000002, + "191": -65.05000000000001, + "192": 4.699999999999995, + "193": 9.00000000000006, + "194": -29.749999999999982, + "195": 11.850000000000065, + "196": -22.65000000000005, + "197": -21.399999999999956, + "198": -1.7500000000000056, + "199": -59.50000000000003, + "200": -9.649999999999995, + "201": 0.5500000000000185, + "202": -15.049999999999981, + "203": 1.1000000000000265, + "204": -17.249999999999975, + "205": 8.599999999999998, + "206": -5.099999999999995, + "207": -15.549999999999981, + "208": 51.949999999999896, + "209": 28.99999999999995, + "210": 17.299999999999848, + "211": 5.500000000000006, + "212": -1.6999999999999924, + "213": -17.39999999999997, + "214": -6.749999999999994, + "215": 3.049999999999989, + "216": -7.84999999999999, + "217": 37.94999999999992, + "218": 7.4499999999999895, + "219": 6.400000000000004, + "220": -81.95, + "221": -4.900000000000002, + "222": 0.9000000000000248, + "223": -62.10000000000001, + "224": 3.0500000000000247, + "225": -42.70000000000002, + "226": -12.549999999999995, + "227": -62.4500000000001, + "228": 32.1999999999998, + "229": -22.79999999999995, + "230": -5.7499999999999805, + "231": -20.599999999999962, + "232": 47.299999999999976, + "233": -25.44999999999996, + "234": 0.6000000000000199, + "235": 45.34999999999975, + "236": -12.049999999999995, + "237": -44.99999999999999, + "238": 9.599999999999971, + "239": 41.89999999999973, + "240": -74.25000000000001, + "241": -30.25000000000002, + "242": -3.350000000000003, + "243": 45.69999999999994, + "244": 39.74999999999981, + "245": -27.600000000000037, + "246": -75.99999999999999, + "247": 10.949999999999982, + "248": 87.25000000000024, + "249": 5.350000000000006, + "250": 33.199999999999996, + "251": 5.500000000000031, + "252": 6.350000000000054, + "253": -11.2, + "254": 13.900000000000038, + "255": 24.699999999999942, + "256": 53.29999999999981, + "257": 83.49999999999991, + "258": -6.100000000000011, + "259": -1.4499999999999624, + "260": -2.3499999999999917, + "261": -1.949999999999969, + "262": -27.89999999999995, + "263": 6.950000000000012, + "264": -25.250000000000004, + "265": 33.95000000000004, + "266": -30.90000000000002, + "267": 77.0499999999998, + "268": 44.899999999999935, + "269": 38.499999999999986, + "270": 17.999999999999986, + "271": 15.350000000000069, + "272": 34.15000000000005, + "273": -7.899999999999995, + "274": 77.0999999999999, + "275": 23.04999999999994, + "276": 62.299999999999834, + "277": 18.79999999999998, + "278": 8.900000000000018, + "279": -3.649999999999981, + "280": 75.44999999999987, + "281": 22.750000000000025, + "282": 39.74999999999993, + "283": 69.69999999999995, + "284": 18.35, + "285": -22.750000000000007, + "286": 5.90000000000002, + "287": -13.10000000000003, + "288": 0.600000000000017, + "289": -60.85000000000002, + "290": -69.19999999999999, + "291": 106.45000000000019, + "292": 31.449999999999925, + "293": 57.299999999999955, + "294": 34.499999999999886, + "295": 55.9500000000001, + "296": 36.90000000000004, + "297": -54.69999999999996, + "298": 102.45000000000017, + "299": 21.349999999999998, + "300": 68.7000000000001, + "301": 53.74999999999988, + "302": 86.80000000000018, + "303": 90.14999999999996, + "304": 83.90000000000023, + "305": 76.94999999999999, + "306": 62.19999999999997, + "307": -16.650000000000034, + "308": 74.4, + "309": 57.70000000000001, + "310": 30.849999999999955, + "311": 119.50000000000009, + "312": 93.10000000000001, + "313": 37.44999999999992, + "314": 93.15000000000005, + "315": 65.44999999999993, + "316": -6.499999999999995, + "317": 63.59999999999996, + "318": 86.90000000000013, + "319": 89.65000000000002, + "320": 63.899999999999885, + "321": 104.30000000000014, + "322": 52.699999999999875, + "323": 105.30000000000015, + "324": 71.80000000000027, + "325": 68.79999999999997, + "326": 2.899999999999973, + "327": 82.50000000000003, + "328": 106.40000000000018, + "329": 30.499999999999922, + "330": 104.29999999999977, + "331": 106.6000000000001, + "332": 92.70000000000007, + "333": 69.19999999999999, + "334": 71.29999999999984, + "335": 44.54999999999992, + "336": 34.19999999999981, + "337": 89.39999999999999, + "338": 89.70000000000013, + "339": 47.699999999999875, + "340": 85.95000000000005, + "341": 112.6000000000001, + "342": 92.35, + "343": 46.94999999999992, + "344": 88.69999999999985, + "345": 61.99999999999984, + "346": 91.10000000000014, + "347": 92.14999999999985, + "348": 72.64999999999992, + "349": 60.649999999999935, + "350": 111.50000000000003, + "351": 94.40000000000016, + "352": 75.74999999999982, + "353": 91.35000000000007, + "354": 75.14999999999995, + "355": 25.799999999999923, + "356": -10.299999999999994, + "357": 116.30000000000018, + "358": 33.50000000000005, + "359": 78.34999999999981, + "360": 80.30000000000001, + "361": 106.34999999999994, + "362": 103.1500000000001, + "363": 114.8500000000002, + "364": 112.95000000000022, + "365": 90.04999999999997, + "366": 68.44999999999996, + "367": 104.49999999999997, + "368": 73.34999999999984, + "369": 85.4, + "370": 93.05000000000007, + "371": 94.75000000000007, + "372": 84.69999999999983, + "373": 91.2, + "374": 101.70000000000012, + "375": 118.95000000000009, + "376": 97.10000000000007, + "377": 26.649999999999995, + "378": 79.95000000000002, + "379": 103.95000000000014, + "380": 80.75000000000004, + "381": 89.24999999999993, + "382": 114.65000000000019, + "383": 94.80000000000003, + "384": 84.25000000000001, + "385": 114.55000000000018, + "386": 112.00000000000013, + "387": 90.20000000000006, + "388": 83.65000000000005, + "389": 74.84999999999981, + "390": 73.15, + "391": 114.65000000000013, + "392": 68.25000000000023, + "393": 109.04999999999994, + "394": 88.60000000000001, + "395": 97.25000000000018, + "396": 100.90000000000018, + "397": 112.20000000000017, + "398": 85.05000000000007, + "399": 105.25000000000014, + "400": 97.0000000000002, + "401": 85.75000000000009, + "402": 93.59999999999982, + "403": 118.2000000000001, + "404": 46.39999999999992, + "405": 107.25000000000014, + "406": 94.85000000000002, + "407": 93.3499999999998, + "408": 110.85, + "409": 107.40000000000022, + "410": 103.7, + "411": 114.24999999999994, + "412": 114.7500000000001, + "413": 93.80000000000004, + "414": 56.79999999999983, + "415": 110.84999999999997, + "416": 103.55000000000015, + "417": 86.95, + "418": 118.10000000000029, + "419": 112.25000000000016, + "420": 110.4000000000001, + "421": 101.60000000000011, + "422": 116.45, + "423": 110.94999999999997, + "424": 52.29999999999983, + "425": 105.59999999999998, + "426": 100.95000000000009, + "427": 83.09999999999987, + "428": 113.15000000000013, + "429": 101.54999999999993, + "430": 102.30000000000014, + "431": 84.70000000000006, + "432": -9.250000000000002, + "433": 113.10000000000008, + "434": 91.65000000000009, + "435": 96.3, + "436": 113.30000000000013, + "437": 100.55, + "438": 94.40000000000008, + "439": 87.30000000000007, + "440": 86.85000000000005, + "441": 115.9500000000001, + "442": 108.10000000000005, + "443": 111.24999999999997, + "444": 104.40000000000008, + "445": 107.7500000000001, + "446": 98.80000000000008, + "447": 112.50000000000028, + "448": 110.44999999999983, + "449": 16.250000000000068, + "450": 97.54999999999984, + "451": 107.34999999999995, + "452": 98.60000000000011, + "453": 106.40000000000022, + "454": 113.70000000000019, + "455": 107.45000000000012, + "456": 98.34999999999995, + "457": 106.50000000000018, + "458": 72.09999999999994, + "459": 103.60000000000002, + "460": 111.89999999999999, + "461": 104.09999999999987, + "462": 108.6500000000001, + "463": 116.10000000000008, + "464": 102.15000000000019, + "465": 79.94999999999996, + "466": 103.50000000000026, + "467": 114.60000000000014, + "468": 100.10000000000004, + "469": 108.40000000000008, + "470": 114.59999999999997, + "471": 111.10000000000018, + "472": 105.49999999999997, + "473": 109.6000000000001, + "474": 111.4500000000002, + "475": 15.04999999999989, + "476": 84.30000000000004, + "477": 88.35000000000016, + "478": 117.70000000000012, + "479": 105.9500000000001, + "480": 112.4500000000001, + "481": 118.75000000000001, + "482": 108.70000000000005, + "483": 119.15000000000002, + "484": 105.64999999999995, + "485": 110.15000000000009, + "486": 115.85000000000024, + "487": 120.70000000000014, + "488": 109.15000000000019, + "489": 8.300000000000004, + "490": 114.19999999999996, + "491": 106.35000000000014, + "492": 65.64999999999986, + "493": 112.85000000000002, + "494": 110.85000000000014, + "495": 103.85000000000015, + "496": 117.05000000000001, + "497": 114.45000000000012, + "498": 111.90000000000019, + "499": 107.60000000000008, + "500": 110.45000000000006, + "501": 100.35000000000012, + "502": 104.1500000000001, + "503": 112.60000000000012, + "504": 101.15000000000009, + "505": 106.85000000000012, + "506": 111.94999999999997, + "507": 112.90000000000012, + "508": 54.39999999999982, + "509": 104.45000000000002, + "510": 105.10000000000022, + "511": 101.44999999999983, + "512": 108.35000000000011, + "513": 93.65000000000013, + "514": 119.00000000000024, + "515": 6.600000000000001, + "516": 107.35000000000007, + "517": 108.2500000000001, + "518": 105.40000000000006, + "519": 112.80000000000003, + "520": 102.95000000000016, + "521": 111.75, + "522": 113.15000000000002, + "523": 47.44999999999986, + "524": 103.90000000000005, + "525": 121.65000000000002, + "526": 92.64999999999999, + "527": 108.79999999999998, + "528": 96.34999999999987, + "529": 113.40000000000012, + "530": 106.90000000000012, + "531": 79.09999999999992, + "532": 107.70000000000002, + "533": 109.0499999999998, + "534": 93.85000000000012, + "535": 102.30000000000003, + "536": 100.59999999999992, + "537": 107.90000000000019, + "538": 113.80000000000013, + "539": 81.45, + "540": 117.70000000000002, + "541": 115.05000000000003, + "542": 99.89999999999992, + "543": 109.30000000000011, + "544": 102.35000000000007, + "545": 109.29999999999997, + "546": 104.85000000000007, + "547": 114.80000000000017, + "548": 118.1000000000002, + "549": 109.75000000000011, + "550": 112.30000000000005, + "551": 93.25000000000006, + "552": 114.50000000000006, + "553": 112.90000000000002, + "554": 115.30000000000004, + "555": 108.5000000000001, + "556": 114.75000000000007, + "557": 109.05000000000008, + "558": 119.20000000000002, + "559": 104.85000000000016, + "560": 112.3, + "561": 112.20000000000009, + "562": 110.15000000000005, + "563": 109.35000000000005, + "564": 116.05000000000007, + "565": 78.39999999999992, + "566": 118.70000000000017, + "567": 108.75000000000007, + "568": 110.09999999999995, + "569": 112.29999999999997, + "570": 27.69999999999983, + "571": 110.90000000000009, + "572": 113.00000000000014, + "573": 102.00000000000017, + "574": 112.94999999999993, + "575": 108.15000000000006, + "576": 113.04999999999995, + "577": 106.09999999999998, + "578": 7.350000000000021, + "579": 116.65000000000009, + "580": 108.70000000000007, + "581": 114.80000000000022, + "582": 93.74999999999986, + "583": 79.85000000000014, + "584": 110.90000000000016, + "585": 123.30000000000004, + "586": 112.05000000000015, + "587": 115.85, + "588": 109.60000000000008, + "589": 111.10000000000012, + "590": 42.79999999999987, + "591": 36.49999999999981, + "592": 109.29999999999987, + "593": 116.65000000000022, + "594": 59.29999999999985, + "595": 108.3, + "596": 115.55000000000001, + "597": 114.00000000000013, + "598": 98.90000000000008, + "599": 111.10000000000011, + "600": 106.85000000000005, + "601": 108.90000000000018, + "602": 88.44999999999996, + "603": 106.40000000000006, + "604": 115.35000000000011, + "605": 96.65000000000003, + "606": 112.89999999999995, + "607": 22.350000000000044, + "608": 105.14999999999998, + "609": 103.54999999999997, + "610": 113.35000000000008, + "611": 102.49999999999997, + "612": 104.85000000000014, + "613": 111.05000000000004, + "614": 117.75000000000021, + "615": 115.1000000000001, + "616": 114.20000000000009, + "617": 110.90000000000012, + "618": 117.40000000000013, + "619": 115.50000000000013, + "620": 101.10000000000004, + "621": 90.85000000000008, + "622": 119.75000000000004, + "623": 81.40000000000032, + "624": 112.50000000000017, + "625": 110.75000000000003, + "626": 117.90000000000013, + "627": 111.85000000000011, + "628": 103.49999999999997, + "629": 113.75000000000004, + "630": 110.65000000000015, + "631": 66.19999999999986, + "632": 102.60000000000007, + "633": 104.85000000000007, + "634": 108.19999999999995, + "635": 104.05000000000005, + "636": 104.75000000000009, + "637": 101.90000000000002, + "638": 107.6500000000001, + "639": 113.30000000000013, + "640": 110.09999999999994, + "641": 113.35, + "642": 114.10000000000016, + "643": 109.1500000000001, + "644": 119.4500000000001, + "645": 106.30000000000007, + "646": 115.10000000000015, + "647": 116.19999999999999, + "648": 111.25000000000001, + "649": 108.74999999999987, + "650": 109.6000000000001, + "651": 114.15000000000019, + "652": 109.44999999999999, + "653": 119.80000000000011, + "654": 111.5000000000002, + "655": 108.10000000000002, + "656": 118.10000000000016, + "657": 108.25000000000003, + "658": 109.60000000000007, + "659": 101.89999999999992, + "660": 106.3000000000001, + "661": 111.70000000000003, + "662": 60.94999999999987, + "663": 111.1, + "664": 116.05000000000007, + "665": 108.85000000000012, + "666": 110.65, + "667": 113.05000000000013, + "668": 108.75000000000009, + "669": 114.00000000000001, + "670": 112.35000000000014, + "671": 111.15000000000009, + "672": 96.85000000000008, + "673": 113.19999999999997, + "674": 109.50000000000011, + "675": 102.05000000000004, + "676": 111.65000000000003, + "677": 109.05000000000025, + "678": 111.8, + "679": 110.90000000000006, + "680": 104.74999999999993, + "681": 115.00000000000021, + "682": 109.15000000000012, + "683": 104.60000000000016, + "684": 120.70000000000006, + "685": 106.60000000000011, + "686": 114.85000000000021, + "687": 32.099999999999916, + "688": 108.5499999999999, + "689": 107.30000000000003, + "690": 114.00000000000004, + "691": 109.89999999999992, + "692": 118.95000000000007, + "693": 112.40000000000002, + "694": 105.50000000000011, + "695": 115.80000000000017, + "696": 122.60000000000008, + "697": 114.9000000000001, + "698": 109.9500000000001, + "699": 102.15000000000008, + "700": 120.30000000000003, + "701": -1.6500000000000123, + "702": 112.05, + "703": 118.85000000000011, + "704": 96.8500000000001, + "705": 107.70000000000002, + "706": 106.60000000000001, + "707": 115.95000000000009, + "708": 105.10000000000008, + "709": 107.05000000000004, + "710": 108.74999999999994, + "711": 81.20000000000002, + "712": 100.05000000000018, + "713": 114.30000000000011, + "714": 115.30000000000003, + "715": 110.20000000000007, + "716": 120.75000000000004, + "717": 104.65000000000019, + "718": 109.5, + "719": 105.6000000000001, + "720": 94.40000000000013, + "721": 81.34999999999997, + "722": 97.99999999999996, + "723": 108.15000000000016, + "724": 108.60000000000008, + "725": 104.40000000000013, + "726": 106.60000000000011, + "727": 108.10000000000008, + "728": 110.34999999999998, + "729": 111.09999999999994, + "730": 109.65000000000005, + "731": 120.65, + "732": 99.60000000000008, + "733": 108.84999999999998, + "734": 110.95000000000006, + "735": 108.85000000000005, + "736": 115.10000000000024, + "737": 110.24999999999999, + "738": 118.35000000000014, + "739": 108.90000000000008, + "740": 112.35000000000016, + "741": 109.65000000000009, + "742": 112.20000000000013, + "743": 116.40000000000016, + "744": 98.14999999999998, + "745": 111.04999999999998, + "746": 45.499999999999865, + "747": 116.10000000000016, + "748": 105.34999999999992, + "749": 109.3500000000002, + "750": 105.90000000000006, + "751": 118.40000000000012, + "752": 114.00000000000017, + "753": 112.80000000000007, + "754": 110.75000000000006, + "755": 112.40000000000012, + "756": 114.70000000000013, + "757": 107.30000000000018, + "758": 102.10000000000002, + "759": 115.10000000000001, + "760": 59.299999999999876, + "761": 111.20000000000013, + "762": 110.44999999999999, + "763": 116.4000000000002, + "764": 111.25000000000018, + "765": 104.5499999999999, + "766": 113.95000000000005, + "767": 112.40000000000009, + "768": 30.099999999999923, + "769": 111.70000000000002, + "770": 117.40000000000006, + "771": 116.7000000000001, + "772": 113.45000000000005, + "773": 115.0500000000001, + "774": 111.30000000000005, + "775": 118.14999999999998, + "776": 113.10000000000018, + "777": 113.95000000000007, + "778": 109.65, + "779": 107.25000000000009, + "780": 100.74999999999997, + "781": 110.7500000000001, + "782": 114.15000000000006, + "783": 110.9000000000001, + "784": 110.55000000000022, + "785": 115.8000000000001, + "786": -27.399999999999988, + "787": 112.10000000000008, + "788": 106.00000000000011, + "789": 113.45000000000019, + "790": 113.90000000000003, + "791": 110.50000000000006, + "792": 110.20000000000017, + "793": 107.54999999999994, + "794": 106.5000000000001, + "795": 80.50000000000018, + "796": 112.95000000000007, + "797": 71.44999999999982, + "798": 110.75000000000006, + "799": 99.75000000000004, + "800": 103.25000000000007, + "801": 107.80000000000022, + "802": 108.09999999999997, + "803": 107.60000000000008, + "804": 54.29999999999988, + "805": 110.50000000000006, + "806": 77.30000000000008, + "807": 113.35000000000002, + "808": 120.60000000000002, + "809": 104.95000000000003, + "810": 106.64999999999999, + "811": 109.70000000000005, + "812": 117.50000000000001, + "813": 111.15000000000003, + "814": 111.10000000000001, + "815": 107.45000000000013, + "816": 114.25000000000023, + "817": 102.34999999999998, + "818": 109.75, + "819": 110.45000000000022, + "820": 112.90000000000013, + "821": 115.00000000000018, + "822": 60.49999999999993, + "823": 114.9000000000001, + "824": 113.35000000000014, + "825": 109.95000000000007, + "826": 108.65, + "827": 113.70000000000012, + "828": 109.65000000000008, + "829": 115.25, + "830": 111.40000000000019, + "831": 115.80000000000008, + "832": 111.40000000000022, + "833": 94.64999999999988, + "834": 116.55000000000005, + "835": 76.15000000000003, + "836": 107.4, + "837": 104.15, + "838": 116.20000000000009, + "839": 116.8000000000001, + "840": 112.60000000000007, + "841": 110.90000000000016, + "842": 109.95000000000002, + "843": 116.55000000000028, + "844": 112.60000000000008, + "845": 118.45000000000006, + "846": 106.75000000000006, + "847": 101.8000000000001, + "848": 111.75000000000011, + "849": 114.70000000000003, + "850": 115.55000000000013, + "851": 109.80000000000001, + "852": 112.00000000000007, + "853": 111.2, + "854": 107.70000000000003, + "855": 116.69999999999999, + "856": 104.45000000000009, + "857": 105.30000000000001, + "858": 114.80000000000018, + "859": 118.35000000000005, + "860": 115.7500000000001, + "861": 121.0000000000001, + "862": 112.20000000000003, + "863": 107.50000000000016, + "864": 107.4500000000001, + "865": 114.50000000000011, + "866": 116.05000000000017, + "867": 106.90000000000008, + "868": 102.95000000000005, + "869": 96.19999999999989, + "870": 118.29999999999995, + "871": 114.50000000000006, + "872": 106.70000000000006, + "873": 110.00000000000009, + "874": 104.15, + "875": 116.99999999999994, + "876": 110.40000000000009, + "877": 107.60000000000008, + "878": 100.85000000000002, + "879": 111.30000000000015, + "880": 110.0, + "881": 103.05000000000001, + "882": 101.2, + "883": 109.10000000000004, + "884": 109.20000000000007, + "885": 107.50000000000013, + "886": 116.60000000000016, + "887": 113.10000000000021, + "888": 112.0000000000001, + "889": 110.6499999999999, + "890": 103.40000000000013, + "891": 113.80000000000003, + "892": 117.30000000000017, + "893": 109.35000000000005, + "894": 89.54999999999994, + "895": 110.15000000000012, + "896": 98.55000000000005, + "897": 112.9500000000001, + "898": 116.80000000000005, + "899": 116.14999999999998, + "900": 117.25000000000006, + "901": 108.85000000000012, + "902": 110.20000000000017, + "903": 106.04999999999997, + "904": 117.19999999999999, + "905": 115.10000000000008, + "906": 109.05000000000024, + "907": 114.90000000000005, + "908": 101.15, + "909": 112.65000000000018, + "910": 110.70000000000016, + "911": 112.75000000000009, + "912": 112.30000000000004, + "913": 117.75000000000006, + "914": 109.95000000000003, + "915": 108.05000000000007, + "916": 115.75, + "917": 115.00000000000011, + "918": 113.75000000000016, + "919": 111.85000000000001, + "920": 106.85000000000015, + "921": 101.15000000000008, + "922": 102.80000000000007, + "923": 107.85000000000015, + "924": 109.30000000000013, + "925": 110.79999999999993, + "926": 118.70000000000016, + "927": 111.4500000000001, + "928": 113.60000000000004, + "929": 116.29999999999998, + "930": 109.60000000000001, + "931": 113.20000000000002, + "932": 114.05000000000017, + "933": 117.0000000000001, + "934": 117.60000000000011, + "935": 114.55000000000015, + "936": 114.60000000000012, + "937": 107.70000000000007, + "938": 109.20000000000012, + "939": 113.3500000000002, + "940": 114.95000000000012, + "941": 113.45000000000016, + "942": 116.35, + "943": 119.65000000000009, + "944": 107.05000000000013, + "945": 115.15000000000008, + "946": 109.55000000000008, + "947": 109.45000000000002, + "948": 109.85000000000002, + "949": 106.99999999999997, + "950": 106.4500000000002, + "951": 109.85, + "952": 117.1500000000001, + "953": 108.10000000000018, + "954": 108.05000000000024, + "955": 101.90000000000003, + "956": 114.65000000000009, + "957": 116.65000000000015, + "958": 99.54999999999997, + "959": 108.40000000000003, + "960": 112.95000000000017, + "961": 109.45000000000012, + "962": 116.95000000000006, + "963": 110.25000000000013, + "964": 109.79999999999993, + "965": 111.85, + "966": 62.29999999999991, + "967": 111.00000000000006, + "968": 62.44999999999991, + "969": 117.50000000000009, + "970": 109.60000000000004, + "971": 109.80000000000018, + "972": 119.55000000000004, + "973": 117.10000000000011, + "974": 106.90000000000013, + "975": 112.95000000000023, + "976": 109.55000000000005, + "977": 112.65000000000028, + "978": 109.20000000000007, + "979": 117.40000000000018, + "980": 114.4000000000001, + "981": 103.7000000000001, + "982": 120.25000000000001, + "983": 115.10000000000014, + "984": 111.85000000000007, + "985": 113.00000000000013, + "986": 115.15000000000006, + "987": 116.30000000000007, + "988": 104.00000000000016, + "989": 110.85000000000004, + "990": 113.10000000000002, + "991": 113.84999999999998, + "992": 109.4500000000002, + "993": 114.60000000000001, + "994": 114.2000000000002, + "995": 107.60000000000008, + "996": 118.45000000000003, + "997": 102.75000000000003, + "998": 109.85000000000005, + "999": 110.6500000000001, + "1000": 113.10000000000015 + } +} \ No newline at end of file diff --git a/benchmark/results/v3/v3.0.0/v3.0.0_benchmark_metadata.json b/benchmark/results/v3/v3.0.0/v3.0.0_benchmark_metadata.json new file mode 100644 index 00000000..ab7b1671 --- /dev/null +++ b/benchmark/results/v3/v3.0.0/v3.0.0_benchmark_metadata.json @@ -0,0 +1,7436 @@ +{ + "start_timestamp": "2024-07-20T14:10:33.681240", + "end_datetime": "2024-07-20T16:11:37.319054", + "primaite_version": "3.0.0", + "system_info": { + "System": { + "OS": "Linux", + "OS Version": "#76~20.04.1-Ubuntu SMP Thu Jun 13 18:00:23 UTC 2024", + "Machine": "x86_64", + "Processor": "x86_64" + }, + "CPU": { + "Physical Cores": 2, + "Total Cores": 4, + "Max Frequency": "0.00Mhz" + }, + "Memory": { + "Total": "15.62GB", + "Swap Total": "0.00B" + }, + "GPU": [] + }, + "total_sessions": 5, + "total_episodes": 5005, + "total_time_steps": 640000, + "av_s_per_session": 1452.5910073999999, + "av_s_per_step": 0.045393468981250004, + "av_s_per_100_steps_10_nodes": 4.539346898125, + "combined_total_reward_per_episode": { + "1": -39.45000000000001, + "2": -29.900000000000034, + "3": -27.789999999999985, + "4": -20.729999999999976, + "5": -45.300000000000026, + "6": -43.55000000000002, + "7": -28.600000000000012, + "8": -59.080000000000084, + "9": -34.67000000000004, + "10": -58.16000000000004, + "11": -30.060000000000013, + "12": -34.22999999999998, + "13": -33.380000000000045, + "14": -23.160000000000018, + "15": -45.41, + "16": -33.48999999999998, + "17": -35.60000000000001, + "18": -49.93000000000004, + "19": -55.90000000000002, + "20": -17.839999999999968, + "21": -35.27999999999999, + "22": -31.32000000000001, + "23": -38.50999999999999, + "24": -21.959999999999987, + "25": -28.20000000000001, + "26": -67.26000000000002, + "27": -19.219999999999974, + "28": -34.800000000000026, + "29": -21.399999999999974, + "30": -35.57000000000001, + "31": -23.020000000000017, + "32": -22.569999999999983, + "33": -38.25000000000004, + "34": -51.629999999999974, + "35": -27.24000000000002, + "36": -38.250000000000036, + "37": -28.769999999999975, + "38": -41.40000000000007, + "39": -16.00999999999997, + "40": -31.140000000000008, + "41": -18.93000000000002, + "42": -16.330000000000002, + "43": -31.559999999999985, + "44": -13.389999999999977, + "45": -19.719999999999956, + "46": -16.469999999999967, + "47": -32.67, + "48": -39.54999999999999, + "49": -19.07999999999999, + "50": -54.71000000000002, + "51": -25.980000000000008, + "52": -24.970000000000006, + "53": -29.799999999999994, + "54": -23.080000000000027, + "55": -37.91000000000001, + "56": -36.34000000000001, + "57": -26.539999999999985, + "58": -35.330000000000034, + "59": -46.60000000000004, + "60": -30.349999999999987, + "61": -48.49999999999998, + "62": -39.42999999999999, + "63": -20.369999999999997, + "64": -17.71999999999999, + "65": -28.059999999999985, + "66": -40.380000000000024, + "67": -41.000000000000014, + "68": -23.819999999999997, + "69": -32.08999999999999, + "70": -24.84000000000001, + "71": -8.719999999999969, + "72": -19.96999999999999, + "73": -16.589999999999982, + "74": -10.339999999999979, + "75": -20.809999999999974, + "76": -27.71999999999996, + "77": -23.80999999999997, + "78": -37.980000000000004, + "79": -56.35000000000001, + "80": -34.93000000000002, + "81": -52.410000000000004, + "82": -33.34999999999998, + "83": -31.369999999999976, + "84": -10.209999999999969, + "85": -29.75000000000001, + "86": -14.959999999999985, + "87": -29.130000000000013, + "88": -29.50000000000004, + "89": -11.769999999999978, + "90": -28.119999999999994, + "91": -24.44000000000002, + "92": -36.38000000000001, + "93": -8.64999999999998, + "94": -29.68999999999998, + "95": -24.54999999999998, + "96": -24.009999999999987, + "97": -12.339999999999986, + "98": -32.089999999999996, + "99": -13.429999999999989, + "100": -9.079999999999972, + "101": -26.75999999999998, + "102": -15.55999999999997, + "103": -10.979999999999972, + "104": -37.230000000000004, + "105": -21.369999999999976, + "106": -17.319999999999986, + "107": -32.15000000000002, + "108": -51.64000000000002, + "109": -45.77, + "110": -28.509999999999955, + "111": -34.35999999999998, + "112": -18.209999999999987, + "113": -45.580000000000005, + "114": -41.62999999999998, + "115": -36.040000000000006, + "116": -4.919999999999996, + "117": -26.339999999999982, + "118": -21.07999999999999, + "119": -30.29999999999999, + "120": -4.029999999999972, + "121": -22.859999999999978, + "122": -21.04999999999998, + "123": -27.75999999999999, + "124": -43.68, + "125": -17.649999999999974, + "126": -10.809999999999985, + "127": -1.9000000000000241, + "128": -28.839999999999996, + "129": -0.9599999999999735, + "130": -21.669999999999995, + "131": -5.569999999999978, + "132": -10.129999999999985, + "133": -27.599999999999984, + "134": -26.119999999999997, + "135": -6.089999999999981, + "136": -30.92999999999998, + "137": -42.260000000000026, + "138": -39.73000000000002, + "139": -9.829999999999984, + "140": -23.739999999999984, + "141": -17.169999999999977, + "142": -9.97999999999997, + "143": -4.909999999999982, + "144": -28.579999999999995, + "145": -25.160000000000014, + "146": -17.95999999999999, + "147": -7.809999999999976, + "148": -35.34999999999999, + "149": -20.75999999999998, + "150": -17.7, + "151": -6.929999999999998, + "152": -29.109999999999996, + "153": -29.319999999999986, + "154": -26.55000000000001, + "155": -19.55999999999999, + "156": -13.209999999999983, + "157": -16.209999999999972, + "158": -20.709999999999997, + "159": -11.079999999999968, + "160": -7.739999999999986, + "161": 3.279999999999994, + "162": -17.099999999999987, + "163": -58.27, + "164": -6.110000000000023, + "165": -40.97999999999999, + "166": -8.029999999999985, + "167": -19.999999999999993, + "168": -23.16999999999998, + "169": 6.720000000000011, + "170": -6.629999999999972, + "171": -29.179999999999986, + "172": -57.17, + "173": -3.410000000000052, + "174": -33.89, + "175": -16.89999999999999, + "176": -21.280000000000005, + "177": -22.86999999999999, + "178": -45.370000000000026, + "179": -19.67999999999998, + "180": -40.48, + "181": -10.360000000000017, + "182": -23.949999999999985, + "183": -44.22999999999999, + "184": -8.670000000000018, + "185": -13.100000000000005, + "186": -15.220000000000056, + "187": -31.92000000000005, + "188": -4.979999999999977, + "189": -23.410000000000004, + "190": -7.970000000000027, + "191": -16.73000000000004, + "192": -17.400000000000027, + "193": 5.899999999999991, + "194": -5.169999999999998, + "195": -11.039999999999988, + "196": -20.260000000000023, + "197": -38.83999999999999, + "198": -30.249999999999993, + "199": -23.37000000000001, + "200": -23.749999999999996, + "201": -27.529999999999994, + "202": -5.76, + "203": -18.319999999999983, + "204": -5.970000000000028, + "205": -10.879999999999985, + "206": -18.090000000000003, + "207": -29.17000000000001, + "208": -3.1600000000000548, + "209": 9.099999999999998, + "210": -24.760000000000026, + "211": -9.169999999999982, + "212": 5.930000000000001, + "213": -13.479999999999984, + "214": -9.80999999999999, + "215": 8.220000000000002, + "216": -2.120000000000026, + "217": 9.560000000000002, + "218": -0.6399999999999876, + "219": 14.849999999999998, + "220": 0.41000000000001646, + "221": -18.969999999999978, + "222": 7.38, + "223": -13.770000000000028, + "224": -10.910000000000034, + "225": -13.070000000000036, + "226": 4.859999999999948, + "227": -20.270000000000035, + "228": 33.469999999999914, + "229": -32.15999999999999, + "230": -38.28999999999998, + "231": -7.349999999999983, + "232": 11.319999999999997, + "233": -21.35, + "234": 29.07999999999999, + "235": -13.40000000000004, + "236": 2.6600000000000112, + "237": -8.960000000000075, + "238": -13.419999999999991, + "239": 18.059999999999903, + "240": -16.75000000000001, + "241": -11.059999999999986, + "242": -0.9800000000000086, + "243": -3.010000000000018, + "244": 23.769999999999914, + "245": -3.610000000000016, + "246": -15.020000000000001, + "247": 20.27999999999995, + "248": 39.27000000000002, + "249": 22.56999999999996, + "250": -5.539999999999975, + "251": 0.3299999999999619, + "252": -19.129999999999978, + "253": -24.17, + "254": 22.57000000000003, + "255": 11.659999999999972, + "256": 39.74999999999992, + "257": 17.779999999999966, + "258": 34.68000000000002, + "259": 27.329999999999977, + "260": -0.3100000000000046, + "261": 46.71999999999998, + "262": 14.339999999999975, + "263": 24.71999999999996, + "264": -4.040000000000024, + "265": 45.030000000000086, + "266": -5.5199999999999685, + "267": 15.609999999999948, + "268": 25.819999999999975, + "269": 15.680000000000001, + "270": 22.45999999999992, + "271": 5.68000000000003, + "272": 42.84999999999993, + "273": 14.80999999999998, + "274": 16.33999999999994, + "275": 23.440000000000012, + "276": 18.539999999999885, + "277": 2.6900000000000026, + "278": 26.389999999999937, + "279": -18.49, + "280": 60.92999999999999, + "281": 21.020000000000024, + "282": 33.179999999999964, + "283": 24.94999999999998, + "284": 19.959999999999944, + "285": 48.26999999999996, + "286": 16.860000000000028, + "287": 20.460000000000043, + "288": 35.64999999999994, + "289": 9.639999999999981, + "290": 18.31999999999993, + "291": 19.72000000000005, + "292": 16.539999999999985, + "293": 41.069999999999936, + "294": 48.810000000000024, + "295": 69.69000000000001, + "296": 26.090000000000014, + "297": 35.410000000000004, + "298": 80.83999999999999, + "299": 21.70000000000002, + "300": 42.43999999999993, + "301": 40.339999999999925, + "302": 34.490000000000045, + "303": 60.219999999999985, + "304": 44.48999999999997, + "305": 40.49999999999997, + "306": 53.509999999999955, + "307": 17.979999999999954, + "308": 46.389999999999965, + "309": 31.310000000000052, + "310": 7.559999999999965, + "311": 44.380000000000074, + "312": 44.44999999999999, + "313": 53.01000000000001, + "314": 55.229999999999976, + "315": 59.889999999999965, + "316": 35.970000000000006, + "317": 42.27999999999991, + "318": 28.60000000000003, + "319": 65.96999999999993, + "320": 57.209999999999965, + "321": 58.54000000000008, + "322": 85.12000000000002, + "323": 72.17999999999995, + "324": 43.85000000000003, + "325": 62.889999999999965, + "326": 27.21, + "327": 42.370000000000026, + "328": 89.61000000000008, + "329": 60.92999999999995, + "330": 38.69999999999994, + "331": 67.21000000000001, + "332": 68.55999999999997, + "333": 78.98999999999998, + "334": 53.33999999999994, + "335": 50.07999999999994, + "336": 43.869999999999855, + "337": 59.80999999999999, + "338": 43.16000000000003, + "339": 41.38999999999997, + "340": 68.38000000000005, + "341": 3.070000000000013, + "342": 55.280000000000044, + "343": 63.43999999999994, + "344": 67.43999999999996, + "345": 46.41999999999998, + "346": 74.54000000000002, + "347": 66.7899999999999, + "348": 24.279999999999994, + "349": 69.25999999999993, + "350": 89.45999999999997, + "351": 86.52000000000001, + "352": 79.77999999999992, + "353": 65.75999999999993, + "354": 40.22999999999996, + "355": 49.67000000000003, + "356": 40.39000000000002, + "357": 74.48, + "358": 66.90000000000008, + "359": 56.40999999999991, + "360": 81.66000000000005, + "361": 77.29999999999991, + "362": 78.94999999999999, + "363": 72.92000000000004, + "364": 71.89000000000001, + "365": 64.73999999999998, + "366": 30.879999999999978, + "367": 76.85999999999997, + "368": 99.71999999999997, + "369": 73.70999999999992, + "370": 64.83999999999999, + "371": 72.27000000000001, + "372": 72.34999999999988, + "373": 64.16, + "374": 96.43000000000002, + "375": 77.41, + "376": 77.9, + "377": 58.80999999999996, + "378": 80.53999999999989, + "379": 54.200000000000045, + "380": 67.18999999999998, + "381": 91.29999999999993, + "382": 75.91000000000001, + "383": 82.68999999999991, + "384": 104.83000000000007, + "385": 82.35000000000002, + "386": 73.42000000000004, + "387": 49.48000000000002, + "388": 61.58999999999993, + "389": 92.41999999999993, + "390": 77.44999999999997, + "391": 38.81000000000002, + "392": 69.34000000000012, + "393": 97.66999999999997, + "394": 79.28, + "395": 82.01999999999995, + "396": 73.55999999999997, + "397": 53.95000000000006, + "398": 89.67999999999995, + "399": 80.14000000000013, + "400": 87.25, + "401": 58.81999999999998, + "402": 76.77999999999994, + "403": 74.82999999999998, + "404": 98.00000000000006, + "405": 93.61999999999996, + "406": 74.66999999999993, + "407": 79.13999999999993, + "408": 101.72999999999995, + "409": 91.01000000000006, + "410": 74.20000000000007, + "411": 71.93000000000002, + "412": 74.78, + "413": 90.32999999999996, + "414": 88.23999999999995, + "415": 81.61999999999995, + "416": 39.84000000000007, + "417": 75.59000000000002, + "418": 72.72000000000006, + "419": 66.35000000000001, + "420": 87.92, + "421": 101.37000000000005, + "422": 85.33999999999999, + "423": 103.26999999999995, + "424": 74.52999999999997, + "425": 104.80999999999999, + "426": 94.81000000000009, + "427": 71.86999999999998, + "428": 87.11000000000004, + "429": 72.42999999999998, + "430": 57.800000000000054, + "431": 105.27000000000002, + "432": 59.44999999999997, + "433": 71.00000000000004, + "434": 91.80999999999995, + "435": 88.99999999999997, + "436": 67.39000000000001, + "437": 95.70999999999998, + "438": 92.53, + "439": 96.52000000000008, + "440": 89.3, + "441": 103.32999999999996, + "442": 102.17000000000003, + "443": 109.58, + "444": 90.77000000000001, + "445": 91.16, + "446": 65.43999999999997, + "447": 88.15000000000003, + "448": 107.21999999999996, + "449": 51.72000000000001, + "450": 108.52000000000002, + "451": 94.03000000000003, + "452": 101.6499999999999, + "453": 72.28999999999999, + "454": 85.44999999999997, + "455": 101.36999999999998, + "456": 75.77999999999994, + "457": 84.77000000000005, + "458": 86.33999999999995, + "459": 95.44, + "460": 86.84, + "461": 84.05999999999993, + "462": 82.69000000000004, + "463": 97.19999999999999, + "464": 88.52000000000002, + "465": 97.70999999999997, + "466": 80.13000000000002, + "467": 93.95, + "468": 78.77999999999994, + "469": 98.73000000000003, + "470": 64.03999999999999, + "471": 95.44, + "472": 74.9, + "473": 106.55, + "474": 104.50000000000004, + "475": 78.03999999999999, + "476": 95.42000000000003, + "477": 95.08000000000001, + "478": 103.23000000000002, + "479": 97.57000000000001, + "480": 99.19999999999996, + "481": 80.83999999999999, + "482": 109.36000000000004, + "483": 104.94000000000001, + "484": 87.87999999999988, + "485": 76.02000000000002, + "486": 92.10000000000016, + "487": 102.9, + "488": 96.93999999999994, + "489": 82.58000000000003, + "490": 106.18999999999998, + "491": 103.67000000000003, + "492": 101.12999999999998, + "493": 86.31, + "494": 108.49000000000005, + "495": 103.92, + "496": 91.04000000000002, + "497": 104.28000000000011, + "498": 99.40000000000005, + "499": 95.66000000000001, + "500": 97.58999999999996, + "501": 98.73000000000005, + "502": 101.73999999999998, + "503": 90.05999999999997, + "504": 88.86999999999998, + "505": 101.64999999999998, + "506": 101.07999999999996, + "507": 101.80000000000004, + "508": 92.37999999999998, + "509": 108.25999999999999, + "510": 95.70000000000002, + "511": 90.04999999999998, + "512": 101.78000000000002, + "513": 103.92000000000003, + "514": 106.37000000000003, + "515": 63.91999999999992, + "516": 99.02999999999999, + "517": 106.42999999999995, + "518": 105.25, + "519": 72.07000000000002, + "520": 87.74, + "521": 85.31999999999996, + "522": 101.39999999999995, + "523": 79.64999999999988, + "524": 95.57, + "525": 112.48000000000006, + "526": 91.01999999999997, + "527": 94.12, + "528": 105.85999999999997, + "529": 94.27000000000005, + "530": 108.83000000000008, + "531": 103.00999999999992, + "532": 110.19000000000008, + "533": 70.31, + "534": 91.79999999999998, + "535": 106.48000000000005, + "536": 103.06999999999996, + "537": 109.17, + "538": 93.50999999999993, + "539": 94.54, + "540": 101.56000000000002, + "541": 106.48999999999998, + "542": 106.7, + "543": 94.44, + "544": 105.57999999999996, + "545": 106.95000000000002, + "546": 94.76999999999997, + "547": 107.71000000000001, + "548": 111.11000000000006, + "549": 103.84000000000003, + "550": 107.39999999999998, + "551": 105.17000000000003, + "552": 82.07, + "553": 106.32000000000001, + "554": 103.48000000000005, + "555": 93.22, + "556": 94.05999999999996, + "557": 104.69000000000001, + "558": 110.63999999999996, + "559": 95.14999999999993, + "560": 103.71000000000001, + "561": 104.42999999999995, + "562": 102.90000000000005, + "563": 106.29000000000003, + "564": 105.15, + "565": 101.42, + "566": 112.50000000000004, + "567": 107.31000000000002, + "568": 104.85999999999993, + "569": 104.51999999999995, + "570": 91.77999999999993, + "571": 106.53999999999996, + "572": 91.58000000000013, + "573": 76.31000000000009, + "574": 102.47000000000003, + "575": 107.56999999999996, + "576": 106.17999999999998, + "577": 102.70999999999995, + "578": 70.67000000000007, + "579": 99.83999999999999, + "580": 101.09000000000003, + "581": 111.0400000000001, + "582": 105.85999999999997, + "583": 95.24, + "584": 109.40000000000005, + "585": 113.34000000000003, + "586": 104.63999999999999, + "587": 110.09, + "588": 105.79000000000003, + "589": 106.06000000000002, + "590": 92.65999999999994, + "591": 94.54000000000009, + "592": 94.99999999999996, + "593": 107.07000000000002, + "594": 100.3, + "595": 96.36999999999999, + "596": 109.2, + "597": 95.69000000000011, + "598": 110.47999999999999, + "599": 100.13000000000011, + "600": 111.02000000000001, + "601": 104.75000000000004, + "602": 90.75, + "603": 101.53999999999996, + "604": 109.18000000000009, + "605": 105.70000000000005, + "606": 100.01999999999995, + "607": 89.05, + "608": 84.77000000000001, + "609": 99.66000000000008, + "610": 106.14000000000006, + "611": 108.85, + "612": 106.83, + "613": 109.94000000000001, + "614": 108.87000000000003, + "615": 112.06000000000002, + "616": 100.91999999999997, + "617": 103.77000000000005, + "618": 109.8, + "619": 110.37999999999997, + "620": 108.11000000000001, + "621": 96.99000000000004, + "622": 114.97000000000003, + "623": 108.02000000000002, + "624": 100.85, + "625": 96.05999999999999, + "626": 102.62999999999995, + "627": 95.97000000000003, + "628": 103.39000000000001, + "629": 105.87000000000005, + "630": 107.08000000000007, + "631": 88.34999999999995, + "632": 98.33000000000001, + "633": 110.18000000000002, + "634": 110.48000000000006, + "635": 106.28, + "636": 104.06000000000002, + "637": 108.39000000000014, + "638": 108.85000000000005, + "639": 112.94000000000001, + "640": 104.36999999999998, + "641": 104.24000000000001, + "642": 103.25999999999999, + "643": 94.6, + "644": 110.36000000000004, + "645": 106.97, + "646": 109.82000000000008, + "647": 110.66, + "648": 110.17, + "649": 107.71000000000001, + "650": 111.78000000000006, + "651": 109.85000000000005, + "652": 107.96000000000004, + "653": 82.91999999999997, + "654": 89.96000000000001, + "655": 103.74000000000001, + "656": 110.46000000000004, + "657": 108.52000000000002, + "658": 103.04999999999993, + "659": 108.41000000000004, + "660": 109.56000000000006, + "661": 108.33999999999999, + "662": 101.95000000000007, + "663": 98.80000000000007, + "664": 95.02999999999997, + "665": 108.62000000000005, + "666": 107.50999999999999, + "667": 99.74000000000007, + "668": 108.86000000000004, + "669": 112.73000000000002, + "670": 113.08000000000008, + "671": 108.70000000000005, + "672": 104.34, + "673": 104.16, + "674": 109.39000000000001, + "675": 104.35999999999997, + "676": 111.46, + "677": 110.6700000000001, + "678": 110.89999999999998, + "679": 107.96000000000006, + "680": 108.65000000000002, + "681": 110.96000000000001, + "682": 104.99000000000004, + "683": 106.3000000000001, + "684": 108.07000000000005, + "685": 101.38999999999999, + "686": 107.44999999999997, + "687": 98.34000000000012, + "688": 96.20999999999995, + "689": 106.94999999999997, + "690": 71.75999999999996, + "691": 109.59999999999998, + "692": 101.01000000000002, + "693": 111.05999999999997, + "694": 110.92000000000004, + "695": 108.17000000000003, + "696": 113.85, + "697": 110.19000000000005, + "698": 101.53000000000003, + "699": 106.2, + "700": 111.32000000000001, + "701": 79.91999999999999, + "702": 96.68, + "703": 90.85000000000002, + "704": 65.53999999999999, + "705": 82.80000000000003, + "706": 100.68999999999997, + "707": 96.4, + "708": 91.77000000000001, + "709": 94.24999999999997, + "710": 108.45, + "711": 93.53999999999996, + "712": 79.76000000000003, + "713": 108.71000000000006, + "714": 96.00000000000009, + "715": 93.29999999999995, + "716": 102.85999999999999, + "717": 94.38000000000002, + "718": 109.5900000000001, + "719": 97.66999999999999, + "720": 85.38000000000008, + "721": 62.73999999999999, + "722": 85.47999999999999, + "723": 93.99000000000002, + "724": 102.74000000000005, + "725": 73.21000000000006, + "726": 83.59000000000006, + "727": 57.00000000000007, + "728": 81.50000000000001, + "729": 77.34000000000003, + "730": 67.90000000000008, + "731": 89.05, + "732": 108.51000000000013, + "733": 75.67999999999999, + "734": 102.01000000000006, + "735": 98.26000000000008, + "736": 85.48000000000009, + "737": 93.58000000000007, + "738": 100.58000000000001, + "739": 92.20000000000003, + "740": 111.28000000000013, + "741": 103.81000000000009, + "742": 81.38000000000002, + "743": 95.02000000000007, + "744": 100.03000000000004, + "745": 98.73999999999997, + "746": 69.14000000000003, + "747": 80.2400000000001, + "748": 104.33999999999999, + "749": 96.22000000000006, + "750": 95.82000000000002, + "751": 85.93000000000004, + "752": 99.13000000000001, + "753": 105.34000000000006, + "754": 65.18000000000004, + "755": 109.50000000000007, + "756": 101.5500000000001, + "757": 110.45000000000007, + "758": 86.49, + "759": 99.90999999999997, + "760": 99.12000000000009, + "761": 105.63000000000004, + "762": 106.75000000000004, + "763": 108.97000000000014, + "764": 106.82000000000012, + "765": 93.73, + "766": 94.16999999999999, + "767": 100.12000000000008, + "768": 95.01, + "769": 105.87000000000005, + "770": 100.22000000000007, + "771": 102.31000000000003, + "772": 109.65000000000006, + "773": 99.95000000000002, + "774": 112.16000000000008, + "775": 105.3, + "776": 97.09000000000009, + "777": 112.26000000000006, + "778": 90.70000000000002, + "779": 107.83000000000008, + "780": 103.75000000000003, + "781": 108.97, + "782": 101.23000000000006, + "783": 103.98000000000002, + "784": 110.2300000000001, + "785": 109.04, + "786": 76.92999999999998, + "787": 111.26000000000006, + "788": 94.22999999999999, + "789": 109.78000000000006, + "790": 104.85000000000005, + "791": 103.47000000000006, + "792": 103.82000000000008, + "793": 108.59, + "794": 108.63000000000002, + "795": 105.1700000000001, + "796": 112.8400000000001, + "797": 106.43999999999998, + "798": 109.12000000000008, + "799": 89.06000000000004, + "800": 108.80000000000011, + "801": 99.99000000000002, + "802": 108.82000000000002, + "803": 112.2300000000001, + "804": 101.69000000000008, + "805": 76.92000000000006, + "806": 87.22000000000004, + "807": 107.63, + "808": 111.83000000000007, + "809": 107.97000000000006, + "810": 112.31000000000009, + "811": 109.0200000000001, + "812": 90.22999999999996, + "813": 113.37000000000008, + "814": 108.32000000000002, + "815": 106.07000000000009, + "816": 112.36000000000013, + "817": 106.84, + "818": 110.33000000000011, + "819": 104.03000000000002, + "820": 109.03000000000009, + "821": 114.76000000000008, + "822": 101.09000000000006, + "823": 111.82000000000009, + "824": 111.2900000000001, + "825": 109.94000000000003, + "826": 108.38000000000004, + "827": 111.23000000000013, + "828": 103.54000000000008, + "829": 102.21000000000001, + "830": 108.02000000000002, + "831": 110.49000000000004, + "832": 107.58000000000018, + "833": 110.52000000000005, + "834": 102.07000000000001, + "835": 104.6800000000001, + "836": 108.56000000000006, + "837": 93.04, + "838": 109.23000000000005, + "839": 109.65000000000006, + "840": 110.83000000000008, + "841": 108.46000000000012, + "842": 105.29000000000003, + "843": 96.4600000000001, + "844": 107.56000000000013, + "845": 117.16000000000004, + "846": 107.92000000000012, + "847": 106.13000000000007, + "848": 99.19000000000004, + "849": 112.66000000000001, + "850": 107.64000000000006, + "851": 107.90000000000009, + "852": 112.11000000000008, + "853": 112.98000000000005, + "854": 80.59000000000003, + "855": 115.58000000000008, + "856": 106.63000000000008, + "857": 110.40000000000009, + "858": 113.59000000000007, + "859": 112.16000000000008, + "860": 105.68000000000006, + "861": 100.71000000000006, + "862": 98.18000000000004, + "863": 109.2300000000001, + "864": 106.32000000000008, + "865": 109.7700000000001, + "866": 115.30000000000011, + "867": 107.73000000000009, + "868": 110.29000000000015, + "869": 107.62000000000008, + "870": 113.21, + "871": 108.66000000000005, + "872": 107.98000000000006, + "873": 111.94000000000014, + "874": 110.59000000000003, + "875": 114.40000000000009, + "876": 107.03000000000006, + "877": 106.14000000000003, + "878": 108.3100000000001, + "879": 113.78000000000011, + "880": 107.57000000000002, + "881": 109.55000000000004, + "882": 105.57000000000002, + "883": 107.8000000000001, + "884": 102.38000000000004, + "885": 106.44000000000013, + "886": 110.02000000000007, + "887": 103.42000000000007, + "888": 110.14000000000003, + "889": 109.89999999999998, + "890": 109.9400000000001, + "891": 109.09000000000006, + "892": 95.39000000000003, + "893": 108.58, + "894": 103.66000000000004, + "895": 110.52000000000002, + "896": 108.87000000000012, + "897": 112.20000000000005, + "898": 108.99000000000008, + "899": 113.07000000000005, + "900": 110.48000000000006, + "901": 105.65000000000005, + "902": 111.24000000000017, + "903": 83.56999999999998, + "904": 109.55000000000004, + "905": 107.57000000000008, + "906": 111.79000000000005, + "907": 108.68000000000006, + "908": 111.36000000000008, + "909": 110.61000000000013, + "910": 111.36000000000008, + "911": 111.62, + "912": 104.16000000000004, + "913": 109.36000000000004, + "914": 110.43000000000009, + "915": 112.49000000000005, + "916": 95.05999999999999, + "917": 113.05000000000014, + "918": 110.45000000000009, + "919": 103.56000000000009, + "920": 111.66000000000005, + "921": 84.71000000000011, + "922": 106.93000000000009, + "923": 105.54000000000005, + "924": 113.6000000000001, + "925": 111.3900000000001, + "926": 112.37000000000012, + "927": 112.82000000000008, + "928": 113.54000000000005, + "929": 109.66000000000001, + "930": 111.37000000000009, + "931": 106.44000000000003, + "932": 89.53000000000006, + "933": 108.4700000000001, + "934": 113.90000000000006, + "935": 112.58000000000011, + "936": 104.40000000000006, + "937": 110.25000000000007, + "938": 111.48000000000013, + "939": 99.67000000000009, + "940": 100.70000000000007, + "941": 109.84000000000006, + "942": 93.77000000000002, + "943": 99.89000000000006, + "944": 108.68000000000006, + "945": 114.31000000000003, + "946": 108.17000000000004, + "947": 114.04000000000012, + "948": 109.70000000000005, + "949": 108.21000000000008, + "950": 105.91000000000011, + "951": 112.74000000000012, + "952": 107.81000000000009, + "953": 109.75000000000009, + "954": 105.64000000000014, + "955": 106.65000000000006, + "956": 112.21000000000012, + "957": 112.28000000000009, + "958": 110.01000000000006, + "959": 114.1000000000001, + "960": 114.22000000000007, + "961": 79.79000000000005, + "962": 113.27000000000007, + "963": 108.53000000000002, + "964": 111.30000000000007, + "965": 109.89000000000007, + "966": 103.20000000000007, + "967": 107.54, + "968": 97.99000000000004, + "969": 113.45000000000012, + "970": 110.39000000000017, + "971": 107.40000000000009, + "972": 111.64000000000001, + "973": 111.35000000000002, + "974": 109.49000000000008, + "975": 109.89000000000013, + "976": 104.55000000000003, + "977": 111.8500000000001, + "978": 112.6400000000001, + "979": 112.37000000000009, + "980": 114.10000000000011, + "981": 111.93000000000006, + "982": 114.86000000000004, + "983": 108.7700000000001, + "984": 110.72999999999999, + "985": 109.80000000000004, + "986": 112.44000000000001, + "987": 110.8500000000001, + "988": 107.2700000000001, + "989": 107.71000000000004, + "990": 110.94000000000005, + "991": 112.76000000000008, + "992": 94.8600000000001, + "993": 109.78000000000006, + "994": 109.67000000000007, + "995": 110.44000000000005, + "996": 110.89000000000007, + "997": 111.91000000000008, + "998": 115.25000000000003, + "999": 107.88000000000004, + "1000": 112.62000000000009 + }, + "session_total_reward_per_episode": { + "1": { + "1": -18.999999999999964, + "2": -51.100000000000165, + "3": -7.849999999999995, + "4": -23.34999999999995, + "5": -50.10000000000007, + "6": -41.15, + "7": -16.999999999999975, + "8": -45.250000000000064, + "9": -27.049999999999986, + "10": -37.85000000000004, + "11": -48.40000000000007, + "12": -96.4, + "13": -17.09999999999997, + "14": -31.49999999999999, + "15": -96.75, + "16": -18.84999999999997, + "17": -60.40000000000009, + "18": -11.350000000000007, + "19": -71.55000000000004, + "20": -20.449999999999957, + "21": -12.799999999999983, + "22": -92.35, + "23": -16.049999999999976, + "24": -14.79999999999998, + "25": -33.05000000000003, + "26": -45.10000000000004, + "27": -19.99999999999996, + "28": -68.00000000000007, + "29": -19.749999999999964, + "30": -33.2, + "31": -21.09999999999996, + "32": -16.849999999999973, + "33": -18.1, + "34": -89.9, + "35": -15.59999999999998, + "36": -43.25000000000004, + "37": -22.099999999999955, + "38": -34.05000000000005, + "39": -24.24999999999996, + "40": -18.099999999999977, + "41": -35.55000000000005, + "42": -10.649999999999993, + "43": -96.10000000000002, + "44": -8.549999999999992, + "45": -24.099999999999948, + "46": -15.099999999999982, + "47": -21.799999999999944, + "48": -49.500000000000064, + "49": -14.349999999999996, + "50": -17.49999999999997, + "51": -5.599999999999979, + "52": -22.79999999999995, + "53": -61.899999999999984, + "54": -8.099999999999993, + "55": -16.199999999999985, + "56": -15.59999999999998, + "57": -12.049999999999988, + "58": -16.399999999999974, + "59": -61.3000000000001, + "60": -9.799999999999986, + "61": -17.199999999999974, + "62": -9.549999999999997, + "63": -25.250000000000018, + "64": -12.199999999999989, + "65": 0.9499999999999997, + "66": -17.69999999999997, + "67": -54.95000000000009, + "68": -6.600000000000015, + "69": -10.449999999999987, + "70": -18.04999999999997, + "71": -21.549999999999955, + "72": -44.850000000000065, + "73": -28.199999999999985, + "74": 4.100000000000025, + "75": -23.89999999999995, + "76": -0.8999999999999659, + "77": -18.19999999999997, + "78": -100.85, + "79": 3.900000000000005, + "80": -34.20000000000005, + "81": -24.849999999999987, + "82": -12.349999999999993, + "83": -23.24999999999995, + "84": 7.750000000000062, + "85": -12.44999999999999, + "86": -23.100000000000012, + "87": -59.5500000000001, + "88": -62.2000000000001, + "89": -4.899999999999981, + "90": -14.54999999999998, + "91": -17.299999999999972, + "92": -12.049999999999986, + "93": -15.899999999999979, + "94": -12.049999999999985, + "95": -17.349999999999973, + "96": -87.89999999999999, + "97": -6.54999999999999, + "98": -13.349999999999994, + "99": -8.850000000000003, + "100": -16.649999999999977, + "101": -88.25, + "102": -15.799999999999983, + "103": -18.299999999999965, + "104": -82.85000000000001, + "105": -17.949999999999967, + "106": -1.7499999999999967, + "107": -80.55000000000001, + "108": -34.20000000000003, + "109": -56.15000000000009, + "110": -15.499999999999977, + "111": -68.54999999999995, + "112": -12.649999999999999, + "113": -62.950000000000074, + "114": -78.54999999999995, + "115": -32.60000000000003, + "116": 3.800000000000053, + "117": -83.70000000000002, + "118": 8.499999999999964, + "119": -95.20000000000002, + "120": -2.699999999999963, + "121": -7.099999999999998, + "122": -3.9999999999999885, + "123": -11.84999999999999, + "124": -9.999999999999986, + "125": -14.449999999999992, + "126": -3.5499999999999767, + "127": -8.399999999999997, + "128": 5.849999999999995, + "129": -3.500000000000015, + "130": -6.7499999999999885, + "131": -12.35, + "132": -6.749999999999994, + "133": -16.899999999999974, + "134": -86.55000000000001, + "135": -9.99999999999999, + "136": -16.199999999999974, + "137": -71.25000000000001, + "138": 33.64999999999992, + "139": -15.749999999999979, + "140": -18.09999999999997, + "141": -10.750000000000004, + "142": -13.99999999999999, + "143": -15.54999999999998, + "144": 23.499999999999954, + "145": -56.0, + "146": -61.80000000000003, + "147": -3.8999999999999977, + "148": -54.250000000000014, + "149": -94.49999999999997, + "150": -12.849999999999987, + "151": -93.05000000000001, + "152": -82.3, + "153": -61.35000000000011, + "154": -3.8999999999999835, + "155": -11.449999999999992, + "156": -2.199999999999969, + "157": -6.999999999999982, + "158": 5.600000000000004, + "159": -25.250000000000004, + "160": 3.3000000000000367, + "161": -7.6499999999999915, + "162": 6.250000000000031, + "163": -84.85, + "164": -14.299999999999978, + "165": -80.85, + "166": -29.749999999999964, + "167": -65.79999999999997, + "168": -41.150000000000006, + "169": -7.949999999999995, + "170": 1.950000000000028, + "171": -84.70000000000002, + "172": -42.10000000000005, + "173": 9.75000000000001, + "174": -13.299999999999986, + "175": -1.9499999999999789, + "176": -87.30000000000001, + "177": -10.100000000000009, + "178": -6.999999999999991, + "179": -14.099999999999984, + "180": -55.64999999999999, + "181": -9.449999999999989, + "182": -68.75, + "183": -75.59999999999997, + "184": -2.8500000000000014, + "185": -78.75, + "186": 15.549999999999908, + "187": -82.10000000000005, + "188": -1.849999999999981, + "189": -27.999999999999996, + "190": -46.00000000000002, + "191": -12.399999999999986, + "192": 6.749999999999887, + "193": -12.84999999999998, + "194": 28.649999999999963, + "195": -60.85, + "196": -64.89999999999995, + "197": -8.049999999999994, + "198": -73.94999999999999, + "199": -16.74999999999998, + "200": 3.949999999999995, + "201": -87.19999999999996, + "202": -17.69999999999997, + "203": -7.549999999999987, + "204": 12.849999999999966, + "205": -21.099999999999973, + "206": -78.04999999999998, + "207": -24.59999999999996, + "208": -66.15, + "209": 3.550000000000021, + "210": -93.45, + "211": 1.8000000000000427, + "212": 7.950000000000027, + "213": 7.799999999999999, + "214": -7.450000000000004, + "215": 4.950000000000019, + "216": -16.849999999999973, + "217": -4.299999999999982, + "218": -11.999999999999972, + "219": 2.8499999999999686, + "220": -5.14999999999998, + "221": -43.94999999999999, + "222": -18.54999999999998, + "223": -4.300000000000009, + "224": -63.85000000000003, + "225": 54.59999999999989, + "226": 14.89999999999996, + "227": -25.550000000000036, + "228": -0.5000000000000246, + "229": -62.5, + "230": -63.249999999999986, + "231": -71.29999999999994, + "232": 3.0999999999999472, + "233": -83.00000000000003, + "234": 17.05000000000001, + "235": 4.299999999999985, + "236": -19.299999999999965, + "237": -5.749999999999991, + "238": -91.55000000000001, + "239": -74.99999999999999, + "240": 9.349999999999978, + "241": -56.800000000000026, + "242": 17.50000000000001, + "243": -9.29999999999998, + "244": -11.39999999999999, + "245": -27.800000000000033, + "246": 4.0000000000000355, + "247": 15.650000000000034, + "248": 20.499999999999964, + "249": 3.200000000000017, + "250": 1.2000000000000304, + "251": 29.84999999999982, + "252": -76.45000000000002, + "253": -33.4, + "254": 12.400000000000002, + "255": -85.39999999999999, + "256": 15.400000000000006, + "257": -60.75000000000001, + "258": -17.70000000000003, + "259": 18.899999999999974, + "260": 22.949999999999967, + "261": 60.849999999999866, + "262": 74.09999999999994, + "263": -0.5000000000000068, + "264": -59.099999999999966, + "265": 118.15000000000012, + "266": -39.700000000000024, + "267": 35.74999999999982, + "268": 85.40000000000002, + "269": -21.949999999999985, + "270": -67.09999999999998, + "271": 7.049999999999962, + "272": 48.09999999999986, + "273": 11.950000000000035, + "274": -49.849999999999994, + "275": 28.25000000000002, + "276": 35.89999999999993, + "277": 18.799999999999997, + "278": -55.80000000000001, + "279": -83.0, + "280": 24.65000000000007, + "281": -4.999999999999982, + "282": 52.74999999999988, + "283": 48.600000000000016, + "284": -18.200000000000003, + "285": 33.6, + "286": 9.099999999999987, + "287": 77.00000000000017, + "288": 75.80000000000005, + "289": 25.20000000000003, + "290": 29.500000000000014, + "291": -83.45, + "292": -21.39999999999998, + "293": -17.099999999999973, + "294": 70.30000000000008, + "295": 62.99999999999985, + "296": -37.64999999999999, + "297": 85.60000000000004, + "298": 101.40000000000009, + "299": 2.950000000000074, + "300": 18.24999999999999, + "301": 8.749999999999993, + "302": -0.1499999999999917, + "303": 82.70000000000026, + "304": 45.54999999999996, + "305": -27.5, + "306": 93.30000000000004, + "307": 7.949999999999961, + "308": 15.650000000000022, + "309": -19.54999999999996, + "310": 75.75000000000011, + "311": 108.15000000000018, + "312": 69.94999999999995, + "313": 102.44999999999993, + "314": 16.300000000000022, + "315": 34.449999999999825, + "316": 75.55000000000005, + "317": 46.649999999999885, + "318": -19.099999999999955, + "319": 51.69999999999991, + "320": 63.150000000000034, + "321": 105.5, + "322": 85.25000000000013, + "323": 49.799999999999876, + "324": -5.49999999999998, + "325": 59.550000000000054, + "326": 5.100000000000001, + "327": 0.44999999999997975, + "328": 75.45000000000005, + "329": 76.00000000000006, + "330": -28.449999999999974, + "331": 102.0000000000001, + "332": 33.04999999999992, + "333": 94.40000000000016, + "334": 74.00000000000004, + "335": 49.94999999999999, + "336": -3.900000000000001, + "337": 55.400000000000006, + "338": 77.74999999999999, + "339": 44.09999999999987, + "340": 101.89999999999996, + "341": -74.85, + "342": 118.60000000000012, + "343": 68.39999999999993, + "344": 70.65000000000002, + "345": 83.30000000000001, + "346": 63.80000000000007, + "347": 98.84999999999997, + "348": 41.599999999999866, + "349": 43.19999999999984, + "350": 109.35000000000018, + "351": 107.00000000000003, + "352": 101.20000000000003, + "353": 80.54999999999998, + "354": 66.00000000000007, + "355": 37.699999999999946, + "356": 44.999999999999964, + "357": 54.49999999999995, + "358": 90.44999999999993, + "359": 51.399999999999835, + "360": 78.20000000000012, + "361": 31.84999999999985, + "362": 88.20000000000006, + "363": 66.60000000000008, + "364": 55.399999999999935, + "365": 76.65000000000013, + "366": 56.09999999999996, + "367": 83.7000000000001, + "368": 117.89999999999998, + "369": 35.75000000000002, + "370": 82.85000000000002, + "371": 84.95, + "372": 83.14999999999992, + "373": 92.05000000000001, + "374": 76.90000000000003, + "375": 109.25000000000006, + "376": 97.85000000000004, + "377": 47.35000000000002, + "378": 52.94999999999992, + "379": 103.00000000000007, + "380": 79.40000000000008, + "381": 77.89999999999995, + "382": 79.00000000000017, + "383": 59.74999999999991, + "384": 107.20000000000007, + "385": 81.05000000000005, + "386": 66.00000000000001, + "387": 89.10000000000002, + "388": 51.34999999999988, + "389": 84.05000000000003, + "390": 70.80000000000007, + "391": 70.55000000000001, + "392": 104.20000000000009, + "393": 119.50000000000009, + "394": 84.20000000000013, + "395": 88.25000000000007, + "396": 18.15000000000001, + "397": 72.09999999999997, + "398": 83.89999999999999, + "399": 94.95000000000016, + "400": 82.95000000000013, + "401": -2.1500000000000004, + "402": 102.05000000000007, + "403": 73.44999999999997, + "404": 112.45000000000006, + "405": 111.25, + "406": 110.14999999999995, + "407": 114.35000000000001, + "408": 111.10000000000001, + "409": 97.65000000000003, + "410": 92.50000000000004, + "411": 92.85000000000012, + "412": 109.45000000000002, + "413": 113.70000000000003, + "414": 117.20000000000007, + "415": 59.89999999999992, + "416": 112.60000000000002, + "417": 117.8500000000002, + "418": 118.50000000000004, + "419": 113.5000000000001, + "420": 93.45, + "421": 100.20000000000014, + "422": 113.60000000000008, + "423": 85.89999999999996, + "424": 95.64999999999999, + "425": 110.55000000000008, + "426": 104.25000000000016, + "427": 90.44999999999997, + "428": 102.2, + "429": 106.14999999999999, + "430": 94.55000000000001, + "431": 109.7000000000001, + "432": 108.60000000000012, + "433": 111.2500000000001, + "434": 89.04999999999998, + "435": 98.60000000000012, + "436": 97.8, + "437": 113.3500000000001, + "438": 119.34999999999998, + "439": 109.60000000000018, + "440": 110.45000000000024, + "441": 112.75000000000007, + "442": 115.19999999999996, + "443": 115.30000000000003, + "444": 109.45000000000005, + "445": 112.44999999999996, + "446": 99.19999999999996, + "447": 111.09999999999998, + "448": 106.05000000000003, + "449": -2.6499999999999924, + "450": 113.49999999999999, + "451": 106.60000000000004, + "452": 94.85000000000004, + "453": 51.45000000000001, + "454": 118.2, + "455": 118.85000000000011, + "456": 106.45000000000005, + "457": 100.50000000000013, + "458": 111.04999999999994, + "459": 110.14999999999996, + "460": 10.750000000000002, + "461": 117.75000000000007, + "462": 108.15000000000008, + "463": 108.05000000000003, + "464": 112.05000000000011, + "465": 116.80000000000004, + "466": 103.54999999999981, + "467": 104.50000000000011, + "468": 68.85000000000007, + "469": 119.80000000000015, + "470": 89.2499999999999, + "471": 103.39999999999998, + "472": 107.90000000000003, + "473": 113.80000000000007, + "474": 105.8000000000001, + "475": 114.20000000000009, + "476": 104.04999999999995, + "477": 105.10000000000008, + "478": 104.85000000000004, + "479": 117.95000000000014, + "480": 114.40000000000003, + "481": 51.699999999999804, + "482": 109.44999999999999, + "483": 113.25000000000009, + "484": 91.44999999999997, + "485": -30.94999999999996, + "486": 110.60000000000012, + "487": 109.95000000000009, + "488": 119.00000000000006, + "489": 113.85000000000004, + "490": 106.35000000000016, + "491": 111.00000000000001, + "492": 114.35000000000001, + "493": 105.55000000000011, + "494": 102.30000000000003, + "495": 100.5500000000002, + "496": 102.55000000000003, + "497": 111.0000000000002, + "498": 95.39999999999999, + "499": 112.00000000000009, + "500": 119.35000000000007, + "501": 114.75, + "502": 95.80000000000007, + "503": 42.44999999999984, + "504": 56.749999999999844, + "505": 113.30000000000004, + "506": 113.59999999999995, + "507": 105.20000000000019, + "508": 117.4000000000001, + "509": 117.7500000000001, + "510": 92.7000000000001, + "511": 107.35000000000011, + "512": 110.55000000000007, + "513": 100.40000000000015, + "514": 108.00000000000004, + "515": 106.85000000000001, + "516": 114.90000000000015, + "517": 108.4000000000002, + "518": 95.15000000000002, + "519": 106.8, + "520": 106.70000000000002, + "521": 111.40000000000009, + "522": 109.99999999999996, + "523": 62.34999999999983, + "524": 109.39999999999993, + "525": 110.45000000000016, + "526": 111.65000000000005, + "527": 108.55000000000014, + "528": 116.19999999999995, + "529": 107.70000000000013, + "530": 110.50000000000014, + "531": 112.15000000000008, + "532": 108.3000000000002, + "533": 101.14999999999992, + "534": 108.70000000000012, + "535": 106.7500000000001, + "536": 110.40000000000008, + "537": 109.74999999999996, + "538": 110.49999999999993, + "539": 103.30000000000011, + "540": 100.3500000000001, + "541": 98.99999999999997, + "542": 111.4, + "543": 110.20000000000019, + "544": 114.64999999999993, + "545": 102.15000000000019, + "546": 112.9000000000002, + "547": 116.49999999999997, + "548": 103.35000000000012, + "549": 106.70000000000009, + "550": 99.40000000000002, + "551": 103.85000000000004, + "552": 113.35000000000008, + "553": 98.45000000000013, + "554": 106.00000000000014, + "555": 114.0500000000001, + "556": 117.69999999999997, + "557": 112.59999999999992, + "558": 110.90000000000013, + "559": 112.54999999999997, + "560": 106.30000000000013, + "561": 114.10000000000002, + "562": 111.45000000000007, + "563": 92.95, + "564": 113.30000000000013, + "565": 100.14999999999999, + "566": 119.65000000000016, + "567": 102.55000000000003, + "568": 96.00000000000001, + "569": 109.44999999999992, + "570": 115.10000000000014, + "571": 112.00000000000004, + "572": 93.85000000000014, + "573": 109.00000000000011, + "574": 106.0500000000001, + "575": 100.99999999999996, + "576": 105.25000000000017, + "577": 117.99999999999993, + "578": 109.75, + "579": 110.75000000000009, + "580": 116.25000000000018, + "581": 107.49999999999991, + "582": 111.00000000000011, + "583": 112.2500000000001, + "584": 118.15000000000008, + "585": 113.45000000000017, + "586": 111.35000000000014, + "587": 115.20000000000007, + "588": 110.30000000000005, + "589": 107.04999999999988, + "590": 112.00000000000004, + "591": 113.50000000000018, + "592": 112.75000000000016, + "593": 110.35000000000008, + "594": 116.00000000000011, + "595": 116.95, + "596": 105.90000000000008, + "597": 112.2500000000001, + "598": 107.65000000000005, + "599": 112.15000000000006, + "600": 116.35000000000008, + "601": 107.55000000000004, + "602": 103.45000000000012, + "603": 100.49999999999991, + "604": 110.85000000000026, + "605": 110.20000000000017, + "606": 94.10000000000015, + "607": 112.15000000000008, + "608": 113.55000000000018, + "609": 65.40000000000003, + "610": 111.3000000000001, + "611": 118.9500000000001, + "612": 109.85000000000001, + "613": 99.89999999999999, + "614": 107.0500000000001, + "615": 108.70000000000005, + "616": 115.30000000000013, + "617": 111.85000000000025, + "618": 106.40000000000002, + "619": 118.45, + "620": 112.50000000000007, + "621": 108.5500000000001, + "622": 114.60000000000004, + "623": 120.44999999999999, + "624": 105.20000000000002, + "625": 105.85000000000001, + "626": 111.55000000000003, + "627": 113.80000000000005, + "628": 110.45000000000014, + "629": 77.50000000000017, + "630": 117.00000000000023, + "631": 113.55000000000007, + "632": 105.75000000000007, + "633": 115.45000000000012, + "634": 111.2999999999999, + "635": 106.05000000000005, + "636": 98.19999999999989, + "637": 109.55000000000014, + "638": 114.10000000000011, + "639": 115.75000000000006, + "640": 105.3, + "641": 96.00000000000003, + "642": 79.89999999999988, + "643": 111.0000000000002, + "644": 109.30000000000007, + "645": 109.2000000000001, + "646": 106.20000000000014, + "647": 113.50000000000004, + "648": 114.9000000000001, + "649": 122.45000000000005, + "650": 114.7500000000001, + "651": 111.05000000000021, + "652": 114.65000000000009, + "653": 107.99999999999996, + "654": 29.49999999999987, + "655": 112.89999999999998, + "656": 105.24999999999996, + "657": 108.99999999999999, + "658": 89.20000000000006, + "659": 111.30000000000022, + "660": 114.95000000000024, + "661": 108.29999999999997, + "662": 112.94999999999999, + "663": 120.25000000000016, + "664": 114.14999999999996, + "665": 104.40000000000006, + "666": 113.75000000000009, + "667": 91.65000000000002, + "668": 114.10000000000008, + "669": 113.8000000000001, + "670": 115.30000000000017, + "671": 107.2500000000002, + "672": 101.45000000000002, + "673": 115.35000000000021, + "674": 101.14999999999996, + "675": 115.40000000000003, + "676": 111.49999999999997, + "677": 113.20000000000016, + "678": 117.85000000000008, + "679": 97.0, + "680": 106.75000000000013, + "681": 113.55000000000001, + "682": 112.55000000000011, + "683": 114.40000000000025, + "684": 118.49999999999996, + "685": 110.0500000000001, + "686": 105.05000000000004, + "687": 115.95000000000014, + "688": 114.4000000000001, + "689": 108.65000000000005, + "690": 22.099999999999937, + "691": 114.60000000000005, + "692": 110.60000000000012, + "693": 116.55000000000007, + "694": 116.30000000000005, + "695": 99.20000000000003, + "696": 118.50000000000006, + "697": 106.60000000000011, + "698": 117.20000000000016, + "699": 98.05000000000008, + "700": 116.15000000000009, + "701": 104.95, + "702": 83.25, + "703": 2.000000000000016, + "704": 11.150000000000055, + "705": -17.599999999999977, + "706": 84.84999999999995, + "707": 50.84999999999998, + "708": 25.94999999999985, + "709": 33.54999999999979, + "710": 93.45000000000005, + "711": 42.399999999999764, + "712": -33.65000000000001, + "713": 92.69999999999995, + "714": 25.75000000000005, + "715": 32.399999999999764, + "716": 67.45000000000002, + "717": 25.9499999999999, + "718": 111.29999999999998, + "719": 55.89999999999982, + "720": -1.24999999999999, + "721": 24.899999999999935, + "722": 110.24999999999996, + "723": 70.19999999999989, + "724": 117.85000000000005, + "725": 11.500000000000071, + "726": 45.84999999999982, + "727": -47.69999999999998, + "728": -0.7499999999999503, + "729": 14.00000000000006, + "730": 85.85000000000004, + "731": 46.39999999999977, + "732": 111.70000000000013, + "733": 55.499999999999844, + "734": 112.75, + "735": 94.60000000000018, + "736": 98.30000000000018, + "737": 106.95000000000003, + "738": 72.04999999999993, + "739": 99.15000000000005, + "740": 101.8000000000001, + "741": 103.45000000000013, + "742": 3.45000000000001, + "743": 100.90000000000013, + "744": 94.24999999999991, + "745": 112.50000000000006, + "746": 99.7, + "747": 94.60000000000025, + "748": 100.64999999999995, + "749": 106.90000000000006, + "750": 48.049999999999855, + "751": 5.7999999999999226, + "752": 74.14999999999979, + "753": 117.15000000000005, + "754": -9.0, + "755": 109.99999999999996, + "756": 82.25000000000001, + "757": 111.7500000000001, + "758": 76.84999999999987, + "759": 103.25000000000007, + "760": 107.65000000000016, + "761": 115.55000000000001, + "762": 110.10000000000012, + "763": 101.04999999999997, + "764": 100.90000000000006, + "765": 108.45000000000017, + "766": 100.95000000000003, + "767": 106.95000000000014, + "768": 115.70000000000005, + "769": 102.5000000000001, + "770": 112.05000000000014, + "771": 82.84999999999988, + "772": 108.40000000000006, + "773": 107.50000000000011, + "774": 117.85000000000007, + "775": 106.9499999999999, + "776": 116.70000000000017, + "777": 118.30000000000007, + "778": 39.80000000000001, + "779": 108.20000000000013, + "780": 112.50000000000004, + "781": 108.89999999999999, + "782": 96.34999999999995, + "783": 120.60000000000001, + "784": 114.95000000000006, + "785": 105.99999999999989, + "786": 108.2, + "787": 119.60000000000005, + "788": 99.15000000000009, + "789": 113.60000000000005, + "790": 76.94999999999997, + "791": 112.7000000000001, + "792": 106.00000000000013, + "793": 109.60000000000004, + "794": 101.00000000000006, + "795": 105.60000000000011, + "796": 114.1000000000001, + "797": 113.30000000000008, + "798": 115.25000000000007, + "799": 113.90000000000002, + "800": 112.25000000000014, + "801": 103.35000000000001, + "802": 111.4, + "803": 113.50000000000006, + "804": 113.80000000000013, + "805": 115.15000000000018, + "806": 116.8, + "807": 113.20000000000007, + "808": 109.70000000000002, + "809": 108.70000000000014, + "810": 112.80000000000014, + "811": 112.60000000000015, + "812": 108.64999999999998, + "813": 114.25000000000007, + "814": 116.70000000000006, + "815": 103.8, + "816": 114.30000000000015, + "817": 117.04999999999995, + "818": 115.65000000000018, + "819": 107.19999999999999, + "820": 112.15000000000015, + "821": 113.15000000000003, + "822": 105.40000000000002, + "823": 109.2, + "824": 115.0500000000002, + "825": 106.95000000000003, + "826": 117.65000000000012, + "827": 109.50000000000023, + "828": 115.60000000000007, + "829": 92.49999999999999, + "830": 115.85000000000002, + "831": 106.0, + "832": 100.70000000000009, + "833": 117.15000000000015, + "834": 120.40000000000005, + "835": 118.90000000000015, + "836": 114.15000000000005, + "837": 104.40000000000003, + "838": 111.3000000000001, + "839": 112.20000000000014, + "840": 113.00000000000017, + "841": 108.75000000000011, + "842": 108.15000000000009, + "843": 111.6000000000001, + "844": 108.60000000000014, + "845": 116.00000000000003, + "846": 107.40000000000019, + "847": 116.55000000000021, + "848": 98.95000000000003, + "849": 109.45000000000002, + "850": 109.80000000000011, + "851": 94.15000000000015, + "852": 108.55000000000007, + "853": 114.50000000000014, + "854": 108.30000000000013, + "855": 119.20000000000006, + "856": 92.49999999999997, + "857": 116.60000000000012, + "858": 115.80000000000001, + "859": 113.10000000000014, + "860": 106.4000000000001, + "861": 110.40000000000009, + "862": 117.70000000000006, + "863": 116.7, + "864": 100.59999999999995, + "865": 113.45000000000022, + "866": 112.80000000000003, + "867": 101.84999999999997, + "868": 105.55000000000018, + "869": 108.40000000000005, + "870": 113.84999999999988, + "871": 114.55000000000008, + "872": 107.2, + "873": 116.45000000000013, + "874": 112.69999999999996, + "875": 114.65, + "876": 108.7500000000001, + "877": 110.10000000000002, + "878": 107.05000000000017, + "879": 114.25000000000011, + "880": 87.70000000000009, + "881": 110.60000000000002, + "882": 107.00000000000009, + "883": 104.70000000000007, + "884": 107.15000000000013, + "885": 105.45000000000002, + "886": 113.90000000000003, + "887": 112.10000000000001, + "888": 115.05000000000018, + "889": 104.44999999999983, + "890": 106.69999999999996, + "891": 109.30000000000003, + "892": 113.59999999999995, + "893": 106.45000000000006, + "894": 115.75000000000006, + "895": 109.89999999999996, + "896": 112.80000000000014, + "897": 108.30000000000011, + "898": 104.35000000000011, + "899": 113.2000000000001, + "900": 110.85000000000012, + "901": 109.10000000000004, + "902": 113.8500000000001, + "903": 102.39999999999985, + "904": 105.0500000000001, + "905": 104.35000000000014, + "906": 108.64999999999999, + "907": 106.05000000000013, + "908": 116.60000000000004, + "909": 111.20000000000009, + "910": 114.95000000000006, + "911": 109.34999999999995, + "912": 103.95000000000002, + "913": 108.90000000000003, + "914": 110.15000000000016, + "915": 112.40000000000003, + "916": 117.40000000000002, + "917": 112.05000000000007, + "918": 119.5, + "919": 104.55000000000015, + "920": 118.6, + "921": 110.9500000000002, + "922": 109.30000000000008, + "923": 112.35000000000011, + "924": 109.95000000000007, + "925": 115.6000000000001, + "926": 110.75000000000017, + "927": 113.45000000000003, + "928": 117.35000000000011, + "929": 110.90000000000009, + "930": 114.90000000000005, + "931": 105.6499999999999, + "932": 115.69999999999999, + "933": 114.70000000000012, + "934": 113.94999999999997, + "935": 118.25, + "936": 114.70000000000024, + "937": 110.75000000000003, + "938": 113.40000000000006, + "939": 108.70000000000023, + "940": 112.2000000000001, + "941": 107.10000000000005, + "942": 107.20000000000007, + "943": 103.60000000000005, + "944": 111.65000000000002, + "945": 112.4500000000001, + "946": 108.55000000000013, + "947": 110.15000000000013, + "948": 107.00000000000006, + "949": 105.80000000000013, + "950": 113.45000000000014, + "951": 110.95000000000005, + "952": 96.89999999999999, + "953": 108.45000000000009, + "954": 110.25000000000001, + "955": 115.65000000000018, + "956": 117.55000000000014, + "957": 112.2500000000001, + "958": 113.55000000000015, + "959": 116.0000000000002, + "960": 116.39999999999999, + "961": 106.9500000000001, + "962": 114.04999999999993, + "963": 106.64999999999992, + "964": 105.25000000000009, + "965": 110.0000000000002, + "966": 113.20000000000007, + "967": 104.90000000000008, + "968": 112.8500000000001, + "969": 107.0500000000001, + "970": 109.85000000000015, + "971": 110.85000000000008, + "972": 112.4500000000001, + "973": 116.90000000000002, + "974": 109.70000000000009, + "975": 122.25000000000009, + "976": 115.50000000000004, + "977": 112.00000000000004, + "978": 119.35000000000014, + "979": 110.95000000000007, + "980": 121.30000000000014, + "981": 111.70000000000007, + "982": 119.40000000000009, + "983": 106.6500000000001, + "984": 102.64999999999998, + "985": 108.2, + "986": 103.8499999999998, + "987": 113.80000000000004, + "988": 104.8500000000001, + "989": 110.9500000000001, + "990": 111.00000000000009, + "991": 114.00000000000009, + "992": 113.30000000000003, + "993": 115.75000000000009, + "994": 114.55000000000014, + "995": 111.60000000000005, + "996": 107.95000000000009, + "997": 117.1000000000001, + "998": 117.35000000000008, + "999": 112.49999999999994, + "1000": 115.2500000000002 + }, + "2": { + "1": -58.850000000000094, + "2": -25.849999999999984, + "3": -10.799999999999988, + "4": -34.04999999999999, + "5": -59.84999999999999, + "6": -62.05000000000015, + "7": -38.200000000000024, + "8": -62.300000000000104, + "9": -4.099999999999996, + "10": -61.1000000000001, + "11": -19.849999999999984, + "12": -5.4, + "13": -35.65000000000015, + "14": -2.19999999999996, + "15": -25.000000000000004, + "16": -17.549999999999972, + "17": -20.44999999999996, + "18": -72.25000000000003, + "19": -35.24999999999999, + "20": -9.849999999999994, + "21": -22.149999999999952, + "22": -18.599999999999984, + "23": -43.350000000000044, + "24": -34.60000000000001, + "25": -46.50000000000011, + "26": -90.3, + "27": -18.199999999999946, + "28": -36.00000000000005, + "29": -11.899999999999991, + "30": -20.999999999999986, + "31": -50.600000000000136, + "32": -32.9, + "33": -22.249999999999954, + "34": -60.299999999999955, + "35": -56.40000000000012, + "36": -32.79999999999999, + "37": -74.35000000000001, + "38": -56.950000000000166, + "39": -10.649999999999986, + "40": -20.149999999999977, + "41": -4.350000000000009, + "42": -8.600000000000021, + "43": -17.099999999999973, + "44": -2.1499999999999755, + "45": -27.749999999999936, + "46": -21.59999999999996, + "47": -48.09999999999996, + "48": -11.149999999999988, + "49": -21.99999999999997, + "50": -55.30000000000012, + "51": -36.65000000000003, + "52": -6.649999999999991, + "53": -16.349999999999977, + "54": -36.250000000000036, + "55": -97.7000000000001, + "56": -36.3000000000001, + "57": -29.04999999999997, + "58": -19.549999999999965, + "59": -95.80000000000003, + "60": -26.399999999999974, + "61": -17.349999999999987, + "62": -84.75000000000001, + "63": -19.89999999999998, + "64": -12.99999999999999, + "65": -16.54999999999998, + "66": -69.7500000000001, + "67": -75.79999999999998, + "68": -22.549999999999955, + "69": -18.899999999999974, + "70": -11.549999999999994, + "71": -14.099999999999982, + "72": -15.999999999999977, + "73": -14.049999999999983, + "74": -3.5499999999999936, + "75": -29.64999999999995, + "76": -23.04999999999995, + "77": -0.04999999999997784, + "78": -20.999999999999996, + "79": -86.64999999999998, + "80": -19.299999999999965, + "81": -20.149999999999963, + "82": -20.44999999999996, + "83": -69.74999999999999, + "84": -8.350000000000001, + "85": -15.799999999999978, + "86": -12.69999999999999, + "87": -44.849999999999994, + "88": -11.89999999999999, + "89": -22.199999999999953, + "90": -13.299999999999988, + "91": -46.250000000000185, + "92": -55.65000000000007, + "93": -21.549999999999958, + "94": -16.44999999999996, + "95": -20.59999999999996, + "96": -2.099999999999987, + "97": -18.399999999999967, + "98": -88.7, + "99": -11.3, + "100": -23.34999999999995, + "101": -1.999999999999969, + "102": -19.99999999999996, + "103": 5.700000000000022, + "104": -15.899999999999983, + "105": 0.5000000000000284, + "106": -21.14999999999996, + "107": -16.499999999999975, + "108": -92.55, + "109": -21.09999999999996, + "110": -17.64999999999997, + "111": -88.55000000000001, + "112": -55.54999999999995, + "113": -85.79999999999998, + "114": -22.199999999999953, + "115": -65.70000000000002, + "116": 29.349999999999866, + "117": -12.249999999999998, + "118": -21.04999999999996, + "119": -13.89999999999998, + "120": -4.3999999999999835, + "121": -1.6499999999999633, + "122": 2.350000000000039, + "123": -65.85000000000002, + "124": -92.55000000000001, + "125": -3.2999999999999705, + "126": -14.099999999999993, + "127": -16.899999999999974, + "128": -76.1, + "129": 13.950000000000042, + "130": -3.3499999999999766, + "131": -8.599999999999936, + "132": -10.049999999999999, + "133": -13.199999999999989, + "134": -3.7499999999999902, + "135": -3.1999999999999877, + "136": -17.24999999999997, + "137": -50.70000000000007, + "138": -88.2, + "139": -4.649999999999982, + "140": -18.14999999999997, + "141": 4.150000000000027, + "142": 4.100000000000055, + "143": 3.9000000000000012, + "144": -62.29999999999999, + "145": 4.550000000000031, + "146": -12.399999999999979, + "147": -32.749999999999964, + "148": -9.549999999999994, + "149": -13.849999999999984, + "150": -54.849999999999994, + "151": 9.599999999999946, + "152": 6.1999999999999655, + "153": 1.250000000000031, + "154": -16.549999999999983, + "155": 4.150000000000016, + "156": -19.599999999999966, + "157": -12.699999999999976, + "158": -7.349999999999994, + "159": -16.799999999999976, + "160": -7.799999999999999, + "161": 17.699999999999918, + "162": -4.649999999999981, + "163": -98.60000000000001, + "164": -2.7999999999999767, + "165": -37.800000000000004, + "166": -11.499999999999995, + "167": -11.049999999999988, + "168": -25.54999999999995, + "169": -17.999999999999968, + "170": -1.9499999999999753, + "171": -23.899999999999977, + "172": -93.6, + "173": 0.05000000000000293, + "174": -20.49999999999996, + "175": -8.450000000000005, + "176": -36.95, + "177": -7.6499999999999915, + "178": -74.44999999999999, + "179": 3.300000000000047, + "180": -93.9, + "181": -2.4499999999999815, + "182": -15.349999999999984, + "183": -63.5, + "184": -4.349999999999986, + "185": 11.249999999999979, + "186": 42.749999999999844, + "187": 50.89999999999979, + "188": -11.349999999999982, + "189": -5.399999999999989, + "190": -26.849999999999966, + "191": 44.24999999999982, + "192": -80.20000000000002, + "193": 4.249999999999983, + "194": 11.900000000000038, + "195": 18.649999999999974, + "196": -40.149999999999984, + "197": -95.75, + "198": -83.65000000000002, + "199": -12.099999999999993, + "200": 2.650000000000005, + "201": -2.549999999999976, + "202": 38.49999999999992, + "203": -78.05000000000001, + "204": -41.50000000000005, + "205": -16.099999999999984, + "206": -7.050000000000021, + "207": -4.449999999999988, + "208": -16.149999999999952, + "209": -5.249999999999993, + "210": 10.20000000000006, + "211": 6.400000000000064, + "212": 20.300000000000022, + "213": -57.3, + "214": -4.2999999999999785, + "215": -14.349999999999989, + "216": 22.099999999999845, + "217": -6.649999999999991, + "218": -17.14999999999998, + "219": 42.349999999999966, + "220": 35.64999999999999, + "221": 18.70000000000002, + "222": 38.300000000000004, + "223": 49.39999999999988, + "224": -33.04999999999997, + "225": 11.34999999999993, + "226": 82.79999999999977, + "227": 3.7500000000000338, + "228": 70.3999999999998, + "229": -16.9, + "230": -73.69999999999997, + "231": -11.400000000000013, + "232": -79.19999999999993, + "233": 18.500000000000046, + "234": 25.44999999999993, + "235": -21.34999999999996, + "236": 0.4000000000000128, + "237": 43.99999999999978, + "238": 4.25000000000005, + "239": 58.64999999999986, + "240": -86.34999999999994, + "241": -51.24999999999997, + "242": 54.54999999999993, + "243": -18.349999999999973, + "244": -23.65000000000004, + "245": -1.9499999999999744, + "246": 18.050000000000004, + "247": 40.39999999999983, + "248": 53.04999999999985, + "249": 30.499999999999773, + "250": -32.54999999999997, + "251": -15.699999999999978, + "252": 27.199999999999985, + "253": -70.10000000000001, + "254": 5.550000000000015, + "255": 55.349999999999746, + "256": 65.24999999999991, + "257": 66.99999999999989, + "258": 18.749999999999947, + "259": 29.649999999999864, + "260": -14.350000000000012, + "261": 75.24999999999979, + "262": 10.700000000000056, + "263": 85.04999999999974, + "264": 37.849999999999994, + "265": 16.850000000000065, + "266": -39.34999999999999, + "267": 9.250000000000014, + "268": 7.3499999999999455, + "269": 16.249999999999968, + "270": 24.049999999999883, + "271": 78.70000000000013, + "272": 54.59999999999981, + "273": -43.0000000000001, + "274": 88.34999999999975, + "275": -27.049999999999958, + "276": 72.64999999999975, + "277": -16.19999999999997, + "278": 92.9999999999998, + "279": 18.699999999999996, + "280": 33.499999999999986, + "281": 80.99999999999993, + "282": -8.10000000000001, + "283": 18.00000000000008, + "284": 94.74999999999977, + "285": 97.3499999999998, + "286": -69.25000000000001, + "287": 11.299999999999997, + "288": 62.749999999999744, + "289": 56.44999999999974, + "290": 60.299999999999756, + "291": -19.100000000000044, + "292": -56.30000000000004, + "293": 71.39999999999975, + "294": 17.150000000000023, + "295": 100.74999999999991, + "296": 46.94999999999996, + "297": 35.94999999999979, + "298": 39.94999999999981, + "299": -36.4, + "300": 52.99999999999981, + "301": 86.74999999999976, + "302": -77.25, + "303": 61.39999999999972, + "304": 73.19999999999976, + "305": 74.94999999999979, + "306": 110.94999999999979, + "307": 67.14999999999978, + "308": 69.8999999999998, + "309": 25.899999999999995, + "310": 66.14999999999975, + "311": -74.30000000000001, + "312": -49.09999999999999, + "313": 7.850000000000069, + "314": 84.89999999999979, + "315": 75.15000000000009, + "316": 20.299999999999976, + "317": 50.69999999999973, + "318": -31.049999999999994, + "319": 41.49999999999982, + "320": 35.850000000000016, + "321": 19.70000000000003, + "322": 96.54999999999977, + "323": 69.04999999999974, + "324": 59.0999999999999, + "325": 51.59999999999997, + "326": 37.39999999999993, + "327": -36.40000000000002, + "328": 102.99999999999976, + "329": 99.04999999999977, + "330": -29.69999999999999, + "331": 22.19999999999998, + "332": 51.599999999999845, + "333": 59.799999999999834, + "334": 104.24999999999979, + "335": 78.04999999999977, + "336": 100.94999999999973, + "337": 80.19999999999978, + "338": 16.700000000000006, + "339": -19.250000000000018, + "340": 90.05000000000025, + "341": -49.09999999999999, + "342": 36.550000000000004, + "343": 103.19999999999973, + "344": 94.34999999999972, + "345": 1.7000000000000282, + "346": 83.39999999999984, + "347": 87.54999999999974, + "348": -56.44999999999997, + "349": 83.64999999999984, + "350": 72.04999999999976, + "351": 67.59999999999977, + "352": 50.64999999999994, + "353": 65.54999999999991, + "354": 74.59999999999985, + "355": -0.5999999999999834, + "356": 25.699999999999985, + "357": 97.29999999999973, + "358": 116.75000000000027, + "359": 37.39999999999974, + "360": 35.84999999999976, + "361": 92.1999999999998, + "362": 99.79999999999974, + "363": 62.2499999999999, + "364": 42.74999999999992, + "365": 63.74999999999973, + "366": -7.69999999999999, + "367": 104.24999999999974, + "368": 95.24999999999977, + "369": 107.99999999999976, + "370": 93.7499999999998, + "371": 82.69999999999989, + "372": 49.04999999999986, + "373": -41.95000000000013, + "374": 105.84999999999981, + "375": 105.74999999999973, + "376": 95.44999999999973, + "377": 61.349999999999724, + "378": 107.49999999999979, + "379": 39.949999999999854, + "380": 68.99999999999977, + "381": 86.99999999999977, + "382": 99.04999999999981, + "383": 105.39999999999974, + "384": 101.54999999999984, + "385": 76.94999999999983, + "386": 111.40000000000006, + "387": 35.899999999999814, + "388": 95.09999999999974, + "389": 89.89999999999975, + "390": 93.79999999999976, + "391": 18.500000000000075, + "392": -44.19999999999999, + "393": 102.94999999999978, + "394": 103.69999999999976, + "395": 107.24999999999976, + "396": 103.74999999999973, + "397": -15.649999999999956, + "398": 75.39999999999989, + "399": 93.35000000000011, + "400": 98.69999999999973, + "401": 83.54999999999974, + "402": 87.79999999999977, + "403": 111.39999999999978, + "404": 98.99999999999977, + "405": 51.399999999999785, + "406": 101.49999999999974, + "407": 84.34999999999974, + "408": 101.84999999999977, + "409": 113.85000000000004, + "410": 10.150000000000077, + "411": -36.30000000000009, + "412": 48.799999999999756, + "413": 99.69999999999979, + "414": 73.04999999999986, + "415": 97.49999999999974, + "416": -73.19999999999999, + "417": -3.1500000000000887, + "418": -20.69999999999998, + "419": 53.149999999999814, + "420": 102.59999999999977, + "421": 109.89999999999975, + "422": 38.549999999999905, + "423": 103.59999999999977, + "424": 112.64999999999992, + "425": 100.74999999999976, + "426": 53.34999999999994, + "427": -7.649999999999986, + "428": 43.69999999999996, + "429": 110.14999999999998, + "430": -20.749999999999986, + "431": 103.94999999999976, + "432": 59.949999999999896, + "433": 63.79999999999992, + "434": 106.69999999999975, + "435": 110.0999999999998, + "436": 90.54999999999978, + "437": 107.59999999999984, + "438": 105.5499999999998, + "439": 66.44999999999989, + "440": 77.79999999999991, + "441": 104.69999999999975, + "442": 74.04999999999983, + "443": 106.04999999999993, + "444": 49.849999999999824, + "445": 82.59999999999975, + "446": 74.6999999999998, + "447": 17.000000000000064, + "448": 106.79999999999995, + "449": 43.24999999999994, + "450": 102.59999999999994, + "451": 115.60000000000025, + "452": 101.04999999999976, + "453": 100.39999999999974, + "454": 107.19999999999973, + "455": 102.34999999999977, + "456": 8.949999999999843, + "457": 14.450000000000003, + "458": 105.44999999999975, + "459": 109.64999999999985, + "460": 107.64999999999975, + "461": 97.74999999999977, + "462": -20.599999999999955, + "463": 46.49999999999996, + "464": 106.34999999999974, + "465": 100.44999999999976, + "466": 7.100000000000035, + "467": 64.54999999999984, + "468": 76.14999999999972, + "469": 103.29999999999977, + "470": 4.349999999999993, + "471": 98.89999999999978, + "472": -8.999999999999998, + "473": 101.49999999999976, + "474": 106.74999999999972, + "475": 80.79999999999988, + "476": 77.59999999999987, + "477": 85.14999999999985, + "478": 101.69999999999975, + "479": 105.94999999999972, + "480": 100.14999999999976, + "481": 58.79999999999992, + "482": 97.54999999999978, + "483": 71.34999999999985, + "484": 106.44999999999972, + "485": 80.74999999999976, + "486": 24.850000000000016, + "487": 72.74999999999987, + "488": 98.04999999999973, + "489": 71.59999999999978, + "490": 105.09999999999991, + "491": 87.9999999999999, + "492": 97.74999999999973, + "493": 96.84999999999972, + "494": 104.69999999999976, + "495": 96.44999999999973, + "496": 46.19999999999994, + "497": 106.35000000000008, + "498": 99.94999999999978, + "499": 89.49999999999993, + "500": 105.34999999999985, + "501": 52.39999999999977, + "502": 104.64999999999972, + "503": 102.39999999999975, + "504": 62.39999999999976, + "505": 94.24999999999974, + "506": 72.14999999999975, + "507": 105.4999999999999, + "508": 73.8499999999999, + "509": 103.44999999999973, + "510": 89.5499999999998, + "511": 45.59999999999995, + "512": 87.04999999999981, + "513": 104.54999999999977, + "514": 100.09999999999977, + "515": 105.79999999999974, + "516": 99.69999999999976, + "517": 108.39999999999974, + "518": 103.64999999999976, + "519": -65.25, + "520": 63.79999999999973, + "521": 94.39999999999974, + "522": 105.79999999999974, + "523": 97.64999999999974, + "524": 49.84999999999987, + "525": 93.74999999999976, + "526": 79.54999999999973, + "527": 105.59999999999974, + "528": 107.19999999999975, + "529": 73.29999999999973, + "530": 93.19999999999973, + "531": 107.34999999999971, + "532": 111.24999999999982, + "533": -82.9, + "534": 103.94999999999976, + "535": 102.19999999999978, + "536": 102.09999999999991, + "537": 101.74999999999977, + "538": 67.74999999999977, + "539": 60.69999999999973, + "540": 82.4999999999998, + "541": 93.59999999999997, + "542": 101.79999999999977, + "543": 97.54999999999977, + "544": 107.59999999999974, + "545": 105.2999999999998, + "546": 97.44999999999976, + "547": 106.89999999999974, + "548": 107.6999999999999, + "549": 92.54999999999976, + "550": 104.24999999999974, + "551": 99.39999999999992, + "552": -10.450000000000088, + "553": 104.44999999999976, + "554": 79.59999999999985, + "555": 102.84999999999977, + "556": 76.54999999999976, + "557": 103.34999999999977, + "558": 98.34999999999974, + "559": 95.59999999999981, + "560": 103.49999999999976, + "561": 106.99999999999974, + "562": 66.34999999999982, + "563": 103.34999999999972, + "564": 102.74999999999977, + "565": 106.84999999999995, + "566": 111.79999999999994, + "567": 103.79999999999976, + "568": 105.24999999999976, + "569": 101.84999999999974, + "570": 103.34999999999977, + "571": 108.49999999999976, + "572": 36.10000000000002, + "573": -23.599999999999945, + "574": 104.04999999999976, + "575": 107.34999999999972, + "576": 109.79999999999974, + "577": 93.49999999999974, + "578": 21.60000000000007, + "579": 91.05000000000007, + "580": 95.69999999999976, + "581": 110.35000000000018, + "582": 101.14999999999979, + "583": 101.64999999999976, + "584": 103.94999999999976, + "585": 107.99999999999973, + "586": 105.49999999999976, + "587": 107.99999999999976, + "588": 91.29999999999976, + "589": 103.34999999999974, + "590": 106.34999999999977, + "591": 107.9000000000002, + "592": 83.94999999999982, + "593": 98.49999999999974, + "594": 104.49999999999976, + "595": 39.49999999999973, + "596": 104.19999999999976, + "597": 27.300000000000022, + "598": 109.44999999999976, + "599": 51.299999999999955, + "600": 108.99999999999973, + "601": 91.44999999999982, + "602": 47.79999999999975, + "603": 104.49999999999974, + "604": 101.44999999999972, + "605": 110.79999999999986, + "606": 95.39999999999975, + "607": 98.14999999999975, + "608": 108.14999999999972, + "609": 116.00000000000027, + "610": 88.94999999999983, + "611": 104.8499999999998, + "612": 101.09999999999977, + "613": 105.24999999999974, + "614": 100.39999999999978, + "615": 110.19999999999982, + "616": 108.49999999999973, + "617": 74.99999999999989, + "618": 108.79999999999977, + "619": 100.14999999999976, + "620": 98.19999999999975, + "621": 77.24999999999982, + "622": 114.45, + "623": 109.64999999999982, + "624": 64.84999999999972, + "625": 42.49999999999976, + "626": 103.44999999999976, + "627": 36.24999999999999, + "628": 82.29999999999974, + "629": 109.74999999999989, + "630": 85.79999999999978, + "631": 46.49999999999997, + "632": 75.49999999999983, + "633": 110.19999999999976, + "634": 107.20000000000019, + "635": 102.84999999999977, + "636": 98.84999999999998, + "637": 118.70000000000034, + "638": 106.84999999999974, + "639": 105.54999999999976, + "640": 86.09999999999981, + "641": 93.2999999999998, + "642": 103.74999999999976, + "643": 64.94999999999975, + "644": 105.44999999999973, + "645": 103.74999999999977, + "646": 105.19999999999972, + "647": 106.39999999999974, + "648": 103.94999999999976, + "649": 81.25000000000007, + "650": 106.29999999999984, + "651": 108.04999999999978, + "652": 96.14999999999985, + "653": 102.99999999999983, + "654": 88.3499999999998, + "655": 96.7499999999999, + "656": 106.99999999999976, + "657": 95.34999999999991, + "658": 104.64999999999975, + "659": 101.44999999999993, + "660": 104.34999999999977, + "661": 104.59999999999978, + "662": 110.95000000000022, + "663": 49.09999999999995, + "664": 44.999999999999766, + "665": 108.4499999999998, + "666": 111.59999999999978, + "667": 80.59999999999985, + "668": 95.39999999999978, + "669": 105.09999999999974, + "670": 107.54999999999974, + "671": 102.09999999999981, + "672": 103.24999999999976, + "673": 87.1999999999998, + "674": 109.84999999999977, + "675": 79.44999999999975, + "676": 105.04999999999976, + "677": 96.89999999999978, + "678": 104.04999999999977, + "679": 103.29999999999991, + "680": 103.59999999999977, + "681": 103.69999999999976, + "682": 75.94999999999978, + "683": 99.54999999999994, + "684": 75.24999999999983, + "685": 70.44999999999973, + "686": 100.94999999999972, + "687": 119.65000000000033, + "688": 89.7499999999998, + "689": 104.54999999999976, + "690": 103.39999999999976, + "691": 106.19999999999976, + "692": 100.09999999999975, + "693": 102.99999999999977, + "694": 103.39999999999976, + "695": 103.19999999999976, + "696": 106.79999999999973, + "697": 105.59999999999974, + "698": 60.54999999999978, + "699": 106.79999999999974, + "700": 106.09999999999975, + "701": 115.7, + "702": 82.7499999999999, + "703": 111.04999999999978, + "704": 102.74999999999986, + "705": 107.69999999999986, + "706": 106.14999999999982, + "707": 104.14999999999974, + "708": 111.19999999999983, + "709": 111.29999999999994, + "710": 105.94999999999973, + "711": 109.84999999999978, + "712": 113.25, + "713": 107.3499999999999, + "714": 116.50000000000028, + "715": 110.99999999999983, + "716": 104.49999999999974, + "717": 110.05000000000003, + "718": 113.60000000000015, + "719": 106.5999999999998, + "720": 115.65000000000005, + "721": -14.500000000000004, + "722": 52.2, + "723": 77.9499999999998, + "724": 65.49999999999986, + "725": 24.64999999999987, + "726": 35.84999999999999, + "727": -5.549999999999993, + "728": 81.45000000000014, + "729": 33.29999999999991, + "730": 95.80000000000011, + "731": 88.10000000000002, + "732": 107.30000000000015, + "733": 26.40000000000004, + "734": 64.95000000000006, + "735": 58.999999999999986, + "736": -6.700000000000006, + "737": 17.850000000000026, + "738": 112.10000000000016, + "739": 28.849999999999884, + "740": 113.75000000000009, + "741": 115.05000000000015, + "742": 77.34999999999981, + "743": 35.5999999999998, + "744": 110.7500000000001, + "745": 53.14999999999979, + "746": -16.699999999999978, + "747": 20.79999999999998, + "748": 87.6, + "749": 42.59999999999981, + "750": 96.65000000000008, + "751": 83.55000000000001, + "752": 93.3500000000001, + "753": 72.99999999999997, + "754": 6.749999999999982, + "755": 102.00000000000011, + "756": 91.90000000000009, + "757": 101.29999999999997, + "758": 25.499999999999986, + "759": 72.54999999999987, + "760": 106.75000000000001, + "761": 86.64999999999996, + "762": 99.80000000000001, + "763": 112.45000000000019, + "764": 101.25000000000016, + "765": 43.14999999999978, + "766": 39.59999999999978, + "767": 49.399999999999935, + "768": 114.65000000000002, + "769": 110.2, + "770": 47.59999999999976, + "771": 94.65000000000002, + "772": 116.85000000000007, + "773": 45.99999999999977, + "774": 116.15000000000015, + "775": 91.10000000000008, + "776": 49.949999999999854, + "777": 110.55000000000007, + "778": 78.29999999999991, + "779": 103.95000000000002, + "780": 97.85000000000005, + "781": 109.80000000000007, + "782": 115.00000000000011, + "783": 65.5999999999999, + "784": 90.05000000000005, + "785": 104.04999999999997, + "786": 77.24999999999979, + "787": 111.80000000000003, + "788": 49.24999999999975, + "789": 109.74999999999991, + "790": 118.65000000000003, + "791": 116.10000000000011, + "792": 94.09999999999985, + "793": 96.94999999999992, + "794": 105.30000000000007, + "795": 123.45000000000005, + "796": 112.60000000000015, + "797": 116.70000000000002, + "798": 102.50000000000011, + "799": 9.550000000000004, + "800": 111.30000000000004, + "801": 114.05, + "802": 108.40000000000005, + "803": 111.15000000000016, + "804": 114.65000000000015, + "805": -66.09999999999992, + "806": 81.25000000000009, + "807": 83.5499999999998, + "808": 109.6500000000001, + "809": 101.25000000000009, + "810": 114.20000000000007, + "811": 99.25000000000013, + "812": 19.199999999999907, + "813": 121.75000000000009, + "814": 92.5999999999998, + "815": 115.30000000000024, + "816": 110.10000000000005, + "817": 105.1000000000001, + "818": 101.0500000000002, + "819": 95.04999999999991, + "820": 100.0, + "821": 109.85000000000004, + "822": 113.70000000000014, + "823": 104.20000000000006, + "824": 111.3000000000002, + "825": 113.85, + "826": 101.55000000000008, + "827": 112.70000000000003, + "828": 106.90000000000003, + "829": 86.35000000000005, + "830": 88.59999999999997, + "831": 113.00000000000004, + "832": 108.15000000000016, + "833": 114.55000000000013, + "834": 102.64999999999998, + "835": 110.64999999999999, + "836": 104.94999999999996, + "837": 77.49999999999999, + "838": 110.1, + "839": 100.89999999999996, + "840": 112.40000000000006, + "841": 102.50000000000006, + "842": 90.5, + "843": 61.999999999999936, + "844": 90.45000000000014, + "845": 115.40000000000012, + "846": 109.20000000000007, + "847": 92.95000000000002, + "848": 70.1999999999999, + "849": 112.85000000000001, + "850": 88.29999999999986, + "851": 116.45000000000014, + "852": 115.0, + "853": 115.5000000000001, + "854": 99.90000000000006, + "855": 115.10000000000007, + "856": 110.1000000000001, + "857": 113.25000000000013, + "858": 114.45000000000003, + "859": 108.00000000000016, + "860": 101.10000000000014, + "861": 118.95000000000005, + "862": 101.75000000000013, + "863": 101.15000000000013, + "864": 106.15000000000012, + "865": 108.2000000000001, + "866": 116.35000000000008, + "867": 116.10000000000016, + "868": 116.50000000000026, + "869": 111.30000000000007, + "870": 110.69999999999999, + "871": 101.80000000000014, + "872": 114.65000000000019, + "873": 108.2000000000002, + "874": 112.0500000000002, + "875": 111.70000000000016, + "876": 101.60000000000011, + "877": 103.40000000000013, + "878": 109.45000000000014, + "879": 111.80000000000021, + "880": 113.0500000000001, + "881": 108.55000000000003, + "882": 108.74999999999999, + "883": 115.20000000000024, + "884": 78.14999999999986, + "885": 111.70000000000029, + "886": 99.8, + "887": 108.85000000000008, + "888": 112.44999999999997, + "889": 110.90000000000009, + "890": 113.75000000000007, + "891": 114.44999999999997, + "892": 114.84999999999998, + "893": 107.20000000000006, + "894": 115.35000000000005, + "895": 117.05000000000007, + "896": 112.2000000000001, + "897": 106.80000000000004, + "898": 98.10000000000008, + "899": 108.80000000000015, + "900": 101.35000000000004, + "901": 99.54999999999994, + "902": 107.10000000000018, + "903": 121.0000000000002, + "904": 111.70000000000023, + "905": 113.30000000000018, + "906": 112.35, + "907": 111.85000000000011, + "908": 110.55000000000013, + "909": 108.25000000000014, + "910": 106.90000000000005, + "911": 110.05, + "912": 112.55000000000001, + "913": 95.15000000000003, + "914": 109.50000000000003, + "915": 120.75000000000007, + "916": 117.44999999999996, + "917": 112.15000000000015, + "918": 110.80000000000008, + "919": 107.65000000000008, + "920": 114.05000000000007, + "921": 109.25000000000018, + "922": 100.70000000000002, + "923": 86.40000000000006, + "924": 118.70000000000002, + "925": 105.5500000000002, + "926": 111.20000000000003, + "927": 113.30000000000007, + "928": 115.39999999999999, + "929": 112.4999999999999, + "930": 106.55000000000017, + "931": 95.95000000000005, + "932": 5.500000000000049, + "933": 98.2, + "934": 104.50000000000001, + "935": 114.20000000000007, + "936": 114.25000000000006, + "937": 109.05000000000024, + "938": 113.75000000000034, + "939": 94.05000000000003, + "940": 113.95000000000023, + "941": 115.40000000000003, + "942": 113.00000000000007, + "943": 112.45000000000006, + "944": 103.8500000000001, + "945": 113.64999999999999, + "946": 108.45000000000005, + "947": 117.10000000000012, + "948": 113.05000000000004, + "949": 106.9500000000001, + "950": 110.0500000000001, + "951": 117.85000000000014, + "952": 98.50000000000003, + "953": 113.50000000000001, + "954": 108.40000000000028, + "955": 115.30000000000008, + "956": 109.75000000000013, + "957": 107.55000000000018, + "958": 108.75000000000011, + "959": 115.85000000000002, + "960": 116.70000000000005, + "961": 108.00000000000009, + "962": 114.30000000000013, + "963": 115.75000000000007, + "964": 117.10000000000012, + "965": 114.05000000000003, + "966": 109.20000000000003, + "967": 106.00000000000006, + "968": 100.50000000000006, + "969": 118.4000000000002, + "970": 108.9000000000002, + "971": 108.10000000000015, + "972": 110.49999999999993, + "973": 103.09999999999994, + "974": 112.35000000000014, + "975": 110.75000000000013, + "976": 67.94999999999979, + "977": 112.10000000000007, + "978": 115.25000000000017, + "979": 104.9500000000001, + "980": 111.00000000000026, + "981": 117.60000000000007, + "982": 106.60000000000008, + "983": 103.45000000000007, + "984": 114.45000000000003, + "985": 107.6, + "986": 114.60000000000001, + "987": 102.75000000000009, + "988": 107.30000000000007, + "989": 109.84999999999997, + "990": 112.40000000000009, + "991": 116.50000000000024, + "992": 117.70000000000006, + "993": 106.50000000000016, + "994": 108.10000000000004, + "995": 114.1, + "996": 106.90000000000006, + "997": 115.40000000000018, + "998": 120.85000000000002, + "999": 103.49999999999994, + "1000": 117.9500000000001 + }, + "3": { + "1": -50.79999999999999, + "2": -38.25000000000007, + "3": -8.149999999999988, + "4": -10.699999999999996, + "5": -59.6500000000001, + "6": -69.40000000000005, + "7": -14.749999999999977, + "8": -48.35000000000015, + "9": -54.25000000000016, + "10": -66.30000000000008, + "11": -23.199999999999967, + "12": -25.649999999999967, + "13": -52.40000000000007, + "14": -46.000000000000206, + "15": -18.699999999999978, + "16": -25.59999999999997, + "17": -15.249999999999984, + "18": -61.25000000000002, + "19": -60.05000000000017, + "20": -21.09999999999996, + "21": -13.899999999999983, + "22": 15.549999999999972, + "23": -11.499999999999982, + "24": -16.899999999999974, + "25": -14.699999999999983, + "26": -10.999999999999986, + "27": -17.74999999999997, + "28": -21.549999999999958, + "29": -29.949999999999957, + "30": -69.20000000000006, + "31": -11.95, + "32": -38.54999999999997, + "33": -63.650000000000105, + "34": -71.49999999999999, + "35": -15.49999999999998, + "36": -47.250000000000064, + "37": -20.49999999999996, + "38": -32.80000000000004, + "39": -20.04999999999996, + "40": -12.549999999999978, + "41": -39.750000000000085, + "42": -16.74999999999998, + "43": -5.899999999999994, + "44": -15.349999999999982, + "45": -6.1499999999999915, + "46": 1.1000000000000034, + "47": -36.90000000000008, + "48": -16.549999999999976, + "49": -34.55000000000002, + "50": -92.05000000000005, + "51": -35.15000000000004, + "52": -16.049999999999976, + "53": -17.950000000000017, + "54": -17.89999999999998, + "55": -39.05000000000005, + "56": -14.599999999999977, + "57": -21.199999999999953, + "58": -49.400000000000105, + "59": -9.949999999999989, + "60": -18.39999999999997, + "61": -89.54999999999998, + "62": -90.5, + "63": -29.70000000000003, + "64": -15.749999999999975, + "65": -51.24999999999993, + "66": -36.64999999999998, + "67": -40.00000000000004, + "68": -5.199999999999986, + "69": -21.499999999999975, + "70": -53.00000000000009, + "71": -5.399999999999988, + "72": -14.249999999999975, + "73": -7.899999999999993, + "74": -25.39999999999996, + "75": -18.499999999999968, + "76": -15.599999999999977, + "77": -86.79999999999998, + "78": -38.20000000000007, + "79": -88.2, + "80": -72.1, + "81": -62.749999999999986, + "82": -100.25, + "83": -27.0, + "84": -18.999999999999968, + "85": -61.150000000000105, + "86": -10.049999999999988, + "87": -14.94999999999998, + "88": -40.85000000000006, + "89": -7.149999999999989, + "90": -15.649999999999975, + "91": -14.599999999999977, + "92": -16.100000000000005, + "93": -6.349999999999994, + "94": -93.95, + "95": -63.3, + "96": -9.9, + "97": -23.999999999999968, + "98": -17.399999999999984, + "99": -18.84999999999997, + "100": -1.6999999999999829, + "101": -20.59999999999996, + "102": 0.6000000000000287, + "103": -4.899999999999991, + "104": -36.05000000000001, + "105": -29.29999999999998, + "106": -36.750000000000014, + "107": -20.39999999999996, + "108": -41.79999999999998, + "109": -77.25, + "110": -20.54999999999996, + "111": -13.049999999999986, + "112": -19.299999999999965, + "113": -11.899999999999995, + "114": -17.999999999999968, + "115": -12.299999999999986, + "116": -15.69999999999998, + "117": -21.949999999999957, + "118": -13.499999999999988, + "119": -14.099999999999989, + "120": -19.24999999999996, + "121": -11.199999999999987, + "122": -19.34999999999997, + "123": -20.39999999999996, + "124": -14.999999999999979, + "125": -18.19999999999997, + "126": -21.049999999999958, + "127": -7.699999999999999, + "128": -19.14999999999996, + "129": -13.099999999999982, + "130": -20.34999999999996, + "131": -8.74999999999999, + "132": 4.950000000000038, + "133": -73.29999999999998, + "134": -7.100000000000002, + "135": 11.60000000000001, + "136": -85.99999999999999, + "137": -38.84999999999997, + "138": -88.25, + "139": -6.149999999999993, + "140": -13.549999999999992, + "141": 1.6500000000000292, + "142": -4.699999999999991, + "143": 6.600000000000034, + "144": -75.79999999999997, + "145": -82.05000000000008, + "146": -15.09999999999998, + "147": 10.150000000000034, + "148": -19.249999999999964, + "149": -1.1499999999999808, + "150": -15.74999999999998, + "151": 6.1000000000000565, + "152": 30.449999999999996, + "153": -18.699999999999967, + "154": -4.999999999999984, + "155": -34.55000000000001, + "156": -20.29999999999996, + "157": 8.799999999999995, + "158": -18.500000000000007, + "159": -17.099999999999973, + "160": -9.949999999999983, + "161": -6.399999999999987, + "162": -5.549999999999992, + "163": -49.30000000000001, + "164": -35.95, + "165": -8.550000000000018, + "166": -10.449999999999989, + "167": 7.000000000000046, + "168": -13.64999999999998, + "169": 21.899999999999974, + "170": -16.29999999999998, + "171": -8.25000000000002, + "172": -31.249999999999986, + "173": 27.399999999999892, + "174": -88.5, + "175": -8.249999999999988, + "176": 1.900000000000028, + "177": -24.100000000000016, + "178": -24.300000000000026, + "179": 4.3500000000000165, + "180": -12.39999999999999, + "181": 29.599999999999966, + "182": -10.74999999999998, + "183": 8.149999999999975, + "184": 18.04999999999998, + "185": 23.19999999999998, + "186": -15.749999999999995, + "187": -24.64999999999999, + "188": -12.349999999999978, + "189": 22.99999999999998, + "190": 37.84999999999985, + "191": -42.650000000000034, + "192": -26.199999999999992, + "193": -3.3999999999999932, + "194": -28.69999999999999, + "195": -6.250000000000017, + "196": -17.29999999999997, + "197": -77.60000000000001, + "198": -15.749999999999979, + "199": -20.450000000000053, + "200": -22.950000000000003, + "201": 36.14999999999997, + "202": -2.8000000000000194, + "203": -14.699999999999967, + "204": 58.749999999999936, + "205": -9.949999999999994, + "206": 13.24999999999998, + "207": -46.900000000000034, + "208": -15.049999999999985, + "209": -0.9499999999999613, + "210": 18.64999999999998, + "211": -1.1999999999999655, + "212": 58.00000000000003, + "213": -3.1500000000000035, + "214": 27.150000000000045, + "215": 61.249999999999964, + "216": -14.90000000000002, + "217": -3.05, + "218": 20.750000000000025, + "219": 21.549999999999994, + "220": 58.00000000000004, + "221": 9.399999999999977, + "222": 28.44999999999994, + "223": 29.599999999999994, + "224": 46.24999999999982, + "225": 6.200000000000018, + "226": 6.049999999999981, + "227": 40.59999999999994, + "228": 63.44999999999995, + "229": 10.700000000000006, + "230": 16.200000000000017, + "231": 82.85, + "232": 85.05, + "233": 50.49999999999994, + "234": 90.89999999999998, + "235": -12.39999999999997, + "236": 59.79999999999999, + "237": 30.44999999999977, + "238": 73.40000000000003, + "239": 45.49999999999998, + "240": 47.94999999999995, + "241": 55.000000000000064, + "242": -2.849999999999966, + "243": 57.69999999999994, + "244": 80.35, + "245": 30.300000000000033, + "246": 12.04999999999998, + "247": 44.2999999999999, + "248": 53.29999999999999, + "249": 78.35000000000002, + "250": 55.9, + "251": 66.39999999999992, + "252": -64.89999999999995, + "253": 10.349999999999966, + "254": 71.35000000000002, + "255": 57.9500000000001, + "256": 11.050000000000011, + "257": -7.800000000000005, + "258": 74.35, + "259": 108.15000000000002, + "260": 70.10000000000002, + "261": 92.70000000000013, + "262": 44.44999999999986, + "263": 111.7000000000001, + "264": 69.84999999999994, + "265": 103.10000000000018, + "266": 76.45000000000014, + "267": 35.000000000000064, + "268": 58.799999999999926, + "269": 91.40000000000005, + "270": 72.39999999999996, + "271": -24.95000000000001, + "272": 86.84999999999997, + "273": 48.449999999999996, + "274": -6.149999999999984, + "275": 85.30000000000004, + "276": 12.199999999999974, + "277": 55.04999999999999, + "278": 23.399999999999935, + "279": 56.20000000000001, + "280": 111.95000000000013, + "281": 84.15000000000015, + "282": 79.74999999999997, + "283": -36.95000000000001, + "284": 84.85, + "285": 105.80000000000003, + "286": 119.20000000000012, + "287": 100.70000000000005, + "288": -1.9499999999999644, + "289": 104.10000000000004, + "290": 69.2499999999999, + "291": 100.35000000000008, + "292": 100.89999999999999, + "293": 84.64999999999995, + "294": 101.95000000000019, + "295": 87.90000000000009, + "296": 91.35000000000004, + "297": 109.35000000000014, + "298": 100.44999999999999, + "299": 99.10000000000004, + "300": 41.699999999999925, + "301": 97.65, + "302": 118.2000000000001, + "303": 38.59999999999999, + "304": 58.79999999999992, + "305": 101.15000000000009, + "306": -6.5499999999999545, + "307": 71.65000000000005, + "308": 60.049999999999926, + "309": 89.05000000000018, + "310": -54.099999999999945, + "311": 84.70000000000012, + "312": 72.10000000000004, + "313": 111.0000000000002, + "314": 95.00000000000001, + "315": 95.95000000000006, + "316": 107.69999999999999, + "317": 89.75000000000003, + "318": 79.69999999999995, + "319": 104.10000000000001, + "320": 89.40000000000009, + "321": 80.85000000000018, + "322": 110.4, + "323": 114.65000000000006, + "324": 107.09999999999995, + "325": 92.45000000000005, + "326": 107.20000000000009, + "327": 90.60000000000016, + "328": 95.25000000000016, + "329": 109.14999999999999, + "330": 80.04999999999998, + "331": 79.90000000000002, + "332": 93.15000000000005, + "333": 107.30000000000001, + "334": 85.40000000000002, + "335": 118.90000000000018, + "336": 37.84999999999998, + "337": 104.6500000000002, + "338": 111.45000000000005, + "339": 109.60000000000012, + "340": 91.54999999999997, + "341": 31.299999999999958, + "342": 94.00000000000006, + "343": 107.1, + "344": 103.60000000000014, + "345": 114.25000000000004, + "346": 116.25000000000006, + "347": 104.1000000000001, + "348": 105.1500000000001, + "349": 111.05000000000005, + "350": 101.40000000000013, + "351": 99.20000000000019, + "352": 108.64999999999998, + "353": 28.499999999999908, + "354": 48.900000000000006, + "355": 98.40000000000006, + "356": 103.85000000000015, + "357": 105.50000000000014, + "358": 105.2000000000001, + "359": 119.40000000000018, + "360": 109.9500000000002, + "361": 57.89999999999987, + "362": 118.80000000000004, + "363": 93.30000000000004, + "364": 120.35000000000005, + "365": 113.25000000000013, + "366": 114.8, + "367": 107.39999999999999, + "368": 103.55000000000004, + "369": 96.50000000000007, + "370": 103.00000000000014, + "371": 104.90000000000003, + "372": 112.64999999999999, + "373": 97.55000000000007, + "374": 111.25000000000011, + "375": 109.45000000000007, + "376": 117.60000000000011, + "377": 110.60000000000012, + "378": 119.54999999999995, + "379": 99.40000000000015, + "380": 111.14999999999998, + "381": 113.50000000000014, + "382": 67.44999999999986, + "383": 114.95000000000005, + "384": 118.10000000000018, + "385": 111.5, + "386": 91.79999999999994, + "387": 108.75000000000023, + "388": 91.85000000000001, + "389": 105.6, + "390": 114.9500000000002, + "391": -64.99999999999993, + "392": 112.1500000000001, + "393": 109.60000000000007, + "394": 111.90000000000006, + "395": 104.59999999999988, + "396": 113.20000000000009, + "397": 106.4000000000001, + "398": 117.30000000000003, + "399": 114.65000000000019, + "400": 107.80000000000005, + "401": 108.75000000000007, + "402": 113.40000000000005, + "403": 107.30000000000005, + "404": 112.8500000000002, + "405": 114.50000000000003, + "406": 106.24999999999993, + "407": 116.10000000000007, + "408": 81.89999999999995, + "409": 96.50000000000009, + "410": 116.55000000000024, + "411": 110.45000000000013, + "412": 100.55000000000011, + "413": 113.35000000000001, + "414": 117.25000000000014, + "415": 106.75000000000011, + "416": 121.40000000000012, + "417": 110.60000000000004, + "418": 106.24999999999997, + "419": 60.24999999999998, + "420": 98.30000000000015, + "421": 109.70000000000013, + "422": 90.35000000000001, + "423": 109.69999999999999, + "424": 117.3000000000001, + "425": 111.3500000000001, + "426": 100.79999999999997, + "427": 109.95, + "428": 106.75000000000006, + "429": 101.50000000000001, + "430": 92.30000000000011, + "431": 114.60000000000007, + "432": 103.3499999999999, + "433": 115.90000000000008, + "434": 103.70000000000014, + "435": 115.14999999999999, + "436": 115.25000000000016, + "437": 101.75000000000004, + "438": 109.40000000000013, + "439": 110.65000000000003, + "440": 105.4, + "441": 111.25000000000007, + "442": 109.00000000000009, + "443": 108.1000000000001, + "444": 110.60000000000004, + "445": 116.10000000000014, + "446": 97.30000000000007, + "447": 115.64999999999996, + "448": 111.74999999999997, + "449": 119.64999999999998, + "450": 112.40000000000005, + "451": 105.09999999999998, + "452": 118.5499999999999, + "453": 113.9500000000001, + "454": 114.10000000000005, + "455": 110.40000000000009, + "456": 107.8, + "457": 115.90000000000002, + "458": 114.25000000000009, + "459": 113.70000000000017, + "460": 114.05000000000015, + "461": 42.69999999999999, + "462": 112.90000000000008, + "463": 109.30000000000013, + "464": 113.70000000000006, + "465": 110.54999999999993, + "466": 107.59999999999997, + "467": 122.49999999999997, + "468": 107.19999999999999, + "469": 106.45000000000019, + "470": 111.20000000000006, + "471": 111.85000000000012, + "472": 99.55000000000003, + "473": 92.7999999999999, + "474": 113.00000000000024, + "475": 117.4500000000002, + "476": 113.95000000000013, + "477": 116.95000000000009, + "478": 114.35000000000012, + "479": 110.00000000000018, + "480": 104.10000000000004, + "481": 117.85000000000018, + "482": 115.90000000000018, + "483": 109.35000000000014, + "484": 50.14999999999981, + "485": 115.39999999999998, + "486": 116.6500000000003, + "487": 111.40000000000005, + "488": 74.29999999999984, + "489": 103.90000000000012, + "490": 106.79999999999995, + "491": 114.95000000000003, + "492": 111.45000000000006, + "493": 10.299999999999963, + "494": 115.3500000000001, + "495": 124.69999999999996, + "496": 114.25000000000014, + "497": 100.75000000000011, + "498": 115.10000000000007, + "499": 110.80000000000015, + "500": 107.25000000000006, + "501": 117.00000000000011, + "502": 108.9000000000001, + "503": 113.9500000000002, + "504": 113.8, + "505": 110.49999999999991, + "506": 107.44999999999999, + "507": 112.9000000000001, + "508": 109.15000000000013, + "509": 109.7, + "510": 114.30000000000003, + "511": 119.90000000000005, + "512": 101.09999999999995, + "513": 105.44999999999987, + "514": 95.25000000000003, + "515": 75.25000000000003, + "516": 112.44999999999999, + "517": 109.79999999999987, + "518": 106.40000000000002, + "519": 112.9500000000001, + "520": 108.45000000000019, + "521": 109.45000000000007, + "522": 119.39999999999999, + "523": 120.40000000000008, + "524": 106.05000000000011, + "525": 116.00000000000011, + "526": 117.60000000000016, + "527": 111.15000000000018, + "528": 100.5000000000001, + "529": 110.95000000000013, + "530": 118.55000000000027, + "531": 115.59999999999991, + "532": 105.10000000000011, + "533": 113.0500000000002, + "534": 105.00000000000011, + "535": 115.60000000000007, + "536": 110.60000000000005, + "537": 113.9000000000002, + "538": 106.90000000000003, + "539": 114.40000000000009, + "540": 118.45000000000005, + "541": 115.9000000000002, + "542": 118.65000000000019, + "543": 116.90000000000006, + "544": 110.00000000000014, + "545": 111.30000000000013, + "546": 111.34999999999997, + "547": 107.75000000000014, + "548": 107.84999999999985, + "549": 115.10000000000008, + "550": 115.85000000000007, + "551": 114.80000000000007, + "552": 110.05000000000003, + "553": 116.00000000000014, + "554": 111.50000000000009, + "555": 105.6000000000001, + "556": 112.95000000000003, + "557": 109.5500000000002, + "558": 112.39999999999999, + "559": 103.84999999999985, + "560": 105.35000000000005, + "561": 102.25000000000007, + "562": 111.65000000000006, + "563": 109.2500000000001, + "564": 115.70000000000009, + "565": 116.70000000000009, + "566": 115.05000000000011, + "567": 107.80000000000021, + "568": 109.19999999999987, + "569": 117.90000000000013, + "570": 102.59999999999991, + "571": 108.14999999999995, + "572": 115.00000000000023, + "573": 108.5500000000002, + "574": 107.30000000000024, + "575": 105.84999999999997, + "576": 112.90000000000015, + "577": 115.25000000000009, + "578": 101.75000000000018, + "579": 109.99999999999994, + "580": 98.8500000000001, + "581": 115.50000000000018, + "582": 115.15000000000006, + "583": 108.55000000000001, + "584": 110.55000000000022, + "585": 118.60000000000007, + "586": 104.8, + "587": 109.15000000000009, + "588": 108.55000000000008, + "589": 112.20000000000022, + "590": 96.3000000000001, + "591": 112.35000000000008, + "592": 110.15000000000009, + "593": 107.4500000000001, + "594": 108.60000000000012, + "595": 106.95000000000007, + "596": 111.55000000000007, + "597": 115.40000000000012, + "598": 114.85000000000007, + "599": 117.00000000000016, + "600": 109.65000000000008, + "601": 109.5500000000001, + "602": 114.20000000000009, + "603": 108.15000000000008, + "604": 104.05000000000014, + "605": 117.0500000000001, + "606": 94.19999999999993, + "607": 115.84999999999998, + "608": 101.30000000000014, + "609": 109.00000000000013, + "610": 113.65000000000013, + "611": 105.70000000000003, + "612": 109.39999999999999, + "613": 119.05000000000015, + "614": 113.05000000000005, + "615": 116.30000000000018, + "616": 115.25000000000006, + "617": 105.64999999999996, + "618": 114.40000000000003, + "619": 102.7499999999999, + "620": 117.05000000000007, + "621": 104.3000000000002, + "622": 116.90000000000012, + "623": 114.60000000000008, + "624": 117.25000000000017, + "625": 112.8000000000001, + "626": 113.99999999999997, + "627": 109.44999999999996, + "628": 109.70000000000006, + "629": 113.50000000000006, + "630": 110.90000000000008, + "631": 106.09999999999987, + "632": 108.55000000000005, + "633": 113.50000000000017, + "634": 113.60000000000011, + "635": 103.90000000000002, + "636": 111.7, + "637": 105.8000000000001, + "638": 106.50000000000017, + "639": 114.80000000000001, + "640": 114.30000000000014, + "641": 105.40000000000012, + "642": 106.19999999999999, + "643": 113.34999999999998, + "644": 110.05000000000015, + "645": 110.40000000000008, + "646": 118.5000000000001, + "647": 109.7000000000001, + "648": 111.10000000000005, + "649": 115.90000000000003, + "650": 113.80000000000007, + "651": 108.20000000000006, + "652": 108.50000000000006, + "653": -26.25000000000002, + "654": 110.85000000000002, + "655": 93.54999999999993, + "656": 111.45000000000022, + "657": 116.25000000000016, + "658": 107.19999999999986, + "659": 109.84999999999998, + "660": 114.50000000000004, + "661": 116.24999999999997, + "662": 110.5500000000002, + "663": 109.15000000000015, + "664": 86.7499999999999, + "665": 109.60000000000024, + "666": 101.30000000000005, + "667": 111.2500000000002, + "668": 119.85000000000014, + "669": 115.95000000000017, + "670": 112.40000000000023, + "671": 108.74999999999993, + "672": 116.05000000000024, + "673": 100.14999999999995, + "674": 109.85000000000001, + "675": 111.6500000000001, + "676": 109.65000000000003, + "677": 116.60000000000016, + "678": 112.15000000000006, + "679": 118.20000000000019, + "680": 114.90000000000013, + "681": 103.65000000000003, + "682": 117.30000000000005, + "683": 107.0, + "684": 110.65000000000019, + "685": 116.95000000000002, + "686": 108.00000000000001, + "687": 113.90000000000016, + "688": 54.79999999999992, + "689": 101.7, + "690": 9.650000000000045, + "691": 107.25000000000003, + "692": 72.65000000000013, + "693": 114.65000000000006, + "694": 116.7000000000001, + "695": 108.75000000000013, + "696": 112.25000000000004, + "697": 109.40000000000019, + "698": 109.00000000000003, + "699": 111.60000000000011, + "700": 108.70000000000003, + "701": 74.7999999999999, + "702": 107.24999999999996, + "703": 112.40000000000005, + "704": 114.79999999999997, + "705": 107.20000000000013, + "706": 87.4500000000001, + "707": 104.95000000000012, + "708": 119.8000000000001, + "709": 108.24999999999999, + "710": 115.30000000000003, + "711": 119.75000000000023, + "712": 109.99999999999999, + "713": 116.1000000000003, + "714": 109.14999999999995, + "715": 107.55000000000001, + "716": 112.70000000000009, + "717": 115.25, + "718": 110.05000000000022, + "719": 107.45000000000009, + "720": 110.40000000000018, + "721": 113.14999999999999, + "722": 114.05000000000005, + "723": 105.25000000000004, + "724": 115.40000000000015, + "725": 112.15000000000013, + "726": 115.3500000000002, + "727": 113.60000000000012, + "728": 111.04999999999998, + "729": 110.90000000000015, + "730": 110.50000000000014, + "731": 78.55000000000001, + "732": 109.0500000000001, + "733": 74.99999999999994, + "734": 113.40000000000019, + "735": 111.64999999999999, + "736": 110.15, + "737": 117.25000000000014, + "738": 101.09999999999982, + "739": 111.3500000000001, + "740": 110.40000000000013, + "741": 114.35000000000004, + "742": 104.35000000000004, + "743": 107.95000000000007, + "744": 116.55000000000011, + "745": 108.45, + "746": 105.35, + "747": 54.24999999999995, + "748": 116.40000000000018, + "749": 112.95000000000017, + "750": 110.35000000000012, + "751": 105.95000000000009, + "752": 108.65000000000005, + "753": 110.25000000000006, + "754": 107.80000000000004, + "755": 106.65000000000003, + "756": 106.25000000000011, + "757": 114.45000000000003, + "758": 121.20000000000007, + "759": 91.79999999999978, + "760": 113.40000000000019, + "761": 104.49999999999997, + "762": 112.15000000000003, + "763": 109.85000000000015, + "764": 108.30000000000003, + "765": 110.75000000000003, + "766": 105.04999999999998, + "767": 117.65000000000013, + "768": 112.24999999999993, + "769": 113.60000000000021, + "770": 115.05000000000014, + "771": 108.60000000000012, + "772": 105.85000000000005, + "773": 115.10000000000007, + "774": 111.65000000000002, + "775": 97.55000000000003, + "776": 92.50000000000007, + "777": 101.74999999999983, + "778": 113.20000000000019, + "779": 107.25000000000006, + "780": 109.00000000000009, + "781": 106.19999999999989, + "782": 79.05000000000007, + "783": 113.30000000000007, + "784": 117.85000000000015, + "785": 113.20000000000002, + "786": 109.65000000000008, + "787": 112.70000000000007, + "788": 112.24999999999997, + "789": 114.6000000000002, + "790": 108.45000000000005, + "791": 111.15000000000003, + "792": 103.70000000000013, + "793": 110.29999999999993, + "794": 116.54999999999991, + "795": 107.60000000000007, + "796": 117.3500000000001, + "797": 112.59999999999997, + "798": 105.14999999999998, + "799": 106.00000000000007, + "800": 114.95000000000019, + "801": 106.29999999999986, + "802": 103.70000000000006, + "803": 112.45000000000005, + "804": 114.1000000000002, + "805": 115.15000000000003, + "806": 122.00000000000018, + "807": 108.64999999999999, + "808": 111.60000000000005, + "809": 118.15000000000009, + "810": 115.90000000000013, + "811": 116.65000000000012, + "812": 96.24999999999991, + "813": 104.8, + "814": 111.35000000000015, + "815": 98.3, + "816": 110.69999999999997, + "817": 104.30000000000005, + "818": 115.40000000000008, + "819": 111.75000000000003, + "820": 107.8500000000001, + "821": 117.7000000000001, + "822": 111.50000000000011, + "823": 112.70000000000017, + "824": 110.05000000000013, + "825": 100.15000000000006, + "826": 107.95000000000013, + "827": 113.70000000000023, + "828": 111.65000000000008, + "829": 114.15, + "830": 110.39999999999996, + "831": 105.49999999999999, + "832": 105.85000000000016, + "833": 113.84999999999997, + "834": 114.9000000000001, + "835": 110.9500000000002, + "836": 109.20000000000009, + "837": 111.80000000000007, + "838": 110.35000000000008, + "839": 110.70000000000009, + "840": 117.75000000000009, + "841": 108.70000000000007, + "842": 105.19999999999996, + "843": 107.85000000000011, + "844": 115.00000000000006, + "845": 113.20000000000002, + "846": 109.95000000000003, + "847": 116.35000000000005, + "848": 114.90000000000016, + "849": 112.05000000000004, + "850": 114.85000000000011, + "851": 111.00000000000009, + "852": 109.40000000000019, + "853": 116.29999999999995, + "854": -23.59999999999998, + "855": 115.90000000000025, + "856": 110.05000000000017, + "857": 114.60000000000002, + "858": 113.20000000000006, + "859": 118.30000000000001, + "860": 109.24999999999999, + "861": 119.70000000000002, + "862": 110.85000000000005, + "863": 111.2500000000002, + "864": 108.65000000000013, + "865": 102.40000000000003, + "866": 118.40000000000009, + "867": 106.15000000000009, + "868": 111.30000000000013, + "869": 111.95000000000009, + "870": 105.50000000000007, + "871": 108.14999999999998, + "872": 110.05000000000014, + "873": 111.00000000000016, + "874": 115.45000000000006, + "875": 117.80000000000015, + "876": 116.49999999999991, + "877": 108.89999999999989, + "878": 109.00000000000007, + "879": 117.5000000000001, + "880": 109.35000000000002, + "881": 116.75000000000023, + "882": 103.85000000000002, + "883": 118.45000000000006, + "884": 107.9000000000001, + "885": 101.85000000000008, + "886": 106.80000000000004, + "887": 106.79999999999997, + "888": 104.99999999999989, + "889": 112.64999999999998, + "890": 114.15000000000016, + "891": 115.55000000000018, + "892": 15.79999999999994, + "893": 111.59999999999995, + "894": 110.85000000000007, + "895": 108.09999999999991, + "896": 114.55000000000014, + "897": 121.05000000000008, + "898": 113.35000000000004, + "899": 112.60000000000002, + "900": 109.6500000000002, + "901": 108.60000000000005, + "902": 112.15000000000018, + "903": 102.4999999999999, + "904": 115.89999999999998, + "905": 102.14999999999984, + "906": 110.39999999999992, + "907": 98.54999999999995, + "908": 110.70000000000007, + "909": 113.55000000000005, + "910": 112.55000000000014, + "911": 117.6, + "912": 74.50000000000006, + "913": 107.85000000000002, + "914": 107.40000000000005, + "915": 114.2500000000001, + "916": 101.24999999999999, + "917": 116.70000000000026, + "918": 108.30000000000017, + "919": 93.95000000000005, + "920": 112.49999999999999, + "921": -16.800000000000008, + "922": 111.75000000000016, + "923": 115.69999999999997, + "924": 108.85000000000004, + "925": 106.85000000000015, + "926": 111.30000000000005, + "927": 111.0000000000001, + "928": 107.5000000000001, + "929": 108.10000000000012, + "930": 115.9000000000002, + "931": 112.25000000000007, + "932": 113.10000000000004, + "933": 111.10000000000016, + "934": 115.75000000000003, + "935": 110.9500000000002, + "936": 107.39999999999999, + "937": 116.90000000000002, + "938": 113.00000000000006, + "939": 69.69999999999995, + "940": 48.39999999999991, + "941": 102.85, + "942": 107.6000000000001, + "943": 103.00000000000014, + "944": 109.15000000000002, + "945": 110.54999999999998, + "946": 103.19999999999997, + "947": 116.2500000000002, + "948": 113.00000000000004, + "949": 108.75000000000009, + "950": 102.20000000000012, + "951": 113.20000000000022, + "952": 109.90000000000019, + "953": 108.45000000000006, + "954": 108.64999999999999, + "955": 100.95, + "956": 107.65000000000003, + "957": 114.84999999999997, + "958": 113.40000000000003, + "959": 113.35000000000008, + "960": 114.80000000000005, + "961": 108.15, + "962": 113.85000000000018, + "963": 97.74999999999996, + "964": 116.2500000000001, + "965": 109.50000000000018, + "966": 114.7500000000002, + "967": 104.39999999999992, + "968": 110.1, + "969": 110.5000000000001, + "970": 111.10000000000012, + "971": 103.60000000000001, + "972": 103.50000000000003, + "973": 108.40000000000006, + "974": 103.99999999999989, + "975": 106.05000000000005, + "976": 119.15000000000008, + "977": 111.15, + "978": 113.1500000000001, + "979": 113.80000000000004, + "980": 114.80000000000005, + "981": 116.00000000000006, + "982": 110.79999999999988, + "983": 123.1000000000001, + "984": 107.94999999999987, + "985": 109.90000000000002, + "986": 111.05000000000014, + "987": 111.50000000000014, + "988": 114.70000000000017, + "989": 105.15, + "990": 108.6500000000001, + "991": 104.05000000000001, + "992": 118.20000000000009, + "993": 103.90000000000003, + "994": 105.44999999999992, + "995": 106.40000000000008, + "996": 110.6000000000002, + "997": 115.90000000000009, + "998": 114.95000000000002, + "999": 112.00000000000017, + "1000": 106.10000000000002 + }, + "4": { + "1": -21.899999999999956, + "2": -14.449999999999976, + "3": -89.60000000000001, + "4": -18.14999999999997, + "5": -17.949999999999967, + "6": -26.049999999999937, + "7": -58.00000000000009, + "8": -63.20000000000011, + "9": -26.699999999999953, + "10": -101.65, + "11": -44.30000000000007, + "12": -24.65, + "13": -48.90000000000007, + "14": -16.099999999999973, + "15": -71.40000000000003, + "16": -87.9, + "17": -77.4, + "18": -62.2500000000001, + "19": -93.1, + "20": -20.64999999999996, + "21": -33.400000000000034, + "22": -43.05000000000006, + "23": -100.29999999999998, + "24": -21.849999999999955, + "25": -15.299999999999983, + "26": -96.05, + "27": -24.25000000000001, + "28": 1.9500000000000421, + "29": -18.999999999999964, + "30": -3.2499999999999916, + "31": -11.349999999999993, + "32": -7.550000000000001, + "33": -36.099999999999966, + "34": -14.599999999999982, + "35": -20.149999999999963, + "36": -30.150000000000023, + "37": -11.049999999999983, + "38": -64.3000000000001, + "39": -3.4499999999999904, + "40": -12.34999999999999, + "41": -8.84999999999998, + "42": -9.499999999999998, + "43": -16.74999999999998, + "44": -14.94999999999998, + "45": -18.999999999999964, + "46": -19.099999999999962, + "47": -40.65000000000004, + "48": -95.59999999999994, + "49": -12.899999999999988, + "50": -86.35000000000001, + "51": -35.35000000000001, + "52": -13.70000000000001, + "53": -8.049999999999988, + "54": -39.20000000000013, + "55": -16.54999999999998, + "56": -21.399999999999967, + "57": -19.699999999999964, + "58": -54.45000000000004, + "59": -51.80000000000008, + "60": -9.100000000000001, + "61": -21.649999999999956, + "62": 0.6000000000000145, + "63": -11.49999999999999, + "64": -17.899999999999974, + "65": -65.75, + "66": -17.34999999999997, + "67": -16.44999999999997, + "68": -70.20000000000005, + "69": -82.55000000000004, + "70": -13.099999999999987, + "71": -10.19999999999999, + "72": -6.29999999999999, + "73": -20.84999999999996, + "74": -9.949999999999994, + "75": -9.100000000000001, + "76": -16.799999999999972, + "77": -13.199999999999957, + "78": -10.199999999999987, + "79": -37.85000000000008, + "80": -13.549999999999985, + "81": -89.95000000000007, + "82": -12.099999999999993, + "83": -15.449999999999974, + "84": -15.699999999999978, + "85": -10.999999999999993, + "86": -5.149999999999979, + "87": -13.699999999999989, + "88": -20.800000000000033, + "89": -15.999999999999975, + "90": -19.449999999999964, + "91": -22.700000000000003, + "92": -83.7, + "93": 13.600000000000007, + "94": -9.399999999999991, + "95": -7.149999999999995, + "96": -5.849999999999979, + "97": 1.299999999999975, + "98": -5.949999999999986, + "99": -22.150000000000016, + "100": -5.899999999999991, + "101": -12.64999999999997, + "102": -18.74999999999999, + "103": -21.049999999999958, + "104": -25.900000000000055, + "105": -14.699999999999978, + "106": -18.049999999999972, + "107": -1.6499999999999966, + "108": -88.90000000000002, + "109": -58.999999999999986, + "110": -63.24999999999991, + "111": 18.100000000000033, + "112": 9.549999999999974, + "113": -70.44999999999999, + "114": -74.35000000000001, + "115": -58.55, + "116": 5.950000000000025, + "117": 7.650000000000025, + "118": -61.75, + "119": -34.69999999999998, + "120": 5.500000000000035, + "121": -72.44999999999999, + "122": -69.9, + "123": -21.000000000000007, + "124": -86.55000000000004, + "125": 4.750000000000042, + "126": -9.099999999999996, + "127": 41.24999999999982, + "128": -53.200000000000045, + "129": -11.949999999999982, + "130": -61.350000000000094, + "131": 16.500000000000014, + "132": -18.54999999999997, + "133": -12.9, + "134": -41.70000000000005, + "135": -28.949999999999996, + "136": -20.149999999999977, + "137": 1.5000000000000069, + "138": -13.599999999999982, + "139": -17.549999999999976, + "140": -67.69999999999999, + "141": -64.64999999999998, + "142": -14.24999999999995, + "143": -2.9999999999999973, + "144": -13.399999999999984, + "145": 22.999999999999954, + "146": -2.549999999999967, + "147": 4.35000000000002, + "148": -7.999999999999975, + "149": 21.70000000000003, + "150": -27.849999999999973, + "151": 20.650000000000052, + "152": -86.39999999999996, + "153": 2.3500000000000476, + "154": -35.550000000000026, + "155": -21.0, + "156": -29.149999999999956, + "157": -65.14999999999993, + "158": -86.30000000000001, + "159": 3.8500000000000907, + "160": -9.950000000000001, + "161": 3.500000000000022, + "162": -74.8, + "163": -64.35, + "164": 30.199999999999825, + "165": -72.04999999999995, + "166": 17.050000000000026, + "167": -18.400000000000063, + "168": -17.799999999999972, + "169": 29.900000000000016, + "170": -2.6499999999999506, + "171": 3.7500000000000444, + "172": -78.69999999999999, + "173": -76.1, + "174": -35.35000000000005, + "175": -61.400000000000006, + "176": 14.449999999999928, + "177": -69.69999999999996, + "178": -40.70000000000012, + "179": -62.44999999999999, + "180": -0.15000000000000413, + "181": 24.049999999999887, + "182": -12.199999999999978, + "183": -98.3, + "184": -78.4, + "185": -13.24999999999999, + "186": -80.25000000000001, + "187": -90.85000000000004, + "188": -14.44999999999998, + "189": -97.10000000000001, + "190": -14.999999999999991, + "191": -7.799999999999985, + "192": 7.950000000000008, + "193": 32.499999999999886, + "194": -7.950000000000016, + "195": -18.599999999999966, + "196": 43.69999999999983, + "197": 8.600000000000033, + "198": 23.850000000000012, + "199": -8.049999999999994, + "200": -92.75, + "201": -84.60000000000001, + "202": -31.74999999999995, + "203": 7.600000000000028, + "204": -42.70000000000002, + "205": -15.84999999999996, + "206": -13.499999999999996, + "207": -54.35000000000009, + "208": 29.59999999999977, + "209": 19.14999999999997, + "210": -76.5, + "211": -58.35000000000006, + "212": -54.90000000000009, + "213": 2.650000000000049, + "214": -57.70000000000002, + "215": -13.799999999999972, + "216": 6.9000000000000075, + "217": 23.850000000000062, + "218": -2.250000000000001, + "219": 1.100000000000045, + "220": -4.499999999999976, + "221": -74.0999999999999, + "222": -12.199999999999992, + "223": -81.44999999999999, + "224": -6.95000000000001, + "225": -94.8, + "226": -66.89999999999998, + "227": -57.70000000000001, + "228": 1.8000000000000302, + "229": -69.30000000000001, + "230": -64.95, + "231": -16.299999999999983, + "232": 0.3499999999999919, + "233": -67.3, + "234": 11.399999999999995, + "235": -82.9, + "236": -15.549999999999981, + "237": -68.49999999999994, + "238": -62.8, + "239": 19.249999999999936, + "240": 19.549999999999965, + "241": 28.00000000000002, + "242": -70.75000000000001, + "243": -90.80000000000001, + "244": 33.7999999999998, + "245": 8.999999999999932, + "246": -33.200000000000045, + "247": -9.899999999999999, + "248": -17.749999999999975, + "249": -4.550000000000008, + "250": -85.44999999999993, + "251": -84.39999999999998, + "252": 12.150000000000034, + "253": -16.499999999999975, + "254": 9.650000000000073, + "255": 5.7000000000000615, + "256": 53.74999999999985, + "257": 6.950000000000031, + "258": 104.10000000000024, + "259": -18.600000000000037, + "260": -77.9, + "261": 6.750000000000051, + "262": -29.65000000000004, + "263": -79.60000000000005, + "264": -43.55000000000008, + "265": -46.89999999999998, + "266": 5.900000000000046, + "267": -78.99999999999996, + "268": -67.34999999999995, + "269": -45.80000000000001, + "270": 64.94999999999975, + "271": -47.74999999999999, + "272": -9.45000000000001, + "273": 64.54999999999995, + "274": -27.74999999999997, + "275": 7.650000000000018, + "276": -90.35000000000005, + "277": -62.999999999999986, + "278": 62.44999999999993, + "279": -80.7, + "280": 59.099999999999895, + "281": -77.8, + "282": 1.7500000000000502, + "283": 25.399999999999885, + "284": -79.95000000000002, + "285": 27.349999999999973, + "286": 19.350000000000037, + "287": -73.6, + "288": 41.04999999999987, + "289": -76.6999999999999, + "290": 1.749999999999981, + "291": -5.649999999999985, + "292": 28.050000000000043, + "293": 9.100000000000009, + "294": 20.149999999999935, + "295": 40.85000000000008, + "296": -7.099999999999973, + "297": 0.849999999999985, + "298": 59.94999999999987, + "299": 21.499999999999982, + "300": 30.54999999999982, + "301": -45.20000000000001, + "302": 44.84999999999991, + "303": 28.249999999999996, + "304": -39.00000000000002, + "305": -23.05000000000001, + "306": 7.649999999999961, + "307": -40.19999999999996, + "308": 11.95000000000007, + "309": 3.450000000000032, + "310": -80.85000000000005, + "311": -16.14999999999998, + "312": 36.19999999999994, + "313": 6.299999999999992, + "314": -13.199999999999982, + "315": 28.449999999999964, + "316": -17.19999999999999, + "317": -39.30000000000007, + "318": 26.55000000000001, + "319": 42.899999999999885, + "320": 33.74999999999984, + "321": -17.64999999999996, + "322": 80.70000000000024, + "323": 22.09999999999993, + "324": -13.249999999999988, + "325": 42.04999999999978, + "326": -16.549999999999976, + "327": 74.69999999999999, + "328": 67.95000000000022, + "329": -10.050000000000011, + "330": 67.2999999999999, + "331": 25.349999999999845, + "332": 72.29999999999998, + "333": 64.24999999999989, + "334": -68.25, + "335": -41.05000000000011, + "336": 50.24999999999977, + "337": -30.600000000000016, + "338": -79.80000000000001, + "339": 24.800000000000022, + "340": -27.549999999999958, + "341": -4.599999999999999, + "342": -65.09999999999992, + "343": -8.44999999999994, + "344": -20.099999999999984, + "345": -29.149999999999995, + "346": 18.150000000000055, + "347": -48.7000000000001, + "348": -41.54999999999997, + "349": 47.75000000000002, + "350": 52.999999999999716, + "351": 64.39999999999993, + "352": 62.64999999999979, + "353": 62.849999999999795, + "354": -63.50000000000006, + "355": 87.05000000000021, + "356": 37.69999999999998, + "357": -1.1999999999999889, + "358": -11.400000000000006, + "359": -4.499999999999992, + "360": 104.00000000000014, + "361": 98.2000000000001, + "362": -15.199999999999976, + "363": 27.600000000000076, + "364": 27.999999999999897, + "365": -20.00000000000003, + "366": -77.25000000000003, + "367": -15.550000000000013, + "368": 108.55000000000021, + "369": 42.89999999999982, + "370": -48.45000000000012, + "371": -5.949999999999975, + "372": 32.19999999999976, + "373": 81.95000000000005, + "374": 86.45000000000005, + "375": -56.34999999999995, + "376": -18.499999999999947, + "377": 48.099999999999945, + "378": 42.74999999999983, + "379": -75.3, + "380": -4.349999999999977, + "381": 88.84999999999981, + "382": 19.40000000000005, + "383": 38.54999999999984, + "384": 113.05000000000024, + "385": 27.70000000000006, + "386": -14.099999999999971, + "387": -76.55000000000004, + "388": -14.000000000000036, + "389": 107.70000000000014, + "390": 34.54999999999983, + "391": 55.349999999999795, + "392": 106.30000000000018, + "393": 47.24999999999993, + "394": 8.00000000000006, + "395": 12.749999999999899, + "396": 31.799999999999805, + "397": -5.29999999999999, + "398": 86.74999999999979, + "399": -7.500000000000018, + "400": 49.79999999999988, + "401": 18.20000000000002, + "402": -12.949999999999964, + "403": -36.199999999999996, + "404": 119.30000000000032, + "405": 83.69999999999987, + "406": -39.40000000000001, + "407": -12.449999999999998, + "408": 102.95000000000009, + "409": 39.64999999999997, + "410": 48.1, + "411": 78.39999999999996, + "412": 0.349999999999976, + "413": 31.099999999999834, + "414": 76.8999999999998, + "415": 33.10000000000002, + "416": -65.14999999999998, + "417": 65.70000000000002, + "418": 41.449999999999946, + "419": -7.399999999999974, + "420": 34.849999999999994, + "421": 85.45000000000012, + "422": 67.74999999999994, + "423": 106.20000000000014, + "424": -5.250000000000014, + "425": 95.80000000000001, + "426": 114.70000000000029, + "427": 83.49999999999999, + "428": 69.75, + "429": -57.19999999999999, + "430": 20.600000000000016, + "431": 113.40000000000018, + "432": 34.59999999999992, + "433": -49.04999999999999, + "434": 67.94999999999975, + "435": 24.84999999999996, + "436": -79.94999999999999, + "437": 55.29999999999992, + "438": 33.949999999999996, + "439": 108.60000000000026, + "440": 65.99999999999976, + "441": 71.99999999999977, + "442": 104.50000000000018, + "443": 107.19999999999993, + "444": 79.55000000000003, + "445": 36.89999999999998, + "446": -42.80000000000002, + "447": 84.49999999999986, + "448": 101.05, + "449": 82.10000000000007, + "450": 116.55000000000028, + "451": 35.499999999999936, + "452": 95.19999999999975, + "453": -10.750000000000044, + "454": -25.950000000000045, + "455": 67.79999999999978, + "456": 57.34999999999985, + "457": 86.49999999999993, + "458": 28.849999999999945, + "459": 40.09999999999998, + "460": 89.8500000000001, + "461": 57.99999999999997, + "462": 104.3499999999999, + "463": 106.04999999999973, + "464": 8.349999999999994, + "465": 80.80000000000011, + "466": 78.9, + "467": 63.59999999999994, + "468": 41.59999999999998, + "469": 55.70000000000001, + "470": 0.8000000000000071, + "471": 51.94999999999991, + "472": 70.54999999999998, + "473": 115.05000000000017, + "474": 85.49999999999996, + "475": 62.699999999999896, + "476": 97.20000000000014, + "477": 79.84999999999994, + "478": 77.55000000000003, + "479": 47.99999999999988, + "480": 64.8999999999999, + "481": 57.10000000000004, + "482": 115.2000000000002, + "483": 111.60000000000005, + "484": 85.69999999999999, + "485": 104.75000000000026, + "486": 92.5500000000001, + "487": 99.6999999999998, + "488": 84.1999999999999, + "489": 115.2500000000002, + "490": 98.49999999999993, + "491": 98.05000000000013, + "492": 116.45000000000026, + "493": 106.0000000000002, + "494": 109.25000000000021, + "495": 94.04999999999997, + "496": 75.15, + "497": 88.85000000000005, + "498": 74.65000000000023, + "499": 58.39999999999983, + "500": 45.549999999999805, + "501": 109.15000000000023, + "502": 95.19999999999996, + "503": 78.89999999999999, + "504": 110.2500000000002, + "505": 83.35000000000001, + "506": 100.25000000000007, + "507": 72.49999999999997, + "508": 107.1, + "509": 105.95000000000013, + "510": 76.85, + "511": 75.94999999999997, + "512": 101.85000000000011, + "513": 115.55000000000025, + "514": 109.50000000000013, + "515": 25.099999999999838, + "516": 60.749999999999936, + "517": 97.29999999999991, + "518": 115.65000000000009, + "519": 93.05000000000003, + "520": 56.799999999999905, + "521": -0.40000000000003405, + "522": 58.649999999999956, + "523": 70.39999999999989, + "524": 108.65, + "525": 120.55000000000024, + "526": 53.649999999999935, + "527": 36.49999999999998, + "528": 109.05000000000018, + "529": 66.00000000000013, + "530": 115.00000000000024, + "531": 100.84999999999995, + "532": 118.60000000000024, + "533": 111.2000000000001, + "534": 47.499999999999844, + "535": 105.55000000000017, + "536": 91.64999999999985, + "537": 112.54999999999997, + "538": 68.59999999999982, + "539": 112.85000000000014, + "540": 88.80000000000005, + "541": 108.89999999999979, + "542": 101.75000000000017, + "543": 38.24999999999989, + "544": 93.29999999999991, + "545": 106.70000000000007, + "546": 47.29999999999987, + "547": 92.60000000000001, + "548": 118.55000000000024, + "549": 95.10000000000014, + "550": 105.19999999999996, + "551": 114.55000000000004, + "552": 82.89999999999986, + "553": 99.8, + "554": 105.00000000000007, + "555": 35.099999999999945, + "556": 48.349999999999945, + "557": 88.90000000000006, + "558": 112.3499999999999, + "559": 58.89999999999984, + "560": 91.10000000000015, + "561": 86.59999999999984, + "562": 114.90000000000018, + "563": 116.55000000000022, + "564": 77.94999999999987, + "565": 105.00000000000006, + "566": 97.29999999999981, + "567": 113.64999999999999, + "568": 103.75000000000007, + "569": 81.10000000000004, + "570": 110.14999999999998, + "571": 93.15000000000003, + "572": 99.95000000000013, + "573": 85.59999999999992, + "574": 82.00000000000013, + "575": 115.50000000000003, + "576": 89.89999999999986, + "577": 80.69999999999993, + "578": 112.90000000000006, + "579": 70.7499999999998, + "580": 85.95000000000005, + "581": 107.05000000000004, + "582": 108.25000000000007, + "583": 73.89999999999996, + "584": 103.44999999999995, + "585": 103.35000000000011, + "586": 89.49999999999987, + "587": 102.25000000000014, + "588": 109.20000000000023, + "589": 96.60000000000005, + "590": 105.84999999999997, + "591": 102.45000000000017, + "592": 58.84999999999985, + "593": 102.4, + "594": 113.10000000000014, + "595": 110.15000000000012, + "596": 108.80000000000011, + "597": 109.5000000000002, + "598": 121.54999999999998, + "599": 109.10000000000024, + "600": 113.25000000000014, + "601": 106.30000000000013, + "602": 99.8500000000001, + "603": 88.15, + "604": 114.20000000000023, + "605": 93.80000000000003, + "606": 103.50000000000007, + "607": 96.7500000000002, + "608": -4.299999999999987, + "609": 104.35000000000005, + "610": 103.45000000000019, + "611": 112.25000000000004, + "612": 108.95000000000013, + "613": 114.45000000000002, + "614": 106.10000000000002, + "615": 109.99999999999991, + "616": 51.349999999999845, + "617": 115.44999999999999, + "618": 102.00000000000004, + "619": 115.05000000000007, + "620": 111.7000000000001, + "621": 104.00000000000003, + "622": 109.14999999999998, + "623": 113.99999999999999, + "624": 104.44999999999992, + "625": 108.40000000000009, + "626": 66.24999999999989, + "627": 108.50000000000001, + "628": 111.0000000000001, + "629": 114.85000000000015, + "630": 111.0500000000001, + "631": 109.39999999999998, + "632": 99.24999999999999, + "633": 106.89999999999996, + "634": 112.10000000000018, + "635": 114.55000000000005, + "636": 106.80000000000004, + "637": 106.0000000000001, + "638": 109.15000000000006, + "639": 115.30000000000004, + "640": 106.05000000000004, + "641": 113.15000000000009, + "642": 112.35000000000012, + "643": 74.55000000000004, + "644": 107.55000000000014, + "645": 105.2, + "646": 104.10000000000022, + "647": 107.49999999999997, + "648": 109.6500000000001, + "649": 110.20000000000005, + "650": 114.45000000000017, + "651": 107.8, + "652": 111.05000000000013, + "653": 110.04999999999998, + "654": 109.60000000000011, + "655": 107.40000000000016, + "656": 110.50000000000007, + "657": 113.75000000000009, + "658": 104.59999999999998, + "659": 117.5500000000001, + "660": 107.70000000000014, + "661": 100.85000000000011, + "662": 114.35000000000007, + "663": 104.40000000000006, + "664": 113.20000000000014, + "665": 111.80000000000004, + "666": 100.25000000000004, + "667": 102.1500000000001, + "668": 106.2000000000001, + "669": 114.80000000000004, + "670": 117.80000000000017, + "671": 114.25000000000013, + "672": 104.1, + "673": 104.90000000000006, + "674": 116.60000000000021, + "675": 113.24999999999996, + "676": 119.45000000000009, + "677": 117.60000000000011, + "678": 108.64999999999995, + "679": 110.40000000000013, + "680": 113.25000000000014, + "681": 118.90000000000006, + "682": 110.00000000000007, + "683": 105.95000000000016, + "684": 115.25000000000018, + "685": 102.89999999999998, + "686": 108.39999999999993, + "687": 110.10000000000002, + "688": 113.5500000000001, + "689": 112.55000000000007, + "690": 109.65000000000005, + "691": 110.05000000000013, + "692": 102.75000000000003, + "693": 108.69999999999993, + "694": 112.70000000000022, + "695": 113.9, + "696": 109.10000000000007, + "697": 114.4500000000001, + "698": 110.95000000000005, + "699": 112.39999999999999, + "700": 105.35000000000008, + "701": 105.80000000000003, + "702": 98.10000000000016, + "703": 109.95000000000014, + "704": 2.150000000000006, + "705": 109.00000000000009, + "706": 118.39999999999999, + "707": 106.10000000000008, + "708": 96.80000000000011, + "709": 111.10000000000015, + "710": 118.80000000000022, + "711": 114.49999999999997, + "712": 109.15000000000003, + "713": 113.10000000000007, + "714": 113.30000000000007, + "715": 105.35000000000014, + "716": 108.90000000000009, + "717": 116.00000000000003, + "718": 103.50000000000009, + "719": 112.80000000000015, + "720": 107.70000000000006, + "721": 108.80000000000004, + "722": 52.89999999999999, + "723": 108.40000000000018, + "724": 106.35000000000007, + "725": 113.35000000000015, + "726": 114.30000000000013, + "727": 116.55000000000013, + "728": 105.3999999999999, + "729": 117.40000000000015, + "730": -62.29999999999995, + "731": 111.5500000000002, + "732": 114.90000000000013, + "733": 112.65000000000019, + "734": 108.00000000000001, + "735": 117.20000000000019, + "736": 110.55000000000001, + "737": 115.60000000000014, + "738": 99.3000000000001, + "739": 112.75000000000007, + "740": 118.10000000000014, + "741": 76.54999999999994, + "742": 109.55000000000013, + "743": 114.2500000000002, + "744": 80.4500000000001, + "745": 108.55000000000001, + "746": 111.85000000000026, + "747": 115.45000000000014, + "748": 111.69999999999992, + "749": 109.30000000000001, + "750": 118.15000000000005, + "751": 115.95000000000006, + "752": 105.49999999999996, + "753": 113.50000000000017, + "754": 109.60000000000014, + "755": 116.45000000000013, + "756": 112.65000000000012, + "757": 117.45000000000007, + "758": 106.80000000000005, + "759": 116.85000000000011, + "760": 108.50000000000018, + "761": 110.25000000000016, + "762": 101.25000000000009, + "763": 105.10000000000018, + "764": 112.40000000000015, + "765": 101.75000000000009, + "766": 111.30000000000011, + "767": 114.20000000000007, + "768": 102.3500000000001, + "769": 91.34999999999994, + "770": 109.00000000000023, + "771": 108.75000000000006, + "772": 103.70000000000016, + "773": 116.10000000000001, + "774": 103.85000000000007, + "775": 112.75000000000003, + "776": 113.20000000000013, + "777": 116.75000000000023, + "778": 112.55, + "779": 112.50000000000016, + "780": 98.65, + "781": 109.20000000000003, + "782": 101.6000000000001, + "783": 109.49999999999997, + "784": 117.75000000000007, + "785": 106.15000000000005, + "786": 116.95000000000006, + "787": 100.1000000000001, + "788": 104.50000000000006, + "789": 97.49999999999993, + "790": 106.30000000000013, + "791": 66.89999999999992, + "792": 105.1000000000001, + "793": 118.55000000000015, + "794": 113.79999999999997, + "795": 108.70000000000003, + "796": 107.2000000000001, + "797": 118.15000000000003, + "798": 111.95000000000013, + "799": 116.10000000000007, + "800": 102.25000000000011, + "801": 68.44999999999996, + "802": 112.49999999999999, + "803": 116.45000000000014, + "804": 111.60000000000008, + "805": 109.9, + "806": 38.74999999999991, + "807": 119.40000000000009, + "808": 107.60000000000014, + "809": 106.79999999999995, + "810": 112.00000000000006, + "811": 106.90000000000005, + "812": 109.55000000000001, + "813": 114.90000000000016, + "814": 109.8500000000002, + "815": 105.50000000000007, + "816": 112.45000000000023, + "817": 105.39999999999998, + "818": 109.80000000000011, + "819": 95.69999999999997, + "820": 112.25000000000013, + "821": 118.10000000000001, + "822": 114.35000000000012, + "823": 118.10000000000014, + "824": 106.69999999999989, + "825": 118.80000000000001, + "826": 106.09999999999994, + "827": 106.55000000000008, + "828": 73.90000000000008, + "829": 102.80000000000008, + "830": 113.85000000000004, + "831": 112.15, + "832": 111.80000000000022, + "833": 112.4000000000001, + "834": 55.84999999999986, + "835": 106.75000000000013, + "836": 107.10000000000022, + "837": 67.3499999999999, + "838": 98.2, + "839": 107.6500000000001, + "840": 98.40000000000008, + "841": 111.45000000000017, + "842": 112.65000000000012, + "843": 84.30000000000003, + "844": 111.15000000000018, + "845": 122.74999999999997, + "846": 106.30000000000018, + "847": 103.00000000000003, + "848": 100.15000000000005, + "849": 114.25, + "850": 109.70000000000006, + "851": 108.10000000000005, + "852": 115.60000000000004, + "853": 107.4, + "854": 110.64999999999995, + "855": 111.00000000000007, + "856": 116.05000000000005, + "857": 102.25000000000017, + "858": 109.70000000000009, + "859": 103.05000000000001, + "860": 95.90000000000002, + "861": 33.499999999999986, + "862": 48.39999999999995, + "863": 109.5500000000001, + "864": 108.75000000000014, + "865": 110.30000000000003, + "866": 112.90000000000023, + "867": 107.65000000000008, + "868": 115.15000000000009, + "869": 110.2500000000002, + "870": 117.7, + "871": 104.30000000000001, + "872": 101.29999999999997, + "873": 114.05000000000015, + "874": 108.5999999999999, + "875": 110.85000000000016, + "876": 97.90000000000006, + "877": 100.7, + "878": 115.20000000000012, + "879": 114.05, + "880": 117.75000000000001, + "881": 108.80000000000004, + "882": 107.05000000000004, + "883": 91.55000000000005, + "884": 109.50000000000001, + "885": 105.70000000000012, + "886": 113.00000000000006, + "887": 76.25000000000009, + "888": 106.2, + "889": 110.85000000000008, + "890": 111.70000000000017, + "891": 92.35000000000007, + "892": 115.40000000000012, + "893": 108.2999999999999, + "894": 86.8000000000001, + "895": 107.40000000000012, + "896": 106.25000000000016, + "897": 111.9, + "898": 112.35000000000015, + "899": 114.60000000000002, + "900": 113.29999999999995, + "901": 102.15000000000015, + "902": 112.90000000000022, + "903": -14.099999999999989, + "904": 97.89999999999999, + "905": 102.95000000000005, + "906": 118.5000000000001, + "907": 112.05000000000013, + "908": 117.80000000000014, + "909": 107.40000000000019, + "910": 111.69999999999997, + "911": 108.34999999999994, + "912": 117.50000000000001, + "913": 117.15000000000008, + "914": 115.15000000000013, + "915": 107.00000000000001, + "916": 23.45, + "917": 109.35000000000007, + "918": 99.90000000000009, + "919": 99.80000000000011, + "920": 106.30000000000007, + "921": 119.00000000000009, + "922": 110.10000000000008, + "923": 105.39999999999998, + "924": 121.20000000000016, + "925": 118.1500000000001, + "926": 109.90000000000009, + "927": 114.90000000000003, + "928": 113.85, + "929": 100.49999999999996, + "930": 109.90000000000009, + "931": 105.15000000000016, + "932": 99.3, + "933": 101.35000000000007, + "934": 117.70000000000022, + "935": 104.95000000000012, + "936": 71.04999999999987, + "937": 106.85000000000005, + "938": 108.05000000000004, + "939": 112.55000000000004, + "940": 114.00000000000009, + "941": 110.40000000000002, + "942": 24.69999999999992, + "943": 60.749999999999915, + "944": 111.70000000000007, + "945": 119.75000000000007, + "946": 111.10000000000005, + "947": 117.2500000000001, + "948": 105.60000000000007, + "949": 112.55000000000008, + "950": 97.40000000000002, + "951": 111.85000000000016, + "952": 116.60000000000008, + "953": 110.2500000000001, + "954": 92.85000000000018, + "955": 99.45000000000006, + "956": 111.45000000000023, + "957": 110.10000000000001, + "958": 114.80000000000003, + "959": 116.90000000000012, + "960": 110.25000000000006, + "961": -33.6, + "962": 107.20000000000003, + "963": 112.25000000000003, + "964": 108.10000000000014, + "965": 104.05000000000003, + "966": 116.55000000000013, + "967": 111.4, + "968": 104.05000000000007, + "969": 113.80000000000007, + "970": 112.50000000000027, + "971": 104.6500000000001, + "972": 112.19999999999995, + "973": 111.25000000000004, + "974": 114.50000000000011, + "975": 97.4500000000001, + "976": 110.60000000000011, + "977": 111.35000000000001, + "978": 106.25000000000003, + "979": 114.75000000000004, + "980": 108.99999999999999, + "981": 110.65000000000006, + "982": 117.25000000000011, + "983": 95.55000000000007, + "984": 116.75000000000006, + "985": 110.3000000000001, + "986": 117.55000000000004, + "987": 109.90000000000006, + "988": 105.50000000000006, + "989": 101.75000000000006, + "990": 109.54999999999995, + "991": 115.40000000000005, + "992": 15.650000000000086, + "993": 108.15000000000006, + "994": 106.05000000000008, + "995": 112.50000000000003, + "996": 110.55000000000003, + "997": 108.40000000000005, + "998": 113.25000000000003, + "999": 100.75000000000009, + "1000": 110.70000000000007 + }, + "5": { + "1": -46.70000000000007, + "2": -19.849999999999977, + "3": -22.549999999999955, + "4": -17.399999999999967, + "5": -38.950000000000024, + "6": -19.099999999999966, + "7": -15.049999999999992, + "8": -76.30000000000003, + "9": -61.25000000000008, + "10": -23.89999999999997, + "11": -14.549999999999985, + "12": -19.049999999999965, + "13": -12.849999999999985, + "14": -19.99999999999996, + "15": -15.199999999999969, + "16": -17.549999999999972, + "17": -4.5, + "18": -42.550000000000054, + "19": -19.54999999999998, + "20": -17.14999999999997, + "21": -94.15, + "22": -18.14999999999997, + "23": -21.34999999999996, + "24": -21.65000000000001, + "25": -31.449999999999953, + "26": -93.85, + "27": -15.899999999999979, + "28": -50.40000000000008, + "29": -26.399999999999988, + "30": -51.199999999999996, + "31": -20.099999999999984, + "32": -16.99999999999997, + "33": -51.15000000000022, + "34": -21.849999999999955, + "35": -28.550000000000065, + "36": -37.80000000000004, + "37": -15.849999999999975, + "38": -18.899999999999967, + "39": -21.649999999999956, + "40": -92.55000000000014, + "41": -6.149999999999979, + "42": -36.15000000000002, + "43": -21.949999999999957, + "44": -25.949999999999964, + "45": -21.599999999999955, + "46": -27.64999999999993, + "47": -15.899999999999977, + "48": -24.949999999999996, + "49": -11.599999999999989, + "50": -22.349999999999955, + "51": -17.149999999999984, + "52": -65.65000000000009, + "53": -44.75000000000001, + "54": -13.949999999999992, + "55": -20.04999999999996, + "56": -93.80000000000004, + "57": -50.70000000000007, + "58": -36.85000000000007, + "59": -14.14999999999998, + "60": -88.05, + "61": -96.75, + "62": -12.94999999999999, + "63": -15.49999999999998, + "64": -29.750000000000043, + "65": -7.700000000000012, + "66": -60.4500000000001, + "67": -17.79999999999997, + "68": -14.54999999999997, + "69": -27.04999999999995, + "70": -28.500000000000007, + "71": 7.650000000000068, + "72": -18.449999999999967, + "73": -11.949999999999987, + "74": -16.899999999999977, + "75": -22.90000000000001, + "76": -82.24999999999994, + "77": -0.7999999999999745, + "78": -19.649999999999963, + "79": -72.94999999999999, + "80": -35.50000000000009, + "81": -64.35, + "82": -21.599999999999955, + "83": -21.399999999999956, + "84": -15.74999999999996, + "85": -48.349999999999994, + "86": -23.799999999999947, + "87": -12.599999999999987, + "88": -11.749999999999993, + "89": -8.599999999999996, + "90": -77.65000000000006, + "91": -21.34999999999996, + "92": -14.39999999999998, + "93": -13.049999999999985, + "94": -16.599999999999973, + "95": -14.349999999999985, + "96": -14.299999999999986, + "97": -14.049999999999978, + "98": -35.05, + "99": -5.999999999999961, + "100": 2.2000000000000473, + "101": -10.299999999999992, + "102": -23.849999999999948, + "103": -16.349999999999977, + "104": -25.449999999999942, + "105": -45.4, + "106": -8.899999999999991, + "107": -41.65000000000016, + "108": -0.7500000000000457, + "109": -15.349999999999975, + "110": -25.59999999999995, + "111": -19.749999999999964, + "112": -13.099999999999984, + "113": 3.2000000000000064, + "114": -15.04999999999998, + "115": -11.04999999999999, + "116": -47.99999999999994, + "117": -21.449999999999957, + "118": -17.599999999999977, + "119": 6.4000000000000306, + "120": 0.7000000000000142, + "121": -21.899999999999956, + "122": -14.349999999999985, + "123": -19.699999999999964, + "124": -14.299999999999985, + "125": -57.04999999999998, + "126": -6.250000000000001, + "127": -17.74999999999997, + "128": -1.5999999999999779, + "129": 9.800000000000068, + "130": -16.549999999999972, + "131": -14.64999999999998, + "132": -20.249999999999996, + "133": -21.699999999999953, + "134": 8.500000000000052, + "135": 0.10000000000005338, + "136": -15.049999999999992, + "137": -52.000000000000085, + "138": -42.250000000000014, + "139": -5.049999999999991, + "140": -1.2000000000000004, + "141": -16.249999999999964, + "142": -21.04999999999997, + "143": -16.49999999999997, + "144": -14.899999999999975, + "145": -15.299999999999974, + "146": 2.050000000000013, + "147": -16.899999999999967, + "148": -85.69999999999999, + "149": -16.0, + "150": 22.79999999999995, + "151": 22.04999999999997, + "152": -13.499999999999982, + "153": -70.14999999999995, + "154": -71.75000000000007, + "155": -34.94999999999997, + "156": 5.199999999999951, + "157": -4.999999999999977, + "158": 3.000000000000022, + "159": -0.0999999999999821, + "160": -14.299999999999985, + "161": 9.25000000000001, + "162": -6.749999999999983, + "163": 5.750000000000025, + "164": -7.699999999999989, + "165": -5.649999999999977, + "166": -5.500000000000006, + "167": -11.749999999999984, + "168": -17.699999999999985, + "169": 7.750000000000034, + "170": -14.199999999999987, + "171": -32.79999999999996, + "172": -40.19999999999999, + "173": 21.84999999999983, + "174": -11.799999999999994, + "175": -4.44999999999998, + "176": 1.5000000000000318, + "177": -2.799999999999975, + "178": -80.40000000000005, + "179": -29.499999999999993, + "180": -40.29999999999997, + "181": -93.54999999999997, + "182": -12.699999999999985, + "183": 8.10000000000004, + "184": 24.199999999999925, + "185": -7.949999999999988, + "186": -38.40000000000002, + "187": -12.899999999999983, + "188": 15.100000000000033, + "189": -9.549999999999995, + "190": 10.150000000000002, + "191": -65.05000000000001, + "192": 4.699999999999995, + "193": 9.00000000000006, + "194": -29.749999999999982, + "195": 11.850000000000065, + "196": -22.65000000000005, + "197": -21.399999999999956, + "198": -1.7500000000000056, + "199": -59.50000000000003, + "200": -9.649999999999995, + "201": 0.5500000000000185, + "202": -15.049999999999981, + "203": 1.1000000000000265, + "204": -17.249999999999975, + "205": 8.599999999999998, + "206": -5.099999999999995, + "207": -15.549999999999981, + "208": 51.949999999999896, + "209": 28.99999999999995, + "210": 17.299999999999848, + "211": 5.500000000000006, + "212": -1.6999999999999924, + "213": -17.39999999999997, + "214": -6.749999999999994, + "215": 3.049999999999989, + "216": -7.84999999999999, + "217": 37.94999999999992, + "218": 7.4499999999999895, + "219": 6.400000000000004, + "220": -81.95, + "221": -4.900000000000002, + "222": 0.9000000000000248, + "223": -62.10000000000001, + "224": 3.0500000000000247, + "225": -42.70000000000002, + "226": -12.549999999999995, + "227": -62.4500000000001, + "228": 32.1999999999998, + "229": -22.79999999999995, + "230": -5.7499999999999805, + "231": -20.599999999999962, + "232": 47.299999999999976, + "233": -25.44999999999996, + "234": 0.6000000000000199, + "235": 45.34999999999975, + "236": -12.049999999999995, + "237": -44.99999999999999, + "238": 9.599999999999971, + "239": 41.89999999999973, + "240": -74.25000000000001, + "241": -30.25000000000002, + "242": -3.350000000000003, + "243": 45.69999999999994, + "244": 39.74999999999981, + "245": -27.600000000000037, + "246": -75.99999999999999, + "247": 10.949999999999982, + "248": 87.25000000000024, + "249": 5.350000000000006, + "250": 33.199999999999996, + "251": 5.500000000000031, + "252": 6.350000000000054, + "253": -11.2, + "254": 13.900000000000038, + "255": 24.699999999999942, + "256": 53.29999999999981, + "257": 83.49999999999991, + "258": -6.100000000000011, + "259": -1.4499999999999624, + "260": -2.3499999999999917, + "261": -1.949999999999969, + "262": -27.89999999999995, + "263": 6.950000000000012, + "264": -25.250000000000004, + "265": 33.95000000000004, + "266": -30.90000000000002, + "267": 77.0499999999998, + "268": 44.899999999999935, + "269": 38.499999999999986, + "270": 17.999999999999986, + "271": 15.350000000000069, + "272": 34.15000000000005, + "273": -7.899999999999995, + "274": 77.0999999999999, + "275": 23.04999999999994, + "276": 62.299999999999834, + "277": 18.79999999999998, + "278": 8.900000000000018, + "279": -3.649999999999981, + "280": 75.44999999999987, + "281": 22.750000000000025, + "282": 39.74999999999993, + "283": 69.69999999999995, + "284": 18.35, + "285": -22.750000000000007, + "286": 5.90000000000002, + "287": -13.10000000000003, + "288": 0.600000000000017, + "289": -60.85000000000002, + "290": -69.19999999999999, + "291": 106.45000000000019, + "292": 31.449999999999925, + "293": 57.299999999999955, + "294": 34.499999999999886, + "295": 55.9500000000001, + "296": 36.90000000000004, + "297": -54.69999999999996, + "298": 102.45000000000017, + "299": 21.349999999999998, + "300": 68.7000000000001, + "301": 53.74999999999988, + "302": 86.80000000000018, + "303": 90.14999999999996, + "304": 83.90000000000023, + "305": 76.94999999999999, + "306": 62.19999999999997, + "307": -16.650000000000034, + "308": 74.4, + "309": 57.70000000000001, + "310": 30.849999999999955, + "311": 119.50000000000009, + "312": 93.10000000000001, + "313": 37.44999999999992, + "314": 93.15000000000005, + "315": 65.44999999999993, + "316": -6.499999999999995, + "317": 63.59999999999996, + "318": 86.90000000000013, + "319": 89.65000000000002, + "320": 63.899999999999885, + "321": 104.30000000000014, + "322": 52.699999999999875, + "323": 105.30000000000015, + "324": 71.80000000000027, + "325": 68.79999999999997, + "326": 2.899999999999973, + "327": 82.50000000000003, + "328": 106.40000000000018, + "329": 30.499999999999922, + "330": 104.29999999999977, + "331": 106.6000000000001, + "332": 92.70000000000007, + "333": 69.19999999999999, + "334": 71.29999999999984, + "335": 44.54999999999992, + "336": 34.19999999999981, + "337": 89.39999999999999, + "338": 89.70000000000013, + "339": 47.699999999999875, + "340": 85.95000000000005, + "341": 112.6000000000001, + "342": 92.35, + "343": 46.94999999999992, + "344": 88.69999999999985, + "345": 61.99999999999984, + "346": 91.10000000000014, + "347": 92.14999999999985, + "348": 72.64999999999992, + "349": 60.649999999999935, + "350": 111.50000000000003, + "351": 94.40000000000016, + "352": 75.74999999999982, + "353": 91.35000000000007, + "354": 75.14999999999995, + "355": 25.799999999999923, + "356": -10.299999999999994, + "357": 116.30000000000018, + "358": 33.50000000000005, + "359": 78.34999999999981, + "360": 80.30000000000001, + "361": 106.34999999999994, + "362": 103.1500000000001, + "363": 114.8500000000002, + "364": 112.95000000000022, + "365": 90.04999999999997, + "366": 68.44999999999996, + "367": 104.49999999999997, + "368": 73.34999999999984, + "369": 85.4, + "370": 93.05000000000007, + "371": 94.75000000000007, + "372": 84.69999999999983, + "373": 91.2, + "374": 101.70000000000012, + "375": 118.95000000000009, + "376": 97.10000000000007, + "377": 26.649999999999995, + "378": 79.95000000000002, + "379": 103.95000000000014, + "380": 80.75000000000004, + "381": 89.24999999999993, + "382": 114.65000000000019, + "383": 94.80000000000003, + "384": 84.25000000000001, + "385": 114.55000000000018, + "386": 112.00000000000013, + "387": 90.20000000000006, + "388": 83.65000000000005, + "389": 74.84999999999981, + "390": 73.15, + "391": 114.65000000000013, + "392": 68.25000000000023, + "393": 109.04999999999994, + "394": 88.60000000000001, + "395": 97.25000000000018, + "396": 100.90000000000018, + "397": 112.20000000000017, + "398": 85.05000000000007, + "399": 105.25000000000014, + "400": 97.0000000000002, + "401": 85.75000000000009, + "402": 93.59999999999982, + "403": 118.2000000000001, + "404": 46.39999999999992, + "405": 107.25000000000014, + "406": 94.85000000000002, + "407": 93.3499999999998, + "408": 110.85, + "409": 107.40000000000022, + "410": 103.7, + "411": 114.24999999999994, + "412": 114.7500000000001, + "413": 93.80000000000004, + "414": 56.79999999999983, + "415": 110.84999999999997, + "416": 103.55000000000015, + "417": 86.95, + "418": 118.10000000000029, + "419": 112.25000000000016, + "420": 110.4000000000001, + "421": 101.60000000000011, + "422": 116.45, + "423": 110.94999999999997, + "424": 52.29999999999983, + "425": 105.59999999999998, + "426": 100.95000000000009, + "427": 83.09999999999987, + "428": 113.15000000000013, + "429": 101.54999999999993, + "430": 102.30000000000014, + "431": 84.70000000000006, + "432": -9.250000000000002, + "433": 113.10000000000008, + "434": 91.65000000000009, + "435": 96.3, + "436": 113.30000000000013, + "437": 100.55, + "438": 94.40000000000008, + "439": 87.30000000000007, + "440": 86.85000000000005, + "441": 115.9500000000001, + "442": 108.10000000000005, + "443": 111.24999999999997, + "444": 104.40000000000008, + "445": 107.7500000000001, + "446": 98.80000000000008, + "447": 112.50000000000028, + "448": 110.44999999999983, + "449": 16.250000000000068, + "450": 97.54999999999984, + "451": 107.34999999999995, + "452": 98.60000000000011, + "453": 106.40000000000022, + "454": 113.70000000000019, + "455": 107.45000000000012, + "456": 98.34999999999995, + "457": 106.50000000000018, + "458": 72.09999999999994, + "459": 103.60000000000002, + "460": 111.89999999999999, + "461": 104.09999999999987, + "462": 108.6500000000001, + "463": 116.10000000000008, + "464": 102.15000000000019, + "465": 79.94999999999996, + "466": 103.50000000000026, + "467": 114.60000000000014, + "468": 100.10000000000004, + "469": 108.40000000000008, + "470": 114.59999999999997, + "471": 111.10000000000018, + "472": 105.49999999999997, + "473": 109.6000000000001, + "474": 111.4500000000002, + "475": 15.04999999999989, + "476": 84.30000000000004, + "477": 88.35000000000016, + "478": 117.70000000000012, + "479": 105.9500000000001, + "480": 112.4500000000001, + "481": 118.75000000000001, + "482": 108.70000000000005, + "483": 119.15000000000002, + "484": 105.64999999999995, + "485": 110.15000000000009, + "486": 115.85000000000024, + "487": 120.70000000000014, + "488": 109.15000000000019, + "489": 8.300000000000004, + "490": 114.19999999999996, + "491": 106.35000000000014, + "492": 65.64999999999986, + "493": 112.85000000000002, + "494": 110.85000000000014, + "495": 103.85000000000015, + "496": 117.05000000000001, + "497": 114.45000000000012, + "498": 111.90000000000019, + "499": 107.60000000000008, + "500": 110.45000000000006, + "501": 100.35000000000012, + "502": 104.1500000000001, + "503": 112.60000000000012, + "504": 101.15000000000009, + "505": 106.85000000000012, + "506": 111.94999999999997, + "507": 112.90000000000012, + "508": 54.39999999999982, + "509": 104.45000000000002, + "510": 105.10000000000022, + "511": 101.44999999999983, + "512": 108.35000000000011, + "513": 93.65000000000013, + "514": 119.00000000000024, + "515": 6.600000000000001, + "516": 107.35000000000007, + "517": 108.2500000000001, + "518": 105.40000000000006, + "519": 112.80000000000003, + "520": 102.95000000000016, + "521": 111.75, + "522": 113.15000000000002, + "523": 47.44999999999986, + "524": 103.90000000000005, + "525": 121.65000000000002, + "526": 92.64999999999999, + "527": 108.79999999999998, + "528": 96.34999999999987, + "529": 113.40000000000012, + "530": 106.90000000000012, + "531": 79.09999999999992, + "532": 107.70000000000002, + "533": 109.0499999999998, + "534": 93.85000000000012, + "535": 102.30000000000003, + "536": 100.59999999999992, + "537": 107.90000000000019, + "538": 113.80000000000013, + "539": 81.45, + "540": 117.70000000000002, + "541": 115.05000000000003, + "542": 99.89999999999992, + "543": 109.30000000000011, + "544": 102.35000000000007, + "545": 109.29999999999997, + "546": 104.85000000000007, + "547": 114.80000000000017, + "548": 118.1000000000002, + "549": 109.75000000000011, + "550": 112.30000000000005, + "551": 93.25000000000006, + "552": 114.50000000000006, + "553": 112.90000000000002, + "554": 115.30000000000004, + "555": 108.5000000000001, + "556": 114.75000000000007, + "557": 109.05000000000008, + "558": 119.20000000000002, + "559": 104.85000000000016, + "560": 112.3, + "561": 112.20000000000009, + "562": 110.15000000000005, + "563": 109.35000000000005, + "564": 116.05000000000007, + "565": 78.39999999999992, + "566": 118.70000000000017, + "567": 108.75000000000007, + "568": 110.09999999999995, + "569": 112.29999999999997, + "570": 27.69999999999983, + "571": 110.90000000000009, + "572": 113.00000000000014, + "573": 102.00000000000017, + "574": 112.94999999999993, + "575": 108.15000000000006, + "576": 113.04999999999995, + "577": 106.09999999999998, + "578": 7.350000000000021, + "579": 116.65000000000009, + "580": 108.70000000000007, + "581": 114.80000000000022, + "582": 93.74999999999986, + "583": 79.85000000000014, + "584": 110.90000000000016, + "585": 123.30000000000004, + "586": 112.05000000000015, + "587": 115.85, + "588": 109.60000000000008, + "589": 111.10000000000012, + "590": 42.79999999999987, + "591": 36.49999999999981, + "592": 109.29999999999987, + "593": 116.65000000000022, + "594": 59.29999999999985, + "595": 108.3, + "596": 115.55000000000001, + "597": 114.00000000000013, + "598": 98.90000000000008, + "599": 111.10000000000011, + "600": 106.85000000000005, + "601": 108.90000000000018, + "602": 88.44999999999996, + "603": 106.40000000000006, + "604": 115.35000000000011, + "605": 96.65000000000003, + "606": 112.89999999999995, + "607": 22.350000000000044, + "608": 105.14999999999998, + "609": 103.54999999999997, + "610": 113.35000000000008, + "611": 102.49999999999997, + "612": 104.85000000000014, + "613": 111.05000000000004, + "614": 117.75000000000021, + "615": 115.1000000000001, + "616": 114.20000000000009, + "617": 110.90000000000012, + "618": 117.40000000000013, + "619": 115.50000000000013, + "620": 101.10000000000004, + "621": 90.85000000000008, + "622": 119.75000000000004, + "623": 81.40000000000032, + "624": 112.50000000000017, + "625": 110.75000000000003, + "626": 117.90000000000013, + "627": 111.85000000000011, + "628": 103.49999999999997, + "629": 113.75000000000004, + "630": 110.65000000000015, + "631": 66.19999999999986, + "632": 102.60000000000007, + "633": 104.85000000000007, + "634": 108.19999999999995, + "635": 104.05000000000005, + "636": 104.75000000000009, + "637": 101.90000000000002, + "638": 107.6500000000001, + "639": 113.30000000000013, + "640": 110.09999999999994, + "641": 113.35, + "642": 114.10000000000016, + "643": 109.1500000000001, + "644": 119.4500000000001, + "645": 106.30000000000007, + "646": 115.10000000000015, + "647": 116.19999999999999, + "648": 111.25000000000001, + "649": 108.74999999999987, + "650": 109.6000000000001, + "651": 114.15000000000019, + "652": 109.44999999999999, + "653": 119.80000000000011, + "654": 111.5000000000002, + "655": 108.10000000000002, + "656": 118.10000000000016, + "657": 108.25000000000003, + "658": 109.60000000000007, + "659": 101.89999999999992, + "660": 106.3000000000001, + "661": 111.70000000000003, + "662": 60.94999999999987, + "663": 111.1, + "664": 116.05000000000007, + "665": 108.85000000000012, + "666": 110.65, + "667": 113.05000000000013, + "668": 108.75000000000009, + "669": 114.00000000000001, + "670": 112.35000000000014, + "671": 111.15000000000009, + "672": 96.85000000000008, + "673": 113.19999999999997, + "674": 109.50000000000011, + "675": 102.05000000000004, + "676": 111.65000000000003, + "677": 109.05000000000025, + "678": 111.8, + "679": 110.90000000000006, + "680": 104.74999999999993, + "681": 115.00000000000021, + "682": 109.15000000000012, + "683": 104.60000000000016, + "684": 120.70000000000006, + "685": 106.60000000000011, + "686": 114.85000000000021, + "687": 32.099999999999916, + "688": 108.5499999999999, + "689": 107.30000000000003, + "690": 114.00000000000004, + "691": 109.89999999999992, + "692": 118.95000000000007, + "693": 112.40000000000002, + "694": 105.50000000000011, + "695": 115.80000000000017, + "696": 122.60000000000008, + "697": 114.9000000000001, + "698": 109.9500000000001, + "699": 102.15000000000008, + "700": 120.30000000000003, + "701": -1.6500000000000123, + "702": 112.05, + "703": 118.85000000000011, + "704": 96.8500000000001, + "705": 107.70000000000002, + "706": 106.60000000000001, + "707": 115.95000000000009, + "708": 105.10000000000008, + "709": 107.05000000000004, + "710": 108.74999999999994, + "711": 81.20000000000002, + "712": 100.05000000000018, + "713": 114.30000000000011, + "714": 115.30000000000003, + "715": 110.20000000000007, + "716": 120.75000000000004, + "717": 104.65000000000019, + "718": 109.5, + "719": 105.6000000000001, + "720": 94.40000000000013, + "721": 81.34999999999997, + "722": 97.99999999999996, + "723": 108.15000000000016, + "724": 108.60000000000008, + "725": 104.40000000000013, + "726": 106.60000000000011, + "727": 108.10000000000008, + "728": 110.34999999999998, + "729": 111.09999999999994, + "730": 109.65000000000005, + "731": 120.65, + "732": 99.60000000000008, + "733": 108.84999999999998, + "734": 110.95000000000006, + "735": 108.85000000000005, + "736": 115.10000000000024, + "737": 110.24999999999999, + "738": 118.35000000000014, + "739": 108.90000000000008, + "740": 112.35000000000016, + "741": 109.65000000000009, + "742": 112.20000000000013, + "743": 116.40000000000016, + "744": 98.14999999999998, + "745": 111.04999999999998, + "746": 45.499999999999865, + "747": 116.10000000000016, + "748": 105.34999999999992, + "749": 109.3500000000002, + "750": 105.90000000000006, + "751": 118.40000000000012, + "752": 114.00000000000017, + "753": 112.80000000000007, + "754": 110.75000000000006, + "755": 112.40000000000012, + "756": 114.70000000000013, + "757": 107.30000000000018, + "758": 102.10000000000002, + "759": 115.10000000000001, + "760": 59.299999999999876, + "761": 111.20000000000013, + "762": 110.44999999999999, + "763": 116.4000000000002, + "764": 111.25000000000018, + "765": 104.5499999999999, + "766": 113.95000000000005, + "767": 112.40000000000009, + "768": 30.099999999999923, + "769": 111.70000000000002, + "770": 117.40000000000006, + "771": 116.7000000000001, + "772": 113.45000000000005, + "773": 115.0500000000001, + "774": 111.30000000000005, + "775": 118.14999999999998, + "776": 113.10000000000018, + "777": 113.95000000000007, + "778": 109.65, + "779": 107.25000000000009, + "780": 100.74999999999997, + "781": 110.7500000000001, + "782": 114.15000000000006, + "783": 110.9000000000001, + "784": 110.55000000000022, + "785": 115.8000000000001, + "786": -27.399999999999988, + "787": 112.10000000000008, + "788": 106.00000000000011, + "789": 113.45000000000019, + "790": 113.90000000000003, + "791": 110.50000000000006, + "792": 110.20000000000017, + "793": 107.54999999999994, + "794": 106.5000000000001, + "795": 80.50000000000018, + "796": 112.95000000000007, + "797": 71.44999999999982, + "798": 110.75000000000006, + "799": 99.75000000000004, + "800": 103.25000000000007, + "801": 107.80000000000022, + "802": 108.09999999999997, + "803": 107.60000000000008, + "804": 54.29999999999988, + "805": 110.50000000000006, + "806": 77.30000000000008, + "807": 113.35000000000002, + "808": 120.60000000000002, + "809": 104.95000000000003, + "810": 106.64999999999999, + "811": 109.70000000000005, + "812": 117.50000000000001, + "813": 111.15000000000003, + "814": 111.10000000000001, + "815": 107.45000000000013, + "816": 114.25000000000023, + "817": 102.34999999999998, + "818": 109.75, + "819": 110.45000000000022, + "820": 112.90000000000013, + "821": 115.00000000000018, + "822": 60.49999999999993, + "823": 114.9000000000001, + "824": 113.35000000000014, + "825": 109.95000000000007, + "826": 108.65, + "827": 113.70000000000012, + "828": 109.65000000000008, + "829": 115.25, + "830": 111.40000000000019, + "831": 115.80000000000008, + "832": 111.40000000000022, + "833": 94.64999999999988, + "834": 116.55000000000005, + "835": 76.15000000000003, + "836": 107.4, + "837": 104.15, + "838": 116.20000000000009, + "839": 116.8000000000001, + "840": 112.60000000000007, + "841": 110.90000000000016, + "842": 109.95000000000002, + "843": 116.55000000000028, + "844": 112.60000000000008, + "845": 118.45000000000006, + "846": 106.75000000000006, + "847": 101.8000000000001, + "848": 111.75000000000011, + "849": 114.70000000000003, + "850": 115.55000000000013, + "851": 109.80000000000001, + "852": 112.00000000000007, + "853": 111.2, + "854": 107.70000000000003, + "855": 116.69999999999999, + "856": 104.45000000000009, + "857": 105.30000000000001, + "858": 114.80000000000018, + "859": 118.35000000000005, + "860": 115.7500000000001, + "861": 121.0000000000001, + "862": 112.20000000000003, + "863": 107.50000000000016, + "864": 107.4500000000001, + "865": 114.50000000000011, + "866": 116.05000000000017, + "867": 106.90000000000008, + "868": 102.95000000000005, + "869": 96.19999999999989, + "870": 118.29999999999995, + "871": 114.50000000000006, + "872": 106.70000000000006, + "873": 110.00000000000009, + "874": 104.15, + "875": 116.99999999999994, + "876": 110.40000000000009, + "877": 107.60000000000008, + "878": 100.85000000000002, + "879": 111.30000000000015, + "880": 110.0, + "881": 103.05000000000001, + "882": 101.2, + "883": 109.10000000000004, + "884": 109.20000000000007, + "885": 107.50000000000013, + "886": 116.60000000000016, + "887": 113.10000000000021, + "888": 112.0000000000001, + "889": 110.6499999999999, + "890": 103.40000000000013, + "891": 113.80000000000003, + "892": 117.30000000000017, + "893": 109.35000000000005, + "894": 89.54999999999994, + "895": 110.15000000000012, + "896": 98.55000000000005, + "897": 112.9500000000001, + "898": 116.80000000000005, + "899": 116.14999999999998, + "900": 117.25000000000006, + "901": 108.85000000000012, + "902": 110.20000000000017, + "903": 106.04999999999997, + "904": 117.19999999999999, + "905": 115.10000000000008, + "906": 109.05000000000024, + "907": 114.90000000000005, + "908": 101.15, + "909": 112.65000000000018, + "910": 110.70000000000016, + "911": 112.75000000000009, + "912": 112.30000000000004, + "913": 117.75000000000006, + "914": 109.95000000000003, + "915": 108.05000000000007, + "916": 115.75, + "917": 115.00000000000011, + "918": 113.75000000000016, + "919": 111.85000000000001, + "920": 106.85000000000015, + "921": 101.15000000000008, + "922": 102.80000000000007, + "923": 107.85000000000015, + "924": 109.30000000000013, + "925": 110.79999999999993, + "926": 118.70000000000016, + "927": 111.4500000000001, + "928": 113.60000000000004, + "929": 116.29999999999998, + "930": 109.60000000000001, + "931": 113.20000000000002, + "932": 114.05000000000017, + "933": 117.0000000000001, + "934": 117.60000000000011, + "935": 114.55000000000015, + "936": 114.60000000000012, + "937": 107.70000000000007, + "938": 109.20000000000012, + "939": 113.3500000000002, + "940": 114.95000000000012, + "941": 113.45000000000016, + "942": 116.35, + "943": 119.65000000000009, + "944": 107.05000000000013, + "945": 115.15000000000008, + "946": 109.55000000000008, + "947": 109.45000000000002, + "948": 109.85000000000002, + "949": 106.99999999999997, + "950": 106.4500000000002, + "951": 109.85, + "952": 117.1500000000001, + "953": 108.10000000000018, + "954": 108.05000000000024, + "955": 101.90000000000003, + "956": 114.65000000000009, + "957": 116.65000000000015, + "958": 99.54999999999997, + "959": 108.40000000000003, + "960": 112.95000000000017, + "961": 109.45000000000012, + "962": 116.95000000000006, + "963": 110.25000000000013, + "964": 109.79999999999993, + "965": 111.85, + "966": 62.29999999999991, + "967": 111.00000000000006, + "968": 62.44999999999991, + "969": 117.50000000000009, + "970": 109.60000000000004, + "971": 109.80000000000018, + "972": 119.55000000000004, + "973": 117.10000000000011, + "974": 106.90000000000013, + "975": 112.95000000000023, + "976": 109.55000000000005, + "977": 112.65000000000028, + "978": 109.20000000000007, + "979": 117.40000000000018, + "980": 114.4000000000001, + "981": 103.7000000000001, + "982": 120.25000000000001, + "983": 115.10000000000014, + "984": 111.85000000000007, + "985": 113.00000000000013, + "986": 115.15000000000006, + "987": 116.30000000000007, + "988": 104.00000000000016, + "989": 110.85000000000004, + "990": 113.10000000000002, + "991": 113.84999999999998, + "992": 109.4500000000002, + "993": 114.60000000000001, + "994": 114.2000000000002, + "995": 107.60000000000008, + "996": 118.45000000000003, + "997": 102.75000000000003, + "998": 109.85000000000005, + "999": 110.6500000000001, + "1000": 113.10000000000015 + } + }, + "config": { + "io_settings": { + "save_agent_actions": true, + "save_step_metadata": false, + "save_pcap_logs": false, + "save_sys_logs": false, + "sys_log_level": "WARNING" + }, + "game": { + "max_episode_length": 128, + "ports": [ + "HTTP", + "POSTGRES_SERVER" + ], + "protocols": [ + "ICMP", + "TCP", + "UDP" + ], + "thresholds": { + "nmne": { + "high": 10, + "medium": 5, + "low": 0 + } + } + }, + "agents": [ + { + "ref": "client_2_green_user", + "team": "GREEN", + "type": "ProbabilisticAgent", + "agent_settings": { + "action_probabilities": { + "0": 0.3, + "1": 0.6, + "2": 0.1 + } + }, + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_2", + "applications": [ + { + "application_name": "WebBrowser" + }, + { + "application_name": "DatabaseClient" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1, + "max_applications_per_node": 2 + }, + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 0 + } + }, + "2": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 1 + } + } + } + }, + "reward_function": { + "reward_components": [ + { + "type": "WEBPAGE_UNAVAILABLE_PENALTY", + "weight": 0.25, + "options": { + "node_hostname": "client_2" + } + }, + { + "type": "GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY", + "weight": 0.05, + "options": { + "node_hostname": "client_2" + } + } + ] + } + }, + { + "ref": "client_1_green_user", + "team": "GREEN", + "type": "ProbabilisticAgent", + "agent_settings": { + "action_probabilities": { + "0": 0.3, + "1": 0.6, + "2": 0.1 + } + }, + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_1", + "applications": [ + { + "application_name": "WebBrowser" + }, + { + "application_name": "DatabaseClient" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1, + "max_applications_per_node": 2 + }, + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 0 + } + }, + "2": { + "action": "NODE_APPLICATION_EXECUTE", + "options": { + "node_id": 0, + "application_id": 1 + } + } + } + }, + "reward_function": { + "reward_components": [ + { + "type": "WEBPAGE_UNAVAILABLE_PENALTY", + "weight": 0.25, + "options": { + "node_hostname": "client_1" + } + }, + { + "type": "GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY", + "weight": 0.05, + "options": { + "node_hostname": "client_1" + } + } + ] + } + }, + { + "ref": "data_manipulation_attacker", + "team": "RED", + "type": "RedDatabaseCorruptingAgent", + "observation_space": null, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_APPLICATION_EXECUTE" + } + ], + "options": { + "nodes": [ + { + "node_name": "client_1", + "applications": [ + { + "application_name": "DataManipulationBot" + } + ] + }, + { + "node_name": "client_2", + "applications": [ + { + "application_name": "DataManipulationBot" + } + ] + } + ], + "max_folders_per_node": 1, + "max_files_per_folder": 1, + "max_services_per_node": 1 + } + }, + "reward_function": { + "reward_components": [ + { + "type": "DUMMY" + } + ] + }, + "agent_settings": { + "start_settings": { + "start_step": 25, + "frequency": 20, + "variance": 5 + } + } + }, + { + "ref": "defender", + "team": "BLUE", + "type": "ProxyAgent", + "observation_space": { + "type": "CUSTOM", + "options": { + "components": [ + { + "type": "NODES", + "label": "NODES", + "options": { + "hosts": [ + { + "hostname": "domain_controller" + }, + { + "hostname": "web_server", + "services": [ + { + "service_name": "WebServer" + } + ] + }, + { + "hostname": "database_server", + "folders": [ + { + "folder_name": "database", + "files": [ + { + "file_name": "database.db" + } + ] + } + ] + }, + { + "hostname": "backup_server" + }, + { + "hostname": "security_suite" + }, + { + "hostname": "client_1" + }, + { + "hostname": "client_2" + } + ], + "num_services": 1, + "num_applications": 0, + "num_folders": 1, + "num_files": 1, + "num_nics": 2, + "include_num_access": false, + "include_nmne": true, + "routers": [ + { + "hostname": "router_1" + } + ], + "num_ports": 0, + "ip_list": [ + "192.168.1.10", + "192.168.1.12", + "192.168.1.14", + "192.168.1.16", + "192.168.1.110", + "192.168.10.21", + "192.168.10.22", + "192.168.10.110" + ], + "wildcard_list": [ + "0.0.0.1" + ], + "port_list": [ + 80, + 5432 + ], + "protocol_list": [ + "ICMP", + "TCP", + "UDP" + ], + "num_rules": 10 + } + }, + { + "type": "LINKS", + "label": "LINKS", + "options": { + "link_references": [ + "router_1:eth-1<->switch_1:eth-8", + "router_1:eth-2<->switch_2:eth-8", + "switch_1:eth-1<->domain_controller:eth-1", + "switch_1:eth-2<->web_server:eth-1", + "switch_1:eth-3<->database_server:eth-1", + "switch_1:eth-4<->backup_server:eth-1", + "switch_1:eth-7<->security_suite:eth-1", + "switch_2:eth-1<->client_1:eth-1", + "switch_2:eth-2<->client_2:eth-1", + "switch_2:eth-7<->security_suite:eth-2" + ] + } + }, + { + "type": "NONE", + "label": "ICS", + "options": {} + } + ] + } + }, + "action_space": { + "action_list": [ + { + "type": "DONOTHING" + }, + { + "type": "NODE_SERVICE_SCAN" + }, + { + "type": "NODE_SERVICE_STOP" + }, + { + "type": "NODE_SERVICE_START" + }, + { + "type": "NODE_SERVICE_PAUSE" + }, + { + "type": "NODE_SERVICE_RESUME" + }, + { + "type": "NODE_SERVICE_RESTART" + }, + { + "type": "NODE_SERVICE_DISABLE" + }, + { + "type": "NODE_SERVICE_ENABLE" + }, + { + "type": "NODE_SERVICE_FIX" + }, + { + "type": "NODE_FILE_SCAN" + }, + { + "type": "NODE_FILE_CHECKHASH" + }, + { + "type": "NODE_FILE_DELETE" + }, + { + "type": "NODE_FILE_REPAIR" + }, + { + "type": "NODE_FILE_RESTORE" + }, + { + "type": "NODE_FOLDER_SCAN" + }, + { + "type": "NODE_FOLDER_CHECKHASH" + }, + { + "type": "NODE_FOLDER_REPAIR" + }, + { + "type": "NODE_FOLDER_RESTORE" + }, + { + "type": "NODE_OS_SCAN" + }, + { + "type": "NODE_SHUTDOWN" + }, + { + "type": "NODE_STARTUP" + }, + { + "type": "NODE_RESET" + }, + { + "type": "ROUTER_ACL_ADDRULE" + }, + { + "type": "ROUTER_ACL_REMOVERULE" + }, + { + "type": "HOST_NIC_ENABLE" + }, + { + "type": "HOST_NIC_DISABLE" + } + ], + "action_map": { + "0": { + "action": "DONOTHING", + "options": {} + }, + "1": { + "action": "NODE_SERVICE_SCAN", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "2": { + "action": "NODE_SERVICE_STOP", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "3": { + "action": "NODE_SERVICE_START", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "4": { + "action": "NODE_SERVICE_PAUSE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "5": { + "action": "NODE_SERVICE_RESUME", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "6": { + "action": "NODE_SERVICE_RESTART", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "7": { + "action": "NODE_SERVICE_DISABLE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "8": { + "action": "NODE_SERVICE_ENABLE", + "options": { + "node_id": 1, + "service_id": 0 + } + }, + "9": { + "action": "NODE_FILE_SCAN", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "10": { + "action": "NODE_FILE_CHECKHASH", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "11": { + "action": "NODE_FILE_DELETE", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "12": { + "action": "NODE_FILE_REPAIR", + "options": { + "node_id": 2, + "folder_id": 0, + "file_id": 0 + } + }, + "13": { + "action": "NODE_SERVICE_FIX", + "options": { + "node_id": 2, + "service_id": 0 + } + }, + "14": { + "action": "NODE_FOLDER_SCAN", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "15": { + "action": "NODE_FOLDER_CHECKHASH", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "16": { + "action": "NODE_FOLDER_REPAIR", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "17": { + "action": "NODE_FOLDER_RESTORE", + "options": { + "node_id": 2, + "folder_id": 0 + } + }, + "18": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 0 + } + }, + "19": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 0 + } + }, + "20": { + "action": "NODE_STARTUP", + "options": { + "node_id": 0 + } + }, + "21": { + "action": "NODE_RESET", + "options": { + "node_id": 0 + } + }, + "22": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 1 + } + }, + "23": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 1 + } + }, + "24": { + "action": "NODE_STARTUP", + "options": { + "node_id": 1 + } + }, + "25": { + "action": "NODE_RESET", + "options": { + "node_id": 1 + } + }, + "26": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 2 + } + }, + "27": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 2 + } + }, + "28": { + "action": "NODE_STARTUP", + "options": { + "node_id": 2 + } + }, + "29": { + "action": "NODE_RESET", + "options": { + "node_id": 2 + } + }, + "30": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 3 + } + }, + "31": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 3 + } + }, + "32": { + "action": "NODE_STARTUP", + "options": { + "node_id": 3 + } + }, + "33": { + "action": "NODE_RESET", + "options": { + "node_id": 3 + } + }, + "34": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 4 + } + }, + "35": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 4 + } + }, + "36": { + "action": "NODE_STARTUP", + "options": { + "node_id": 4 + } + }, + "37": { + "action": "NODE_RESET", + "options": { + "node_id": 4 + } + }, + "38": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 5 + } + }, + "39": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 5 + } + }, + "40": { + "action": "NODE_STARTUP", + "options": { + "node_id": 5 + } + }, + "41": { + "action": "NODE_RESET", + "options": { + "node_id": 5 + } + }, + "42": { + "action": "NODE_OS_SCAN", + "options": { + "node_id": 6 + } + }, + "43": { + "action": "NODE_SHUTDOWN", + "options": { + "node_id": 6 + } + }, + "44": { + "action": "NODE_STARTUP", + "options": { + "node_id": 6 + } + }, + "45": { + "action": "NODE_RESET", + "options": { + "node_id": 6 + } + }, + "46": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 1, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 1, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 1, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "47": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 2, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 1, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 1, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "48": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 3, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 3, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "49": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 4, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 3, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "50": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 5, + "permission": 2, + "source_ip_id": 7, + "dest_ip_id": 4, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "51": { + "action": "ROUTER_ACL_ADDRULE", + "options": { + "target_router_nodename": "router_1", + "position": 6, + "permission": 2, + "source_ip_id": 8, + "dest_ip_id": 4, + "source_port_id": 1, + "dest_port_id": 1, + "protocol_id": 3, + "source_wildcard_id": 0, + "dest_wildcard_id": 0 + } + }, + "52": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 0 + } + }, + "53": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 1 + } + }, + "54": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 2 + } + }, + "55": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 3 + } + }, + "56": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 4 + } + }, + "57": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 5 + } + }, + "58": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 6 + } + }, + "59": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 7 + } + }, + "60": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 8 + } + }, + "61": { + "action": "ROUTER_ACL_REMOVERULE", + "options": { + "target_router_nodename": "router_1", + "position": 9 + } + }, + "62": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 0, + "nic_id": 0 + } + }, + "63": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 0, + "nic_id": 0 + } + }, + "64": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 1, + "nic_id": 0 + } + }, + "65": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 1, + "nic_id": 0 + } + }, + "66": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 2, + "nic_id": 0 + } + }, + "67": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 2, + "nic_id": 0 + } + }, + "68": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 3, + "nic_id": 0 + } + }, + "69": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 3, + "nic_id": 0 + } + }, + "70": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 4, + "nic_id": 0 + } + }, + "71": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 4, + "nic_id": 0 + } + }, + "72": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 4, + "nic_id": 1 + } + }, + "73": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 4, + "nic_id": 1 + } + }, + "74": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 5, + "nic_id": 0 + } + }, + "75": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 5, + "nic_id": 0 + } + }, + "76": { + "action": "HOST_NIC_DISABLE", + "options": { + "node_id": 6, + "nic_id": 0 + } + }, + "77": { + "action": "HOST_NIC_ENABLE", + "options": { + "node_id": 6, + "nic_id": 0 + } + } + }, + "options": { + "nodes": [ + { + "node_name": "domain_controller" + }, + { + "node_name": "web_server", + "applications": [ + { + "application_name": "DatabaseClient" + } + ], + "services": [ + { + "service_name": "WebServer" + } + ] + }, + { + "node_name": "database_server", + "folders": [ + { + "folder_name": "database", + "files": [ + { + "file_name": "database.db" + } + ] + } + ], + "services": [ + { + "service_name": "DatabaseService" + } + ] + }, + { + "node_name": "backup_server" + }, + { + "node_name": "security_suite" + }, + { + "node_name": "client_1" + }, + { + "node_name": "client_2" + } + ], + "max_folders_per_node": 2, + "max_files_per_folder": 2, + "max_services_per_node": 2, + "max_nics_per_node": 8, + "max_acl_rules": 10, + "ip_list": [ + "192.168.1.10", + "192.168.1.12", + "192.168.1.14", + "192.168.1.16", + "192.168.1.110", + "192.168.10.21", + "192.168.10.22", + "192.168.10.110" + ] + } + }, + "reward_function": { + "reward_components": [ + { + "type": "DATABASE_FILE_INTEGRITY", + "weight": 0.4, + "options": { + "node_hostname": "database_server", + "folder_name": "database", + "file_name": "database.db" + } + }, + { + "type": "SHARED_REWARD", + "weight": 1.0, + "options": { + "agent_name": "client_1_green_user" + } + }, + { + "type": "SHARED_REWARD", + "weight": 1.0, + "options": { + "agent_name": "client_2_green_user" + } + } + ] + }, + "agent_settings": { + "flatten_obs": true + } + } + ], + "simulation": { + "network": { + "nmne_config": { + "capture_nmne": true, + "nmne_capture_keywords": [ + "DELETE" + ] + }, + "nodes": [ + { + "hostname": "router_1", + "type": "router", + "num_ports": 5, + "ports": { + "1": { + "ip_address": "192.168.1.1", + "subnet_mask": "255.255.255.0" + }, + "2": { + "ip_address": "192.168.10.1", + "subnet_mask": "255.255.255.0" + } + }, + "acl": { + "18": { + "action": "PERMIT", + "src_port": "POSTGRES_SERVER", + "dst_port": "POSTGRES_SERVER" + }, + "19": { + "action": "PERMIT", + "src_port": "DNS", + "dst_port": "DNS" + }, + "20": { + "action": "PERMIT", + "src_port": "FTP", + "dst_port": "FTP" + }, + "21": { + "action": "PERMIT", + "src_port": "HTTP", + "dst_port": "HTTP" + }, + "22": { + "action": "PERMIT", + "src_port": "ARP", + "dst_port": "ARP" + }, + "23": { + "action": "PERMIT", + "protocol": "ICMP" + } + } + }, + { + "hostname": "switch_1", + "type": "switch", + "num_ports": 8 + }, + { + "hostname": "switch_2", + "type": "switch", + "num_ports": 8 + }, + { + "hostname": "domain_controller", + "type": "server", + "ip_address": "192.168.1.10", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "services": [ + { + "type": "DNSServer", + "options": { + "domain_mapping": { + "arcd.com": "192.168.1.12" + } + } + } + ] + }, + { + "hostname": "web_server", + "type": "server", + "ip_address": "192.168.1.12", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "WebServer" + } + ], + "applications": [ + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ] + }, + { + "hostname": "database_server", + "type": "server", + "ip_address": "192.168.1.14", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "DatabaseService", + "options": { + "backup_server_ip": "192.168.1.16" + } + }, + { + "type": "FTPClient" + } + ] + }, + { + "hostname": "backup_server", + "type": "server", + "ip_address": "192.168.1.16", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "services": [ + { + "type": "FTPServer" + } + ] + }, + { + "hostname": "security_suite", + "type": "server", + "ip_address": "192.168.1.110", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.1.1", + "dns_server": "192.168.1.10", + "network_interfaces": { + "2": { + "ip_address": "192.168.10.110", + "subnet_mask": "255.255.255.0" + } + } + }, + { + "hostname": "client_1", + "type": "computer", + "ip_address": "192.168.10.21", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.10.1", + "dns_server": "192.168.1.10", + "applications": [ + { + "type": "DataManipulationBot", + "options": { + "port_scan_p_of_success": 0.8, + "data_manipulation_p_of_success": 0.8, + "payload": "DELETE", + "server_ip": "192.168.1.14" + } + }, + { + "type": "WebBrowser", + "options": { + "target_url": "http://arcd.com/users/" + } + }, + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ], + "services": [ + { + "type": "DNSClient" + } + ] + }, + { + "hostname": "client_2", + "type": "computer", + "ip_address": "192.168.10.22", + "subnet_mask": "255.255.255.0", + "default_gateway": "192.168.10.1", + "dns_server": "192.168.1.10", + "applications": [ + { + "type": "WebBrowser", + "options": { + "target_url": "http://arcd.com/users/" + } + }, + { + "type": "DataManipulationBot", + "options": { + "port_scan_p_of_success": 0.8, + "data_manipulation_p_of_success": 0.8, + "payload": "DELETE", + "server_ip": "192.168.1.14" + } + }, + { + "type": "DatabaseClient", + "options": { + "db_server_ip": "192.168.1.14" + } + } + ], + "services": [ + { + "type": "DNSClient" + } + ] + } + ], + "links": [ + { + "endpoint_a_hostname": "router_1", + "endpoint_a_port": 1, + "endpoint_b_hostname": "switch_1", + "endpoint_b_port": 8 + }, + { + "endpoint_a_hostname": "router_1", + "endpoint_a_port": 2, + "endpoint_b_hostname": "switch_2", + "endpoint_b_port": 8 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 1, + "endpoint_b_hostname": "domain_controller", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 2, + "endpoint_b_hostname": "web_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 3, + "endpoint_b_hostname": "database_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 4, + "endpoint_b_hostname": "backup_server", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_1", + "endpoint_a_port": 7, + "endpoint_b_hostname": "security_suite", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 1, + "endpoint_b_hostname": "client_1", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 2, + "endpoint_b_hostname": "client_2", + "endpoint_b_port": 1 + }, + { + "endpoint_a_hostname": "switch_2", + "endpoint_a_port": 7, + "endpoint_b_hostname": "security_suite", + "endpoint_b_port": 2 + } + ] + } + } + } +} \ No newline at end of file diff --git a/benchmark/utils.py b/benchmark/utils.py new file mode 100644 index 00000000..2e92d80d --- /dev/null +++ b/benchmark/utils.py @@ -0,0 +1,47 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +import platform +from typing import Dict + +import psutil +from GPUtil import GPUtil + + +def get_size(size_bytes: int) -> str: + """ + Scale bytes to its proper format. + + e.g: + 1253656 => '1.20MB' + 1253656678 => '1.17GB' + + : + """ + factor = 1024 + for unit in ["", "K", "M", "G", "T", "P"]: + if size_bytes < factor: + return f"{size_bytes:.2f}{unit}B" + size_bytes /= factor + + +def _get_system_info() -> Dict: + """Builds and returns a dict containing system info.""" + uname = platform.uname() + cpu_freq = psutil.cpu_freq() + virtual_mem = psutil.virtual_memory() + swap_mem = psutil.swap_memory() + gpus = GPUtil.getGPUs() + return { + "System": { + "OS": uname.system, + "OS Version": uname.version, + "Machine": uname.machine, + "Processor": uname.processor, + }, + "CPU": { + "Physical Cores": psutil.cpu_count(logical=False), + "Total Cores": psutil.cpu_count(logical=True), + "Max Frequency": f"{cpu_freq.max:.2f}Mhz", + }, + "Memory": {"Total": get_size(virtual_mem.total), "Swap Total": get_size(swap_mem.total)}, + "GPU": [{"Name": gpu.name, "Total Memory": f"{gpu.memoryTotal}MB"} for gpu in gpus], + } diff --git a/diagram/classes.puml b/diagram/classes.puml index 4505f3e0..71e0b0b1 100644 --- a/diagram/classes.puml +++ b/diagram/classes.puml @@ -48,7 +48,7 @@ class "ActiveNode" as primaite.nodes.active_node.ActiveNode { file_system_state_actual : GOOD file_system_state_observed : REPAIRING, RESTORING, GOOD ip_address : str - patching_count : int + fixing_count : int software_state software_state : GOOD set_file_system_state(file_system_state: FileSystemState) -> None @@ -353,10 +353,10 @@ class "SB3Agent" as primaite.agents.sb3.SB3Agent { } class "Service" as primaite.common.service.Service { name : str - patching_count : int + fixing_count : int port : str software_state : GOOD - reduce_patching_count() -> None + reduce_fixing_count() -> None } class "ServiceNode" as primaite.nodes.service_node.ServiceNode { services : Dict[str, Service] @@ -455,7 +455,7 @@ class "TrainingConfig" as primaite.config.training_config.TrainingConfig { sb3_output_verbose_level scanning : float seed : Optional[int] - service_patching_duration : int + service_fixing_duration : int session_type time_delay : int from_dict(config_dict: Dict[str, Any]) -> TrainingConfig diff --git a/docs/Makefile b/docs/Makefile index dd71ec33..fcf64a6a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -6,7 +6,7 @@ SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build -AUTOSUMMARY="source\_autosummary" +AUTOSUMMARY="source/_autosummary" # Remove command is different depending on OS ifdef OS @@ -29,6 +29,5 @@ clean: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile | clean - pip-licenses --format=rst --with-urls --output-file=source/primaite-dependencies.rst @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/component_relationship.png b/docs/_static/component_relationship.png new file mode 100644 index 00000000..c2dd1102 Binary files /dev/null and b/docs/_static/component_relationship.png differ diff --git a/docs/_static/firewall_acl.png b/docs/_static/firewall_acl.png new file mode 100644 index 00000000..1e596575 Binary files /dev/null and b/docs/_static/firewall_acl.png differ diff --git a/docs/_static/four_node_two_switch_network.png b/docs/_static/four_node_two_switch_network.png new file mode 100644 index 00000000..42839107 Binary files /dev/null and b/docs/_static/four_node_two_switch_network.png differ diff --git a/docs/_static/node_nic_link_component_diagram.png b/docs/_static/node_nic_link_component_diagram.png new file mode 100644 index 00000000..00a3d939 Binary files /dev/null and b/docs/_static/node_nic_link_component_diagram.png differ diff --git a/docs/_static/notebooks/extensions.png b/docs/_static/notebooks/extensions.png new file mode 100644 index 00000000..8441802d Binary files /dev/null and b/docs/_static/notebooks/extensions.png differ diff --git a/docs/_static/notebooks/install_extensions.png b/docs/_static/notebooks/install_extensions.png new file mode 100644 index 00000000..db026ce3 Binary files /dev/null and b/docs/_static/notebooks/install_extensions.png differ diff --git a/docs/_static/primAITE_architecture.png b/docs/_static/primAITE_architecture.png new file mode 100644 index 00000000..45425e42 Binary files /dev/null and b/docs/_static/primAITE_architecture.png differ diff --git a/docs/_static/switched_p2p_network.png b/docs/_static/switched_p2p_network.png new file mode 100644 index 00000000..d1769942 Binary files /dev/null and b/docs/_static/switched_p2p_network.png differ diff --git a/docs/api.rst b/docs/api.rst index aeaef4e2..e74be627 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,3 +1,5 @@ +:orphan: + .. only:: comment © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK @@ -17,4 +19,3 @@ :recursive: primaite - tests diff --git a/docs/build-sphinx-docs-to-github-pages.sh b/docs/build-sphinx-docs-to-github-pages.sh new file mode 100644 index 00000000..f1d40647 --- /dev/null +++ b/docs/build-sphinx-docs-to-github-pages.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -x + +apt-get update +apt-get -y install git rsync python3-sphinx + +pwd ls -lah +export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) + +############## +# BUILD DOCS # +############## + +cd docs +# Python Sphinx, configured with source/conf.py +# See https://www.sphinx-doc.org/ +make clean +make html + +cd .. +####################### +# Update GitHub Pages # +####################### + +git config --global user.name "${GITHUB_ACTOR}" +git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" + +docroot=`mktemp -d` + +rsync -av $PWD/docs/_build/html/ "${docroot}/" + +pushd "${docroot}" + +git init +git remote add deploy "https://token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +git checkout -b sphinx-docs-github-pages + +# Adds .nojekyll file to the root to signal to GitHub that +# directories that start with an underscore (_) can remain +touch .nojekyll + +# Add README +cat > README.md < + .stderr { + color: #000 !important + } + +""" + + +def replace_token(app: Any, docname: Any, source: Any): + """Replaces a token from the list of tokens.""" + result = source[0] + for key in app.config.tokens: + result = result.replace(key, app.config.tokens[key]) + source[0] = result + + +tokens = { + "{VERSION}": release, +} # Token VERSION is replaced by the value of the PrimAITE version in the version file +"""Dict containing the tokens that need to be replaced in documentation.""" + + +def notebook_assets(ignored_files: Optional[List[str]] = [], include_file_types: Optional[List[str]] = []) -> Any: + """ + Creates a function to be used with `shutil.copytree`'s `ignore` parameter. + + :param ignored_files: A list of specific file names to ignore. If a file in the directory matches one of these + names, it will be excluded from the copy process. + :type ignored_files: Optional[List[str]] + :param include_file_types: A list of file extensions to include in the copy process. Files that do not match these + extensions will be excluded. If this list is empty, all files will be excluded, effectively copying only + directories. + :type include_file_types: Optional[List[str]] + """ + + def ignore_items(directory: List[str], contents: List[str]) -> List[str]: + """ + Determines which files and directories should be ignored during the copy process. + + :param directory: The directory being copied. + :type directory: str + :param contents: A list of contents in the directory. + :type contents: List[str] + :return: A list of items to exclude from the copy process. + :rtype: List[str] + """ + exclude_items = [] + + for item in contents: + if item in ignored_files: + exclude_items.append(item) + continue + + if len(include_file_types) > 0: + if not any(item.lower().endswith(ext.lower()) for ext in include_file_types) and os.path.isdir(item): + exclude_items.append(item) + else: + # if we dont specify which files to include, exclude everything + exclude_items.append(item) + + # exclude files but not directories + return [path for path in exclude_items if not (Path(directory) / path).is_dir()] + + return ignore_items + + +def copy_notebooks_to_docs() -> Any: + """ + Incredibly over-engineered method that copies the notebooks and its assets to a directory within the docs directory. + + This allows developers to create new notebooks without having to worry about updating documentation when + a new notebook is included within PrimAITE. + """ + notebook_asset_types = [".ipynb", ".png"] + notebook_directories = [] + + # find paths where notebooks are contained + for notebook in Path("../src/primaite").rglob("*.ipynb"): + # add parent path to notebook directory if not already added + if notebook.parent not in notebook_directories: + notebook_directories.append(notebook.parent) + + # go through the notebook directories and copy the notebooks and extra assets + for notebook_parent in notebook_directories: + shutil.copytree( + src=notebook_parent, + dst=Path("source") / "notebooks" / notebook_parent.name, + ignore=notebook_assets(include_file_types=notebook_asset_types), + dirs_exist_ok=True, + ) + + +def suppress_log_output(): + """Sets the log level while building the documentation.""" + from primaite import _FILE_HANDLER, _LOGGER, _STREAM_HANDLER + + log_level = "WARN" + + _LOGGER.setLevel(log_level) + _STREAM_HANDLER.setLevel(log_level) + _FILE_HANDLER.setLevel(log_level) + + +def setup(app: Any): + """Custom setup for sphinx.""" + suppress_log_output() + copy_notebooks_to_docs() + app.add_config_value("tokens", {}, True) + app.connect("source-read", replace_token) diff --git a/docs/index.rst b/docs/index.rst index 208d5abc..5749ad56 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,27 +1,94 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK Welcome to PrimAITE's documentation ==================================== What is PrimAITE? ------------------------- +----------------- -PrimAITE (Primary-level AI Training Environment) is a simulation environment for training AI under the ARCD programme. It incorporates the functionality required of a Primary-level environment, as specified in the Dstl ARCD Training Environment Matrix document: +Overview +^^^^^^^^ -* The ability to model a relevant platform / system context; -* The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems, file system, services and processes; -* Operates at machine-speed to enable fast training cycles. +The ARCD Primary-level AI Training Environment (**PrimAITE**) provides an effective simulation capability for training and evaluating AI in a cyber-defensive role. It incorporates the functionality required of a primary-level ARCD environment: -PrimAITE aims to evolve into an ARCD environment that could be used as the follow-on from Reception level approaches (e.g. YAWNING TITAN), and help bridge the Sim-to-Real gap into Secondary level environments (e.g. IMAGINARY YAK). +- The ability to model a relevant system context; +- Modelling an adversarial agent that the defensive agent can be trained and evaluated against; +- The ability to model key characteristics of a system by representing hosts, servers, network devices, IP addresses, ports, operating systems, folders / files, applications, services and links; +- Modelling background (green) pattern-of-life; +- Operates at machine-speed to enable fast training cycles via Reinforcement Learning (RL). -This is similar to the approach taken by FVEY international partners (e.g. AUS CyBORG, US NSA FARLAND and CAN CyGil). These environments are referenced by the Dstl ARCD Agent Training Environments Knowledge Transfer document (TR141342). +Features +^^^^^^^^ + +PrimAITE incorporates the following features: + +- Architected with a separate Simulation layer and Game layer. This separation of concerns defines a clear path towards transfer learning with environments of differing fidelity; +- Ability to reconfigure an RL reward function based on (a) the ability to counter the modelled adversarial cyber-attack, and (b) the ability to ensure success for green agents; +- Access Control List (ACL) functions for network devices (routers and firewalls), following standard ACL rule format (e.g., DENY / ALLOW, source / destination IP addresses, protocol and port); +- Application of traffic to the links of the system laydown adheres to the ACL rulesets and routing tables contained within each network device; +- Provides RL environments adherent to the Farama Foundation Gymnasium (Previously OpenAI Gym) API, allowing integration with any compliant RL Agent frameworks; +- Provides RL environments adherent to Ray RLlib environment specifications for single-agent and multi-agent scenarios; +- Assessed for compatibility with Stable-Baselines3 (SB3), Ray RLlib, and bespoke agents; +- Persona-based adversarial (Red) agent behaviour; several out-the-box personas are provided, and more can be developed to suit the needs of the task. Stochastic variations in Red agent behaviour are also included as required; +- A robust system logging tool, automatically enabled at the node level and featuring various log levels and terminal output options, enables PrimAITE users to conduct in-depth network simulations; +- A PCAP service is seamlessly integrated within the simulation, automatically capturing and logging frames for both + inbound and outbound traffic at the network interface level. This automatic functionality, combined with the ability + to separate traffic directions, significantly enhances network analysis and troubleshooting capabilities; +- Agent action logs provide a description of every action taken by each agent during the episode. This includes timestep, action, parameters, request and response, for all Blue agent activity, which is aligned with the Track 2 Common Action / Observation Space (CAOS) format. Action logs also details of all scripted / stochastic red / green agent actions; +- Environment ground truth is provided at every timestep, providing a full description of the environment’s true state; +- Alignment with CAOS provides the ability to transfer agents between CAOS compliant environments. + +Architecture +^^^^^^^^^^^^ + +PrimAITE is a Python application and will operate on multiple Operating Systems (Windows, Linux and Mac); +a comprehensive installation and user guide is provided with each release to support its usage. + +Configuration of PrimAITE is achieved via included YAML files which support full control over the network / system laydown being modelled, background pattern of life, adversarial (red agent) behaviour, and step and episode count. +A Simulation Controller layer manages the overall running of the simulation, keeping track of all low-level objects. + +It is agnostic to the number of agents, their action / observation spaces, and the RL library being used. + +It presents a public API providing a method for describing the current state of the simulation, a method that accepts action requests and provides responses, and a method that triggers a timestep advancement. +The Game Layer converts the simulation into a playable game for the agent(s). + +It translates between simulation state and Gymnasium.Spaces to pass action / observation data between the agent(s) and the simulation. It is responsible for calculating rewards, managing Multi-Agent RL (MARL) action turns, and via a single agent interface can interact with Blue, Red and Green agents. + +Agents can either generate their own scripted behaviour or accept input behaviour from an RL agent. + +Finally, a Gymnasium / Ray RLlib Environment Layer forwards requests to the Game Layer as the agent sends them. This layer also manages most of the I/O, such as reading in the configuration files and saving agent logs. + +.. image:: ../../_static/primAITE_architecture.png + :width: 500 + :align: center + + +Training & Evaluation Capability +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PrimAITE provides a training and evaluation capability to AI agents in the context of cyber-attack, via its Gymnasium / Ray RLlib compliant interface. + +Scenarios can be constructed to reflect network / system laydowns consisting of any configuration of nodes (e.g., PCs, servers etc.) and the networking equipment and links between them. + +All nodes can be configured to contain applications, services, folders and files (and their status). + +Traffic flows between services and applications as directed by an ‘execution definition,’ with the traffic flow on the network governed by the network equipment (switches, routers and firewalls) and the ACL rules and routing tables they employ. + +Highlights of PrimAITE’s training and evaluation capability are: + +- The scenario is not bound to a representation of any platform, system, or technology; +- Fully configurable (network / system laydown, green pattern-of-life, red personas, reward function, ACL rules for each device, number of episodes / steps, action / observation space) and repeatable to suit the requirements of AI agents; +- Can integrate with any Gymnasium / Ray RLlib compliant AI agent . + + +PrimAITE provides a number of use cases (network and red/green action configurations) by default which the user is able to extend and modify as required. What is PrimAITE built with --------------------------------------- +--------------------------- -* `OpenAI's Gym `_ is used as the basis for AI blue agent interaction with the PrimAITE environment +* `Gymnasium `_ is used as the basis for AI blue agent interaction with the PrimAITE environment * `Networkx `_ is used as the underlying data structure used for the PrimAITE environment * `Stable Baselines 3 `_ is used as a default source of RL algorithms (although PrimAITE is not limited to SB3 agents) * `Ray RLlib `_ is used as an additional source of RL algorithms @@ -31,35 +98,45 @@ What is PrimAITE built with * `Plotly `_ is used for building high level charts -Where next? ------------- +Getting Started with PrimAITE +----------------------------- Head over to the :ref:`getting-started` page to install and setup PrimAITE! .. toctree:: :maxdepth: 8 - :caption: Contents: + :caption: About PrimAITE: + :hidden: + + source/about + source/dependencies + source/glossary + +.. toctree:: + :caption: Usage: :hidden: source/getting_started - source/about + source/simulation + source/game_layer source/config - source/primaite_session - source/custom_agent - PrimAITE API - PrimAITE Tests - source/dependencies - source/glossary - source/migration_1.2_-_2.0 - - -.. TODO: Add project links once public repo has been created + source/environment + source/customising_scenarios + source/varying_config_files .. toctree:: - :caption: Project Links: + :caption: Notebooks: :hidden: - Code - Issues - Pull Requests - Discussions + source/example_notebooks + source/notebooks/executed_notebooks + +.. toctree:: + :caption: Developer information: + :hidden: + + source/developer_tools + source/state_system + source/request_system + PrimAITE API + PrimAITE Tests diff --git a/docs/make.bat b/docs/make.bat index 399e9150..a341af57 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -36,11 +36,6 @@ IF EXIST %AUTOSUMMARYDIR% ( RMDIR %AUTOSUMMARYDIR% /s /q ) -REM print the YT licenses -set LICENSEBUILD=pip-licenses --format=rst --with-urls -set DEPS="%cd%\source\primaite-dependencies.rst" - -%LICENSEBUILD% --output-file=%DEPS% %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end diff --git a/docs/source/about.rst b/docs/source/about.rst index d12a59de..cc247623 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -1,414 +1,317 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK .. _about: About PrimAITE ============== +PrimAITE is a simulation environment for training agents to protect a computer network from cyber attacks. + Features ******** PrimAITE provides the following features: -* A flexible network / system laydown based on the Python networkx framework -* Nodes and links (edges) host Python classes in order to present attributes and methods (and hence, a more representative model of a platform / system) -* A 'green agent' Information Exchange Requirement (IER) function allows the representation of traffic (protocols and loading) on any / all links. Application of IERs is based on the status of node operating systems and services -* A 'green agent' node Pattern-of-Life (PoL) function allows the representation of core behaviours on nodes (e.g. changing the Hardware state, Software State, Service state, or File System state) -* An Access Control List (ACL) function, mimicking the behaviour of a network firewall, is applied across the model, following standard ACL rule format (e.g. DENY/ALLOW, source IP, destination IP, protocol and port). Application of IERs adheres to any ACL restrictions -* Presents an OpenAI Gym interface to the environment, allowing integration with any OpenAI Gym compliant defensive agents -* Red agent activity based on 'red' IERs and 'red' PoL -* Defined reward function for use with RL agents (based on nodes status, and green / red IER success) -* Fully configurable (network / system laydown, IERs, node PoL, ACL, episode step period, episode max steps) and repeatable to suit the training requirements of agents. Therefore, not bound to a representation of any particular platform, system or technology -* Full capture of discrete metrics relating to agent training (full system state, agent actions taken, average reward) -* Networkx provides laydown visualisation capability - -Architecture - Nodes and Links -****************************** - -**Nodes** - -An inheritance model has been adopted in order to model nodes. All nodes have the following base attributes (Class: Node): - -* ID -* Name -* Type (e.g. computer, switch, RTU - enumeration) -* Priority (P1, P2, P3, P4 or P5 - enumeration) -* Hardware State (ON, OFF, RESETTING, SHUTTING_DOWN, BOOTING - enumeration) - -Active Nodes also have the following attributes (Class: Active Node): - -* IP Address -* Software State (GOOD, PATCHING, COMPROMISED - enumeration) -* File System State (GOOD, CORRUPT, DESTROYED, REPAIRING, RESTORING - enumeration) - -Service Nodes also have the following attributes (Class: Service Node): - -* List of Services (where service is composed of service name and port). There is no theoretical limit on the number of services that can be modelled. Services and protocols are currently intrinsically linked (i.e. a service is an application on a node transmitting traffic of this protocol type) -* Service state (GOOD, PATCHING, COMPROMISED, OVERWHELMED - enumeration) - -Passive Nodes are currently not used (but may be employed for non IP-based components such as machinery actuators in future releases). - -**Links** - -Links are modelled both as network edges (networkx) and as Python classes, in order to extend their functionality. Links include the following attributes: - -* ID -* Name -* Bandwidth (bits/s) -* Source node ID -* Destination node ID -* Protocol list (containing the loading of protocols currently running on the link) - -When the simulation runs, IERs are applied to the links in order to model traffic loading, individually assigned to each protocol. This allows green (background) and red agent behaviour to be modelled, and defensive agents to identify suspicious traffic patterns at a protocol / traffic loading level of fidelity. - -Information Exchange Requirements (IERs) -**************************************** - -PrimAITE adopts the concept of Information Exchange Requirements (IERs) to model both green agent (background) and red agent (adversary) behaviour. IERs are used to initiate modelling of traffic loading on the network, and have the following attributes: - -* ID -* Start step (i.e. which step in the training episode should the IER start) -* End step (i.e. which step in the training episode should the IER end) -* Source node ID -* Destination node ID -* Load (bits/s) -* Protocol -* Port -* Running status (i.e. on / off) - -The application of green agent IERs between a source and destination follows a number of rules. Specifically: - -1. Does the current simulation time step fall between IER start and end step -2. Is the source node operational (both physically and at an O/S level), and is the service (protocol / port) associated with the IER (a) present on this node, and (b) in an operational state (i.e. not PATCHING) -3. Is the destination node operational (both physically and at an O/S level), and is the service (protocol / port) associated with the IER (a) present on this node, and (b) in an operational state (i.e. not PATCHING) -4. Are there any Access Control List rules in place that prevent the application of this IER -5. Are all switches in the (OSPF) path between source and destination operational (both physically and at an O/S level) - -For red agent IERs, the application of IERs between a source and destination follows a number of subtly different rules. Specifically: - -1. Does the current simulation time step fall between IER start and end step -2. Is the source node operational, and is the service (protocol / port) associated with the IER (a) present on that node and (b) already in a compromised state -3. Is the destination node operational, and is the service (protocol / port) associated with the IER present on that node -4. Are there any Access Control List rules in place that prevent the application of this IER -5. Are all switches in the (OSPF) path between source and destination operational (both physically and at an O/S level) - -Assuming the rules pass, the IER is applied to all relevant links (based on use of OSPF) between source and destination. - -Node Pattern-of-Life -******************** - -Every node can be impacted (i.e. have a status change applied to it) by either green agent pattern-of-life or red agent pattern-of-life. This is distinct from IERs, and allows for attacks (and defence) to be modelled purely within the confines of a node. - -The status changes that can be made to a node are as follows: - -* All Nodes: - - * Hardware State: - - * ON - * OFF - * RESETTING - when a status of resetting is entered, the node will automatically exit this state after a number of steps (as defined by the nodeResetDuration configuration item) after which it returns to an ON state - * BOOTING - * SHUTTING_DOWN - -* Active Nodes and Service Nodes: - - * Software State: - - * GOOD - * PATCHING - when a status of patching is entered, the node will automatically exit this state after a number of steps (as defined by the osPatchingDuration configuration item) after which it returns to a GOOD state - * COMPROMISED - - * File System State: - - * GOOD - * CORRUPT (can be resolved by repair or restore) - * DESTROYED (can be resolved by restore only) - * REPAIRING - when a status of repairing is entered, the node will automatically exit this state after a number of steps (as defined by the fileSystemRepairingLimit configuration item) after which it returns to a GOOD state - * RESTORING - when a status of repairing is entered, the node will automatically exit this state after a number of steps (as defined by the fileSystemRestoringLimit configuration item) after which it returns to a GOOD state - -* Service Nodes only: - - * Service State (for any associated service): - - * GOOD - * PATCHING - when a status of patching is entered, the service will automatically exit this state after a number of steps (as defined by the servicePatchingDuration configuration item) after which it returns to a GOOD state - * COMPROMISED - * OVERWHELMED - -Red agent pattern-of-life has an additional feature not found in the green pattern-of-life. This is the ability to influence the state of the attributes of a node via a number of different conditions: - - * DIRECT: - - The pattern-of-life described by the configuration file item will be applied regardless of any other conditions in the network. This is particularly useful for direct red agent entry into the network. - - * IER: - - The pattern-of-life described by the configuration file item will be applied to the service on the node, only if there is an IER of the same protocol / service type incoming at the specified timestep. - - * SERVICE: - - The pattern-of-life described by the configuration file item will be applied to the node based on the state of a service. The service can either be on the same node, or a different node within the network. - -Access Control List modelling -***************************** - -An Access Control List (ACL) is modelled to provide the means to manage traffic flows in the system. This will allow defensive agents the means to turn on / off rules, or potentially create new rules, to counter an attack. - -The ACL follows a standard network firewall format. For example: - -.. list-table:: ACL example - :widths: 25 25 25 25 25 - :header-rows: 1 - - * - Permission - - Source IP - - Dest IP - - Protocol - - Port - * - DENY - - 192.168.1.2 - - 192.168.1.3 - - HTTPS - - 443 - * - ALLOW - - 192.168.1.4 - - ANY - - SMTP - - 25 - * - DENY - - ANY - - 192.168.1.5 - - ANY - - ANY - -All ACL rules are considered when applying an IER. Logic follows the order of rules, so a DENY or ALLOW for the same parameters will override an earlier entry. - -Observation Spaces -****************** -The observation space provides the blue agent with information about the current status of nodes and links. - -PrimAITE builds on top of Gym Spaces to create an observation space that is easily configurable for users. It's made up of components which are managed by the :py:class:`primaite.environment.observations.ObservationsHandler`. Each training scenario can define its own observation space, and the user can choose which information to inlude, and how it should be formatted. - -NodeLinkTable component ------------------------ -For example, the :py:class:`primaite.environment.observations.NodeLinkTable` component represents the status of nodes and links as a ``gym.spaces.Box`` with an example format shown below: - -An example observation space is provided below: - -.. list-table:: Observation Space example - :widths: 25 25 25 25 25 25 25 - :header-rows: 1 - - * - - - ID - - Hardware State - - Software State - - File System State - - Service / Protocol A - - Service / Protocol B - * - Node A - - 1 - - 1 - - 1 - - 1 - - 1 - - 1 - * - Node B - - 2 - - 1 - - 3 - - 1 - - 1 - - 1 - * - Node C - - 3 - - 2 - - 1 - - 1 - - 3 - - 2 - * - Link 1 - - 5 - - 0 - - 0 - - 0 - - 0 - - 10000 - * - Link 2 - - 6 - - 0 - - 0 - - 0 - - 0 - - 10000 - * - Link 3 - - 7 - - 0 - - 0 - - 0 - - 5000 - - 0 - -For the nodes, the following values are represented: - -.. code-block:: - - [ - ID - Hardware State (1=ON, 2=OFF, 3=RESETTING, 4=SHUTTING_DOWN, 5=BOOTING) - Operating System State (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - File System State (0=none, 1=GOOD, 2=CORRUPT, 3=DESTROYED, 4=REPAIRING, 5=RESTORING) - Service1/Protocol1 state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - Service2/Protocol2 state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - ] - -(Note that each service available in the network is provided as a column, although not all nodes may utilise all services) - -For the links, the following statuses are represented: - -.. code-block:: - - [ - ID - Hardware State (0=not applicable) - Operating System State (0=not applicable) - File System State (0=not applicable) - Service1/Protocol1 state (Traffic load from this protocol on this link) - Service2/Protocol2 state (Traffic load from this protocol on this link) - ] - -NodeStatus component ----------------------- -This is a MultiDiscrete observation space that can be though of as a one-dimensional vector of discrete states. -The example above would have the following structure: - -.. code-block:: - - [ - node1_info - node2_info - node3_info - ] - -Each ``node_info`` contains the following: - -.. code-block:: - - [ - hardware_state (0=none, 1=ON, 2=OFF, 3=RESETTING, 4=SHUTTING_DOWN, 5=BOOTING) - software_state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - file_system_state (0=none, 1=GOOD, 2=CORRUPT, 3=DESTROYED, 4=REPAIRING, 5=RESTORING) - service1_state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - service2_state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) - ] - -In a network with three nodes and two services, the full observation space would have 15 elements. It can be written with ``gym`` notation to indicate the number of discrete options for each of the elements of the observation space. For example: - -.. code-block:: - - gym.spaces.MultiDiscrete([4,5,6,4,4,4,5,6,4,4,4,5,6,4,4]) - -.. note:: - NodeStatus observation component provides information only about nodes. Links are not considered. - -LinkTrafficLevels ------------------ -This component is a MultiDiscrete space showing the traffic flow levels on the links in the network, after applying a threshold to convert it from a continuous to a discrete value. -There are two configurable parameters: -* ``quantisation_levels`` determines how many discrete bins to use for converting the continuous traffic value to discrete (default is 5). -* ``combine_service_traffic`` determines whether to separately output traffic use for each network protocol or whether to combine them into an overall value for the link. (default is ``True``) - -For example, with default parameters and a network with three links, the structure of this component would be: - -.. code-block:: - - [ - link1_status - link2_status - link3_status - ] - -Each ``link_status`` is a number from 0-4 representing the network load in relation to bandwidth. - -.. code-block:: - - 0 = No traffic (0%) - 1 = low traffic (1%-33%) - 2 = medium traffic (33%-66%) - 3 = high traffic (66%-99%) - 4 = max traffic/ overwhelmed (100%) - -Using ``gym`` notation, the shape of the obs space is: ``gym.spaces.MultiDiscrete([5,5,5])``. - - -Action Spaces -************** - -The action space available to the blue agent comes in two types: - - 1. Node-based - 2. Access Control List - 3. Any (Agent can take both node-based and ACL-based actions) - -The choice of action space used during a training session is determined in the config_[name].yaml file. - -**Node-Based** - -The agent is able to influence the status of nodes by switching them off, resetting, or patching operating systems and services. In this instance, the action space is an OpenAI Gym spaces.Discrete type, as follows: - - * Dictionary item {... ,1: [x1, x2, x3,x4] ...} - The placeholders inside the list under the key '1' mean the following: - - * [0, num nodes] - Node ID (0 = nothing, node ID) - * [0, 4] - What property it's acting on (0 = nothing, 1 = state, 2 = SoftwareState, 3 = service state, 4 = file system state) - * [0, 3] - Action on property (0 = nothing, 1 = on / scan, 2 = off / repair, 3 = reset / patch / restore) - * [0, num services] - Resolves to service ID (0 = nothing, resolves to service) - -**Access Control List** - -The blue agent is able to influence the configuration of the Access Control List rule set (which implements a system-wide firewall). In this instance, the action space is an OpenAI spaces.Discrete type, as follows: - - * Dictionary item {... ,1: [x1, x2, x3, x4, x5, x6] ...} - The placeholders inside the list under the key '1' mean the following: - - * [0, 2] - Action (0 = do nothing, 1 = create rule, 2 = delete rule) - * [0, 1] - Permission (0 = DENY, 1 = ALLOW) - * [0, num nodes] - Source IP (0 = any, then 1 -> x resolving to IP addresses) - * [0, num nodes] - Dest IP (0 = any, then 1 -> x resolving to IP addresses) - * [0, num services] - Protocol (0 = any, then 1 -> x resolving to protocol) - * [0, num ports] - Port (0 = any, then 1 -> x resolving to port) - -**ANY** -The agent is able to carry out both **Node-Based** and **Access Control List** operations. - -This means the dictionary will contain key-value pairs in the format of BOTH Node-Based and Access Control List as seen above. - -Rewards -******* - -A reward value is presented back to the blue agent on the conclusion of every step. The reward value is calculated via two methods which combine to give the total value: - - 1. Node and service status - 2. IER status - -**Node and service status** - -On every step, the status of each node is compared against both a reference environment (simulating the situation if the red and blue agents had not impacted the environment) -and the before and after state of the environment. If the comparison against the reference environment shows no difference, then the score provided is "AllOK". If there is a -difference with respect to the reference environment, the before and after states are compared, and a score determined. See :ref:`config` for details of reward values. - -**IER status** - -On every step, the full IER set is examined to determine whether green and red agent IERs are being permitted to run. Any red agent IERs running incur a penalty; any green agent -IERs not permitted to run also incur a penalty. See :ref:`config` for details of reward values. - -Future Enhancements -******************* - -The PrimAITE project has an ambition to include the following enhancements in future releases: - -* Integration with a suitable standardised framework to allow multi-agent integration -* Integration with external threat emulation tools, either using off-line data, or integrating at runtime +* A flexible system for defining network layouts and host configurations +* Highly configurable network hosts, including definition of software, file system, and network interfaces, +* Realistic network traffic simulation, including address and sending packets via internet protocols like TCP, UDP, ICMP, etc. +* Routers with traffic routing and firewall capabilities +* Simulation of customisable deterministic agents +* Support for multiple agents, each having their own customisable observation space, action space, and reward function definition. + + +Structure +********* + +PrimAITE consists of a simulator and a 'game' layer that allows agents to interact with the simulator. The simulator is built in a modular way where each component such as network hosts, links, networking devices, softwares, etc. are implemented as instances of a base class, meaning they all support the same interface. This allows for standardised configuration using either the Python API or YAML files. +The game layer is built on top of the simulator and it consumes the simulation action/state interface to allow agents to interact with the simulator. The game layer is also responsible for defining the reward function and observation space for the agents. + + +.. + Architecture - Nodes and Links + ****************************** + **Nodes** + An inheritance model has been adopted in order to model nodes. All nodes have the following base attributes (Class: Node): + * ID + * Name + * Type (e.g. computer, switch, RTU - enumeration) + * Priority (P1, P2, P3, P4 or P5 - enumeration) + * Hardware State (ON, OFF, RESETTING, SHUTTING_DOWN, BOOTING - enumeration) + Active Nodes also have the following attributes (Class: Active Node): + * IP Address + * Software State (GOOD, FIXING, COMPROMISED - enumeration) + * File System State (GOOD, CORRUPT, DESTROYED, REPAIRING, RESTORING - enumeration) + Service Nodes also have the following attributes (Class: Service Node): + * List of Services (where service is composed of service name and port). There is no theoretical limit on the number of services that can be modelled. Services and protocols are currently intrinsically linked (i.e. a service is an application on a node transmitting traffic of this protocol type) + * Service state (GOOD, FIXING, COMPROMISED, OVERWHELMED - enumeration) + Passive Nodes are currently not used (but may be employed for non IP-based components such as machinery actuators in future releases). + **Links** + Links are modelled both as network edges (networkx) and as Python classes, in order to extend their functionality. Links include the following attributes: + * ID + * Name + * Bandwidth (bits/s) + * Source node ID + * Destination node ID + * Protocol list (containing the loading of protocols currently running on the link) + When the simulation runs, IERs are applied to the links in order to model traffic loading, individually assigned to each protocol. This allows green (background) and red agent behaviour to be modelled, and defensive agents to identify suspicious traffic patterns at a protocol / traffic loading level of fidelity. + Information Exchange Requirements (IERs) + **************************************** + PrimAITE adopts the concept of Information Exchange Requirements (IERs) to model both green agent (background) and red agent (adversary) behaviour. IERs are used to initiate modelling of traffic loading on the network, and have the following attributes: + * ID + * Start step (i.e. which step in the training episode should the IER start) + * End step (i.e. which step in the training episode should the IER end) + * Source node ID + * Destination node ID + * Load (bits/s) + * Protocol + * Port + * Running status (i.e. on / off) + The application of green agent IERs between a source and destination follows a number of rules. Specifically: + 1. Does the current simulation time step fall between IER start and end step + 2. Is the source node operational (both physically and at an O/S level), and is the service (protocol / port) associated with the IER (a) present on this node, and (b) in an operational state (i.e. not FIXING) + 3. Is the destination node operational (both physically and at an O/S level), and is the service (protocol / port) associated with the IER (a) present on this node, and (b) in an operational state (i.e. not FIXING) + 4. Are there any Access Control List rules in place that prevent the application of this IER + 5. Are all switches in the (OSPF) path between source and destination operational (both physically and at an O/S level) + For red agent IERs, the application of IERs between a source and destination follows a number of subtly different rules. Specifically: + 1. Does the current simulation time step fall between IER start and end step + 2. Is the source node operational, and is the service (protocol / port) associated with the IER (a) present on that node and (b) already in a compromised state + 3. Is the destination node operational, and is the service (protocol / port) associated with the IER present on that node + 4. Are there any Access Control List rules in place that prevent the application of this IER + 5. Are all switches in the (OSPF) path between source and destination operational (both physically and at an O/S level) + Assuming the rules pass, the IER is applied to all relevant links (based on use of OSPF) between source and destination. + Node Pattern-of-Life + ******************** + Every node can be impacted (i.e. have a status change applied to it) by either green agent pattern-of-life or red agent pattern-of-life. This is distinct from IERs, and allows for attacks (and defence) to be modelled purely within the confines of a node. + The status changes that can be made to a node are as follows: + * All Nodes: + * Hardware State: + * ON + * OFF + * RESETTING - when a status of resetting is entered, the node will automatically exit this state after a number of steps (as defined by the nodeResetDuration configuration item) after which it returns to an ON state + * BOOTING + * SHUTTING_DOWN + * Active Nodes and Service Nodes: + * Software State: + * GOOD + * FIXING - when a status of FIXING is entered, the node will automatically exit this state after a number of steps (as defined by the osFIXINGDuration configuration item) after which it returns to a GOOD state + * COMPROMISED + * File System State: + * GOOD + * CORRUPT (can be resolved by repair or restore) + * DESTROYED (can be resolved by restore only) + * REPAIRING - when a status of repairing is entered, the node will automatically exit this state after a number of steps (as defined by the fileSystemRepairingLimit configuration item) after which it returns to a GOOD state + * RESTORING - when a status of repairing is entered, the node will automatically exit this state after a number of steps (as defined by the fileSystemRestoringLimit configuration item) after which it returns to a GOOD state + * Service Nodes only: + * Service State (for any associated service): + * GOOD + * FIXING - when a status of FIXING is entered, the service will automatically exit this state after a number of steps (as defined by the serviceFIXINGDuration configuration item) after which it returns to a GOOD state + * COMPROMISED + * OVERWHELMED + Red agent pattern-of-life has an additional feature not found in the green pattern-of-life. This is the ability to influence the state of the attributes of a node via a number of different conditions: + * DIRECT: + The pattern-of-life described by the configuration file item will be applied regardless of any other conditions in the network. This is particularly useful for direct red agent entry into the network. + * IER: + The pattern-of-life described by the configuration file item will be applied to the service on the node, only if there is an IER of the same protocol / service type incoming at the specified timestep. + * SERVICE: + The pattern-of-life described by the configuration file item will be applied to the node based on the state of a service. The service can either be on the same node, or a different node within the network. + Access Control List modelling + ***************************** + An Access Control List (ACL) is modelled to provide the means to manage traffic flows in the system. This will allow defensive agents the means to turn on / off rules, or potentially create new rules, to counter an attack. + The ACL follows a standard network firewall format. For example: + .. list-table:: ACL example + :widths: 25 25 25 25 25 + :header-rows: 1 + * - Permission + - Source IP + - Dest IP + - Protocol + - Port + * - DENY + - 192.168.1.2 + - 192.168.1.3 + - HTTPS + - 443 + * - ALLOW + - 192.168.1.4 + - ANY + - SMTP + - 25 + * - DENY + - ANY + - 192.168.1.5 + - ANY + - ANY + All ACL rules are considered when applying an IER. Logic follows the order of rules, so a DENY or ALLOW for the same parameters will override an earlier entry. + Observation Spaces + ****************** + The observation space provides the blue agent with information about the current status of nodes and links. + PrimAITE builds on top of Gymnasium Spaces to create an observation space that is easily configurable for users. It's made up of components which are managed by the :py:class:`primaite.environment.observations.ObservationsHandler`. Each training scenario can define its own observation space, and the user can choose which information to inlude, and how it should be formatted. + NodeLinkTable component + ----------------------- + For example, the :py:class:`primaite.environment.observations.NodeLinkTable` component represents the status of nodes and links as a ``gym.spaces.Box`` with an example format shown below: + An example observation space is provided below: + .. list-table:: Observation Space example + :widths: 25 25 25 25 25 25 25 + :header-rows: 1 + * - + - ID + - Hardware State + - Software State + - File System State + - Service / Protocol A + - Service / Protocol B + * - Node A + - 1 + - 1 + - 1 + - 1 + - 1 + - 1 + * - Node B + - 2 + - 1 + - 3 + - 1 + - 1 + - 1 + * - Node C + - 3 + - 2 + - 1 + - 1 + - 3 + - 2 + * - Link 1 + - 5 + - 0 + - 0 + - 0 + - 0 + - 10000 + * - Link 2 + - 6 + - 0 + - 0 + - 0 + - 0 + - 10000 + * - Link 3 + - 7 + - 0 + - 0 + - 0 + - 5000 + - 0 + For the nodes, the following values are represented: + .. code-block:: + [ + ID + Hardware State (1=ON, 2=OFF, 3=RESETTING, 4=SHUTTING_DOWN, 5=BOOTING) + Operating System State (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) + File System State (0=none, 1=GOOD, 2=CORRUPT, 3=DESTROYED, 4=REPAIRING, 5=RESTORING) + Service1/Protocol1 state (0=none, 1=GOOD, 2=FIXING, 3=COMPROMISED) + Service2/Protocol2 state (0=none, 1=GOOD, 2=FIXING, 3=COMPROMISED) + ] + (Note that each service available in the network is provided as a column, although not all nodes may utilise all services) + For the links, the following statuses are represented: + .. code-block:: + [ + ID + Hardware State (0=not applicable) + Operating System State (0=not applicable) + File System State (0=not applicable) + Service1/Protocol1 state (Traffic load from this protocol on this link) + Service2/Protocol2 state (Traffic load from this protocol on this link) + ] + NodeStatus component + ---------------------- + This is a MultiDiscrete observation space that can be though of as a one-dimensional vector of discrete states. + The example above would have the following structure: + .. code-block:: + [ + node1_info + node2_info + node3_info + ] + Each ``node_info`` contains the following: + .. code-block:: + [ + hardware_state (0=none, 1=ON, 2=OFF, 3=RESETTING, 4=SHUTTING_DOWN, 5=BOOTING) + software_state (0=none, 1=GOOD, 2=PATCHING, 3=COMPROMISED) + file_system_state (0=none, 1=GOOD, 2=CORRUPT, 3=DESTROYED, 4=REPAIRING, 5=RESTORING) + service1_state (0=none, 1=GOOD, 2=FIXING, 3=COMPROMISED) + service2_state (0=none, 1=GOOD, 2=FIXING, 3=COMPROMISED) + ] + In a network with three nodes and two services, the full observation space would have 15 elements. It can be written with ``gym`` notation to indicate the number of discrete options for each of the elements of the observation space. For example: + .. code-block:: + gym.spaces.MultiDiscrete([4,5,6,4,4,4,5,6,4,4,4,5,6,4,4]) + .. note:: + NodeStatus observation component provides information only about nodes. Links are not considered. + LinkTrafficLevels + ----------------- + This component is a MultiDiscrete space showing the traffic flow levels on the links in the network, after applying a threshold to convert it from a continuous to a discrete value. + There are two configurable parameters: + * ``quantisation_levels`` determines how many discrete bins to use for converting the continuous traffic value to discrete (default is 5). + * ``combine_service_traffic`` determines whether to separately output traffic use for each network protocol or whether to combine them into an overall value for the link. (default is ``True``) + For example, with default parameters and a network with three links, the structure of this component would be: + .. code-block:: + [ + link1_status + link2_status + link3_status + ] + Each ``link_status`` is a number from 0-4 representing the network load in relation to bandwidth. + .. code-block:: + 0 = No traffic (0%) + 1 = low traffic (1%-33%) + 2 = medium traffic (33%-66%) + 3 = high traffic (66%-99%) + 4 = max traffic/ overwhelmed (100%) + Using ``gym`` notation, the shape of the obs space is: ``gym.spaces.MultiDiscrete([5,5,5])``. + Action Spaces + ************** + The action space available to the blue agent comes in two types: + 1. Node-based + 2. Access Control List + 3. Any (Agent can take both node-based and ACL-based actions) + The choice of action space used during a training session is determined in the config_[name].yaml file. + **Node-Based** + The agent is able to influence the status of nodes by switching them off, resetting, or FIXING operating systems and services. In this instance, the action space is a Gymnasium spaces.Discrete type, as follows: + * Dictionary item {... ,1: [x1, x2, x3,x4] ...} + The placeholders inside the list under the key '1' mean the following: + * [0, num nodes] - Node ID (0 = nothing, node ID) + * [0, 4] - What property it's acting on (0 = nothing, 1 = state, 2 = SoftwareState, 3 = service state, 4 = file system state) + * [0, 3] - Action on property (0 = nothing, 1 = on / scan, 2 = off / repair, 3 = reset / patch / restore) + * [0, num services] - Resolves to service ID (0 = nothing, resolves to service) + **Access Control List** + The blue agent is able to influence the configuration of the Access Control List rule set (which implements a system-wide firewall). In this instance, the action space is an Gymnasium spaces.Discrete type, as follows: + * Dictionary item {... ,1: [x1, x2, x3, x4, x5, x6] ...} + The placeholders inside the list under the key '1' mean the following: + * [0, 2] - Action (0 = do nothing, 1 = create rule, 2 = delete rule) + * [0, 1] - Permission (0 = DENY, 1 = ALLOW) + * [0, num nodes] - Source IP (0 = any, then 1 -> x resolving to IP addresses) + * [0, num nodes] - Dest IP (0 = any, then 1 -> x resolving to IP addresses) + * [0, num services] - Protocol (0 = any, then 1 -> x resolving to protocol) + * [0, num ports] - Port (0 = any, then 1 -> x resolving to port) + **ANY** + The agent is able to carry out both **Node-Based** and **Access Control List** operations. + This means the dictionary will contain key-value pairs in the format of BOTH Node-Based and Access Control List as seen above. + Rewards + ******* + A reward value is presented back to the blue agent on the conclusion of every step. The reward value is calculated via two methods which combine to give the total value: + 1. Node and service status + 2. IER status + **Node and service status** + On every step, the status of each node is compared against both a reference environment (simulating the situation if the red and blue agents had not impacted the environment) + and the before and after state of the environment. If the comparison against the reference environment shows no difference, then the score provided is "AllOK". If there is a + difference with respect to the reference environment, the before and after states are compared, and a score determined. See :ref:`config` for details of reward values. + **IER status** + On every step, the full IER set is examined to determine whether green and red agent IERs are being permitted to run. Any red agent IERs running incur a penalty; any green agent + IERs not permitted to run also incur a penalty. See :ref:`config` for details of reward values. + Future Enhancements + ******************* + The PrimAITE project has an ambition to include the following enhancements in future releases: + * Integration with a suitable standardised framework to allow multi-agent integration + * Integration with external threat emulation tools, either using off-line data, or integrating at runtime diff --git a/docs/source/config.rst b/docs/source/config.rst index daf7f90b..eb0b9906 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -1,489 +1,41 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK -.. _config: +PrimAITE |VERSION| Configuration +******************************** -The Config Files Explained -========================== +PrimAITE uses YAML configuration files to define everything needed to create the training environment for RL agents, including the network, the scripted agents, and the RL agent's action space, observation space, and reward function. -PrimAITE uses two configuration files for its operation: +Example Configuration Hierarchy +############################### +The top level configuration items in a configuration file is as follows -* **The Training Config** +.. code-block:: yaml - Used to define the top-level settings of the PrimAITE environment, the reward values, and the session that is to be run. + io_settings: + ... + game: + ... + agents: + ... + simulation: + ... -* **The Lay Down Config** +These are expanded upon in the Configurable items section below - Used to define the low-level settings of a session, including the network laydown, green / red agent information exchange requirements (IERSs) and Access Control Rules. +Configurable items +################## -Training Config: -******************* +.. toctree:: + :maxdepth: 1 -The Training Config file consists of the following attributes: + configuration/io_settings.rst + configuration/game.rst + configuration/agents.rst + configuration/simulation.rst -**Generic Config Values** +Varying The Configuration Each Episode +###################################### - -* **agent_framework** [enum] - - This identifies the agent framework to be used to instantiate the agent algorithm. Select from one of the following: - - * NONE - Where a user developed agent is to be used - * SB3 - Stable Baselines3 - * RLLIB - Ray RLlib. - -* **agent_identifier** - - This identifies the agent to use for the session. Select from one of the following: - - * A2C - Advantage Actor Critic - * PPO - Proximal Policy Optimization - * HARDCODED - A custom built deterministic agent - * RANDOM - A Stochastic random agent - - -* **random_red_agent** [bool] - - Determines if the session should be run with a random red agent - -* **action_type** [enum] - - Determines whether a NODE, ACL, or ANY (combined NODE & ACL) action space format is adopted for the session - - -* **OBSERVATION_SPACE** [dict] - - Allows for user to configure observation space by combining one or more observation components. List of available - components is in :py:mod:`primaite.environment.observations`. - - The observation space config item should have a ``components`` key which is a list of components. Each component - config must have a ``name`` key, and can optionally have an ``options`` key. The ``options`` are passed to the - component while it is being initialised. - - This example illustrates the correct format for the observation space config item - - .. code-block:: yaml - - observation_space: - components: - - name: NODE_LINK_TABLE - - name: NODE_STATUSES - - name: LINK_TRAFFIC_LEVELS - - name: ACCESS_CONTROL_LIST - options: - combine_service_traffic : False - quantisation_levels: 99 - - - Currently available components are: - - * :py:mod:`NODE_LINK_TABLE` this does not accept any additional options - * :py:mod:`NODE_STATUSES`, this does not accept any additional options - * :py:mod:`ACCESS_CONTROL_LIST`, this does not accept additional options - * :py:mod:`LINK_TRAFFIC_LEVELS`, this accepts the following options: - - * ``combine_service_traffic`` - whether to consider bandwidth use separately for each network protocol or combine them into a single bandwidth reading (boolean) - * ``quantisation_levels`` - how many discrete bandwidth usage levels to use for encoding. This can be an integer equal to or greater than 3. - - The other configurable item is ``flatten`` which is false by default. When set to true, the observation space is flattened (turned into a 1-D vector). You should use this if your RL agent does not natively support observation space types like ``gym.Spaces.Tuple``. - -* **num_train_episodes** [int] - - This defines the number of episodes that the agent will train for. - - -* **num_train_steps** [int] - - Determines the number of steps to run in each episode of the training session. - - -* **num_eval_episodes** [int] - - This defines the number of episodes that the agent will be evaluated over. - - -* **num_eval_steps** [int] - - Determines the number of steps to run in each episode of the evaluation session. - - -* **time_delay** [int] - - The time delay (in milliseconds) to take between each step when running a GENERIC agent session - - -* **session_type** [text] - - Type of session to be run (TRAINING, EVALUATION, or BOTH) - -* **load_agent** [bool] - - Determine whether to load an agent from file - -* **agent_load_file** [text] - - File path and file name of agent if you're loading one in - -* **observation_space_high_value** [int] - - The high value to use for values in the observation space. This is set to 1000000000 by default, and should not need changing in most cases - -* **implicit_acl_rule** [str] - - Determines which Explicit rule the ACL list has - two options are: DENY or ALLOW. - -* **max_number_acl_rules** [int] - - Sets a limit on how many ACL rules there can be in the ACL list throughout the training session. - -**Reward-Based Config Values** - -Rewards are calculated based on the difference between the current state and reference state (the 'should be' state) of the environment. - -* **Generic [all_ok]** [float] - - The score to give when the current situation (for a given component) is no different from that expected in the baseline (i.e. as though no blue or red agent actions had been undertaken) - -* **Node Hardware State [off_should_be_on]** [float] - - The score to give when the node should be on, but is off - -* **Node Hardware State [off_should_be_resetting]** [float] - - The score to give when the node should be resetting, but is off - -* **Node Hardware State [on_should_be_off]** [float] - - The score to give when the node should be off, but is on - -* **Node Hardware State [on_should_be_resetting]** [float] - - The score to give when the node should be resetting, but is on - -* **Node Hardware State [resetting_should_be_on]** [float] - - The score to give when the node should be on, but is resetting - -* **Node Hardware State [resetting_should_be_off]** [float] - - The score to give when the node should be off, but is resetting - -* **Node Hardware State [resetting]** [float] - - The score to give when the node is resetting - -* **Node Operating System or Service State [good_should_be_patching]** [float] - - The score to give when the state should be patching, but is good - -* **Node Operating System or Service State [good_should_be_compromised]** [float] - - The score to give when the state should be compromised, but is good - -* **Node Operating System or Service State [good_should_be_overwhelmed]** [float] - - The score to give when the state should be overwhelmed, but is good - -* **Node Operating System or Service State [patching_should_be_good]** [float] - - The score to give when the state should be good, but is patching - -* **Node Operating System or Service State [patching_should_be_compromised]** [float] - - The score to give when the state should be compromised, but is patching - -* **Node Operating System or Service State [patching_should_be_overwhelmed]** [float] - - The score to give when the state should be overwhelmed, but is patching - -* **Node Operating System or Service State [patching]** [float] - - The score to give when the state is patching - -* **Node Operating System or Service State [compromised_should_be_good]** [float] - - The score to give when the state should be good, but is compromised - -* **Node Operating System or Service State [compromised_should_be_patching]** [float] - - The score to give when the state should be patching, but is compromised - -* **Node Operating System or Service State [compromised_should_be_overwhelmed]** [float] - - The score to give when the state should be overwhelmed, but is compromised - -* **Node Operating System or Service State [compromised]** [float] - - The score to give when the state is compromised - -* **Node Operating System or Service State [overwhelmed_should_be_good]** [float] - - The score to give when the state should be good, but is overwhelmed - -* **Node Operating System or Service State [overwhelmed_should_be_patching]** [float] - - The score to give when the state should be patching, but is overwhelmed - -* **Node Operating System or Service State [overwhelmed_should_be_compromised]** [float] - - The score to give when the state should be compromised, but is overwhelmed - -* **Node Operating System or Service State [overwhelmed]** [float] - - The score to give when the state is overwhelmed - -* **Node File System State [good_should_be_repairing]** [float] - - The score to give when the state should be repairing, but is good - -* **Node File System State [good_should_be_restoring]** [float] - - The score to give when the state should be restoring, but is good - -* **Node File System State [good_should_be_corrupt]** [float] - - The score to give when the state should be corrupt, but is good - -* **Node File System State [good_should_be_destroyed]** [float] - - The score to give when the state should be destroyed, but is good - -* **Node File System State [repairing_should_be_good]** [float] - - The score to give when the state should be good, but is repairing - -* **Node File System State [repairing_should_be_restoring]** [float] - - The score to give when the state should be restoring, but is repairing - -* **Node File System State [repairing_should_be_corrupt]** [float] - - The score to give when the state should be corrupt, but is repairing - -* **Node File System State [repairing_should_be_destroyed]** [float] - - The score to give when the state should be destroyed, but is repairing - -* **Node File System State [repairing]** [float] - - The score to give when the state is repairing - -* **Node File System State [restoring_should_be_good]** [float] - - The score to give when the state should be good, but is restoring - -* **Node File System State [restoring_should_be_repairing]** [float] - - The score to give when the state should be repairing, but is restoring - -* **Node File System State [restoring_should_be_corrupt]** [float] - - The score to give when the state should be corrupt, but is restoring - -* **Node File System State [restoring_should_be_destroyed]** [float] - - The score to give when the state should be destroyed, but is restoring - -* **Node File System State [restoring]** [float] - - The score to give when the state is restoring - -* **Node File System State [corrupt_should_be_good]** [float] - - The score to give when the state should be good, but is corrupt - -* **Node File System State [corrupt_should_be_repairing]** [float] - - The score to give when the state should be repairing, but is corrupt - -* **Node File System State [corrupt_should_be_restoring]** [float] - - The score to give when the state should be restoring, but is corrupt - -* **Node File System State [corrupt_should_be_destroyed]** [float] - - The score to give when the state should be destroyed, but is corrupt - -* **Node File System State [corrupt]** [float] - - The score to give when the state is corrupt - -* **Node File System State [destroyed_should_be_good]** [float] - - The score to give when the state should be good, but is destroyed - -* **Node File System State [destroyed_should_be_repairing]** [float] - - The score to give when the state should be repairing, but is destroyed - -* **Node File System State [destroyed_should_be_restoring]** [float] - - The score to give when the state should be restoring, but is destroyed - -* **Node File System State [destroyed_should_be_corrupt]** [float] - - The score to give when the state should be corrupt, but is destroyed - -* **Node File System State [destroyed]** [float] - - The score to give when the state is destroyed - -* **Node File System State [scanning]** [float] - - The score to give when the state is scanning - -* **IER Status [red_ier_running]** [float] - - The score to give when a red agent IER is permitted to run - -* **IER Status [green_ier_blocked]** [float] - - The score to give when a green agent IER is prevented from running - -**Patching / Reset Durations** - -* **os_patching_duration** [int] - - The number of steps to take when patching an Operating System - -* **node_reset_duration** [int] - - The number of steps to take when resetting a node's hardware state - -* **service_patching_duration** [int] - - The number of steps to take when patching a service - -* **file_system_repairing_limit** [int]: - - The number of steps to take when repairing the file system - -* **file_system_restoring_limit** [int] - - The number of steps to take when restoring the file system - -* **file_system_scanning_limit** [int] - - The number of steps to take when scanning the file system - -* **deterministic** [bool] - - Set to true if the agent evaluation should be deterministic. Default is ``False`` - -* **seed** [int] - - Seed used in the randomisation in agent training. Default is ``None`` - -The Lay Down Config -******************* - -The lay down config file consists of the following attributes: - - -* **itemType: STEPS** [int] - -* **item_type: PORTS** [int] - - Provides a list of ports modelled in this session - -* **item_type: SERVICES** [freetext] - - Provides a list of services modelled in this session - -* **item_type: NODE** - - Defines a node included in the system laydown being simulated. It should consist of the following attributes: - - * **id** [int]: Unique ID for this YAML item - * **name** [freetext]: Human-readable name of the component - * **node_class** [enum]: Relates to the base type of the node. Can be SERVICE, ACTIVE or PASSIVE. PASSIVE nodes do not have an operating system or services. ACTIVE nodes have an operating system, but no services. SERVICE nodes have both an operating system and one or more services - * **node_type** [enum]: Relates to the component type. Can be one of CCTV, SWITCH, COMPUTER, LINK, MONITOR, PRINTER, LOP, RTU, ACTUATOR or SERVER - * **priority** [enum]: Provides a priority for each node. Can be one of P1, P2, P3, P4 or P5 (which P1 being the highest) - * **hardware_state** [enum]: The initial hardware state of the node. Can be one of ON, OFF or RESETTING - * **ip_address** [IP address]: The IP address of the component in format xxx.xxx.xxx.xxx - * **software_state** [enum]: The intial state of the node operating system. Can be GOOD, PATCHING or COMPROMISED - * **file_system_state** [enum]: The initial state of the node file system. Can be GOOD, CORRUPT, DESTROYED, REPAIRING or RESTORING - * **services**: For each service associated with the node: - - * **name** [freetext]: Free-text name of the service, but must match one of the services defined for the system in the services list - * **port** [int]: Integer value of the port related to this service, but must match one of the ports defined for the system in the ports list - * **state** [enum]: The initial state of the service. Can be one of GOOD, PATCHING, COMPROMISED or OVERWHELMED - -* **item_type: LINK** - - Defines a link included in the system laydown being simulated. It should consist of the following attributes: - - * **id** [int]: Unique ID for this YAML item - * **name** [freetext]: Human-readable name of the component - * **bandwidth** [int]: The bandwidth (in bits/s) of the link - * **source** [int]: The ID of the source node - * **destination** [int]: The ID of the destination node - -* **item_type: GREEN_IER** - - Defines a green agent Information Exchange Requirement (IER). It should consist of: - - * **id** [int]: Unique ID for this YAML item - * **start_step** [int]: The start step (in the episode) for this IER to begin - * **end_step** [int]: The end step (in the episode) for this IER to finish - * **load** [int]: The load (in bits/s) for this IER to apply to links - * **protocol** [freetext]: The protocol to apply to the links. This must match a value in the services list - * **port** [int]: The port that the protocol is running on. This must match a value in the ports list - * **source** [int]: The ID of the source node - * **destination** [int]: The ID of the destination node - * **mission_criticality** [enum]: The mission criticality of this IER (with 5 being highest, 1 lowest) - -* **item_type: RED_IER** - - Defines a red agent Information Exchange Requirement (IER). It should consist of: - - * **id** [int]: Unique ID for this YAML item - * **start_step** [int]: The start step (in the episode) for this IER to begin - * **end_step** [int]: The end step (in the episode) for this IER to finish - * **load** [int]: The load (in bits/s) for this IER to apply to links - * **protocol** [freetext]: The protocol to apply to the links. This must match a value in the services list - * **port** [int]: The port that the protocol is running on. This must match a value in the ports list - * **source** [int]: The ID of the source node - * **destination** [int]: The ID of the destination node - * **mission_criticality** [enum]: Not currently used. Default to 0 - -* **item_type: GREEN_POL** - - Defines a green agent pattern-of-life instruction. It should consist of: - - * **id** [int]: Unique ID for this YAML item - * **start_step** [int]: The start step (in the episode) for this PoL to begin - * **end_step** [int]: Not currently used. Default to same as start step - * **nodeId** [int]: The ID of the node to apply the PoL to - * **type** [enum]: The type of PoL to apply. Can be one of OPERATING, OS or SERVICE - * **protocol** [freetext]: The protocol to be affected if SERVICE type is chosen. Must match a value in the services list - * **state** [enuum]: The state to apply to the node (which represents the PoL change). Can be one of ON, OFF or RESETTING (for node state) or GOOD, PATCHING or COMPROMISED (for Software State) or GOOD, PATCHING, COMPROMISED or OVERWHELMED (for service state) - -* **item_type: RED_POL** - - Defines a red agent pattern-of-life instruction. It should consist of: - - * **id** [int]: Unique ID for this YAML item - * **start_step** [int]: The start step (in the episode) for this PoL to begin - * **end_step** [int]: Not currently used. Default to same as start step - * **targetNodeId** [int]: The ID of the node to apply the PoL to - * **initiator** [enum]: What initiates the PoL. Can be DIRECT, IER or SERVICE - * **type** [enum]: The type of PoL to apply. Can be one of OPERATING, OS or SERVICE - * **protocol** [freetext]: The protocol to be affected if SERVICE type is chosen. Must match a value in the services list - * **state** [enum]: The state to apply to the node (which represents the PoL change). Can be one of ON, OFF or RESETTING (for node state) or GOOD, PATCHING or COMPROMISED (for Software State) or GOOD, PATCHING, COMPROMISED or OVERWHELMED (for service state) or GOOD, CORRUPT, DESTROYED, REPAIRING or RESTORING (for file system state) - * **sourceNodeId** [int] The ID of the source node containing the service to check (used for SERVICE initiator) - * **sourceNodeService** [freetext]: The service on the source node to check (used for SERVICE initiator). Must match a value in the services list for this node - * **sourceNodeServiceState** [enum]: The state of the source node service to check (used for SERVICE initiator). Can be one of GOOD, PATCHING, COMPROMISED or OVERWHELMED - -* **item_type: ACL_RULE** - - Defines an initial Access Control List (ACL) rule. It should consist of: - - * **id** [int]: Unique ID for this YAML item - * **permission** [enum]: Defines either an allow or deny rule. Value must be either DENY or ALLOW - * **source** [IP address]: Defines the source IP address for the rule in xxx.xxx.xxx.xxx format - * **destination** [IP address]: Defines the destination IP address for the rule in xxx.xxx.xxx.xxx format - * **protocol** [freetext]: Defines the protocol for the rule. Must match a value in the services list - * **port** [int]: Defines the port for the rule. Must match a value in the ports list - * **position** [int]: Defines where to place the ACL rule in the list. Lower index or (higher up in the list) means they are checked first. Index starts at 0 (Python indexes). +PrimAITE allows for the configuration to be varied each episode. This is done by specifying a configuration folder instead of a single file. A full explanation is provided in the notebook `Using-Episode-Schedules.ipynb`. Please find the notebook in the user notebooks directory. diff --git a/docs/source/configuration/agents.rst b/docs/source/configuration/agents.rst new file mode 100644 index 00000000..5acf17a4 --- /dev/null +++ b/docs/source/configuration/agents.rst @@ -0,0 +1,174 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + + +``agents`` +========== +Agents can be scripted (deterministic and stochastic), or controlled by a reinforcement learning algorithm. Not to be confused with an RL agent, the term agent here is used to refer to an entity that sends requests to the simulated network. In this part of the config, each agent's action space, observation space, and reward function can be defined. All three are defined in a modular way. + +``agents`` hierarchy +-------------------- + +.. code-block:: yaml + + agents: + - ref: red_agent_example + ... + - ref: blue_agent_example + ... + - ref: green_agent_example + team: GREEN + type: ProbabilisticAgent + observation_space: + type: UC2GreenObservation + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 5 + frequency: 4 + variance: 3 + flatten_obs: False + +``ref`` +------- +The reference to be used for the given agent. + +``team`` +-------- +Specifies if the agent is malicious (``RED``), benign (``GREEN``), or defensive (``BLUE``). Currently this value is not used for anything other than for human readability in the configuration file. + +``type`` +-------- +Specifies which class should be used for the agent. ``ProxyAgent`` is used for agents that receive instructions from an RL algorithm. Scripted agents like ``RedDatabaseCorruptingAgent`` and ``ProbabilisticAgent`` generate their own behaviour. + +Available agent types: + +- ``ProbabilisticAgent`` +- ``ProxyAgent`` +- ``RedDatabaseCorruptingAgent`` + +``observation_space`` +--------------------- +Defines the observation space of the agent. + +``type`` +^^^^^^^^ + +selects which python class from the :py:mod:`primaite.game.agent.observation` module is used for the overall observation structure. + +``options`` +^^^^^^^^^^^ + +Allows configuration of the chosen observation type. These are optional. + + * ``num_services_per_node``, ``num_folders_per_node``, ``num_files_per_folder``, ``num_nics_per_node`` all define the shape of the observation space. The size and shape of the obs space must remain constant, but the number of files, folders, ACL rules, and other components can change within an episode. Therefore padding is performed and these options set the size of the obs space. + * ``nodes``: list of nodes that will be present in this agent's observation space. The ``node_ref`` relates to the human-readable unique reference defined later in the ``simulation`` part of the config. Each node can also be configured with services, and files that should be monitored. + * ``links``: list of links that will be present in this agent's observation space. The ``link_ref`` relates to the human-readable unique reference defined later in the ``simulation`` part of the config. + * ``acl``: configure how the agent reads the access control list on the router in the simulation. ``router_node_ref`` is for selecting which router's ACL table should be used. ``ip_list`` sets the encoding of ip addresses as integers within the observation space. + +For more information see :py:mod:`primaite.game.agent.observations` + +``action_space`` +---------------- + +The action space is configured to be made up of individual action types. Once configured, the agent can select an action type and some optional action parameters at every step. For example: The ``NODE_SERVICE_SCAN`` action takes the parameters ``node_id`` and ``service_id``. + +``action_list`` +^^^^^^^^^^^^^^^ + +A list of action modules. The options are listed in the :py:mod:`primaite.game.agent.actions.ActionManager.act_class_identifiers` module. + +``action_map`` +^^^^^^^^^^^^^^ + +Restricts the possible combinations of action type / action parameter values to reduce the overall size of the action space. By default, every possible combination of actions and parameters will be assigned an integer for the agent's ``MultiDiscrete`` action space. Instead, the ``action_map`` allows you to list the actions corresponding to each integer in the ``MultiDiscrete`` space. + +This is Optional. + +``options`` +^^^^^^^^^^^ + +Options that apply to all action components. These are optional. + + * ``nodes``: list the nodes that the agent can act on, the order of this list defines the mapping between nodes and ``node_id`` integers. + * ``max_folders_per_node``, ``max_files_per_folder``, ``max_services_per_node``, ``max_nics_per_node``, ``max_acl_rules`` all are used to define the size of the action space. + +For more information see :py:mod:`primaite.game.agent.actions` + +``reward_function`` +------------------- + +Similar to action space, this is defined as a list of components from the :py:mod:`primaite.game.agent.rewards` module. + +``reward_components`` +^^^^^^^^^^^^^^^^^^^^^ + +A list of reward types from :py:mod:`primaite.game.agent.rewards.RewardFunction.rew_class_identifiers` + +e.g. + +.. code-block:: yaml + + reward_components: + - type: DUMMY + - type: DATABASE_FILE_INTEGRITY + + +``agent_settings`` +------------------ + +Settings passed to the agent during initialisation. Determines how the agent will behave during training. + +e.g. + +.. code-block:: yaml + + agent_settings: + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + +``start_step`` +^^^^^^^^^^^^^^ + +Optional. Default value is ``5``. + +The timestep where the agent begins performing actions. + +``frequency`` +^^^^^^^^^^^^^ + +Optional. Default value is ``5``. + +The number of timesteps the agent will wait before performing another action. + +``variance`` +^^^^^^^^^^^^ + +Optional. Default value is ``0``. + +The amount of timesteps that the frequency can randomly change. + +``flatten_obs`` +--------------- + +If ``True``, gymnasium flattening will be performed on the observation space before sending to the agent. Set this to ``True`` if your agent does not support nested observation spaces. diff --git a/docs/source/configuration/game.rst b/docs/source/configuration/game.rst new file mode 100644 index 00000000..828571a7 --- /dev/null +++ b/docs/source/configuration/game.rst @@ -0,0 +1,56 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + + +``game`` +======== +This section defines high-level settings that apply across the game, currently it's used to help shape the action and observation spaces by restricting which ports and internet protocols should be considered. Here, users can also set the maximum number of steps in an episode. + +``game`` hierarchy +------------------ + +.. code-block:: yaml + + game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +``max_episode_length`` +---------------------- + +Optional. Default value is ``256``. + +The maximum number of episodes a Reinforcement Learning agent(s) can be trained for. + +``ports`` +--------- + +A list of ports that the Reinforcement Learning agent(s) are able to see in the observation space. + +See :ref:`List of Ports ` for a list of ports. + +``protocols`` +------------- + +A list of protocols that the Reinforcement Learning agent(s) are able to see in the observation space. + +See :ref:`List of IPProtocols ` for a list of protocols. + +``thresholds`` +-------------- + +These are used to determine the thresholds of high, medium and low categories for counted observation occurrences. diff --git a/docs/source/configuration/io_settings.rst b/docs/source/configuration/io_settings.rst new file mode 100644 index 00000000..46b2f1b2 --- /dev/null +++ b/docs/source/configuration/io_settings.rst @@ -0,0 +1,90 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + + +``io_settings`` +=============== +This section configures how PrimAITE saves data during simulation and training. + +``io_settings`` hierarchy +------------------------- + +.. code-block:: yaml + + io_settings: + # save_logs: True + save_agent_actions: True + save_step_metadata: False + save_pcap_logs: False + save_sys_logs: False + write_sys_log_to_terminal: False + sys_log_level: WARNING + + +``save_logs`` +------------- + +*currently unused*. + +``save_agent_actions`` +---------------------- + +Optional. Default value is ``True``. + +If ``True``, this will create a JSON file each episode detailing every agent's action in each step of that episode, formatted according to the CAOS format. This includes scripted, RL, and red agents. + +``save_step_metadata`` +---------------------- + +Optional. Default value is ``False``. + +If ``True``, The RL agent(s) actions, environment states and other data will be saved at every single step. + + +``save_pcap_logs`` +------------------ + +Optional. Default value is ``False``. + +If ``True``, then the pcap files which contain all network traffic during the simulation will be saved. + + +``save_sys_logs`` +----------------- + +Optional. Default value is ``False``. + +If ``True``, then the log files which contain all node actions during the simulation will be saved. + + +``write_sys_log_to_terminal`` +----------------------------- + +Optional. Default value is ``False``. + +If ``True``, PrimAITE will print sys log to the terminal. + + +``sys_log_level`` +------------- + +Optional. Default value is ``WARNING``. + +The level of logging that should be visible in the sys logs or the logs output to the terminal. + +``save_sys_logs`` or ``write_sys_log_to_terminal`` has to be set to ``True`` for this setting to be used. + +Available options are: + +- ``DEBUG``: Debug level items and the items below +- ``INFO``: Info level items and the items below +- ``WARNING``: Warning level items and the items below +- ``ERROR``: Error level items and the items below +- ``CRITICAL``: Only critical level logs + +See also |logging_levels| + +.. |logging_levels| raw:: html + + Python logging levels diff --git a/docs/source/configuration/simulation.rst b/docs/source/configuration/simulation.rst new file mode 100644 index 00000000..b19574f4 --- /dev/null +++ b/docs/source/configuration/simulation.rst @@ -0,0 +1,103 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + + +``simulation`` +============== +In this section the network layout is defined. This part of the config follows a hierarchical structure. Almost every component defines a ``ref`` field which acts as a human-readable unique identifier, used by other parts of the config, such as agents. + +At the top level of the network are ``nodes`` and ``links``. + +e.g. + +.. code-block:: yaml + + simulation: + network: + nodes: + ... + links: + ... + +``nodes`` +--------- + +This is where the list of nodes are defined. Some items will differ according to the node type, however, there will be common items such as a node's reference (which is used by the agent), the node's ``type`` and ``hostname`` + +To see the configuration for these nodes, refer to the following: + +.. toctree:: + :maxdepth: 1 + :glob: + + simulation/nodes/computer + simulation/nodes/firewall + simulation/nodes/router + simulation/nodes/server + simulation/nodes/switch + simulation/nodes/wireless_router + simulation/nodes/network_examples + +``links`` +--------- + +This is where the links between the nodes are formed. + +e.g. + +In order to recreate the network below, we will need to create 2 links: + +- a link from computer_1 to the switch +- a link from computer_2 to the switch + +.. image:: ../../_static/switched_p2p_network.png + :width: 500 + :align: center + +this results in: + +.. code-block:: yaml + + links: + - endpoint_a_hostname: computer_1 + endpoint_a_port: 1 # port 1 on computer_1 + endpoint_b_hostname: switch + endpoint_b_port: 1 # port 1 on switch + bandwidth: 100 + - endpoint_a_hostname: computer_2 + endpoint_a_port: 1 # port 1 on computer_2 + endpoint_b_hostname: switch + endpoint_b_port: 2 # port 2 on switch + bandwidth: 100 + +``ref`` +^^^^^^^ + +The human readable name for the link. Not used in code, however is useful for a human to understand what the link is for. + +``endpoint_a_hostname`` +^^^^^^^^^^^^^^^^^^^^^^^ + +The ``hostname`` of the node which must be connected. + +``endpoint_a_port`` +^^^^^^^^^^^^^^^^^^^ + +The port on ``endpoint_a_hostname`` which is to be connected to ``endpoint_b_port``. +This accepts an integer value e.g. if port 1 is to be connected, the configuration should be ``endpoint_a_port: 1`` + +``endpoint_b_hostname`` +^^^^^^^^^^^^^^^^^^^^^^^ + +The ``hostname`` of the node which must be connected. + +``endpoint_b_port`` +^^^^^^^^^^^^^^^^^^^ + +The port on ``endpoint_b_hostname`` which is to be connected to ``endpoint_a_port``. +This accepts an integer value e.g. if port 1 is to be connected, the configuration should be ``endpoint_b_port: 1`` + +``bandwidth`` + +This is an integer value specifying the allowed bandwidth across the connection. Units are in Mbps. diff --git a/docs/source/configuration/simulation/nodes/common/common.rst b/docs/source/configuration/simulation/nodes/common/common.rst new file mode 100644 index 00000000..d1c8f307 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/common/common.rst @@ -0,0 +1,35 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _Node Attributes: + +Common Attributes +################# + +Node Attributes +=============== + +Attributes that are shared by all nodes. + +.. include:: common_node_attributes.rst + +.. _Network Node Attributes: + +Network Node Attributes +======================= + +Attributes that are shared by nodes that inherit from :py:mod:`primaite.simulator.network.hardware.nodes.network.network_node.NetworkNode` + +.. include:: common_host_node_attributes.rst + +.. _Host Node Attributes: + +Host Node Attributes +==================== + +Attributes that are shared by nodes that inherit from :py:mod:`primaite.simulator.network.hardware.nodes.host.host_node.HostNode` + +.. include:: common_host_node_attributes.rst + +.. |NODE| replace:: node diff --git a/docs/source/configuration/simulation/nodes/common/common_host_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_host_node_attributes.rst new file mode 100644 index 00000000..929d5714 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/common/common_host_node_attributes.rst @@ -0,0 +1,26 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _common_host_node_attributes: + +``ip_address`` +-------------- + +The IP address of the |NODE| in the network. + +``subnet_mask`` +--------------- + +Optional. Default value is ``255.255.255.0``. + +The subnet mask for the |NODE| to use. + +``default_gateway`` +------------------- + +The IP address that the |NODE| will use as the default gateway. Typically, this is the IP address of the closest router that the |NODE| is connected to. + +.. include:: ../software/applications.rst + +.. include:: ../software/services.rst diff --git a/docs/source/configuration/simulation/nodes/common/common_network_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_network_node_attributes.rst new file mode 100644 index 00000000..1161059f --- /dev/null +++ b/docs/source/configuration/simulation/nodes/common/common_network_node_attributes.rst @@ -0,0 +1,51 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _common_network_node_attributes: + +``routes`` +---------- + +A list of routes which tells the |NODE| where to forward the packet to depending on the target IP address. + +e.g. + +.. code-block:: yaml + + nodes: + - ref: node + ... + routes: + - address: 192.168.0.10 + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.1 + metric: 0 + +``address`` +""""""""""" + +The target IP address for the route. If the packet destination IP address matches this, the |NODE| will route the packet according to the ``next_hop_ip_address``. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``subnet_mask`` +""""""""""""""" + +Optional. Default value is ``255.255.255.0``. + +The subnet mask setting for the route. + +``next_hop_ip_address`` +""""""""""""""""""""""" + +The IP address of the next hop IP address that the packet will follow if the address matches the packet's destination IP address. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``metric`` +"""""""""" + +Optional. Default value is ``0``. This value accepts floats. + +The cost or distance of a route. The higher the value, the more cost or distance is attributed to the route. diff --git a/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst new file mode 100644 index 00000000..34519adc --- /dev/null +++ b/docs/source/configuration/simulation/nodes/common/common_node_attributes.rst @@ -0,0 +1,55 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _common_node_attributes: + +``ref`` +------- + +Human readable name used as reference for the |NODE|. Not used in code. + +``hostname`` +------------ + +The hostname of the |NODE|. This will be used to reference the |NODE|. + +``operating_state`` +------------------- + +The initial operating state of the node. + +Optional. Default value is ``ON``. + +Options available are: + +- ``ON`` +- ``OFF`` +- ``BOOTING`` +- ``SHUTTING_DOWN`` + +Note that YAML may assume non quoted ``ON`` and ``OFF`` as ``True`` and ``False`` respectively. To prevent this, use ``"ON"`` or ``"OFF"`` + +See :py:mod:`primaite.simulator.network.hardware.node_operating_state.NodeOperatingState` + + +``dns_server`` +-------------- + +Optional. Default value is ``None``. + +The IP address of the node which holds an instance of the :ref:`DNSServer`. Some applications may use a domain name e.g. the :ref:`WebBrowser` + +``start_up_duration`` +--------------------- + +Optional. Default value is ``3``. + +The number of time steps required to occur in order for the node to cycle from ``OFF`` to ``BOOTING_UP`` and then finally ``ON``. + +``shut_down_duration`` +---------------------- + +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``. diff --git a/docs/source/configuration/simulation/nodes/common/node_type_list.rst b/docs/source/configuration/simulation/nodes/common/node_type_list.rst new file mode 100644 index 00000000..ceee8207 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/common/node_type_list.rst @@ -0,0 +1,18 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``type`` +-------- + +The type of node to add. + +Available options are: + +- ``computer`` +- ``firewall`` +- ``router`` +- ``server`` +- ``switch`` + +To create a |NODE|, type must be |NODE_TYPE|. diff --git a/docs/source/configuration/simulation/nodes/computer.rst b/docs/source/configuration/simulation/nodes/computer.rst new file mode 100644 index 00000000..04a45766 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/computer.rst @@ -0,0 +1,41 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _computer_configuration: + +``computer`` +============ + +A basic representation of a computer within the simulation. + +See :py:mod:`primaite.simulator.network.hardware.nodes.host.computer.Computer` + +example computer +---------------- + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: client_1 + hostname: client_1 + type: computer + ip_address: 192.168.0.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.0.1 + dns_server: 192.168.1.10 + applications: + ... + services: + ... + +.. include:: common/common_node_attributes.rst + +.. include:: common/node_type_list.rst + +.. include:: common/common_host_node_attributes.rst + +.. |NODE| replace:: computer +.. |NODE_TYPE| replace:: ``computer`` diff --git a/docs/source/configuration/simulation/nodes/firewall.rst b/docs/source/configuration/simulation/nodes/firewall.rst new file mode 100644 index 00000000..77e6cd12 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/firewall.rst @@ -0,0 +1,300 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _firewall_configuration: + +``firewall`` +============ + +A basic representation of a network firewall within the simulation. + +The firewall is similar to how :ref:`Router ` works, with the difference being how firewall has specific ACL rules for inbound and outbound traffic as well as firewall being limited to 3 ports. + +See :py:mod:`primaite.simulator.network.hardware.nodes.network.firewall.Firewall` + +example firewall +---------------- + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: firewall + hostname: firewall + type: firewall + start_up_duration: 0 + shut_down_duration: 0 + ports: + external_port: # port 1 + ip_address: 192.168.20.1 + subnet_mask: 255.255.255.0 + internal_port: # port 2 + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + dmz_port: # port 3 + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + internal_inbound_acl: + ... + internal_outbound_acl: + ... + dmz_inbound_acl: + ... + dmz_outbound_acl: + ... + external_inbound_acl: + ... + external_outbound_acl: + ... + routes: + ... + +.. include:: common/common_node_attributes.rst + +.. include:: common/node_type_list.rst + +``ports`` +--------- + +The firewall node only has 3 ports. These specifically are: + +- ``external_port`` (port 1) +- ``internal_port`` (port 2) +- ``dmz_port`` (port 3) (can be optional) + +The ports should be defined with an ip address and subnet mask e.g. + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + ports: + external_port: # port 1 + ip_address: 192.168.20.1 + subnet_mask: 255.255.255.0 + internal_port: # port 2 + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + dmz_port: # port 3 + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + +``ip_address`` +"""""""""""""" + +The IP address for the given port. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``subnet_mask`` +""""""""""""""" + +Optional. Default value is ``255.255.255.0``. + +The subnet mask setting for the port. + +``acl`` +------- + +There are 6 ACLs that can be defined for a firewall + +- ``internal_inbound_acl`` for traffic going towards the internal network +- ``internal_outbound_acl`` for traffic coming from the internal network +- ``dmz_inbound_acl`` for traffic going towards the dmz network +- ``dmz_outbound_acl`` for traffic coming from the dmz network +- ``external_inbound_acl`` for traffic coming from the external network +- ``external_outbound_acl`` for traffic going towards the external network + +.. image:: ../../../../_static/firewall_acl.png + :width: 500 + :align: center + +By default, ``external_inbound_acl`` and ``external_outbound_acl`` will permit any traffic through. + +``internal_inbound_acl``, ``internal_outbound_acl``, ``dmz_inbound_acl`` and ``dmz_outbound_acl`` will deny any traffic by default, so must be configured to allow defined ``src_port`` and ``dst_port`` or ``protocol``. + +See :py:mod:`primaite.simulator.network.hardware.nodes.network.router.AccessControlList` + +See :ref:`List of Ports ` for a list of ports. + +``internal_inbound_acl`` +"""""""""""""""""""""""" + +ACL rules for packets that have a destination IP address in what is considered the internal network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + internal_inbound_acl: + 21: # position 21 on ACL list + action: PERMIT # allow packets that + src_port: POSTGRES_SERVER # are emitted from the POSTGRES_SERVER port + dst_port: POSTGRES_SERVER # are going towards an POSTGRES_SERVER port + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + +``internal_outbound_acl`` +""""""""""""""""""""""""" + +ACL rules for packets that have a source IP address in what is considered the internal network and is going towards the DMZ network or the external network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + internal_outbound_acl: + 21: # position 21 on ACL list + action: PERMIT # allow packets that + src_port: POSTGRES_SERVER # are emitted from the POSTGRES_SERVER port + dst_port: POSTGRES_SERVER # are going towards an POSTGRES_SERVER port + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + + +``dmz_inbound_acl`` +""""""""""""""""""" + +ACL rules for packets that have a destination IP address in what is considered the DMZ network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + dmz_inbound_acl: + 19: # position 19 on ACL list + action: PERMIT # allow packets that + src_port: POSTGRES_SERVER # are emitted from the POSTGRES_SERVER port + dst_port: POSTGRES_SERVER # are going towards an POSTGRES_SERVER port + 20: # position 20 on ACL list + action: PERMIT # allow packets that + src_port: HTTP # are emitted from the HTTP port + dst_port: HTTP # are going towards an HTTP port + 21: # position 21 on ACL list + action: PERMIT # allow packets that + src_port: HTTPS # are emitted from the HTTPS port + dst_port: HTTPS # are going towards an HTTPS port + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + +``dmz_outbound_acl`` +"""""""""""""""""""" + +ACL rules for packets that have a source IP address in what is considered the DMZ network and is going towards the internal network or the external network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + dmz_outbound_acl: + 19: # position 19 on ACL list + action: PERMIT # allow packets that + src_port: POSTGRES_SERVER # are emitted from the POSTGRES_SERVER port + dst_port: POSTGRES_SERVER # are going towards an POSTGRES_SERVER port + 20: # position 20 on ACL list + action: PERMIT # allow packets that + src_port: HTTP # are emitted from the HTTP port + dst_port: HTTP # are going towards an HTTP port + 21: # position 21 on ACL list + action: PERMIT # allow packets that + src_port: HTTPS # are emitted from the HTTPS port + dst_port: HTTPS # are going towards an HTTPS port + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + + + +``external_inbound_acl`` +"""""""""""""""""""""""" + +Optional. By default, this will allow any traffic through. + +ACL rules for packets that have a destination IP address in what is considered the external network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + external_inbound_acl: + 21: # position 19 on ACL list + action: DENY # deny packets that + src_port: POSTGRES_SERVER # are emitted from the POSTGRES_SERVER port + dst_port: POSTGRES_SERVER # are going towards an POSTGRES_SERVER port + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + +``external_outbound_acl`` +""""""""""""""""""""""""" + +Optional. By default, this will allow any traffic through. + +ACL rules for packets that have a source IP address in what is considered the external network and is going towards the DMZ network or the internal network. + +example: + +.. code-block:: yaml + + nodes: + - ref: firewall + ... + acl: + external_outbound_acl: + 22: # position 22 on ACL list + action: PERMIT # allow packets that + src_port: ARP # are emitted from the ARP port + dst_port: ARP # are going towards an ARP port + 23: # position 23 on ACL list + action: PERMIT # allow packets that + protocol: ICMP # are ICMP + +.. include:: common/common_network_node_attributes.rst + +.. |NODE| replace:: firewall +.. |NODE_TYPE| replace:: ``firewall`` diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_dark.png b/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_dark.png new file mode 100644 index 00000000..c76f3daf Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_dark.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_light.png b/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_light.png new file mode 100644 index 00000000..c8c3f40c Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_basic_lan_network_light.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_dark.png b/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_dark.png new file mode 100644 index 00000000..066695f5 Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_dark.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_light.png b/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_light.png new file mode 100644 index 00000000..d6de4686 Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_client_server_p2p_network_light.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_dark.png b/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_dark.png new file mode 100644 index 00000000..a8eab7ee Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_dark.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_light.png b/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_light.png new file mode 100644 index 00000000..c3483cf9 Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_example_multi_lan_with_internet_network_light.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_dark.png b/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_dark.png new file mode 100644 index 00000000..fa803ec7 Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_dark.png differ diff --git a/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_light.png b/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_light.png new file mode 100644 index 00000000..4b02e9df Binary files /dev/null and b/docs/source/configuration/simulation/nodes/images/primaite_node_type_colour_key_light.png differ diff --git a/docs/source/configuration/simulation/nodes/network_examples.rst b/docs/source/configuration/simulation/nodes/network_examples.rst new file mode 100644 index 00000000..c1036c97 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/network_examples.rst @@ -0,0 +1,1348 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _network_examples: + +``Network Examples`` +==================== + +The below examples demonstrate how to configure different types of network in PrimAITE. These examples all have network +topology diagrams. Each rectangle represents a single Node, with the hostname inside of the rectangle. Physical links are +represented by lines between two nodes. At each end of the line is the network interface number on the node the link is +connected to. Where the network interface is also a layer-3 device, the label also contains the ip address and subnet +mask in CIDR format (``/``). All network diagrams on this page use the following node type +colour key: + +.. image:: images/primaite_node_type_colour_key_dark.png + :width: 300 + :align: center + :class: only-dark + +.. image:: images/primaite_node_type_colour_key_light.png + :width: 300 + :align: center + :class: only-light + +#1. Client-Server P2P Network +----------------------------- + +This example demonstrates how to create a minimal two-node client-server P2P network. The network consists of a Computer +and a Server on the same subnet with a single Link connecting the two. + + +.. image:: images/primaite_example_client_server_p2p_network_dark.png + :align: center + :class: only-dark + +.. image:: images/primaite_example_client_server_p2p_network_light.png + :align: center + :class: only-light + + +Node Configuration +^^^^^^^^^^^^^^^^^^ + +Each node in the network is defined with several attributes, crucial for determining its role and functionality within +the Network: + +- **Hostname**: The hostname assigned to the node on the Network. +- **Type**: Specifies the role of the node (e.g., computer, server, etc.). +- **IP Address and Subnet Mask**: These settings define the network interface's IP configuration which is essential for + network communication. + +Link Configuration +^^^^^^^^^^^^^^^^^^ + +The links section of the YAML configuration file specifies the physical connections between different network nodes +through their respective ports. This section is crucial for setting up the topology of the network, ensuring each node +is properly interconnected to facilitate communication and data transfer within the network. Each link in the network +is described with several attributes that define the connection between two endpoints: + +- **endpoint_a_hostname**: The hostname of the first node in the connection. +- **endpoint_a_port**: The port number on the first node where the link is connected. +- **endpoint_b_hostname**: The hostname of the second node in the connection. +- **endpoint_b_port**: The port number on the second node where the link is connected. + +Building the Config File +^^^^^^^^^^^^^^^^^^^^^^^^ + +**Defining the Network Scope and Scale** + +1. **Identify the Participants**: The first step is to determine how many nodes are required and their roles. In this case, + we've chosen a simple two-node P2P architecture with one client (`pc_1`) and one server (`server_1`). This setup is + chosen to facilitate direct communication between a user (client) and a resource or service (server). +2. **Assign IP Addresses**: Choosing IP addresses that are within the same subnet (`192.168.1.x` with a subnet mask of + `255.255.255.0`) ensures that the two nodes can communicate without routing. + +**Configuring Individual Components** + +3. **Node Configuration Simplicity**: With only two participants, the network design is straightforward, focusing on direct + connectivity. Each node is configured with the minimal required settings: hostname, type, IP address, and subnet mask. + The simplicity ensures that the configuration is easy to understand and manage. +4. **Logical Assignment of Roles**: The computer is designated as the client and the server as the service provider. This + reflects typical real-world scenarios where a user's machine connects to a server that hosts resources or services. + +**Configuring Connectivity** + +5. **Direct Link Setup**: A direct link is planned between the two nodes. This is logical in a minimal setup where the + primary goal is to ensure efficient, high-speed communication between the client and the server. This direct + connection is configured through specified ports on each node, ensuring that these are the only two devices on this + segment of the network. +6. **Port Selection**: Choosing port 1 for both nodes for the connection might be based on convention or simplicity, as + typically, port numbering starts at 1. This makes it straightforward to follow and document. + +.. code-block:: yaml + + simulation: + network: + nodes: + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + + links: + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: server_1 + endpoint_b_port: 1 + +Inspection and Connectivity Test +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following codeblock demonstrates how to access this network and all ``.show()`` to output the network details: + +.. code-block:: python + + from primaite.simulator.network.networks import client_server_p2p_network + + network = client_server_p2p_network() + + network.show() + +Which gives the output: + +.. code-block:: text + + +---------------------------------------+ + | Nodes | + +----------+----------+-----------------+ + | Node | Type | Operating State | + +----------+----------+-----------------+ + | server_1 | Server | ON | + | pc_1 | Computer | ON | + +----------+----------+-----------------+ + +------------------------------------------------------------------+ + | IP Addresses | + +----------+------+--------------+---------------+-----------------+ + | Node | Port | IP Address | Subnet Mask | Default Gateway | + +----------+------+--------------+---------------+-----------------+ + | server_1 | 1 | 192.168.1.13 | 255.255.255.0 | None | + | pc_1 | 1 | 192.168.1.11 | 255.255.255.0 | None | + +----------+------+--------------+---------------+-----------------+ + +------------------------------------------------------------------------------------------------------------------------------------------------------+ + | Links | + +------------+----------------------------------------+------------+----------------------------------------+-------+-------------------+--------------+ + | Endpoint A | A Port | Endpoint B | B Port | is Up | Bandwidth (MBits) | Current Load | + +------------+----------------------------------------+------------+----------------------------------------+-------+-------------------+--------------+ + | pc_1 | Port 1: dd:70:be:52:b1:a9/192.168.1.11 | server_1 | Port 1: 17:3a:11:af:9b:b1/192.168.1.13 | True | 100.0 | 0.00000% | + +------------+----------------------------------------+------------+----------------------------------------+-------+-------------------+--------------+ + +Finally, once the network is configured as expected, a connectivity test should be carried out. This can be done by +"pinging" one node from another node. The below code block demonstrates how `pc_1` pings `server_1`. + +.. code-block:: python + + from primaite.simulator.network.networks import client_server_p2p_network_example + + network = client_server_p2p_network_example() + + pc_1 = network.get_node_by_hostname("pc_1") + pc_1.ping("192.168.1.13) + +If SysLog capture is toggled on and the simulation log level is set to INFO, the `pc_1` the result of the ping should be +captured in the `pc_1` SysLog: + +.. code-block:: text + + +--------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | pc_1 Sys Log | + +-------------------------+-------+----------------------------------------------------------------------------------------------------------------------------+ + | Timestamp | Level | Message | + +-------------------------+-------+----------------------------------------------------------------------------------------------------------------------------+ + | 2024-04-24 20:50:06,016 | INFO | Network Interface Port 1: b6:76:56:5b:4a:94/192.168.1.11 enabled | + | 2024-04-24 20:50:06,017 | INFO | Pinging 192.168.1.13: | + | 2024-04-24 20:50:06,017 | INFO | Sending ARP request from NIC Port 1: b6:76:56:5b:4a:94/192.168.1.11 for ip 192.168.1.13 | + | 2024-04-24 20:50:06,018 | INFO | Adding ARP cache entry for ee:7e:d5:37:41:b8/192.168.1.13 via NIC Port 1: b6:76:56:5b:4a:94/192.168.1.11 | + | 2024-04-24 20:50:06,018 | INFO | Received ARP response for 192.168.1.13 from ee:7e:d5:37:41:b8 via Network Interface Port 1: b6:76:56:5b:4a:94/192.168.1.11 | + | 2024-04-24 20:50:06,019 | INFO | Reply from 192.168.1.13: bytes=32, time=<1ms, TTL=63 | + | 2024-04-24 20:50:06,020 | INFO | Reply from 192.168.1.13: bytes=32, time=<1ms, TTL=63 | + | 2024-04-24 20:50:06,021 | INFO | Reply from 192.168.1.13: bytes=32, time=<1ms, TTL=63 | + | 2024-04-24 20:50:06,022 | INFO | Reply from 192.168.1.13: bytes=32, time=<1ms, TTL=63 | + | 2024-04-24 20:50:06,022 | INFO | Ping statistics for 192.168.1.13: Packets: Sent = 4, Received = 4, Lost = 0 (0.0% loss) | + +-------------------------+-------+----------------------------------------------------------------------------------------------------------------------------+ + + +#2. Basic LAN +------------- + +This example demonstrates setting up a basic Local Area Network (LAN) consisting of two Computers, a Server, a Switch, +and a Router, all configured on the same subnet. This type of network is commonly used in small office or home office +settings, providing shared access to resources like files and printers, while also offering a connection to the +internet through a router. This network provides a deeper dive into the new concepts introduced, including default +gateways, router configurations with ACLs, and port settings. + +.. image:: images/primaite_example_basic_lan_network_dark.png + :align: center + :class: only-dark + +.. image:: images/primaite_example_basic_lan_network_light.png + :align: center + :class: only-light + +Node Configuration +^^^^^^^^^^^^^^^^^^ + +- **Type**: We now introduce two new node types, switch and router. + +**Computers & Servers** + +- **Default Gateway**: The IP address of the router that provides connectivity beyond the local network, essential for + accessing external networks. + +**Routers & Switches** + +- **Number of Ports**: Indicates how many physical connections the switch supports, which determines how many devices + can be connected. + +**Routers** + +- **Ports Configuration**: Each port on the router can be independently configured with an IP address and subnet mask, + important for managing different network interfaces. +- **Access Control Lists** (ACLs): Specifies rules that control the flow of traffic into and out of the router, + enhancing security by permitting or denying traffic based on source/destination IP addresses, and/or source/destination + ports, and/or protocol. + +Building the Config File +^^^^^^^^^^^^^^^^^^^^^^^^ + +**Defining the Network Scope and Scale** + +1. **Identify the Participants**: For the basic LAN, we have identified the need for two computers (pc_1 and pc_2), a + server (server_1), and networking devices including a switch (switch_1) and a router (router_1). This configuration + supports a typical small office environment where multiple users require access to shared resources and external + network connectivity (not configured in this network). + +2. **Role Assignment**: + + - **Computers** (`pc_1` and `pc_2`): Act as client systems for users to perform daily tasks and access shared + resources on the server. + - **Server** (`server_1`): Hosts resources such as files and applications needed by client systems. + - **Switch** (`switch_1`): Serves as the central hub connecting all nodes within the LAN to facilitate internal + network communications. + - **Router** (`router_1`): Would provide a gateway to external networks, routing traffic between the LAN and the + internet or other external networks. + +**Configuring Connectivity** + +3. **Switch Configuration**: The switch is configured with four ports to accommodate the two computers, the server, and + a connection to the router. This setup ensures all nodes are interconnected for seamless communication within the LAN. +4. **Router Setup as default Gateway**: The router is set up as the default gateway. It has one port that connects to + the switch. +5. **Security Settings with ACLs**: + + - The ACL on the router (acl: 10) is configured to permit traffic from the specified internal IP range + (`192.168.0.0/24`) to access the router’s IP address (`192.168.1.1`). Essentially, this ACL allows the nodes in + the LAN to communicate with their default gateway (but no further at this stage). + +6. **Physical Layout Planning**: Each node is strategically connected to the switch to minimise links and optimise + network performance. The computers (`pc_1` and `pc_2`) and the server (`server_1`) are each connected to individual + ports on the switch, maintaining an organised and efficient network topology. + + +.. code-block:: yaml + + simulation: + network: + nodes: + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: pc_2 + type: computer + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: switch_1 + type: switch + num_ports: 4 + + - hostname: router_1 + type: router + num_ports: 1 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 10: + action: PERMIT + src_ip_address: 192.168.0.0 + src_wildcard_mask: 0.0.0.255 + dst_ip_address: 192.168.1.1 + + links: + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + - endpoint_a_hostname: pc_2 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 + - endpoint_a_hostname: server_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 3 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 4 + + +Inspection and Connectivity Test +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + +The following codeblock demonstrates how to access this network and all ``.show()`` to output the network details: + +.. code-block:: python + + from primaite.simulator.network.networks import basic_lan_network_example + + network = basic_lan_network_example() + + network.show() + +Which gives the output: + +.. code-block:: text + + +---------------------------------------+ + | Nodes | + +----------+----------+-----------------+ + | Node | Type | Operating State | + +----------+----------+-----------------+ + | router_1 | Router | ON | + | switch_1 | Switch | ON | + | server_1 | Server | ON | + | pc_1 | Computer | ON | + | pc_2 | Computer | ON | + +----------+----------+-----------------+ + +------------------------------------------------------------------+ + | IP Addresses | + +----------+------+--------------+---------------+-----------------+ + | Node | Port | IP Address | Subnet Mask | Default Gateway | + +----------+------+--------------+---------------+-----------------+ + | router_1 | 1 | 192.168.1.1 | 255.255.255.0 | None | + | server_1 | 1 | 192.168.1.13 | 255.255.255.0 | 192.168.1.1 | + | pc_1 | 1 | 192.168.1.11 | 255.255.255.0 | 192.168.1.1 | + | pc_2 | 1 | 192.168.1.12 | 255.255.255.0 | 192.168.1.1 | + +----------+------+--------------+---------------+-----------------+ + +-----------------------------------------------------------------------------------------------------------------------------------------+ + | Links | + +------------+----------------------------------------+------------+---------------------------+-------+-------------------+--------------+ + | Endpoint A | A Port | Endpoint B | B Port | is Up | Bandwidth (MBits) | Current Load | + +------------+----------------------------------------+------------+---------------------------+-------+-------------------+--------------+ + | router_1 | Port 1: 63:7e:be:05:fa:72/192.168.1.1 | switch_1 | Port 4: 99:e0:be:79:c4:0a | True | 100.0 | 0.00000% | + | server_1 | Port 1: ee:1d:f5:a1:92:85/192.168.1.13 | switch_1 | Port 3: 6c:17:28:4b:98:b9 | True | 100.0 | 0.00000% | + | pc_2 | Port 1: a3:f2:02:bf:f0:7d/192.168.1.12 | switch_1 | Port 2: c5:3e:f2:c0:da:66 | True | 100.0 | 0.00000% | + | pc_1 | Port 1: 27:db:3f:be:ce:9b/192.168.1.11 | switch_1 | Port 1: d1:ff:2f:be:9d:97 | True | 100.0 | 0.00000% | + +------------+----------------------------------------+------------+---------------------------+-------+-------------------+--------------+ + +Finally, once the network is configured as expected, a connectivity test should be carried out. This can be done by +"pinging" the default gateway of the server and computers (port 1 on `router_1`). Not only will this test the physical +connections, but the ACL that allows the nodes in the LAN to communicate with their default gateway. + +.. code-block:: python + + from primaite.simulator.network.networks import basic_lan_network_example + + network = basic_lan_network_example() + + pc_1 = network.get_node_by_hostname("pc_1") + pc_1.ping(pc_1.default_gateway) + +pc_1.sys_log.show() + +If SysLog capture is toggled on and the simulation log level is set to INFO, the `pc_1` the result of the ping should be +captured in the `pc_1` SysLog: + +.. code-block:: text + + +-------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | pc_1 Sys Log | + +-------------------------+-------+---------------------------------------------------------------------------------------------------------------------------+ + | Timestamp | Level | Message | + +-------------------------+-------+---------------------------------------------------------------------------------------------------------------------------+ + | 2024-04-24 21:35:09,888 | INFO | Pinging 192.168.1.1: | + | 2024-04-24 21:35:09,889 | INFO | Sending ARP request from NIC Port 1: 50:fe:d9:ff:a9:4d/192.168.1.11 for ip 192.168.1.1 | + | 2024-04-24 21:35:09,890 | INFO | Adding ARP cache entry for d2:eb:16:1b:56:0d/192.168.1.1 via NIC Port 1: 50:fe:d9:ff:a9:4d/192.168.1.11 | + | 2024-04-24 21:35:09,890 | INFO | Received ARP response for 192.168.1.1 from d2:eb:16:1b:56:0d via Network Interface Port 1: 50:fe:d9:ff:a9:4d/192.168.1.11 | + | 2024-04-24 21:35:09,892 | INFO | Reply from 192.168.1.1: bytes=32, time=1ms, TTL=62 | + | 2024-04-24 21:35:09,892 | INFO | Reply from 192.168.1.1: bytes=32, time=<1ms, TTL=62 | + | 2024-04-24 21:35:09,893 | INFO | Reply from 192.168.1.1: bytes=32, time=<1ms, TTL=62 | + | 2024-04-24 21:35:09,894 | INFO | Reply from 192.168.1.1: bytes=32, time=<1ms, TTL=62 | + | 2024-04-24 21:35:09,894 | INFO | Ping statistics for 192.168.1.1: Packets: Sent = 4, Received = 4, Lost = 0 (0.0% loss) | + +-------------------------+-------+---------------------------------------------------------------------------------------------------------------------------+ + +To verify that the ACL on `router_1` worked, we can call `.acl.show()`. This not only shows the ACL rules, but the +number of times each rule has been hit. the code block below is an extension of the above code block that accesses the +`basic_lan_network_example`. + +.. code-block:: python + + router_1 = network.get_node_by_hostname("router_1") + router_1.acl.show() + +This gives the output: + +.. code-block:: text + + +---------------------------------------------------------------------------------------------------------------------+ + | router_1 Access Control List | + +-------+--------+----------+-------------+--------------+----------+-------------+--------------+----------+---------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+-------------+--------------+----------+-------------+--------------+----------+---------+ + | 10 | PERMIT | ANY | 192.168.1.0 | 0.0.0.255 | ANY | 192.168.1.1 | 0.0.0.0 | ANY | 5 | + | 24 | DENY | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + +-------+--------+----------+-------------+--------------+----------+-------------+--------------+----------+---------+ + +#3. Multi-LAN with Internet +--------------------------- + +This example presents an advanced network configuration that simulates a real-world scenario involving a home or office +network, an Internet Service Provider (ISP), and a comprehensive corporate network for a fictional company named +SomeTech. This extended network includes detailed sub-networks with specialised services, multiple routers featuring +complex routing capabilities, and robust security protocols implemented through Access Control Lists (ACLs). Designed +to mimic the intricacies of actual network environments, this network provides a detailed look at how various network +components interact and function together to support both internal corporate activities and external communications. + + +.. image:: images/primaite_example_multi_lan_with_internet_network_dark.png + :align: center + :class: only-dark + +.. image:: images/primaite_example_multi_lan_with_internet_network_light.png + :align: center + :class: only-light + + +Node Configuration +^^^^^^^^^^^^^^^^^^ + +**Computers and Servers** + +- **DNS Server**: Specifies the server that resolves domain names, which is crucial for accessing network services by + hostname instead of IP addresses. In this scenario, DNS servers play a vital role in connecting with external + internet services and internal applications. + +**Routers & Firewalls** + +- **Routes**: Routers also manage specific routes that direct traffic between subnets within the larger network. These routes are defined in the routing table and include: + + - **IP Address**: The IP address of the destination node/subnet. + - **Subnet Mask**: Defines the size of the destination subnet and differentiates between network address and host identifier. + - **Next Hop IP Address**: The address of the next hop router or gateway that packets should be sent to when trying + to reach the destination subnet. This setting is essential for routing decisions in multi-network environments. +- **Default Route**: This is a crucial setting in complex network environments where multiple routers are used. It + directs outbound traffic to a specified gateway, typically used for accessing the Internet or connecting to upstream + networks. + +**Firewalls** + +- **Ports Configuration**: Similar to routers but with named ports to differentiate between external (internet-facing), + internal, and demilitarized zone (DMZ) connections. +- **ACLs** - The firewall is configured with six primary ACLs, designed to manage the traffic across three key network + junctions: internal, external, and DMZ. + + - **Internal Port ACLs**: + + - **Inbound ACL**: Controls traffic entering the internal network from other network zones. + - **Outbound ACL**: Controls traffic leaving the internal network to other parts of the network or the internet. + + - **DMZ Port ACLs**: + - **Inbound ACL**: Controls traffic coming into the DMZ from the internet or internal network. + - **Outbound ACL**: Controls traffic leaving the DMZ to reach the internal network or the internet. + + - **External Port ACLs**: + + External ACLs can be used as a single 'catch-all' where two separate but identical rules would be required for both + internal and DMZ ports. + + - **Inbound ACL**: Controls traffic coming in from the internet, allowing only authorised access to the network. + - **Outbound ACL**: Regulates what internal traffic can exit to the internet. + +Building the Config File +^^^^^^^^^^^^^^^^^^^^^^^^ + +**Defining the Network Scope and Scale** + +1. **Identify the Participants**: + + - **Home/Office Network**: Consists of PCs and servers that handle daily operations and access to shared resources + like files and applications. + - **ISP (Internet Service Provider)**: Manages internet connectivity and external routing, acting as the gateway to + the internet for the SomeTech network. Also enabled DNS lookups. + - **SomeTech Corporate Network**: A complex internal network with multiple subnets, including a DMZ for public-facing + services, and segregated internal zones like HR, Engineering, and Data/Storage. + + +**Node Placement and Configuration** + +2. **Strategic Node Placement** + + - **Web Server in the DMZ**: The web server is strategically placed within the Demilitarized Zone (DMZ) to ensure + that it is accessible from the internet without exposing the internal network to potential security threats. The + DMZ acts as a segregated area that isolates public-facing services from critical internal resources, reducing the + risk of external attacks spreading into the corporate network. + - **Database and Storage Servers**: These servers are located on a separate subnet to enhance security and + performance. Segmenting these servers allows for more granular control over access and traffic management, + ensuring that sensitive data is tightly secured and that the traffic does not interfere with other operations + within the corporate network. + +3. **Subnetting Strategy** + + - **/30 Subnets for Router Links**: Links between routers are configured with /30 subnets, which provide just enough + addresses for two endpoints and a broadcast address, maximizing the efficiency of IP address usage. This subnet + size is typically used for router-to-router connections to minimise the wastage of IP addresses and to simplify + network management. + +4. **Routing Configurations** + + - **Defining Static Routes**: Static routes are meticulously defined to ensure that data packets find the most + direct and secure path to their destinations. This involves specifying routes that direct traffic from the + internal network to the internet, between internal subnets, and to the DMZ. + - **Use of Default Routes**: Default routes are critical in guiding traffic towards a predefined exit point, + typically towards the ISP, when no other specific routes match. This setup ensures that external traffic is + efficiently routed through the network gateway, simplifying the routing table. + +5. **Security Measures** + + - **ACLs on Routers and Firewalls**: Access Control Lists (ACLs) are crucial in enforcing security policies. + They are configured to: + + - **Permit or Deny Specific Traffic**: Depending on the node type and the network segment, ACLs are tailored to + control what traffic can enter or leave the network. For instance, ACLs on the firewall regulate traffic between + the internet, DMZ, and internal network. + - **Support Specific Applications**: ACLs also facilitate the operation of specific applications by allowing + necessary communications. For example, permitting HTTP traffic to and from the web server in the DMZ ensures + that web services are accessible without compromising the security of other network segments. + - **Route Security**: Routing configurations are secured by ensuring that routes do not inadvertently expose + sensitive parts of the network to unauthorised traffic. Routes are carefully planned to keep internal and external + traffic separate unless explicitly allowed via ACLs. + + +SomeTech Corporate Network Explained +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The SomeTech corporate network is designed to support complex and varied operational needs across different departments +and functionalities. It includes a detailed setup with multiple subnets, each tailored to specific functions such as +development, human resources, and data/storage. + +**Network Segmentation and Node Deployment** + +- **Web Server (some_tech_web_srv)**: Located in the Demilitarized Zone (DMZ) to ensure it is accessible from the + internet for hosting SomeTech's corporate website or external applications. This placement is crucial for security + as it isolates the web server from the internal corporate network, protecting sensitive internal data from external + threats. +- **Firewall (some_tech_fw)**: Acts as a gatekeeper between the external internet, the internal network, and the DMZ. + It is equipped with multiple ports to distinctly manage traffic: + + - **External Port**: Faces the ISP and handles all inbound and outbound internet traffic. + - **Internal Port**: Connects to the corporate router, managing traffic to and from the internal subnets. + - **DMZ Port**: Dedicated to traffic to and from the DMZ, specifically for the web server. + +- **Corporate Router (some_tech_rt)**: Central to internal network management, routing traffic across various subnets + designated for specific departmental functions: + + - **Engineering Subnet**: Hosts developer PCs like some_tech_snr_dev_pc and some_tech_jnr_dev_pc, which are used by + senior and junior engineers respectively. These workstations are equipped with tools and applications for software + development and database interaction. + - **HR Subnet**: Contains the HR PC (some_tech_hr_1), used for managing human resources functions and accessing + sensitive employee data securely. + - **Data/Storage Subnet**: Contains the some_tech_db_srv and some_tech_storage_srv servers. Critical for storing and + managing the company's data. The database server hosts central databases accessed by various applications across + the network, while the storage server provides data storage solutions and backup services. + +**Security and Access Control** + +Each node is configured to ensure it meets the specific security and operational requirements of its function: + +- **ACLs on Firewall and Routers**: Carefully crafted Access Control Lists ensure that traffic is meticulously screened + before moving between the DMZ, external internet, and internal subnets. Specific rules include: + + - Permitting database queries from the development PCs to the database server. + - Permitting database queries from the web server to the database server. + - Restricting FTP access from junior developer PCs to sensitive areas like the storage server. + - Allowing necessary web traffic to and from the web server in the DMZ. + + +.. code-block:: yaml + + simulation: + network: + nodes: + # Home/Office Network + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: pc_2 + type: computer + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + + - hostname: switch_1 + type: switch + num_ports: 4 + + - hostname: router_1 + type: router + num_ports: 2 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 43.35.240.2 + subnet_mask: 255.255.255.252 + acl: + 10: + action: PERMIT + default_route: # Default route to all external networks + next_hop_ip_address: 43.35.240.1 # NI 1 on icp_router + + # ISP Network + - hostname: isp_rt + type: router + num_ports: 3 + ports: + 1: + ip_address: 43.35.240.1 + subnet_mask: 255.255.255.252 + 2: + ip_address: 94.10.180.1 + subnet_mask: 255.255.255.252 + 3: + ip_address: 8.8.8.1 + subnet_mask: 255.255.255.252 + acl: + 10: + action: PERMIT + routes: + - address: 192.168.1.0 # Route to the Home/Office LAN + subnet_mask: 255.255.255.0 + next_hop_ip_address: 43.35.240.2 # NI 2 on router_1 + - address: 10.10.0.0 # Route to the SomeTech internal network + subnet_mask: 255.255.0.0 + next_hop_ip_address: 94.10.180.2 # NI ext on some_tech_fw + - address: 94.10.180.6 # Route to the Web Server in the SomeTech DMZ + subnet_mask: 255.255.255.255 + next_hop_ip_address: 94.10.180.2 # NI ext on some_tech_fw + + - hostname: isp_dns_srv + type: server + ip_address: 8.8.8.2 + subnet_mask: 255.255.255.252 + default_gateway: 8.8.8.1 + services: + - ref: dns_server + type: DNSServer + options: + domain_mapping: + sometech.ai: 94.10.180.6 + + # SomeTech Network + - hostname: some_tech_fw + type: firewall + ports: + external_port: # port 1 + ip_address: 94.10.180.2 + subnet_mask: 255.255.255.252 + internal_port: # port 2 + ip_address: 10.10.4.2 + subnet_mask: 255.255.255.252 + dmz_port: # port 3 + ip_address: 94.10.180.5 + subnet_mask: 255.255.255.252 + acl: + internal_inbound_acl: + 8: # Permit some_tech_web_srv to connect to Database service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 10.10.1.11 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 9: # Permit SomeTech to use HTTP + action: PERMIT + src_port: HTTP + 10: # Permit SomeTech to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + internal_outbound_acl: + 10: # Permit all internal outbound traffic + action: PERMIT + dmz_inbound_acl: + 7: # Permit Database service on some_tech_db_srv to respond to some_tech_web_srv + action: PERMIT + src_ip: 10.10.1.11 + src_port: POSTGRES_SERVER + src_wildcard_mask: 0.0.0.0 + dst_ip: 94.10.180.6 + dst_port: POSTGRES_SERVER + dst_wildcard_mask: 0.0.0.0 + 8: # Permit SomeTech DMZ to use ARP + action: PERMIT + src_port: ARP + dst_port: ARP + 9: # Permit SomeTech DMZ to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + 10: # Permit all inbound HTTP requests + action: PERMIT + dst_port: HTTP + dmz_outbound_acl: + 7: # Permit some_tech_web_srv to connect to Database service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_port: POSTGRES_SERVER + src_wildcard_mask: 0.0.0.0 + dst_ip: 10.10.1.11 + dst_port: POSTGRES_SERVER + dst_wildcard_mask: 0.0.0.0 + 8: # Permit SomeTech DMZ to use ARP + action: PERMIT + src_port: ARP + dst_port: ARP + 9: # Permit SomeTech DMZ to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + 10: # Permit all outbound HTTP requests + action: PERMIT + src_port: HTTP + default_route: # Default route to all external networks + next_hop_ip_address: 94.10.180.1 # NI 2 on isp_rt + routes: + - address: 10.10.0.0 # Route to the SomeTech internal LAN + subnet_mask: 255.255.0.0 + next_hop_ip_address: 10.10.4.1 # NI 1 on some_tech_rt + + + - hostname: some_tech_web_srv + type: server + ip_address: 94.10.180.6 + subnet_mask: 255.255.255.252 + default_gateway: 94.10.180.5 + dns_server: 8.8.8.2 + services: + - ref: web_server + type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + + - hostname: some_tech_rt + type: router + num_ports: 4 + ports: + 1: + ip_address: 10.10.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 10.10.4.1 + subnet_mask: 255.255.255.252 + 3: + ip_address: 10.10.3.1 + subnet_mask: 255.255.255.0 + 4: + ip_address: 10.10.2.1 + subnet_mask: 255.255.255.0 + + acl: + 2: # Allow the some_tech_web_srv to connect to the Database Service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 10.10.1.11 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 3: # Allow the Database Service on some_tech_db_srv to respond to some_tech_web_srv + action: PERMIT + src_ip: 10.10.1.11 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 94.10.180.6 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 4: # Prevent the Junior engineer from downloading files from the some_tech_storage_srv over FTP + action: DENY + src_ip: 10.10.2.12 + src_wildcard_mask: 0.0.0.0 + src_port: FTP + dst_ip: 10.10.1.12 + dst_wildcard_mask: 0.0.0.0 + dst_port: FTP + 5: # Allow communication between Engineering and the DB & Storage subnet + action: PERMIT + src_ip: 10.10.2.0 + src_wildcard_mask: 0.0.0.255 + dst_ip: 10.10.1.0 + dst_wildcard_mask: 0.0.0.255 + 6: # Allow communication between the DB & Storage subnet and Engineering + action: PERMIT + src_ip: 10.10.1.0 + src_wildcard_mask: 0.0.0.255 + dst_ip: 10.10.2.0 + dst_wildcard_mask: 0.0.0.255 + 7: # Allow the SomeTech network to use HTTP + action: PERMIT + src_port: HTTP + dst_port: HTTP + 8: # Allow the SomeTech internal network to use ARP + action: PERMIT + src_ip: 10.10.0.0 + src_wildcard_mask: 0.0.255.255 + src_port: ARP + 9: # Allow the SomeTech internal network to use ICMP + action: PERMIT + src_ip: 10.10.0.0 + src_wildcard_mask: 0.0.255.255 + protocol: ICMP + 10: + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: HTTP + dst_ip: 10.10.0.0 + dst_wildcard_mask: 0.0.255.255 + dst_port: HTTP + 11: # Permit SomeTech to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + default_route: # Default route to all external networks + next_hop_ip_address: 10.10.4.2 # NI int on some_tech_fw + + + - hostname: some_tech_data_sw + type: switch + num_ports: 3 + + - hostname: some_tech_hr_sw + type: switch + num_ports: 2 + + - hostname: some_tech_eng_sw + type: switch + num_ports: 3 + + - hostname: some_tech_db_srv + type: server + ip_address: 10.10.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.1.1 + dns_server: 8.8.8.2 + services: + - type: DatabaseService + options: + backup_server_ip: 10.10.1.12 # The some_tech_storage_srv server + - type: FTPClient + + - hostname: some_tech_storage_srv + type: server + ip_address: 10.10.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.1.1 + dns_server: 8.8.8.2 + services: + - type: FTPServer + + - hostname: some_tech_hr_1 + type: computer + ip_address: 10.10.3.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.3.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: some_tech_snr_dev_pc + type: computer + ip_address: 10.10.2.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.2.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: some_tech_jnr_dev_pc + type: computer + ip_address: 10.10.2.12 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.2.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + links: + # Home/Office Lan Links + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + - endpoint_a_hostname: pc_2 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 + - endpoint_a_hostname: server_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 3 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 4 + + # ISP Links + - endpoint_a_hostname: isp_rt + endpoint_a_port: 1 + endpoint_b_hostname: router_1 + endpoint_b_port: 2 + - endpoint_a_hostname: isp_rt + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_fw + endpoint_b_port: 1 + - endpoint_a_hostname: isp_rt + endpoint_a_port: 3 + endpoint_b_hostname: isp_dns_srv + endpoint_b_port: 1 + + + # SomeTech LAN Links + - endpoint_a_hostname: some_tech_fw + endpoint_a_port: 3 + endpoint_b_hostname: some_tech_web_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_fw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_rt + endpoint_b_port: 2 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_data_sw + endpoint_b_port: 3 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 3 + endpoint_b_hostname: some_tech_hr_sw + endpoint_b_port: 2 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 4 + endpoint_b_hostname: some_tech_eng_sw + endpoint_b_port: 3 + - endpoint_a_hostname: some_tech_data_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_db_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_data_sw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_storage_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_hr_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_hr_1 + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_eng_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_snr_dev_pc + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_eng_sw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_jnr_dev_pc + endpoint_b_port: 1 + + + +Inspection and Connectivity Test +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + +The following codeblock demonstrates how to access this network and all ``.show()`` to output the network details: + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + + network = multi_lan_internet_network_example() + + network.show() + +Which gives the output: + +.. code-block:: text + + +----------------------------------------------------+ + | Nodes | + +-----------------------+----------+-----------------+ + | Node | Type | Operating State | + +-----------------------+----------+-----------------+ + | router_1 | Router | ON | + | isp_rt | Router | ON | + | some_tech_rt | Router | ON | + | some_tech_fw | Firewall | ON | + | switch_1 | Switch | ON | + | some_tech_data_sw | Switch | ON | + | some_tech_hr_sw | Switch | ON | + | some_tech_eng_sw | Switch | ON | + | server_1 | Server | ON | + | isp_dns_srv | Server | ON | + | some_tech_web_srv | Server | ON | + | some_tech_db_srv | Server | ON | + | some_tech_storage_srv | Server | ON | + | pc_1 | Computer | ON | + | pc_2 | Computer | ON | + | some_tech_hr_1 | Computer | ON | + | some_tech_snr_dev_pc | Computer | ON | + | some_tech_jnr_dev_pc | Computer | ON | + +-----------------------+----------+-----------------+ + +-------------------------------------------------------------------------------------+ + | IP Addresses | + +-----------------------+----------+--------------+-----------------+-----------------+ + | Node | Port | IP Address | Subnet Mask | Default Gateway | + +-----------------------+----------+--------------+-----------------+-----------------+ + | router_1 | 1 | 192.168.1.1 | 255.255.255.0 | None | + | router_1 | 2 | 43.35.240.2 | 255.255.255.252 | None | + | isp_rt | 1 | 43.35.240.1 | 255.255.255.252 | None | + | isp_rt | 2 | 94.10.180.1 | 255.255.255.252 | None | + | isp_rt | 3 | 8.8.8.1 | 255.255.255.252 | None | + | some_tech_rt | 1 | 10.10.1.1 | 255.255.255.0 | None | + | some_tech_rt | 2 | 10.10.4.1 | 255.255.255.252 | None | + | some_tech_rt | 3 | 10.10.3.1 | 255.255.255.0 | None | + | some_tech_rt | 4 | 10.10.2.1 | 255.255.255.0 | None | + | some_tech_fw | external | 94.10.180.2 | 255.255.255.252 | None | + | some_tech_fw | internal | 10.10.4.2 | 255.255.255.252 | None | + | some_tech_fw | dmz | 94.10.180.5 | 255.255.255.252 | None | + | server_1 | 1 | 192.168.1.13 | 255.255.255.0 | 192.168.1.1 | + | isp_dns_srv | 1 | 8.8.8.2 | 255.255.255.252 | 8.8.8.1 | + | some_tech_web_srv | 1 | 94.10.180.6 | 255.255.255.252 | 94.10.180.5 | + | some_tech_db_srv | 1 | 10.10.1.11 | 255.255.255.0 | 10.10.1.1 | + | some_tech_storage_srv | 1 | 10.10.1.12 | 255.255.255.0 | 10.10.1.1 | + | pc_1 | 1 | 192.168.1.11 | 255.255.255.0 | 192.168.1.1 | + | pc_2 | 1 | 192.168.1.12 | 255.255.255.0 | 192.168.1.1 | + | some_tech_hr_1 | 1 | 10.10.3.11 | 255.255.255.0 | 10.10.3.1 | + | some_tech_snr_dev_pc | 1 | 10.10.2.11 | 255.255.255.0 | 10.10.2.1 | + | some_tech_jnr_dev_pc | 1 | 10.10.2.12 | 255.255.255.0 | 10.10.2.1 | + +-----------------------+----------+--------------+-----------------+-----------------+ + +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | Links | + +-------------------+--------------------------------------------+-----------------------+----------------------------------------------+-------+-------------------+--------------+ + | Endpoint A | A Port | Endpoint B | B Port | is Up | Bandwidth (MBits) | Current Load | + +-------------------+--------------------------------------------+-----------------------+----------------------------------------------+-------+-------------------+--------------+ + | isp_rt | Port 1: 8c:3c:c0:80:f2:07/43.35.240.1 | router_1 | Port 2: e1:b1:56:2c:fa:df/43.35.240.2 | True | 100.0 | 0.00000% | + | router_1 | Port 1: 54:6c:a6:23:4e:fd/192.168.1.1 | switch_1 | Port 4: fe:fd:f9:00:a7:62 | True | 100.0 | 0.00000% | + | isp_rt | Port 3: 2a:af:5c:2b:bc:e1/8.8.8.1 | isp_dns_srv | Port 1: 23:a3:81:d8:bb:b2/8.8.8.2 | True | 100.0 | 0.00003% | + | isp_rt | Port 2: 89:9b:bd:03:ab:89/94.10.180.1 | some_tech_fw | Port external: 9f:4b:76:68:6a:0c/94.10.180.2 | True | 100.0 | 0.00000% | + | some_tech_rt | Port 4: be:f3:e4:f8:d9:05/10.10.2.1 | some_tech_eng_sw | Port 3: e2:0c:dc:c5:49:c7 | True | 100.0 | 0.00006% | + | some_tech_rt | Port 3: c9:55:0c:c3:f9:af/10.10.3.1 | some_tech_hr_sw | Port 2: 25:ee:a2:f0:a5:87 | True | 100.0 | 0.00003% | + | some_tech_rt | Port 1: 16:0c:1a:ec:91:82/10.10.1.1 | some_tech_data_sw | Port 3: 70:ea:69:f8:1f:cf | True | 100.0 | 0.00006% | + | some_tech_fw | Port internal: fc:dd:9d:67:23:73/10.10.4.2 | some_tech_rt | Port 2: f4:af:8e:a4:c7:5a/10.10.4.1 | True | 100.0 | 0.00000% | + | some_tech_fw | Port dmz: 1b:50:ac:9d:fd:20/94.10.180.5 | some_tech_web_srv | Port 1: 95:f6:f1:79:57:2d/94.10.180.6 | True | 100.0 | 0.00003% | + | server_1 | Port 1: b8:39:55:01:6b:8b/192.168.1.13 | switch_1 | Port 3: 74:3d:af:db:69:6e | True | 100.0 | 0.00000% | + | pc_2 | Port 1: 3b:59:9b:44:22:47/192.168.1.12 | switch_1 | Port 2: 2e:eb:17:f7:a1:92 | True | 100.0 | 0.00000% | + | pc_1 | Port 1: 82:72:eb:cb:67:50/192.168.1.11 | switch_1 | Port 1: 18:1a:6e:fc:b4:18 | True | 100.0 | 0.00000% | + | some_tech_data_sw | Port 2: 96:3b:0e:28:95:f2 | some_tech_storage_srv | Port 1: 05:ee:9e:87:f9:49/10.10.1.12 | True | 100.0 | 0.00003% | + | some_tech_data_sw | Port 1: 0a:69:b6:2e:49:f9 | some_tech_db_srv | Port 1: 81:1c:18:96:7f:cf/10.10.1.11 | True | 100.0 | 0.00003% | + | some_tech_hr_sw | Port 1: 36:4a:02:b7:0c:45 | some_tech_hr_1 | Port 1: f6:a4:cc:19:15:3b/10.10.3.11 | True | 100.0 | 0.00003% | + | some_tech_eng_sw | Port 2: f8:54:70:20:97:5d | some_tech_jnr_dev_pc | Port 1: a7:5f:7c:9a:ab:32/10.10.2.12 | True | 100.0 | 0.00003% | + | some_tech_eng_sw | Port 1: ba:77:f5:9b:89:ae | some_tech_snr_dev_pc | Port 1: ab:3c:5d:27:50:52/10.10.2.11 | True | 100.0 | 0.00003% | + +-------------------+--------------------------------------------+-----------------------+----------------------------------------------+-------+-------------------+--------------+ + +Once the network in configured, it's vital we check all the services are working as expected, with Router and Firewall +ACLs permitting or denying traffic as per our configured ACL rules. + +**Testing Home/Office PCs Can Access the SomeTech website** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from src.primaite.simulator.system.applications.web_browser import WebBrowser + + network = multi_lan_internet_network_example() + + pc_1_browser: WebBrowser = network.get_node_by_hostname("pc_1").software_manager.software["WebBrowser"] + pc_2_browser: WebBrowser = network.get_node_by_hostname("pc_2").software_manager.software["WebBrowser"] + + assert pc_1_browser.get_webpage() + assert pc_2_browser.get_webpage() + + +**Testing Home/Office PCs Cannot Access SomeTech DB Servers Database Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.applications.database_client import DatabaseClient + + network = multi_lan_internet_network_example() + + pc_1_db_client: DatabaseClient = network.get_node_by_hostname("pc_1").software_manager.software["DatabaseClient"] + pc_2_db_client: DatabaseClient = network.get_node_by_hostname("pc_2").software_manager.software["DatabaseClient"] + + assert not pc_1_db_client.get_new_connection() + assert not pc_2_db_client.get_new_connection() + +**Testing Home/Office PCs Cannot Access SomeTech Storage Servers FTP Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + pc_1_ftp_client: FTPClient = network.get_node_by_hostname("pc_1").software_manager.software["FTPClient"] + pc_2_ftp_client: FTPClient = network.get_node_by_hostname("pc_2").software_manager.software["FTPClient"] + + assert not pc_1_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + assert not pc_2_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + +**Test SomeTech Web Server Can Access SomeTech DB Servers Database Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.applications.database_client import DatabaseClient + + network = multi_lan_internet_network_example() + + web_db_client: DatabaseClient = network.get_node_by_hostname("some_tech_web_srv").software_manager.software["DatabaseClient"] + + assert web_db_client.get_new_connection() + +**Test SomeTech Web Server Cannot Access SomeTech Storage Servers FTP Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + from primaite.simulator.network.hardware.nodes.host.server import Server + + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + web_server: Server = network.get_node_by_hostname("some_tech_web_srv") + + web_ftp_client: FTPClient = web_server.software_manager.software["FTPClient"] + + assert not web_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + +**Test SomeTech Dev PCs Can Access SomeTech DB Servers Database Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.applications.database_client import DatabaseClient + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + network = multi_lan_internet_network_example() + + some_tech_snr_dev_pc: Computer = network.get_node_by_hostname("some_tech_snr_dev_pc") + snr_dev_db_client: DatabaseClient = some_tech_snr_dev_pc.software_manager.software["DatabaseClient"] + + assert snr_dev_db_client.get_new_connection() + + some_tech_jnr_dev_pc: Computer = network.get_node_by_hostname("some_tech_jnr_dev_pc") + jnr_dev_db_client: DatabaseClient = some_tech_jnr_dev_pc.software_manager.software["DatabaseClient"] + + assert jnr_dev_db_client.get_new_connection() + +**Test SomeTech Senior Dev Can Access SomeTech Storage Servers FTP Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + network = multi_lan_internet_network_example() + + some_tech_storage_srv: Server = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_snr_dev_pc: Computer = network.get_node_by_hostname("some_tech_snr_dev_pc") + snr_dev_ftp_client: FTPClient = some_tech_snr_dev_pc.software_manager.software["FTPClient"] + + assert snr_dev_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + +**Test SomeTech Junior Dev Cannot Access SomeTech Storage Servers FTP Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + network = multi_lan_internet_network_example() + + some_tech_storage_srv: Server = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_jnr_dev_pc: Computer = network.get_node_by_hostname("some_tech_jnr_dev_pc") + jnr_dev_ftp_client: FTPClient = some_tech_jnr_dev_pc.software_manager.software["FTPClient"] + + assert not jnr_dev_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + +**Test SomeTech HR PC Cannot Access SomeTech DB Servers Database Service**** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.applications.database_client import DatabaseClient + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + network = multi_lan_internet_network_example() + + some_tech_hr_pc: Computer = network.get_node_by_hostname("some_tech_hr_1") + + hr_db_client: DatabaseClient = some_tech_hr_pc.software_manager.software["DatabaseClient"] + + assert not hr_db_client.get_new_connection() + + + +**Test SomeTech HR PC Cannot Access SomeTech Storage Servers FTP Service** + +.. code-block:: python + + from primaite.simulator.network.networks import multi_lan_internet_network_example + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.network.hardware.nodes.host.computer import Computer + + network = multi_lan_internet_network_example() + + some_tech_storage_srv: Server = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_hr_pc: Computer = network.get_node_by_hostname("some_tech_hr_1") + hr_ftp_client: FTPClient = some_tech_hr_pc.software_manager.software["FTPClient"] + + assert not hr_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) diff --git a/docs/source/configuration/simulation/nodes/router.rst b/docs/source/configuration/simulation/nodes/router.rst new file mode 100644 index 00000000..b9ba1ad5 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/router.rst @@ -0,0 +1,127 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _router_configuration: + +``router`` +========== + +A basic representation of a network router within the simulation. + +See :py:mod:`primaite.simulator.network.hardware.nodes.network.router.Router` + +example router +-------------- + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: router_1 + hostname: router_1 + type: router + num_ports: 5 + ports: + ... + acl: + ... + +.. include:: common/common_node_attributes.rst + +.. include:: common/node_type_list.rst + +``num_ports`` +------------- + +Optional. Default value is ``5``. + +The number of ports the router will have. + +``ports`` +--------- + +Sets up the router's ports with an IP address and a subnet mask. + +Example of setting ports for a router with 2 ports: + +.. code-block:: yaml + + nodes: + - ref: router_1 + ... + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + +``ip_address`` +"""""""""""""" + +The IP address for the given port. This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``subnet_mask`` +""""""""""""""" + +Optional. Default value is ``255.255.255.0``. + +The subnet mask setting for the port. + +``acl`` +------- + +Sets up the ACL rules for the router. + +e.g. + +.. code-block:: yaml + + nodes: + - ref: router_1 + ... + acl: + 1: + action: PERMIT + src_port: ARP + dst_port: ARP + 2: + action: PERMIT + protocol: ICMP + +See :py:mod:`primaite.simulator.network.hardware.nodes.network.router.AccessControlList` + +See :ref:`List of Ports ` for a list of ports. + +``action`` +"""""""""" + +Available options are + +- ``PERMIT`` : Allows the specified ``protocol`` or ``src_port`` and ``dst_port`` pairs +- ``DENY`` : Blocks the specified ``protocol`` or ``src_port`` and ``dst_port`` pairs + +``src_port`` +"""""""""""" + +Is used alongside ``dst_port``. Specifies the port where a packet originates. Used by the ACL Rule to determine if a packet with a specific source port is allowed to pass through the network node. + +``dst_port`` +"""""""""""" + +Is used alongside ``src_port``. Specifies the port where a packet is destined to arrive. Used by the ACL Rule to determine if a packet with a specific destination port is allowed to pass through the network node. + +``protocol`` +"""""""""""" + +Specifies which protocols are allowed by the ACL Rule to pass through the network node. + +See :ref:`List of IPProtocols ` for a list of protocols. + +.. include:: common/common_network_node_attributes.rst + +.. |NODE| replace:: router +.. |NODE_TYPE| replace:: ``router`` diff --git a/docs/source/configuration/simulation/nodes/server.rst b/docs/source/configuration/simulation/nodes/server.rst new file mode 100644 index 00000000..dbc32235 --- /dev/null +++ b/docs/source/configuration/simulation/nodes/server.rst @@ -0,0 +1,41 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _server_configuration: + +``server`` +========== + +A basic representation of a server within the simulation. + +See :py:mod:`primaite.simulator.network.hardware.nodes.host.server.Server` + +example server +-------------- + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: server_1 + hostname: server_1 + type: server + ip_address: 192.168.10.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + ... + services: + ... + +.. include:: common/common_node_attributes.rst + +.. include:: common/node_type_list.rst + +.. include:: common/common_host_node_attributes.rst + +.. |NODE| replace:: server +.. |NODE_TYPE| replace:: ``server`` diff --git a/docs/source/configuration/simulation/nodes/switch.rst b/docs/source/configuration/simulation/nodes/switch.rst new file mode 100644 index 00000000..263bedbb --- /dev/null +++ b/docs/source/configuration/simulation/nodes/switch.rst @@ -0,0 +1,39 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _switch_configuration: + +``switch`` +========== + +A basic representation of a network switch within the simulation. + +See :py:mod:`primaite.simulator.network.hardware.nodes.network.switch.Switch` + +example switch +-------------- + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: switch_1 + hostname: switch_1 + type: switch + num_ports: 8 + +.. include:: common/common_node_attributes.rst + +.. include:: common/node_type_list.rst + +``num_ports`` +------------- + +Optional. Default value is ``8``. + +The number of ports the switch will have. + +.. |NODE| replace:: switch +.. |NODE_TYPE| replace:: ``switch`` diff --git a/docs/source/configuration/simulation/software/applications.rst b/docs/source/configuration/simulation/software/applications.rst new file mode 100644 index 00000000..90ae3ec1 --- /dev/null +++ b/docs/source/configuration/simulation/software/applications.rst @@ -0,0 +1,25 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``applications`` +---------------- + +List of available applications that can be installed on a |NODE| can be found in :ref:`List of Applications ` + +application in configuration +"""""""""""""""""""""""""""" + +Applications takes a list of applications as shown in the example below. + +.. code-block:: yaml + + - ref: client_1 + hostname: client_1 + type: computer + ... + applications: + - ref: example_application + type: example_application_type + options: + # this section is different for each application diff --git a/docs/source/configuration/simulation/software/services.rst b/docs/source/configuration/simulation/software/services.rst new file mode 100644 index 00000000..88957001 --- /dev/null +++ b/docs/source/configuration/simulation/software/services.rst @@ -0,0 +1,25 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``services`` +------------ + +List of available services that can be installed on a |NODE| can be found in :ref:`List of Services ` + +services in configuration +""""""""""""""""""""""""" + +Services takes a list of services as shown in the example below. + +.. code-block:: yaml + + - ref: client_1 + hostname: client_1 + type: computer + ... + applications: + - ref: example_service + type: example_service_type + options: + # this section is different for each service diff --git a/docs/source/custom_agent.rst b/docs/source/custom_agent.rst deleted file mode 100644 index 8a95d3ae..00000000 --- a/docs/source/custom_agent.rst +++ /dev/null @@ -1,142 +0,0 @@ -.. only:: comment - - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -Custom Agents -============= - - -Integrating a user defined blue agent -************************************* - -.. note:: - - If you are planning to implement custom RL agents into PrimAITE, you must use the project as a repository. If you install PrimAITE as a python package from wheel, custom agents are not supported. - -PrimAITE has integration with Ray RLLib and StableBaselines3 agents. All agents interface with PrimAITE through an :py:class:`primaite.agents.agent.AgentSessionABC` which provides Input/Output of agent savefiles, as well as capturing and plotting performance metrics during training and evaluation. If you wish to integrate a custom blue agent, it is recommended to create a subclass of the :py:class:`primaite.agents.agent.AgentSessionABC` and implement the ``__init__()``, ``_setup()``, ``_save_checkpoint()``, ``learn()``, ``evaluate()``, ``_get_latest_checkpoint``, ``load()``, and ``save()`` methods. - -Below is a barebones example of a custom agent implementation: - -.. code:: python - - # src/primaite/agents/my_custom_agent.py - - from primaite.agents.agent import AgentSessionABC - from primaite.common.enums import AgentFramework, AgentIdentifier - - class CustomAgent(AgentSessionABC): - def __init__(self, training_config_path, lay_down_config_path): - super().__init__(training_config_path, lay_down_config_path) - assert self._training_config.agent_framework == AgentFramework.CUSTOM - assert self._training_config.agent_identifier == AgentIdentifier.MY_AGENT - self._setup() - - def _setup(self): - super()._setup() - self._env = Primaite( - training_config_path=self._training_config_path, - lay_down_config_path=self._lay_down_config_path, - session_path=self.session_path, - timestamp_str=self.timestamp_str, - ) - self._agent = ... # your code to setup agent - - def _save_checkpoint(self): - checkpoint_num = self._training_config.checkpoint_every_n_episodes - episode_count = self._env.episode_count - save_checkpoint = False - if checkpoint_num: - save_checkpoint = episode_count % checkpoint_num == 0 - # saves checkpoint if the episode count is not 0 and save_checkpoint flag was set to true - if episode_count and save_checkpoint: - ... - # your code to save checkpoint goes here. - # The path should start with self.checkpoints_path and include the episode number. - - def learn(self): - ... - # call your agent's learning function here. - - super().learn() # this will finalise learning and output session metadata - self.save() - - def evaluate(self): - ... - # call your agent's evaluation function here. - - self._env.close() - super().evaluate() - - def _get_latest_checkpoint(self): - ... - # Load an agent from file. - - @classmethod - def load(cls, path): - ... - # Create a CustomAgent object which loads model weights from file. - - def save(self): - ... - # Call your agent's function that saves it to a file - - -You will also need to modify :py:class:`primaite.primaite_session.PrimaiteSession` and :py:mod:`primaite.common.enums` to capture your new agent identifiers. - -.. code-block:: python - :emphasize-lines: 17, 18 - - # src/primaite/common/enums.py - - class AgentIdentifier(Enum): - """The Red Agent algo/class.""" - A2C = 1 - "Advantage Actor Critic" - PPO = 2 - "Proximal Policy Optimization" - HARDCODED = 3 - "The Hardcoded agents" - DO_NOTHING = 4 - "The DoNothing agents" - RANDOM = 5 - "The RandomAgent" - DUMMY = 6 - "The DummyAgent" - CUSTOM_AGENT = 7 - "Your custom agent" - -.. code-block:: python - :emphasize-lines: 3, 11, 12 - - # src/primaite_session.py - - from primaite.agents.my_custom_agent import CustomAgent - - # ... - - def setup(self): - """Performs the session setup.""" - if self._training_config.agent_framework == AgentFramework.CUSTOM: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Framework = {AgentFramework.CUSTOM}") - if self._training_config.agent_identifier == AgentIdentifier.CUSTOM_AGENT: - self._agent_session = CustomAgent(self._training_config_path, self._lay_down_config_path) - if self._training_config.agent_identifier == AgentIdentifier.HARDCODED: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Identifier =" f" {AgentIdentifier.HARDCODED}") - if self._training_config.action_type == ActionType.NODE: - # Deterministic Hardcoded Agent with Node Action Space - self._agent_session = HardCodedNodeAgent(self._training_config_path, self._lay_down_config_path) - -Finally, specify your agent in your training config. - -.. code-block:: yaml - - # ~/primaite/2.0.0/config/path/to/your/config_main.yaml - - # Training Config File - - agent_framework: CUSTOM - agent_identifier: CUSTOM_AGENT - random_red_agent: False - # ... - -Now you can :ref:`run a primaite session` with your custom agent by passing in the custom ``config_main``. diff --git a/docs/source/customising_scenarios.rst b/docs/source/customising_scenarios.rst new file mode 100644 index 00000000..709f032a --- /dev/null +++ b/docs/source/customising_scenarios.rst @@ -0,0 +1,4 @@ +Customising Agents +****************** + +For an example of how to customise red agent behaviour in the Data Manipulation scenario, please refer to the notebook ``Data-Manipulation-Customising-Red-Agent.ipynb``. diff --git a/docs/source/dependencies.rst b/docs/source/dependencies.rst index 942ccfd8..74f3cd14 100644 --- a/docs/source/dependencies.rst +++ b/docs/source/dependencies.rst @@ -1,10 +1,12 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK .. role:: raw-html(raw) :format: html +.. _Dependencies: + Dependencies ============ diff --git a/docs/source/developer_tools.rst b/docs/source/developer_tools.rst new file mode 100644 index 00000000..3d781e1d --- /dev/null +++ b/docs/source/developer_tools.rst @@ -0,0 +1,210 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +.. _Developer Tools: + +Developer Tools +*************** + +PrimAITE includes developer CLI tools that are intended to be used by developers. + +dev-mode +======== + +The dev-mode contains configuration which override any of the config files during runtime. + +This is intended to make debugging easier by removing the need to find the relevant configuration file/settings. + +Enabling dev-mode +----------------- + +The PrimAITE dev-mode can be enabled via the use of + +.. code-block:: + + primaite dev-mode enable + +Disabling dev-mode +------------------ + +The PrimAITE dev-mode can be disabled via the use of + +.. code-block:: + + primaite dev-mode disable + +Show current mode +----------------- + +To show if the dev-mode is enabled or not, use +The PrimAITE dev-mode can be disabled via the use of + +.. code-block:: + + primaite dev-mode show + +dev-mode configuration +====================== + +The following configures some specific items that the dev-mode overrides, if enabled. + +`--sys-log-level` or `-level` +---------------------------- + +The level of system logs can be overridden by dev-mode. + +By default, this is set to DEBUG + +The available options are [DEBUG|INFO|WARNING|ERROR|CRITICAL] + +.. code-block:: + + primaite dev-mode config -level INFO + +or + +.. code-block:: + + primaite dev-mode config --sys-log-level INFO + +`--output-sys-logs` or `-sys` +----------------------------- + +The outputting of system logs can be overridden by dev-mode. + +By default, this is set to False + +Enabling system logs +"""""""""""""""""""" + +To enable outputting of system logs + +.. code-block:: + + primaite dev-mode config --output-sys-logs + +or + +.. code-block:: + + primaite dev-mode config -sys + +Disabling system logs +""""""""""""""""""""" + +To disable outputting of system logs + +.. code-block:: + + primaite dev-mode config --no-sys-logs + +or + +.. code-block:: + + primaite dev-mode config -nsys + +`--output-pcap-logs` or `-pcap` +------------------------------- + +The outputting of packet capture logs can be overridden by dev-mode. + +By default, this is set to False + +Enabling PCAP logs +"""""""""""""""""" + +To enable outputting of packet capture logs + +.. code-block:: + + primaite dev-mode config --output-pcap-logs + +or + +.. code-block:: + + primaite dev-mode config -pcap + +Disabling PCAP logs +""""""""""""""""""" + +To disable outputting of packet capture logs + +.. code-block:: + + primaite dev-mode config --no-pcap-logs + +or + +.. code-block:: + + primaite dev-mode config -npcap + +`--output-to-terminal` or `-t` +------------------------------ + +The outputting of system logs to the terminal can be overridden by dev-mode. + +By default, this is set to False + +Enabling system log output to terminal +"""""""""""""""""""""""""""""""""""""" + +To enable outputting of system logs to terminal + +.. code-block:: + + primaite dev-mode config --output-to-terminal + +or + +.. code-block:: + + primaite dev-mode config -t + +Disabling system log output to terminal +""""""""""""""""""""""""""""""""""""""" + +To disable outputting of system logs to terminal + +.. code-block:: + + primaite dev-mode config --no-terminal + +or + +.. code-block:: + + primaite dev-mode config -nt + +path +---- + +PrimAITE dev-mode can override where sessions are output. + +By default, PrimAITE will output the sessions in USER_HOME/primaite/sessions + +With dev-mode enabled, by default, this will be changed to PRIMAITE_REPOSITORY_ROOT/sessions + +However, providing a path will let dev-mode output sessions to the given path e.g. + +.. code-block:: bash + :caption: Unix + + primaite dev-mode config path ~/output/path + +.. code-block:: powershell + :caption: Windows (Powershell) + + primaite dev-mode config path ~\output\path + +default path +"""""""""""" + +To reset the path to use the PRIMAITE_REPOSITORY_ROOT/sessions, run the command + +.. code-block:: + + primaite dev-mode config path --default diff --git a/docs/source/environment.rst b/docs/source/environment.rst new file mode 100644 index 00000000..2b76572d --- /dev/null +++ b/docs/source/environment.rst @@ -0,0 +1,10 @@ +RL Environments +*************** + +RL environments are the objects that directly interface with RL libraries such as Stable-Baselines3 and Ray RLLib. The PrimAITE simulation is exposed via three different environment APIs: + +* Gymnasium API - this is the standard interface that works with many RL libraries like SB3, Ray, Tianshou, etc. ``PrimaiteGymEnv`` adheres to the `Official Gymnasium documentation `_. +* Ray Single agent API - For training a single Ray RLLib agent +* Ray MARL API - For training multi-agent systems with Ray RLLib. ``PrimaiteRayMARLEnv`` adheres to the `Official Ray documentation `_. + +There are Jupyter notebooks which demonstrate integration with each of these three environments. They are located in ``~/primaite//notebooks/example_notebooks``. diff --git a/docs/source/example_notebooks.rst b/docs/source/example_notebooks.rst new file mode 100644 index 00000000..731ea566 --- /dev/null +++ b/docs/source/example_notebooks.rst @@ -0,0 +1,82 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +Example Jupyter Notebooks +========================= + +Executed Notebooks +------------------ + +There are a few example notebooks included which help with the understanding of PrimAITE's capabilities. + +The PrimAITE documentation includes a pre executed example of notebooks. See :ref:`Executed Notebooks`. + +In order to run the notebooks interactively, :ref:`install PrimAITE ` and follow these steps: + +Running Jupyter Notebooks +------------------------- + +1. Navigate to the PrimAITE directory + +.. code-block:: bash + :caption: Unix + + cd ~/primaite/{VERSION} + +.. code-block:: powershell + :caption: Windows (Powershell) + + cd ~\primaite\{VERSION} + +2. Run jupyter notebook (the python environment to which you installed PrimAITE must be active) + +.. code-block:: bash + :caption: Unix + + jupyter notebook + +.. code-block:: powershell + :caption: Windows (Powershell) + + jupyter notebook + +3. Opening the jupyter webpage (optional) + +The default web browser may automatically open the webpage. However, if that is not the case, click the link shown in your command prompt output. It should look like this: ``http://localhost:8888/?token=0123456798abc0123456789abc`` + + +4. Navigate to the list of notebooks + +The example notebooks are located in ``notebooks/example_notebooks/``. The file system shown in the jupyter webpage is relative to the location in which the ``jupyter notebook`` command was used. + + +Running Jupyter Notebooks via VSCode +------------------------------------ + +It is also possible to view the Jupyter notebooks within VSCode. + +The best place to start is by opening a notebook file (.ipynb) in VSCode. If using VSCode to view a notebook for the first time, follow the steps below. + +Installing extensions +""""""""""""""""""""" + +VSCode may need some extensions to be installed if not already done. +To do this, press the "Select Kernel" button on the top right. + +This should open a dialog which has the option to install python and jupyter extensions. + +.. image:: ../../_static/notebooks/install_extensions.png + :width: 700 + :align: center + :alt: :: The top dialog option that appears will automatically install the extensions + +The following extensions should now be installed + +.. image:: ../../_static/notebooks/extensions.png + :width: 300 + :align: center + +VSCode will then ask for a Python environment version to use. PrimAITE is compatible with Python versions 3.8 - 3.11 + +You should now be able to interact with the notebook. diff --git a/docs/source/game_layer.rst b/docs/source/game_layer.rst new file mode 100644 index 00000000..68984a1b --- /dev/null +++ b/docs/source/game_layer.rst @@ -0,0 +1,87 @@ +PrimAITE Game layer +******************* + +The Primaite codebase consists of two main modules: + +* ``simulator``: The simulation logic including the network topology, the network state, and behaviour of various hardware and software classes. +* ``game``: The agent-training infrastructure which helps reinforcement learning agents interface with the simulation. This includes the observation, action, and rewards, for RL agents, but also scripted deterministic agents. The game layer orchestrates all the interactions between modules. + +The simulator and game layer communicate using the PrimAITE State API and the PrimAITE Request API. + +The game layer is responsible for managing agents and getting them to interface with the simulator correctly. It consists of several components: + + +Agents +====== + +All agents inherit from the :py:class:`primaite.game.agent.interface.AbstractAgent` class, which mandates that they have an ObservationManager, ActionManager, and RewardManager. The agent behaviour depends on the type of agent, but there are two main types: + +* RL agents action during each step is decided by an appropriate RL algorithm. The agent within PrimAITE just acts to format and forward actions decided by an RL policy. +* Deterministic agents perform all of their decision making within the PrimAITE game layer. They typically have a scripted policy which always performs the same action or a rule-based policy which performs actions based on the current state of the simulation. They can have a stochastic element, and their seed is settable. + + +Observations +============ + +An agent's observations are managed by the ``ObservationManager`` class. It generates observations based on the current simulation state dictionary. It also provides the observation space during initial setup. The data is formatted so it's compatible with ``Gymnasium.spaces``. Observation spaces are composed of one or more components which are defined by the ``AbstractObservation`` base class. + +Actions +======= + +An agent's actions are managed by the ``ActionManager``. It converts actions selected by agents (which are typically integers chosen from a ``gymnasium.spaces.Discrete`` space) into simulation-friendly requests. It also provides the action space during initial setup. Action spaces are composed of one or more components which are defined by the ``AbstractAction`` base class. + +Rewards +======= + +An agent's reward function is managed by the ``RewardManager``. It calculates rewards based on the simulation state (in a way similar to observations). Rewards can be defined as a weighted sum of small reward components. For example, an agents reward can be based on the uptime of a database service plus the loss rate of packets between clients and a web server. + +Reward Components +----------------- + +Currently implemented are reward components tailored to the data manipulation scenario. View the full API and description of how they work here: :py:module:`primaite.game.agent.reward`. + +Reward Sharing +-------------- + +An agent's reward can be based on rewards of other agents. This is particularly useful for modelling a situation where the blue agent's job is to protect the ability of green agents to perform their pattern-of-life. This can be configured in the YAML file this way: + +```yaml +green_agent_1: # this agent sometimes tries to access the webpage, and sometimes the database + # actions, observations, and agent settings go here + reward_function: + reward_components: + + # When the webpage loads, the reward goes up by 0.25 when it fails to load, it goes down to -0.25 + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + + # When the database is reachable, the reward goes up by 0.05, when it is unreachable it goes down to -0.05 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + +blue_agent: + # actions, observations, and agent settings go here + reward_function: + reward_components: + + # When the database file is in a good state, blue's reward is 0.4, when it's in a corrupted state the reward is -0.4 + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + # The green's reward is added onto the blue's reward. + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + +``` + +When defining agent reward sharing, users must be careful to avoid circular references, as that would lead to an infinite calculation loop. PrimAITE will prevent circular dependencies and provide a helpful error message if they are detected in the yaml. diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index f07f1d27..28630cb4 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -1,6 +1,6 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK .. _getting-started: @@ -11,100 +11,93 @@ Getting Started Pre-Requisites -In order to get **PrimAITE** installed, you will need to have a python version between 3.8 and 3.10 installed. If you don't already have it, this is how to install it: +In order to get **PrimAITE** installed, you will need Python, venv, and pip. If you don't already have them, this is how to install it: -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + sudo add-apt-repository ppa:deadsnakes/ppa + sudo apt install python3.10 + sudo apt-get install python3-pip + sudo apt-get install python3-venv - sudo add-apt-repository ppa:deadsnakes/ppa - sudo apt install python3.10 - sudo apt-get install python3-pip - sudo apt-get install python3-venv - .. code-tab:: text - :caption: Windows (Powershell) +.. code-block:: text + :caption: Windows (Powershell) - - Manual install from: https://www.python.org/downloads/release/python-31011/ + - Manual install from: https://www.python.org/downloads/release/python-31011/ **PrimAITE** is designed to be OS-agnostic, and thus should work on most variations/distros of Linux, Windows, and MacOS. +Installing PrimAITE has been tested with all supported python versions, venv 20.24.1, and pip 23. + Install PrimAITE **************** -1. Create a primaite directory in your home directory: +1. Create a directory for your PrimAITE project: -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + mkdir -p ~/primaite/{VERSION} - mkdir ~/primaite/2.0.0 +.. code-block:: powershell + :caption: Windows (Powershell) - .. code-tab:: powershell - :caption: Windows (Powershell) + mkdir ~\primaite\{VERSION} - mkdir ~\primaite\2.0.0 2. Navigate to the primaite directory and create a new python virtual environment (venv) -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + cd ~/primaite/{VERSION} + python3 -m venv .venv - cd ~/primaite/2.0.0 - python3 -m venv .venv +.. code-block:: powershell + :caption: Windows (Powershell) - .. code-tab:: powershell - :caption: Windows (Powershell) - - cd ~\primaite\2.0.0 + cd ~\primaite\{VERSION} python3 -m venv .venv attrib +h .venv /s /d # Hides the .venv directory + 3. Activate the venv -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + source .venv/bin/activate - source .venv/bin/activate +.. code-block:: powershell + :caption: Windows (Powershell) - .. code-tab:: powershell - :caption: Windows (Powershell) - - .\.venv\Scripts\activate + .\.venv\Scripts\activate -4. Install PrimAITE using pip from PyPi +4. Install PrimAITE from your saved wheel file -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + pip install path/to/your/primaite.whl[rl] - pip install primaite +.. code-block:: powershell + :caption: Windows (Powershell) - .. code-tab:: powershell - :caption: Windows (Powershell) - - pip install primaite + pip install path\to\your\primaite.whl 5. Perform the PrimAITE setup -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + primaite setup - primaite setup - - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) primaite setup @@ -114,42 +107,67 @@ Clone & Install PrimAITE for Development To be able to extend PrimAITE further, or to build wheels manually before install, clone the repository to a location of your choice: -.. TODO:: Add repo path once we know what it is +1. Clone the repository. + +For example: .. code-block:: bash - git clone + git clone https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE cd primaite -Create and activate your Python virtual environment (venv) +2. Create and activate your Python virtual environment (venv) -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + python3 -m venv venv + source venv/bin/activate - python3 -m venv venv - source venv/bin/activate +.. code-block:: powershell + :caption: Windows (Powershell) - .. code-tab:: powershell - :caption: Windows (Powershell) + python3 -m venv venv + .\venv\Scripts\activate - python3 -m venv venv - .\venv\Scripts\activate +3. Install PrimAITE with the dev extra -Install PrimAITE with the dev extra +.. code-block:: bash + :caption: Unix -.. tabs:: lang + pip install -e .[dev,rl] - .. code-tab:: bash - :caption: Unix - - pip install -e .[dev] - - .. code-tab:: powershell - :caption: Windows (Powershell) - - pip install -e .[dev] +.. code-block:: powershell + :caption: Windows (Powershell) + pip install -e .[dev,rl] To view the complete list of packages installed during PrimAITE installation, go to the dependencies page (:ref:`Dependencies`). + +4. Set PrimAITE to run on development mode + +Running step 3 should have installed PrimAITE, verify this by running + +.. code-block:: bash + :caption: Unix + + primaite setup + +.. code-block:: powershell + :caption: Windows (Powershell) + + primaite setup + +To set PrimAITE to run in development mode: + +.. code-block:: bash + :caption: Unix + + primaite dev-mode enable + +.. code-block:: powershell + :caption: Windows (Powershell) + + primaite dev-mode enable + +More information about :ref:`Developer Tools` diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst index 8340d559..8fff0ea3 100644 --- a/docs/source/glossary.rst +++ b/docs/source/glossary.rst @@ -1,6 +1,6 @@ .. only:: comment - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK Glossary ============= @@ -38,14 +38,11 @@ Glossary Blue Agent A defensive agent that protects the network from Red Agent attacks to minimise disruption to green agents and protect data. - Information Exchange Requirement (IER) - Simulates network traffic by sending data from one network node to another via links for a specified amount of time. IERs can be part of green agent behaviour or red agent behaviour. PrimAITE can be configured to apply a penalty for green agents' IERs being blocked and a reward for red agents' IERs being blocked. - Pattern-of-Life (PoL) PoLs allow agents to change the current hardware, OS, file system, or service statuses of nodes during the course of an episode. For example, a green agent may restart a server node to represent scheduled maintainance. A red agent's Pattern-of-Life can be used to attack nodes by changing their states to CORRUPTED or COMPROMISED. Reward - The reward is a single number used by the blue agent to understand whether it's performing well or poorly. RL agents change their behaviour in an attempt to increase the expected reward each episode. The reward is generated based on the current states of the environment / :term:`reference environment` and is impacted positively by things like green IERS running successfully and negatively by things like nodes being compromised. + The reward is a single number used by the blue agent to understand whether it's performing well or poorly. RL agents change their behaviour in an attempt to increase the expected reward each episode. The reward is generated based on the current states of the environment and is impacted positively by things like green PoL running successfully and negatively by things like nodes being compromised. Observation An observation is a representation of the current state of the environment that is given to the learning agent so it can decide on which action to perform. If the environment is 'fully observable', the observation contains information about every possible aspect of the environment. More commonly, the environment is 'partially observable' which means the learning agent has to make decisions without knowing every detail of the current environment state. @@ -65,17 +62,11 @@ Glossary Episode When an episode starts, the network simulation is reset to an initial state. The agents take actions on each step of the episode until it reaches a terminal state, which usually happens after a predetermined number of steps. After the terminal state is reached, a new episode starts and the RL agent has another opportunity to protect the network. - Reference environment - While the network simulation is unfolding, a parallel simulation takes place which is identical to the main one except that blue and red agent actions are not applied. This reference environment essentially shows what would be happening to the network if there had been no cyberattack or defense. The reference environment is used to calculate rewards. - - Transaction - PrimAITE records the decisions of the learning agent by saving its observation, action, and reward at every time step. During each session, this data is saved to disk to allow for full inspection. - Laydown The laydown is a file which defines the training scenario. It contains the network topology, firewall rules, services, protocols, and details about green and red agent behaviours. - Gym - PrimAITE uses the Gym reinforcement learning framework API to create a training environment and interface with RL agents. Gym defines a common way of creating observations, actions, and rewards. + Gymnasium + PrimAITE uses the Gymnasium reinforcement learning framework API to create a training environment and interface with RL agents. Gymnasium defines a common way of creating observations, actions, and rewards. User app home - PrimAITE supports upgrading software version while retaining user data. The user data directory is where configs, notebooks, and results are stored, this location is `~/primaite` on linux/darwin and `C:\Users\\primaite\` on Windows. + PrimAITE supports upgrading software version while retaining user data. The user data directory is where configs, notebooks, and results are stored, this location is `~/primaite/` on linux/darwin and `C:\\Users\\\\primaite` on Windows. diff --git a/docs/source/migration_1.2_-_2.0.rst b/docs/source/migration_1.2_-_2.0.rst deleted file mode 100644 index c38fcbe9..00000000 --- a/docs/source/migration_1.2_-_2.0.rst +++ /dev/null @@ -1,57 +0,0 @@ -.. only:: comment - - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -v1.2 to v2.0 Migration guide -============================ - -**1. Installing PrimAITE** - - Like before, you can install primaite from the repository by running ``pip install -e .``. But, there is now an additional setup step which does several things, like setting up user directories, copy default configs and notebooks, etc. Once you have installed PrimAITE to your virtual environment, run this command to finalise setup. - - .. code-block:: bash - - primaite setup - -**2. Running a training session** - - In version 1.2 of PrimAITE, the main entry point for training or evaluating agents was the ``src/primaite/main.py`` file. v2.0.0 introduced managed 'sessions' which are responsible for reading configuration files, performing training, and writing outputs. - - ``main.py`` file still runs a training session but it now uses the new `PrimaiteSession`, and it now requires you to provide the path to your config files. - - .. code-block:: bash - - python src/primaite/main.py --tc path/to/training-config.yaml --ldc path/to/laydown-config.yaml - - Alternatively, the session can be invoked via the commandline by running: - - .. code-block:: bash - - primaite session --tc path/to/training-config.yaml --ldc path/to/laydown-config.yaml - -**3. Location of configs** - - In version 1.2, training configs and laydown configs were all stored in the project repository under ``src/primaite/config``. Version 2.0.0 introduced user data directories, and now when you install and setup PrimAITE, config files are stored in your user data location. On Linux/OSX, this is stored in ``~/primaite/2.0.0/config``. On Windows, this is stored in ``C:\Users\\primaite\configs``. Upon first setup, the configs folder is populated with some default yaml files. It is recommended that you store all your custom configuration files here. - -**4. Contents of configs** - - Some things that were previously part of the laydown config are now part of the traning config. - - * Actions - - If you have custom configs which use these, you will need to adapt them by moving the configuration from the laydown config to the training config. - - Also, there are new configurable items in the training config: - - * Observations - * Agent framework - * Agent - * Deep learning framework - * random red agents - * seed - * deterministic - * hard coded agent view - - Each of these items have default values which are designed so that PrimAITE has the same behaviour as it did in 1.2.0, so you do not have to specify them. - - ACL Rules in laydown configs have a new required parameter: ``position``. The lower the position, the higher up in the ACL table the rule will placed. If you have custom laydowns, you will need to go through them and add a position to each ACL_RULE. diff --git a/docs/source/notebooks/executed_notebooks.rst b/docs/source/notebooks/executed_notebooks.rst new file mode 100644 index 00000000..f99b13bb --- /dev/null +++ b/docs/source/notebooks/executed_notebooks.rst @@ -0,0 +1,16 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _Executed Notebooks: + +Executed Jupyter Notebooks +========================== + +Below is a list of available pre-executed notebooks. + +.. toctree:: + :maxdepth: 1 + :glob: + + **/* diff --git a/docs/source/primaite-dependencies.rst b/docs/source/primaite-dependencies.rst new file mode 100644 index 00000000..c70a299d --- /dev/null +++ b/docs/source/primaite-dependencies.rst @@ -0,0 +1,37 @@ ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| Name | Version | License | Description | URL | ++===================+=========+====================================+=======================================================================================================+==============================================+ +| gymnasium | 0.28.1 | MIT License | A standard API for reinforcement learning and a diverse set of reference environments (formerly Gym). | https://farama.org | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| ipywidgets | 8.1.3 | BSD License | Jupyter interactive widgets | http://jupyter.org | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| jupyterlab | 3.6.1 | BSD License | JupyterLab computational environment | https://jupyter.org | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| kaleido | 0.2.1 | MIT | Static image export for web-based visualization libraries with zero dependencies | https://github.com/plotly/Kaleido | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| matplotlib | 3.7.1 | Python Software Foundation License | Python plotting package | https://matplotlib.org | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| networkx | 3.1 | BSD License | Python package for creating and manipulating graphs and networks | https://networkx.org/ | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| numpy | 1.23.5 | BSD License | NumPy is the fundamental package for array computing with Python. | https://www.numpy.org | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| platformdirs | 3.5.1 | MIT License | A small Python package for determining appropriate platform-specific dirs, e.g. a "user data dir". | https://github.com/platformdirs/platformdirs | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| plotly | 5.15.0 | MIT License | An open-source, interactive data visualization library for Python | https://plotly.com/python/ | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| polars | 0.18.4 | MIT License | Blazingly fast DataFrame library | https://www.pola.rs/ | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| prettytable | 3.8.0 | BSD License (BSD (3 clause)) | A simple Python library for easily displaying tabular data in a visually appealing ASCII table format | https://github.com/jazzband/prettytable | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| pydantic | 2.7.0 | MIT License | Data validation using Python type hints | https://github.com/pydantic/pydantic | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| PyYAML | 6.0 | MIT License | YAML parser and emitter for Python | https://pyyaml.org/ | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| ray | 2.23.0 | Apache 2.0 | Ray provides a simple, universal API for building distributed applications. | https://github.com/ray-project/ray | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| stable-baselines3 | 2.1.0 | MIT | Pytorch version of Stable Baselines, implementations of reinforcement learning algorithms. | https://github.com/DLR-RM/stable-baselines3 | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| tensorflow | 2.12.0 | Apache Software License | TensorFlow is an open source machine learning framework for everyone. | https://www.tensorflow.org/ | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ +| typer | 0.9.0 | MIT License | Typer, build great CLIs. Easy to code. Based on Python type hints. | https://github.com/tiangolo/typer | ++-------------------+---------+------------------------------------+-------------------------------------------------------------------------------------------------------+----------------------------------------------+ diff --git a/docs/source/primaite_session.rst b/docs/source/primaite_session.rst deleted file mode 100644 index ed023499..00000000 --- a/docs/source/primaite_session.rst +++ /dev/null @@ -1,182 +0,0 @@ -.. only:: comment - - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -.. _run a primaite session: - -Run a PrimAITE Session -====================== - -Run ---- - -A PrimAITE session can be ran either with the ``primaite session`` command from the cli -(See :func:`primaite.cli.session`), or by calling :func:`primaite.main.run` from a Python terminal or Jupyter Notebook. -Both the ``primaite session`` and :func:`primaite.main.run` take a training config and a lay down config as parameters. - - -.. tabs:: - - .. code-tab:: bash - :caption: Unix CLI - - cd ~/primaite/2.0.0 - source ./.venv/bin/activate - primaite session --tc ./config/my_training_config.yaml --ldc ./config/my_lay_down_config.yaml - - .. code-tab:: powershell - :caption: Powershell CLI - - cd ~\primaite\2.0.0 - .\.venv\Scripts\activate - primaite session --tc .\config\my_training_config.yaml --ldc .\config\my_lay_down_config.yaml - - - .. code-tab:: python - :caption: Python - - from primaite.main import run - - training_config = - lay_down_config = - run(training_config, lay_down_config) - -When a session is ran, a session output sub-directory is created in the users app sessions directory (``~/primaite/2.0.0/sessions``). -The sub-directory is formatted as such: ``~/primaite/2.0.0/sessions//_/`` - -For example, when running a session at 17:30:00 on 31st January 2023, the session will output to: -``~/primaite/2.0.0/sessions/2023-01-31/2023-01-31_17-30-00/``. - -``primaite session`` can be ran in the terminal/command prompt without arguments. It will use the default configs in the directory ``primaite/config/example_config``. - - -Outputs -------- - -PrimAITE produces four types of outputs: - -* Session Metadata -* Results -* Diagrams -* Saved agents (training checkpoints and a final trained agent) - - -**Session Metadata** - -PrimAITE creates a ``session_metadata.json`` file that contains the following metadata: - - * **uuid** - The UUID assigned to the session upon instantiation. - * **start_datetime** - The date & time the session started in iso format. - * **end_datetime** - The date & time the session ended in iso format. - * **learning** - * **total_episodes** - The total number of training episodes completed. - * **total_time_steps** - The total number of training time steps completed. - * **evaluation** - * **total_episodes** - The total number of evaluation episodes completed. - * **total_time_steps** - The total number of evaluation time steps completed. - * **env** - * **training_config** - * **All training config items** - * **lay_down_config** - * **All lay down config items** - - -**Results** - -PrimAITE automatically creates two sets of results from each learning and evaluation session: - -* Average reward per episode - a csv file listing the average reward for each episode of the session. This provides, for example, an indication of the change over a training session of the reward value -* All transactions - a csv file listing the following values for every step of every episode: - - * Timestamp - * Episode number - * Step number - * Reward value - * Action taken (as presented by the blue agent on this step). Individual elements of the action space are presented in the format AS_X - * Initial observation space (what the blue agent observed when it decided its action) - -**Diagrams** - -* For each session, PrimAITE automatically creates a visualisation of the system / network lay down configuration. -* For each learning and evaluation task within the session, PrimAITE automatically plots the average reward per episode using PlotLY and saves it to the learning or evaluation subdirectory in the session directory. - -**Saved agents** - -For each training session, assuming the agent being trained implements the *save()* function and this function is called by the code, PrimAITE automatically saves the agent state. - -**Example Session Directory Structure** - -.. code-block:: text - - ~/ - └── primaite/ - └── 2.0.0/ - └── sessions/ - └── 2023-07-18/ - └── 2023-07-18_11-06-04/ - ├── evaluation/ - │ ├── all_transactions_2023-07-18_11-06-04.csv - │ ├── average_reward_per_episode_2023-07-18_11-06-04.csv - │ └── average_reward_per_episode_2023-07-18_11-06-04.png - ├── learning/ - │ ├── all_transactions_2023-07-18_11-06-04.csv - │ ├── average_reward_per_episode_2023-07-18_11-06-04.csv - │ ├── average_reward_per_episode_2023-07-18_11-06-04.png - │ ├── checkpoints/ - │ │ └── sb3ppo_10.zip - │ ├── SB3_PPO.zip - │ └── tensorboard_logs/ - │ ├── PPO_1/ - │ │ └── events.out.tfevents.1689674765.METD-9PMRFB3.42960.0 - │ ├── PPO_2/ - │ │ └── events.out.tfevents.1689674766.METD-9PMRFB3.42960.1 - │ ├── PPO_3/ - │ │ └── events.out.tfevents.1689674766.METD-9PMRFB3.42960.2 - │ ├── PPO_4/ - │ │ └── events.out.tfevents.1689674767.METD-9PMRFB3.42960.3 - │ ├── PPO_5/ - │ │ └── events.out.tfevents.1689674767.METD-9PMRFB3.42960.4 - │ ├── PPO_6/ - │ │ └── events.out.tfevents.1689674768.METD-9PMRFB3.42960.5 - │ ├── PPO_7/ - │ │ └── events.out.tfevents.1689674768.METD-9PMRFB3.42960.6 - │ ├── PPO_8/ - │ │ └── events.out.tfevents.1689674769.METD-9PMRFB3.42960.7 - │ ├── PPO_9/ - │ │ └── events.out.tfevents.1689674770.METD-9PMRFB3.42960.8 - │ └── PPO_10/ - │ └── events.out.tfevents.1689674770.METD-9PMRFB3.42960.9 - ├── network_2023-07-18_11-06-04.png - └── session_metadata.json - -Loading a session ------------------ - -A previous session can be loaded by providing the **directory** of the previous session to either the ``primaite session`` command from the cli -(See :func:`primaite.cli.session`), or by calling :func:`primaite.main.run` with session_path. - -.. tabs:: - - .. code-tab:: bash - :caption: Unix CLI - - cd ~/primaite/2.0.0 - source ./.venv/bin/activate - primaite session --load "path/to/session" - - .. code-tab:: bash - :caption: Powershell CLI - - cd ~\primaite\2.0.0 - .\.venv\Scripts\activate - primaite session --load "path\to\session" - - - .. code-tab:: python - :caption: Python - - from primaite.main import run - - run(session_path=) - -When PrimAITE runs a loaded session, PrimAITE will output in the provided session directory diff --git a/docs/source/request_system.rst b/docs/source/request_system.rst new file mode 100644 index 00000000..cc784cd4 --- /dev/null +++ b/docs/source/request_system.rst @@ -0,0 +1,136 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +Request System +************** + +``SimComponent`` objects in the simulation are decoupled from the agent training logic. However, they still need a managed means of accepting requests to perform actions. For this, they use ``RequestManager`` and ``RequestType``. + +Just like other aspects of SimComponent, the request types are not managed centrally for the whole simulation, but instead they are dynamically created and updated based on the nodes, links, and other components that currently exist in the simulation. This is achieved in the following way: + +- API + When requesting an action within the simulation, these two arguments must be provided: + + 1. ``request`` - selects which action you want to take on this ``SimComponent``. This is formatted as a list of strings such as ``['network', 'node', '', 'service', '', 'restart']``. + 2. ``context`` - optional extra information that can be used to decide how to process the request. This is formatted as a dictionary. For example, if the request requires authentication, the context can include information about the user that initiated the request to decide if their permissions are sufficient. + + When a request is resolved, it returns a success status, and optional additional data about the request. + + ``status`` can be one of: + + * ``success``: the request was executed + * ``failure``: the request could not be executed + * ``unreachable``: the target for the request was not found + * ``pending``: the request was initiated, but has not finished during this step + + ``data`` can be a dictionary with any arbitrary JSON-like data to describe the outcome of the request. + +- ``request`` detail + The request is a list of strings which help specify who should handle the request. The strings in the request list help RequestManagers traverse the 'ownership tree' of SimComponent. The example given above would be handled in the following way: + + 1. ``Simulation`` receives ``['network', 'node', 'computer_1', 'service', 'DNSService', 'restart']``. + The first element of the request is ``network``, therefore it passes the request down to its network. + 2. ``Network`` receives ``['node', 'computer_1', 'service', 'DNSService', 'restart']``. + The first element of the request is ``node``, therefore the network looks at the node name and passes the request down to the node with that name. + 3. ``computer_1`` receives ``['service', 'DNSService', 'restart']``. + The first element of the request is ``service``, therefore the node looks at the service name and passes the rest of the request to the service with that name. + 4. ``DNSService`` receives ``['restart']``. + Since ``restart`` is a defined request type in the service's own RequestManager, the service performs a restart. + +- ``context`` detail + The context is not used by any of the currently implemented components or requests. + +- Request response + When the simulator receives a request, it returns a response with a success status. The possible statuses are: + + * **success**: The request was received and successfully executed. + * For example, the agent tries to add an ACL rule and specifies correct parameters, and the ACL rule is added successfully. + + * **failure**: The request was received, but it could not be executed, or it failed while executing. + * For example, the agent tries to execute the ``WebBrowser`` application, but the webpage wasn't retrieved because the DNS server is not setup on the node. + + * **unreachable**: The request was sent to a simulation component that does not exist. + * For example, the agent tries to scan a file that has not been created yet. + +For more information, please refer to the ``Requests-and-Responses.ipynb`` jupyter notebook + +Technical Detail +================ + +This system was achieved by implementing two classes, :py:class:`primaite.simulator.core.RequestType`, and :py:class:`primaite.simulator.core.RequestManager`. + +``RequestType`` +--------------- + +The ``RequestType`` object stores a reference to a method that executes the request, for example a node could have a request type that stores a reference to ``self.turn_on()``. Technically, this can be any callable that accepts `request, context` as it's parameters. In practice, this is often defined using ``lambda`` functions within a component's ``self._init_request_manager()`` method. Optionally, the ``RequestType`` object can also hold a validator that will permit/deny the request depending on context. + +``RequestManager`` +------------------ + +The ``RequestManager`` object stores a mapping between strings and request types. It is responsible for processing the request and passing it down the ownership tree. Technically, the ``RequestManager`` is itself a callable that accepts `request, context` tuple, and so it can be chained with other request managers. + +A simple example without chaining can be seen in the :py:class:`primaite.simulator.file_system.file_system.File` class. + +.. code-block:: python + + class File(FileSystemItemABC): + ... + def _init_request_manager(self): + ... + request_manager.add_request("scan", RequestType(func=lambda request, context: RequestResponse.from_bool(self.scan()))) + request_manager.add_request("repair", RequestType(func=lambda request, context: RequestResponse.from_bool(self.repair()))) + request_manager.add_request("restore", RequestType(func=lambda request, context: RequestResponse.from_bool(self.restore()))) + +*ellipses (``...``) used to omit code impertinent to this explanation* + +Chaining RequestManagers +------------------------ + +A request function needs to be a callable that accepts ``request, context`` as parameters. Since the request manager resolves requests by invoking it with ``request, context`` as parameter, it is possible to use a ``RequestManager`` as a ``RequestType``. + +When a RequestManager accepts a request, it pops the first element and uses it to decide where it should send the remaining request. This is how PrimAITE traverses the ownership tree. If the ``RequestType`` has another ``RequestManager`` as its function, the request will be routed again. Each time the request is passed to a new request manager, the first element is popped. + +An example of how this works is in the :py:class:`primaite.simulator.network.hardware.base.Node` class. + +.. code-block:: python + + class Node(SimComponent): + ... + def _init_request_manager(self): + ... + # a regular action which is processed by the Node itself + request_manager.add_request("turn_on", RequestType(func=lambda request, context: self.turn_on())) + + # if the Node receives a request where the first word is 'service', it will use a dummy manager + # called self._service_request_manager to pass on the request to the relevant service. This dummy + # manager is simply here to map the service name that that service's own action manager. This is + # done because the next string after "service" is always the name of that service, so we need an + # RequestManager to pop that string before sending it onto the relevant service's RequestManager. + self._service_request_manager = RequestManager() + request_manager.add_request("service", RequestType(func=self._service_request_manager)) + ... + + def install_service(self, service): + self.services[service.name] = service + ... + # Here, the service name is registered to allow passing actions between the node and the service. + self._service_request_manager.add_request(service.name, RequestType(func=service._request_manager)) + +This process is repeated until the request word corresponds to a callable function rather than another ``RequestManager`` . + +Request Validation +------------------ + +There are times when a request should be rejected. For instance, if an agent attempts to run an application on a node that is currently off. For this purpose, requests are filtered by an object called a validator. :py:class:`primaite.simulator.core.RequestPermissionValidator` is a basic class whose ``__call__()`` method returns ``True`` if the request should be permitted or ``False`` if it cannot be permitted. For example, the Node class has a validator called :py:class:`primaite.simulator.network.hardware.base.Node._NodeIsOnValidator<_NodeIsOnValidator>` which allows requests only when the operating status of the node is ``ON``. + +Requests that are specified without a validator automatically get assigned an ``AllowAllValidator`` which allows requests no matter what. + +Request Response +---------------- + +The :py:class:`primaite.interface.request.RequestResponse` carries response data between the simulator and the game layer. The ``status`` field reports on the success or failure, and the ``data`` field is for any additional data. The most common way that this class is used is by the ``from_bool`` method. This way, given a True or False, a successful or failed request response is generated, respectively (with an empty data field). + +For instance, the ``execute`` action on a :py:class:`primaite.simulator.system.applications.web_browser.WebBrowser` calls the ``get_webpage()`` method. ``get_webpage()`` returns a True if the webpage was successfully retrieved, and False if unsuccessful for any reason, such as being blocked by an ACL, or if the database server is unresponsive. The boolean returned from ``get_webpage()`` is used to create the request response with ``from_bool()``. + +Just as the requests themselves were passed from owner to component, the request response is bubbled back up from component to owner until it arrives at the game layer. diff --git a/docs/source/simulation.rst b/docs/source/simulation.rst new file mode 100644 index 00000000..a8870cb4 --- /dev/null +++ b/docs/source/simulation.rst @@ -0,0 +1,34 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + + +Simulation +========== + + + + +Contents +######## + +.. toctree:: + :maxdepth: 8 + + simulation_structure + simulation_components/network/base_hardware + simulation_components/network/network_interfaces + simulation_components/network/transport_to_data_link_layer + simulation_components/network/nodes/host_node + simulation_components/network/nodes/network_node + simulation_components/network/nodes/router + simulation_components/network/nodes/switch + simulation_components/network/nodes/wireless_router + simulation_components/network/nodes/firewall + simulation_components/network/switch + simulation_components/network/network + simulation_components/system/internal_frame_processing + simulation_components/system/sys_log + simulation_components/system/pcap + simulation_components/system/session_and_software_manager + simulation_components/system/software diff --git a/docs/source/simulation_components/network/base_hardware.rst b/docs/source/simulation_components/network/base_hardware.rst new file mode 100644 index 00000000..1b83f3f4 --- /dev/null +++ b/docs/source/simulation_components/network/base_hardware.rst @@ -0,0 +1,111 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +############# +Base Hardware +############# + +The ``base.py`` module in ``primaite.simulator.network.hardware`` provides foundational components, interfaces, and classes for +modeling network hardware within PrimAITE simulations. It establishes core building blocks and abstractions that more +complex, specialized hardware components inherit from and build upon. + +The key elements defined in ``base.py`` are: + +``NetworkInterface`` +==================== + +- Abstract base class for network interfaces like NICs. Defines common attributes like MAC address, speed, MTU. +- Requires subclasses to implement ``enable()``, ``disable()``, ``send_frame()`` and ``receive_frame()``. +- Provides basic state description and request handling capabilities. + +``Node`` +======== +The Node class stands as a central component in ``base.py``, acting as the superclass for all network nodes within a +PrimAITE simulation. + +Node Attributes +--------------- + +See :ref:`Node Attributes` + +.. _Node Start up and Shut down: + +Node Start up and Shut down +--------------------------- +Nodes are powered on and off over multiple timesteps. By default, the node ``start_up_duration`` and ``shut_down_duration`` is 3 timesteps. + +Example code where a node is turned on: + +.. code-block:: python + + from primaite.simulator.network.hardware.base import Node + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + + node = Node(hostname="pc_a") + + assert node.operating_state is NodeOperatingState.OFF # By default, node is instantiated in an OFF state + + node.power_on() # power on the node + + assert node.operating_state is NodeOperatingState.BOOTING # node is booting up + + for i in range(node.start_up_duration + 1): + # apply timestep until the node start up duration + node.apply_timestep(timestep=i) + + assert node.operating_state is NodeOperatingState.ON # node is in ON state + + +If the node needs to be instantiated in an on state: + + +.. code-block:: python + + from primaite.simulator.network.hardware.base import Node + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + + node = Node(hostname="pc_a", operating_state=NodeOperatingState.ON) + + assert node.operating_state is NodeOperatingState.ON # node is in ON state + +Setting ``start_up_duration`` and/or ``shut_down_duration`` to ``0`` will allow for the ``power_on`` and ``power_off`` methods to be completed instantly without applying timesteps: + +.. code-block:: python + + from primaite.simulator.network.hardware.base import Node + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + + node = Node(hostname="pc_a", start_up_duration=0, shut_down_duration=0) + + assert node.operating_state is NodeOperatingState.OFF # node is in OFF state + + node.power_on() + + assert node.operating_state is NodeOperatingState.ON # node is in ON state + + node.power_off() + + assert node.operating_state is NodeOperatingState.OFF # node is in OFF state + +Node Behaviours/Functions +------------------------- + + +- **connect_nic()**: Connects a ``NetworkInterface`` to the node for network communication. +- **disconnect_nic()**: Removes a ``NetworkInterface`` from the node. +- **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`. +- **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. +- **show()**: Provides a summary of the node's current state, including network interfaces, operational status, and + other key attributes. + + +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. diff --git a/docs/source/simulation_components/network/network.rst b/docs/source/simulation_components/network/network.rst new file mode 100644 index 00000000..36e8ee48 --- /dev/null +++ b/docs/source/simulation_components/network/network.rst @@ -0,0 +1,115 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _network: + +Network +======= + +The ``Network`` class serves as the backbone of the simulation. It offers a framework to manage various network +components such as routers, switches, servers, and clients. This document provides a detailed explanation of how to +effectively use the ``Network`` class. + +Example Usage +------------- + +Below demonstrates how to use the Router node to connect Nodes, and block traffic using ACLs. For this demonstration, +we'll use the following Network that has a client, server, two switches, and a router. + +.. code-block:: text + + +------------+ +------------+ +------------+ +------------+ +------------+ + | | | | | | | | | | + | client_1 +------+ switch_2 +------+ router_1 +------+ switch_1 +------+ server_1 | + | | | | | | | | | | + +------------+ +------------+ +------------+ +------------+ +------------+ + +1. Relevant imports + +.. code-block:: python + + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.base import NetworkInterface + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.network.hardware.nodes.network.router import Router, ACLAction + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.network.hardware.nodes.network.switch import Switch + from primaite.simulator.network.transmission.network_layer import IPProtocol + from primaite.simulator.network.transmission.transport_layer import Port + +2. Create the Network + +.. code-block:: python + + network = Network() + +3. Create and configure the Router + +.. code-block:: python + + router_1 = Router(hostname="router_1", num_ports=3) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.2.1", subnet_mask="255.255.255.0") + +4. Create and configure the two Switches + +.. code-block:: python + + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() + switch_2 = Switch(hostname="switch_2", num_ports=6) + switch_2.power_on() + +5. Connect the Switches to the Router + +.. code-block:: python + + network.connect(endpoint_a=router_1.network_interfaces[1], endpoint_b=switch_1.network_interface[6]) + router_1.enable_port(1) + network.connect(endpoint_a=router_1.network_interfaces[2], endpoint_b=switch_2.network_interface[6]) + router_1.enable_port(2) + +6. Create the Client and Server nodes. + +.. code-block:: python + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1" + ) + client_1.power_on() + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + server_1.power_on() + +7. Connect the Client and Server to the relevant Switch + +.. code-block:: python + + network.connect(endpoint_a=switch_2.network_interface[1], endpoint_b=client_1.network_interface[1]) + network.connect(endpoint_a=switch_1.network_interface[1], endpoint_b=server_1.network_interface[1]) + +8. Add ACL rules on the Router to allow ARP and ICMP traffic. + +.. code-block:: python + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + src_port=Port.ARP, + dst_port=Port.ARP, + position=22 + ) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.ICMP, + position=23 + ) diff --git a/docs/source/simulation_components/network/network_interfaces.rst b/docs/source/simulation_components/network/network_interfaces.rst new file mode 100644 index 00000000..f50a1baa --- /dev/null +++ b/docs/source/simulation_components/network/network_interfaces.rst @@ -0,0 +1,125 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +################################# +Network Interface Hierarchy Model +################################# + +The network interface hierarchy model is designed to represent the various types of network interfaces and their +functionalities within a networking system. This model is organised into five distinct layers, each serving a specific +purpose in the abstraction, implementation, and utilisation of network interfaces. This hierarchical structure +facilitates modular development, enhances maintainability, and supports scalability by clearly separating concerns and +allowing for focused enhancements within each layer. + +.. image:: primaite_network_interface_model.png + :width: 500 + :align: center + +Layer Descriptions +================== + +#. **Base Layer** + + * **Purpose:** Serves as the foundation of the hierarchy, defining the most abstract properties and behaviours common + to all network interfaces. + * **Content:** Contains the NetworkInterface class, which abstracts basic functionalities such as enabling/disabling + the interface, sending, and receiving frames. + * **Significance:** Ensures that core functionalities are universally available across all types of network + interfaces, promoting code reuse and consistency. + +#. **Connection Type Layer** + + * **Purpose:** Differentiates network interfaces based on their physical connection type: wired or wireless. + * **Content:** Includes ``WiredNetworkInterface`` and ``WirelessNetworkInterface`` classes, each tailoring the base + functionalities to specific mediums. + * **Significance:** Allows the development of medium-specific features (e.g., handling point-to-point links in + wired devices) while maintaining a clear separation from IP-related functionalities. + +#. **IP Layer** + + * **Purpose:** Introduces Internet Protocol (IP) capabilities to network interfaces, enabling IP-based networking. + * **Content:** Includes ``IPWiredNetworkInterface`` and ``IPWirelessNetworkInterface`` classes, extending connection + type-specific classes with IP functionalities. + * **Significance:** Facilitates the implementation of IP address assignment, subnetting, and other Layer 3 + networking features, crucial for modern networking applications. + +#. **Interface Layer** + + * **Purpose:** Defines concrete implementations of network interfaces for specific devices or roles within a network. + * **Content:** Includes ``NIC``, ``RouterInterface``, ``SwitchPort``, ``WirelessNIC``, and ``WirelessAccessPoint`` + classes, each designed for a particular networking function or device. + * **Significance:** This layer allows developers to directly utilise or extend pre-built interfaces tailored to + specific networking tasks, enhancing development efficiency and clarity. + +#. **Device Layer** + + * **Purpose:** Maps the concrete interface implementations to their respective devices within a network, + illustrating practical usage scenarios. + * **Content:** Conceptually groups devices such as ``Computer``, ``Server``, ``Switch``, ``Router``, and ``Firewall`` + with the interfaces they utilise (e.g., ``Computer`` might use ``NIC`` or ``WirelessNIC``). + * **Significance:** Provides a clear understanding of how various network interfaces are applied in real-world + devices, aiding in system design and architecture planning. + + +Network Interface Classes +========================= + +**NetworkInterface (Base Layer)** + +- Abstract base class defining core interface properties like MAC address, speed, MTU. +- Requires subclasses implement key methods like send/receive frames, enable/disable interface. +- Establishes universal network interface capabilities. +- Malicious Network Events Monitoring: + + * Enhances network interfaces with the capability to monitor and capture Malicious Network Events (MNEs) based on predefined criteria such as specific keywords or traffic patterns. + * Integrates Number of Malicious Network Events (NMNE) detection functionalities, leveraging configurable settings like ``capture_nmne``, `nmne_capture_keywords``, and observation mechanisms such as ``NICObservation`` to classify and record network anomalies. + * Offers an additional layer of security and data analysis, crucial for identifying and mitigating malicious activities within the network infrastructure. Provides vital information for network security analysis and reinforcement learning algorithms. + +**WiredNetworkInterface (Connection Type Layer)** + +- Extends NetworkInterface for wired connection interfaces. +- Adds notions of physical/logical connectivity and link management. +- Mandates subclasses implement wired-specific methods. + +**WirelessNetworkInterface (Connection Type Layer)** + +- Extends NetworkInterface for wireless interfaces. +- Encapsulates wireless-specific behaviours like signal strength handling. +- Requires wireless-specific methods in subclasses. + +**Layer3Interface (IP Layer)** + +- Introduces IP addressing abilities with ip_address and subnet_mask. +- Validates address configuration. +- Enables participation in IP networking. + +**IPWiredNetworkInterface (IP Layer)** + +- Merges Layer3Interface and WiredNetworkInterface. +- Defines wired interfaces with IP capabilities. +- Meant to be extended, doesn't implement methods. + +**IPWirelessNetworkInterface (IP Layer)** + +- Combines Layer3Interface and WirelessNetworkInterface. +- Represents wireless interfaces with IP capabilities. +- Intended to be extended and specialised. + +**NIC (Interface Layer)** + +- Concrete wired NIC implementation combining IPWiredNetworkInterface and Layer3Interface. +- Provides network connectivity for host nodes. +- Manages MAC and IP addressing, frame processing. + +**WirelessNIC (Interface Layer)** + +- Concrete wireless NIC implementation combining IPWirelessNetworkInterface and Layer3Interface. +- Delivers wireless connectivity with IP for hosts. +- Handles wireless transmission/reception. + +**WirelessAccessPoint (Interface Layer)** + +- Concrete wireless access point implementation using IPWirelessNetworkInterface and Layer3Interface. +- Bridges wireless and wired networks. +- Manages wireless network. diff --git a/docs/source/simulation_components/network/nodes/firewall.rst b/docs/source/simulation_components/network/nodes/firewall.rst new file mode 100644 index 00000000..2f948081 --- /dev/null +++ b/docs/source/simulation_components/network/nodes/firewall.rst @@ -0,0 +1,432 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +######## +Firewall +######## + +The ``firewall.py`` module is a cornerstone in network security within the PrimAITE simulation, designed to simulate +the functionalities of a firewall in monitoring, controlling, and securing network traffic. + +Firewall Class +-------------- + +The ``Firewall`` class extends the ``Router`` class, incorporating advanced capabilities to scrutinise, direct, +and filter traffic between various network zones, guided by predefined security rules and policies. + +Key Features +============ + + +- **Access Control Lists (ACLs):** Employs ACLs to establish security rules for permitting or denying traffic + based on IP addresses, protocols, and port numbers, offering detailed oversight of network traffic. +- **Network Zone Segmentation:** Facilitates network division into distinct zones, including internal, external, + and DMZ (De-Militarized Zone), each governed by specific inbound and outbound traffic rules. +- **Interface Configuration:** Enables the configuration of network interfaces for connectivity to external, + internal, and DMZ networks, including setting up IP addressing and subnetting. +- **Protocol and Service Management:** Oversees and filters traffic across different protocols and services, + enforcing adherence to established security policies. +- **Dynamic Traffic Processing:** Actively processes incoming and outgoing traffic via relevant ACLs, determining + whether to forward or block based on the evaluation of rules. +- **Logging and Diagnostics:** Integrates with ``SysLog`` for detailed logging of firewall actions, supporting + security monitoring and incident investigation. + +Operations +========== + +- **Rule Definition and Management:** Permits the creation and administration of ACL rules for precise traffic + control, enabling the firewall to serve as an effective guard against unauthorised access. +- **Traffic Forwarding and Filtering:** Assesses network frames against ACL rules to allow or block traffic, + forwarding permitted traffic towards its destination whilst obstructing malicious or unauthorised requests. +- **Interface and Zone Configuration:** Provides mechanisms for configuring and managing network interfaces, + aligning with logical network architecture and security zoning requisites. + +Configuring Interfaces +====================== + +To set up firewall interfaces, allocate IP addresses and subnet masks to the external, internal, and DMZ interfaces +using the respective configuration methods: + +.. code-block:: python + + firewall.configure_external_port(ip_address="10.0.0.1", subnet_mask="255.255.255.0") + firewall.configure_internal_port(ip_address="192.168.1.1", subnet_mask="255.255.255.0") + firewall.configure_dmz_port(ip_address="172.16.0.1", subnet_mask="255.255.255.0") + + +Firewall ACLs +============= + +In the PrimAITE network simulation, six Access Control Lists (ACLs) are crucial for delineating and enforcing +comprehensive network security measures. These ACLs, designated as internal inbound, internal outbound, DMZ inbound, +DMZ outbound, external inbound, and external outbound, each serve a specific role in orchestrating the flow of data +through the network. They allow for meticulous control of traffic entering, exiting, and moving within the network, +ensuring robust protection against unauthorised access and potential cyber threats. By leveraging these ACLs both +individually and collectively, users can simulate a multi-layered security architecture. + +Internal Inbound ACL +^^^^^^^^^^^^^^^^^^^^ + +This ACL controls incoming traffic from the external network and DMZ to the internal network. It's crucial for +preventing unauthorised access to internal resources. By filtering incoming requests, it ensures that only legitimate +and necessary traffic can enter the internal network, protecting sensitive data and systems. + +Internal Outbound ACL +^^^^^^^^^^^^^^^^^^^^^ + +The internal outbound ACL manages traffic leaving the internal network to the external network or DMZ. It can restrict +internal users or systems from accessing potentially harmful external sites or services, mitigate data exfiltration +risks. + +DMZ Inbound ACL +^^^^^^^^^^^^^^^ + +This ACL regulates access to services hosted in the DMZ from the external network and internal network. Since the DMZ +hosts public-facing services like web and email servers, the DMZ inbound ACL is pivotal in allowing necessary access +while blocking malicious or unauthorised attempts, thus serving as a first line of defence. + +DMZ Outbound ACL +^^^^^^^^^^^^^^^^ + +The ACL controls traffic from DMZ to the external network and internal network. It's used to restrict the DMZ services +from initiating unauthorised connections, which is essential for preventing compromised DMZ services from being used +as launchpads for attacks or data exfiltration. + +External Inbound ACL +^^^^^^^^^^^^^^^^^^^^ + +This ACL filters all incoming traffic from the external network towards the internal network or DMZ. It's instrumental +in blocking unwanted or potentially harmful external traffic, ensuring that only traffic conforming to the security +policies is allowed into the network. **This ACL should only be used when the rule applies to both internal and DMZ +networks.** + +External Outbound ACL +^^^^^^^^^^^^^^^^^^^^^ + +This ACL governs traffic leaving the internal network or DMZ to the external network. It plays a critical role in data +loss prevention (DLP) by restricting the types of data and services that internal users and systems can access or +interact with on external networks. **This ACL should only be used when the rule applies to both internal and DMZ +networks.** + +Using ACLs Together +^^^^^^^^^^^^^^^^^^^ + +When these ACLs are used in concert, they create a robust security matrix that controls traffic flow in all directions: +into the internal network, out of the internal network, into the DMZ, out of the DMZ, and between these networks and +the external world. For example, while the external inbound ACL might block all incoming SSH requests to protect both +the internal network and DMZ, the internal outbound ACL could allow SSH access to specific external servers for +management purposes. Simultaneously, the DMZ inbound ACL might permit HTTP and HTTPS traffic to specific servers to +provide access to web services while the DMZ outbound ACL ensures these servers cannot make unauthorised outbound +connections. + +By effectively configuring and managing these ACLs, users can establish and experiment with detailed security policies +that are finely tuned to their simulated network's unique requirements and threat models, achieving granular oversight +over traffic flows. This not only enables secure simulated interactions and data exchanges within PrimAITE environments +but also fortifies the virtual network against unauthorised access and cyber threats, mirroring real-world network +security practices. + + +ACL Configuration Examples +========================== + +The subsequent examples provide detailed illustrations on configuring ACL rules within PrimAITE's firewall setup, +addressing various scenarios that encompass external attempts to access resources not only within the internal network +but also within the DMZ. These examples reflect the firewall's specific port configurations and showcase the +versatility and control that ACLs offer in managing network traffic, ensuring that security policies are precisely +enforced. Each example highlights different aspects of ACL usage, from basic traffic filtering to more complex +scenarios involving specific service access and protection against external threats. + +**Blocking External Traffic to Internal Network** + +To prevent all external traffic from accessing the internal network, with exceptions for approved services: + +.. code-block:: python + + # Default rule to deny all external traffic to the internal network + firewall.internal_inbound_acl.add_rule( + action=ACLAction.DENY, + src_ip_address="0.0.0.0", + src_wildcard_mask="255.255.255.255", + dst_ip_address="192.168.1.0", + dst_wildcard_mask="0.0.0.255", + position=1 + ) + + # Exception rule to allow HTTP traffic from external to internal network + firewall.internal_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTP, + dst_ip_address="192.168.1.0", + dst_wildcard_mask="0.0.0.255", + position=2 + ) + +**Allowing External Access to Specific Services in DMZ** + +To enable external traffic to access specific services hosted within the DMZ: + +.. code-block:: python + + # Allow HTTP and HTTPS traffic to the DMZ + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTP, + dst_ip_address="172.16.0.0", + dst_wildcard_mask="0.0.0.255", + position=3 + ) + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTPS, + dst_ip_address="172.16.0.0", + dst_wildcard_mask="0.0.0.255", + position=4 + ) + +**Edge Case - Permitting External SSH Access to a Specific Internal Server** + +To permit SSH access from a designated external IP to a specific server within the internal network: + +.. code-block:: python + + # Allow SSH from a specific external IP to an internal server + firewall.internal_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="10.0.0.2", + dst_port=Port.SSH, + dst_ip_address="192.168.1.10", + position=5 + ) + +**Restricting Access to Internal Database Server** + +To limit database server access to selected external IP addresses: + +.. code-block:: python + + # Allow PostgreSQL traffic from an authorized external IP to the internal DB server + firewall.internal_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="10.0.0.3", + dst_port=Port.POSTGRES_SERVER, + dst_ip_address="192.168.1.20", + position=6 + ) + + # Deny all other PostgreSQL traffic from external sources + firewall.internal_inbound_acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + dst_port=Port.POSTGRES_SERVER, + dst_ip_address="192.168.1.0", + dst_wildcard_mask="0.0.0.255", + position=7 + ) + +**Permitting DMZ Web Server Access while Blocking Specific Threats** + +To authorize HTTP/HTTPS access to a DMZ-hosted web server, excluding known malicious IPs: + +.. code-block:: python + + # Deny access from a known malicious IP to any DMZ service + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.DENY, + src_ip_address="10.0.0.4", + dst_ip_address="172.16.0.0", + dst_wildcard_mask="0.0.0.255", + position=8 + ) + + # Allow HTTP/HTTPS traffic to the DMZ web server + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTP, + dst_ip_address="172.16.0.2", + position=9 + ) + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTPS, + dst_ip_address="172.16.0.2", + position=10 + ) + +**Enabling Internal to DMZ Restricted Access** + +To facilitate restricted access from the internal network to DMZ-hosted services: + +.. code-block:: python + + # Permit specific internal application server HTTPS access to a DMZ-hosted API + firewall.internal_outbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.30", # Internal application server IP + dst_port=Port.HTTPS, + dst_ip_address="172.16.0.3", # DMZ API server IP + position=11 + ) + + # Deny all other traffic from the internal network to the DMZ + firewall.internal_outbound_acl.add_rule( + action=ACLAction.DENY, + src_ip_address="192.168.1.0", + src_wildcard_mask="0.0.0.255", + dst_ip_address="172.16.0.0", + dst_wildcard_mask="0.0.0.255", + position=12 + ) + + # Corresponding rule in DMZ inbound ACL to allow the traffic from the specific internal server + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.30", # Ensuring this specific source is allowed + dst_port=Port.HTTPS, + dst_ip_address="172.16.0.3", # DMZ API server IP + position=13 + ) + + # Deny all other internal traffic to the specific DMZ API server + firewall.dmz_inbound_acl.add_rule( + action=ACLAction.DENY, + src_ip_address="192.168.1.0", + src_wildcard_mask="0.0.0.255", + dst_port=Port.HTTPS, + dst_ip_address="172.16.0.3", # DMZ API server IP + position=14 + ) + +**Blocking Unwanted External Access** + +To block all SSH access attempts from the external network: + +.. code-block:: python + + # Deny all SSH traffic from any external source + firewall.external_inbound_acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + dst_port=Port.SSH, + position=1 + ) + +**Allowing Specific External Communication** + +To allow the internal network to initiate HTTP connections to the external network: + +.. code-block:: python + + # Permit outgoing HTTP traffic from the internal network to any external destination + firewall.external_outbound_acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + dst_port=Port.HTTP, + position=2 + ) + + +The examples above demonstrate the versatility and power of ACLs in crafting nuanced security policies. By combining +rules that specify permitted and denied traffic, both broadly and narrowly defined, administrators can construct +a firewall policy that safeguards network resources while ensuring necessary access is maintained. + +Show Rules Function +=================== + +The show_rules function in the Firewall class displays the configurations of Access Control Lists (ACLs) within a +network firewall. It presents a comprehensive table detailing the rules that govern the filtering and management of +network traffic. + +**Functionality:** + +This function showcases each rule in an ACL, outlining its: + +- **Index**: The rule's position within the ACL. +- **Action**: Specifies whether to permit or deny matching traffic. +- **Protocol**: The network protocol to which the rule applies. +- **Src IP and Dst IP**: Source and destination IP addresses. +- **Src Wildcard and Dst** Wildcard: Wildcard masks for source and destination IP ranges. +- **Src Port and Dst Port**: Source and destination ports. +- **Matched**: The number of times the rule has been matched by traffic. + +Example Output: + +.. code-block:: text + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - External Inbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 1 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | PERMIT | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 2 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - External Outbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 0 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | PERMIT | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 2 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - Internal Inbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 1 | PERMIT | ANY | ANY | ANY | 123 (NTP) | ANY | ANY | 123 (NTP) | 1 | + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 0 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | DENY | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - Internal Outbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 1 | PERMIT | ANY | ANY | ANY | 123 (NTP) | ANY | ANY | 123 (NTP) | 1 | + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 1 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | DENY | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - DMZ Inbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 1 | PERMIT | ANY | ANY | ANY | 123 (NTP) | ANY | ANY | 123 (NTP) | 1 | + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 0 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | DENY | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +---------------------------------------------------------------------------------------------------------------+ + | firewall_1 - DMZ Outbound Access Control List | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | Index | Action | Protocol | Src IP | Src Wildcard | Src Port | Dst IP | Dst Wildcard | Dst Port | Matched | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + | 1 | PERMIT | ANY | ANY | ANY | 123 (NTP) | ANY | ANY | 123 (NTP) | 1 | + | 22 | PERMIT | ANY | ANY | ANY | 219 (ARP) | ANY | ANY | 219 (ARP) | 1 | + | 23 | PERMIT | ICMP | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + | 24 | DENY | ANY | ANY | ANY | ANY | ANY | ANY | ANY | 0 | + +-------+--------+----------+--------+--------------+-----------+--------+--------------+-----------+-----------+ + + +The ``firewall.py`` module within PrimAITE empowers users to accurately model and simulate the pivotal role of +firewalls in network security. It provides detailed command over traffic flow and enforces security policies to safeguard +networked assets. diff --git a/docs/source/simulation_components/network/nodes/host_node.rst b/docs/source/simulation_components/network/nodes/host_node.rst new file mode 100644 index 00000000..bc3c13a5 --- /dev/null +++ b/docs/source/simulation_components/network/nodes/host_node.rst @@ -0,0 +1,47 @@ + +######### +Host Node +######### + +The ``host_node.py`` module is a core component of the PrimAITE project, aimed at simulating network host. It +encapsulates the functionality necessary for modelling the behaviour, communication capabilities, and interactions of +hosts in a networked environment. + + +HostNode Class +============== + +The ``HostNode`` class acts as a foundational representation of a networked device or computer, capable of both +initiating and responding to network communications. + +**Attributes:** + +- Manages IP addressing with support for IPv4. +- Employs ``NIC`` or ``WirelessNIC`` (subclasses of``IPWiredNetworkInterface``) to simulate wired network connections. +- Integrates with ``SysLog`` for logging operational events, aiding in debugging and monitoring the host node's + behaviour. + +**Key Methods:** + +- Facilitates the sending and receiving of ``Frame`` objects to simulate data link layer communications. +- Manages a variety of network services and applications, enhancing the simulation's realism and functionality. + +Default Services and Applications +================================= + +Both the ``HostNode`` and its subclasses come equipped with a suite of default services and applications critical for +fundamental network operations: + +1. **ARP (Address Resolution Protocol):** The ``HostARP`` subclass enhances ARP functionality for host-specific + operations. + +2. **DNS (Domain Name System) Client:** Facilitates domain name resolution to IP addresses, enabling web navigation. + +3. **FTP (File Transfer Protocol) Client:** Supports file transfers across the network. + +4. **ICMP (Internet Control Message Protocol):** Utilised for network diagnostics and control, such as executing ping + requests. + +5. **NTP (Network Time Protocol) Client:** Synchronises the host's clock with network time servers. + +6. **Web Browser:** A simulated application that allows the host to request and display web content. diff --git a/docs/source/simulation_components/network/nodes/network_node.rst b/docs/source/simulation_components/network/nodes/network_node.rst new file mode 100644 index 00000000..33bcea5b --- /dev/null +++ b/docs/source/simulation_components/network/nodes/network_node.rst @@ -0,0 +1,41 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +############ +Network Node +############ + + +The ``network_node.py`` module within the PrimAITE project is pivotal for simulating network nodes like routers and +switches, which are integral to network traffic management. This module establishes the framework for these devices, +enabling them to receive and process network frames effectively. + +Overview +======== + +The module defines the ``NetworkNode`` class, an abstract base class that outlines essential behaviours for network +devices tasked with handling network traffic. It is designed to be extended by more specific device simulations that +implement these foundational capabilities. + +NetworkNode Class +================= + +The ``NetworkNode`` class is at the heart of the module, providing an interface for network devices that participate +in the transmission and routing of data within the simulated environment. + +**Key Features:** + +- **Frame Processing:** Central to the class is the ability to receive and process network frames, facilitating the + simulation of data flow through network devices. + +- **Abstract Methods:** Includes abstract methods such as ``receive_frame``, which subclasses must implement to specify + how devices handle incoming traffic. + +- **Extensibility:** Designed for extension, allowing for the creation of specific device simulations, such as router + and switch classes, that embody unique behaviours and functionalities. + + +The ``network_node.py`` module's abstract approach to defining network devices allows the PrimAITE project to simulate +a wide range of network behaviours and scenarios comprehensively. By providing a common framework for all network +nodes, it facilitates the development of a modular and scalable simulation environment. diff --git a/docs/source/simulation_components/network/nodes/router.rst b/docs/source/simulation_components/network/nodes/router.rst new file mode 100644 index 00000000..7679baa0 --- /dev/null +++ b/docs/source/simulation_components/network/nodes/router.rst @@ -0,0 +1,41 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +###### +Router +###### + +The ``router.py`` module is a pivotal component of the PrimAITE, designed to simulate the complex functionalities of a +router within a network simulation. Routers are essential for directing traffic between different network segments, +and this module provides the tools necessary to model these devices' behaviour and capabilities accurately. + +Router Class +------------ + +The ``Router`` class embodies the core functionalities of a network router, extending the ``NetworkNode`` class to +incorporate routing-specific behaviours. + +**Key Features:** + +- **IP Routing:** Supports dynamic handling of IP packets, including forwarding based on destination IP addresses and + subnetting. +- **Routing Table:** Maintains a routing table to determine the best path for forwarding packets. +- **Protocol Support:** Implements support for key networking protocols, including ARP for address resolution and ICMP + for diagnostic messages. +- **Interface Management:** Manages multiple ``RouterInterface`` instances, enabling connections to different network + segments. +- **Network Interface Configuration:** Tools for configuring router interfaces, including setting IP addresses, subnet + masks, and enabling/disabling interfaces. +- **Logging and Monitoring:** Integrates with ``SysLog`` for logging operational events, aiding in debugging and + monitoring router behaviour. + +**Operations:** + +- **Packet Forwarding:** Utilises the routing table to forward packets to their correct destination across + interconnected networks. +- **ARP Handling:** Responds to ARP requests for any IP addresses configured on its interfaces, facilitating + communication within local networks. +- **ICMP Processing:** Generates and processes ICMP packets, such as echo requests and replies, for network diagnostics. + +The ``router.py`` module offers a comprehensive simulation of router functionalities. By providing detailed modelling of router operations, including packet forwarding, interface management, and protocol handling, PrimAITE enables the exploration of advanced network topologies and routing scenarios. diff --git a/docs/source/simulation_components/network/nodes/switch.rst b/docs/source/simulation_components/network/nodes/switch.rst new file mode 100644 index 00000000..0595f363 --- /dev/null +++ b/docs/source/simulation_components/network/nodes/switch.rst @@ -0,0 +1,29 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +###### +Switch +###### + +The ``switch.py`` module is a crucial component of the PrimAITE, aimed at simulating network switches within a network simulation environment. Network switches play a vital role in managing data flow within local area networks (LANs) by forwarding frames based on MAC addresses. This module provides a comprehensive framework for modelling switch operations and behaviours. + +Switch Class Overview +--------------------- + +The module introduces the concept of switch ports through the ``SwitchPort`` class, which extends the functionality of ``WiredNetworkInterface`` to simulate the operation of switch ports in a network. + +**Key Features:** + +- **Data Link Layer Operation:** Operates at the data link layer (Layer 2) of the OSI model, handling the reception and forwarding of frames based on MAC addresses. +- **Port Management:** Tools for configuring switch ports, including enabling/disabling ports, setting port speeds, and managing port security features. +- **Logging and Monitoring:** Integrates with ``SysLog`` for logging operational events, aiding in debugging and + monitoring switch behaviour. + +Functionality and Implementation +--------------------------------- + +- **MAC Address Learning:** Dynamically learns and associates MAC addresses with switch ports, enabling intelligent frame forwarding. +- **Frame Forwarding:** Utilises the learned MAC address table to forward frames only to the specific port associated with the destination MAC address, minimising unnecessary network traffic. + +The ``switch.py`` module offers a realistic and configurable representation of switch operations. By detailing the functionalities of the ``SwitchPort`` class, the module lays the foundation for simulating complex network topologies. diff --git a/docs/source/simulation_components/network/nodes/wireless_router.rst b/docs/source/simulation_components/network/nodes/wireless_router.rst new file mode 100644 index 00000000..75cbe0f7 --- /dev/null +++ b/docs/source/simulation_components/network/nodes/wireless_router.rst @@ -0,0 +1,193 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +###### +Router +###### + +The ``WirelessRouter`` class extends the functionality of the standard ``Router`` class within PrimAITE, +integrating wireless networking capabilities. This class enables the simulation of a router that supports both wired +and wireless connections, allowing for a more comprehensive network simulation environment. + +Overview +-------- + +The ``WirelessRouter`` class is designed to simulate the operations of a real-world wireless router, offering both +Ethernet and Wi-Fi connectivity. This includes managing wireless access points, configuring network interfaces for +different frequencies, and handling wireless frames transmission. + +Features +-------- + +- **Dual Interface Support:** Supports both wired (Ethernet) and wireless network interfaces. +- **Wireless Access Point Configuration:** Allows configuring a wireless access point, including setting its IP + address, subnet mask, and operating frequency. +- **Frequency Management:** Utilises the ``AirSpaceFrequency`` enum to set the operating frequency of wireless + interfaces, supporting common Wi-Fi bands like 2.4 GHz and 5 GHz. +- **Seamless Wireless Communication:** Integrates with the ``AirSpace`` class to manage wireless transmissions across + different frequencies, ensuring that wireless communication is realistically simulated. + +Usage +----- + +To use the ``WirelessRouter`` class in a network simulation, instantiate it similarly to a regular router but with +additional steps to configure wireless settings: + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter + from primaite.simulator.network.airspace import AirSpaceFrequency + + # Instantiate the WirelessRouter + wireless_router = WirelessRouter(hostname="MyWirelessRouter") + + # Configure a wired Ethernet interface + wireless_router.configure_port(port=2, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + + # Configure a wireless access point + wireless_router.configure_wireless_access_point( + port=1, ip_address="192.168.2.1", + subnet_mask="255.255.255.0", + frequency=AirSpaceFrequency.WIFI_2_4 + ) + + + +Integration with AirSpace +------------------------- + +The ``WirelessRouter`` class works closely with the ``AirSpace`` class to simulate the transmission of wireless frames. +Frames sent from wireless interfaces are transmitted across the simulated airspace, allowing for interactions with +other wireless devices within the same frequency band. + +Example Scenario +---------------- + +This example sets up a network with two PCs (PC A and PC B), each connected to their own `WirelessRouter` +(Router 1 and Router 2). These routers are then wirelessly connected to each other, enabling communication between the +PCs through the routers over the airspace. Access Control Lists (ACLs) are configured on the routers to permit ARP and +ICMP traffic, ensuring basic network connectivity and ping functionality. + +.. code-block:: python + + from primaite.simulator.network.airspace import AIR_SPACE, AirSpaceFrequency + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.network.hardware.nodes.network.router import ACLAction + from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter + from primaite.simulator.network.transmission.network_layer import IPProtocol + from primaite.simulator.network.transmission.transport_layer import Port + + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = WirelessRouter(hostname="router_1", start_up_duration=0) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_router_interface("192.168.0.1", "255.255.255.0") + network.connect(pc_a.network_interface[1], router_1.router_interface) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = WirelessRouter(hostname="router_2", start_up_duration=0) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_router_interface("192.168.2.1", "255.255.255.0") + network.connect(pc_b.network_interface[1], router_2.router_interface) + + # Configure the wireless connection between Router 1 and Router 2 + router_1.configure_wireless_access_point( + port=1, + ip_address="192.168.1.1", + subnet_mask="255.255.255.0", + frequency=AirSpaceFrequency.WIFI_2_4 + ) + router_2.configure_wireless_access_point( + port=1, + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + frequency=AirSpaceFrequency.WIFI_2_4 + ) + + # Configure routes for inter-router communication + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + router_2.route_table.add_route( + address="192.168.0.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + # Test connectivity + print(pc_a.ping(pc_b.network_interface[1].ip_address)) + print(pc_b.ping(pc_a.network_interface[1].ip_address)) + +This setup demonstrates the `WirelessRouter` class's capability to manage both wired and wireless connections within a +simulated network environment. By configuring the wireless access points and enabling the appropriate ACL rules, the +example facilitates basic network operations such as ARP resolution and ICMP pinging between devices across different +network segments. + +Viewing Wireless Network Configuration +-------------------------------------- + +The `AirSpace.show()` function is an invaluable tool for inspecting the current wireless network configuration within +the PrimAITE environment. It presents a table summarising all wireless interfaces, including routers and access points, +that are active within the airspace. The table outlines each device's connected node name, MAC address, IP address, +subnet mask, operating frequency, and status, providing a comprehensive view of the wireless network topology. + +Example Output +^^^^^^^^^^^^^^^ + +Below is an example output of the `AirSpace.show()` function, demonstrating the visibility it provides into the +wireless network: + +.. code-block:: none + + +----------------+-------------------+-------------+---------------+--------------+---------+ + | Connected Node | MAC Address | IP Address | Subnet Mask | Frequency | Status | + +----------------+-------------------+-------------+---------------+--------------+---------+ + | router_1 | 31:29:46:53:ed:f8 | 192.168.1.1 | 255.255.255.0 | WiFi 2.4 GHz | Enabled | + | router_2 | 34:c8:47:43:98:78 | 192.168.1.2 | 255.255.255.0 | WiFi 2.4 GHz | Enabled | + +----------------+-------------------+-------------+---------------+--------------+---------+ + +This table aids in verifying that wireless devices are correctly configured and operational. It also helps in +diagnosing connectivity issues by ensuring that devices are on the correct frequency and have the appropriate network +settings. The `Status` column, indicating whether a device is enabled or disabled, further assists in troubleshooting +by quickly identifying any devices that are not actively participating in the network. + +Utilising the `AirSpace.show()` function is particularly beneficial in complex network simulations where multiple +wireless devices are in use. It provides a snapshot of the wireless landscape, facilitating the understanding of how +devices interact within the network and ensuring that configurations are aligned with the intended network architecture. + +The addition of the ``WirelessRouter`` class enriches the PrimAITE simulation toolkit by enabling the simulation of +mixed wired and wireless network environments. diff --git a/docs/source/simulation_components/network/primaite_network_interface_model.png b/docs/source/simulation_components/network/primaite_network_interface_model.png new file mode 100644 index 00000000..68b05293 Binary files /dev/null and b/docs/source/simulation_components/network/primaite_network_interface_model.png differ diff --git a/docs/source/simulation_components/network/transport_to_data_link_layer.rst b/docs/source/simulation_components/network/transport_to_data_link_layer.rst new file mode 100644 index 00000000..0220ec45 --- /dev/null +++ b/docs/source/simulation_components/network/transport_to_data_link_layer.rst @@ -0,0 +1,148 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +Transport Layer to Data Link Layer +================================== + +From the `OSI Model <[OSI Model](https://en.wikipedia.org/wiki/OSI_model,>`_, the transport layer (layer 4) through to +the data link layer (layer 2) have been loosely modelled to provide somewhat realistic network Frame generation. + +Transport Layer (Layer 4) +######################### + +**UDPHeader:** Represents a UDP header for the transport layer of a Network Frame. It includes source and destination +ports. UDP (User Datagram Protocol) is a connectionless and unreliable transport protocol used for data transmission. + +**TCPFlags:** Enum representing TCP control flags used in a TCP connection, such as SYN, ACK, FIN, and RST. TCP +(Transmission Control Protocol) is a connection-oriented and reliable transport protocol used for establishing and +maintaining data streams. + +**TCPHeader:** Represents a TCP header for the transport layer of a Network Frame. It includes source and destination +ports and TCP flags. This header is used for establishing and managing TCP connections. + +Network Layer (Layer 3) +####################### + + +**IPProtocol:** Enum representing transport layer protocols in the IP header, such as TCP, UDP, and ICMP. It is used to +indicate the type of transport layer protocol being used in the IP header. + +**Precedence:** Enum representing the Precedence levels in Quality of Service (QoS) for IP packets. It is used to +specify the priority of IP packets for Quality of Service handling. + +**ICMPType:** Enumeration of common ICMP (Internet Control Message Protocol) types. It defines various types of ICMP +messages used for network troubleshooting and error reporting. + +**ICMPPacket:** Models an ICMP header and includes ICMP type, code, identifier, and sequence number. It is used to +create ICMP packets for network control and error reporting. + +**IPPacket:** Represents the IP layer of a network frame. It includes source and destination IP addresses, protocol +(TCP/UDP/ICMP), Time to Live (TTL), and Precedence for QoS. This header is used to route data packets across the +network based on IP addresses. + + +PrimAITE Layer (Custom Layer) +############################# + +The PrimAITE layer has a custom header represented by the ``PrimaiteHeader`` class. It is designed to carry +PrimAITE-specific metadata required for reinforcement learning (RL) purposes. + +**PrimaiteHeader:** This is a custom header for carrying PrimAITE-specific metadata. It contains the following fields: + - **agent_source:** Enum representing the agent source of the transmission, such as RED, GREEN, or BLUE. This field helps identify the source or category of the data transmission. + - **data_status:** Enum representing the status of the data in transmission, such as GOOD, COMPROMISED, or CORRUPT. This field indicates the integrity of the data being transmitted. + +Data Link Layer (Layer 2) +######################### + +**ARPEntry:** Represents an entry in the ARP cache. It consists of the following fields: + + - **mac_address:** The MAC address associated with the IP address. + - **nic_uuid:** The NIC (Network Interface Card) UUID through which the NIC with the IP address is reachable. + +**ARPPacket:** Represents the ARP layer of a network frame, and it includes the following fields: + + - **request:** ARP operation. Set to True for a request and False for a reply. + - **sender_mac_addr:** Sender's MAC address. + - **sender_ip_address:** Sender's IP address (IPv4 format). + - **target_mac_addr:** Target's MAC address. + - **target_ip_address:** Target's IP address (IPv4 format). + +**EthernetHeader:** Represents the Ethernet layer of a network frame. It includes source and destination MAC addresses. +This header is used to identify the physical hardware addresses of devices on a local network. + +**Frame:** Represents a complete network frame with all layers. It includes an ``EthernetHeader``, an ``IPPacket``, an +optional ``TCPHeader``, ``UDPHeader``, or ``ICMPPacket``, a ``PrimaiteHeader`` and an optional payload. This class +combines all the headers and data to create a complete network frame that can be sent over the network and used in the +PrimAITE simulation. + +Basic Usage +########### + +TCP SYN Frame +------------- + +Here we will model a TCP synchronize request from a port 80 on the host 192.168.0.100 which has a NIC with a MAC +address of 'aa:bb:cc:dd:ee:ff' to port 8080 on the host 10.0.0.10 which has a NIC with a MAC address of +'11:22:33:44:55:66'. + + +.. code-block:: python + + from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame + from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol + from primaite.simulator.network.transmission.transport_layer import TCPFlags, TCPHeader + + # Transport Layer + tcp_header = TCPHeader( + src_port=80, + dst_port=8080, + flags=[TCPFlags.SYN] + ) + + # Network Layer + ip_packet = IPPacket( + src_ip_address="192.168.0.100", + dst_ip_address="10.0.0.10", + protocol=IPProtocol.TCP + ) + # Data Link Layer + ethernet_header = EthernetHeader( + src_mac_addr="aa:bb:cc:dd:ee:ff", + dst_mac_addr="11:22:33:44:55:66" + ) + + frame = Frame( + ethernet=ethernet_header, + ip=ip_packet, + tcp=tcp_header, + ) + +This produces the following ``Frame`` (displayed in json format) + +.. code-block:: json + + { + "ethernet": { + "src_mac_addr": "aa:bb:cc:dd:ee:ff", + "dst_mac_addr": "11:22:33:44:55:66" + }, + "ip": { + "src_ip_address": "192.168.0.100", + "dst_ip_address": "10.0.0.10", + "protocol": "tcp", + "ttl": 64, + "precedence": 0 + }, + "tcp": { + "src_port": 80, + "dst_port": 8080, + "flags": [ + 1 + ] + }, + "primaite": { + "agent_source": 2, + "data_status": 1 + } + } diff --git a/docs/source/simulation_components/system/applications/data_manipulation_bot.rst b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst new file mode 100644 index 00000000..9188733b --- /dev/null +++ b/docs/source/simulation_components/system/applications/data_manipulation_bot.rst @@ -0,0 +1,205 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DataManipulationBot: + +DataManipulationBot +################### + +The ``DataManipulationBot`` class provides functionality to connect to a :ref:`DatabaseService` and execute malicious SQL statements. + +Overview +======== + +The bot is intended to simulate a malicious actor carrying out attacks like: + +- Dropping tables +- Deleting records +- Modifying data + +on a database server by abusing an application's trusted database connectivity. + +The bot performs attacks in the following stages to simulate the real pattern of an attack: + +- Logon - *The bot gains credentials and accesses the node.* +- Port Scan - *The bot finds accessible database servers on the network.* +- Attacking - *The bot delivers the payload to the discovered database servers.* + +Each of these stages has a random, configurable probability of succeeding (by default 10%). The bot can also be configured to repeat the attack once complete. + +Usage +===== + +- Create an instance and call ``configure`` to set: + - Target database server IP + - Database password (if needed) + - SQL statement payload + - Probabilities for succeeding each of the above attack stages +- Call ``run`` to connect and execute the statement. + +The bot handles connecting, executing the statement, and disconnecting. + +In a simulation, the bot can be controlled by using ``DataManipulationAgent`` which calls ``run`` on the bot at configured timesteps. + +Implementation +============== + +The bot connects to a :ref:`DatabaseClient` and leverages its connectivity. The host running ``DataManipulationBot`` must also have a :ref:`DatabaseClient` installed on it. + +- Uses the Application base class for lifecycle management. +- Credentials, target IP and other options set via ``configure``. +- ``run`` handles connecting, executing statement, and disconnecting. +- SQL payload executed via ``query`` method. +- Results in malicious SQL being executed on remote database server. + + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot + from primaite.simulator.system.applications.database_client import DatabaseClient + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + operating_state=NodeOperatingState.ON # initialise the computer in an ON state + ) + network.connect(endpoint_b=client_1.network_interface[1], endpoint_a=switch_2.network_interface[1]) + client_1.software_manager.install(DatabaseClient) + client_1.software_manager.install(DataManipulationBot) + data_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + data_manipulation_bot.configure(server_ip_address=IPv4Address("192.168.1.14"), payload="DELETE") + data_manipulation_bot.run() + +This would connect to the database service at 192.168.1.14, authenticate, and execute the SQL statement to delete database contents. + +Example with ``DataManipulationAgent`` +"""""""""""""""""""""""""""""""""""""" + +If not using the data manipulation bot manually, it needs to be used with a data manipulation agent. Below is an example section of configuration file for setting up a simulation with data manipulation bot and agent. + +.. code-block:: yaml + + game: + # ... + agents: + - ref: data_manipulation_red_bot + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: + type: UC2RedObservation + options: + nodes: + - node_name: client_1 + observations: + - logon_status + - operating_status + applications: + - application_ref: data_manipulation_bot + observations: + operating_status + health_status + folders: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_ref: data_manipulation_bot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + # ... + + simulation: + network: + nodes: + - ref: client_1 + type: computer + # ... additional configuration here + applications: + - ref: data_manipulation_bot + type: DataManipulationBot + options: + port_scan_p_of_success: 0.1 + data_manipulation_p_of_success: 0.1 + payload: "DELETE" + server_ip: 192.168.1.14 + - ref: web_server_database_client + type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DataManipulationBot +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DataManipulationBot`` + +``server_ip`` +""""""""""""" + +IP address of the :ref:`DatabaseService` which the ``DataManipulationBot`` will try to attack. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``server_password`` +""""""""""""""""""" + +Optional. Default value is ``None``. + +The password that the ``DataManipulationBot`` will use to access the :ref:`DatabaseService`. + +``payload`` +""""""""""" + +Optional. Default value is ``DELETE``. + +The payload that the ``DataManipulationBot`` will send to the :ref:`DatabaseService`. + +.. include:: ../common/db_payload_list.rst + +``port_scan_p_of_success`` +"""""""""""""""""""""""""" + +Optional. Default value is ``0.1``. + +The chance of the ``DataManipulationBot`` to succeed with a port scan (and therefore continue the attack). + +This must be a float value between ``0`` and ``1``. + +``data_manipulation_p_of_success`` +"""""""""""""""""""""""""""""""""" + +Optional. Default value is ``0.1``. + +The chance of the ``DataManipulationBot`` to succeed with a data manipulation attack. + +This must be a float value between ``0`` and ``1``. diff --git a/docs/source/simulation_components/system/applications/database_client.rst b/docs/source/simulation_components/system/applications/database_client.rst new file mode 100644 index 00000000..363c4f4e --- /dev/null +++ b/docs/source/simulation_components/system/applications/database_client.rst @@ -0,0 +1,111 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DatabaseClient: + +DatabaseClient +############## + +The ``DatabaseClient`` provides a client interface for connecting to the :ref:`DatabaseService`. + +Key features +============ + +- Connects to the :ref:`DatabaseService` via the ``SoftwareManager``. +- Handles connecting and disconnecting. +- Handles multiple connections using a dictionary, mapped to connection UIDs +- Executes SQL queries and retrieves result sets. + +Usage +===== + +- Initialise with server IP address and optional password. +- Connect to the :ref:`DatabaseService` with ``get_new_connection``. +- Retrieve results in a dictionary. +- Disconnect when finished. + +Implementation +============== + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Active sessions are held as ``DatabaseClientConnection`` objects in a dictionary. +- Connect and disconnect methods manage sessions. +- Payloads serialised as dictionaries for transmission. +- Extends base Application class. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.applications.database_client import DatabaseClient + + client = Computer( + hostname="client", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + operating_state=NodeOperatingState.ON # initialise the computer in an ON state + ) + + # install DatabaseClient + client.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = client.software_manager.software.get("DatabaseClient") + + # Configure the DatabaseClient + database_client.configure(server_ip_address=IPv4Address("192.168.0.1")) # address of the DatabaseService + database_client.run() + + # Establish a new connection + database_client.get_new_connection() + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_computer + hostname: example_computer + type: computer + ... + applications: + - ref: database_client + type: DatabaseClient + options: + db_server_ip: 192.168.0.1 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DatabaseClient +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DatabaseClient`` + + +``db_server_ip`` +"""""""""""""""" + +IP address of the :ref:`DatabaseService` that the ``DatabaseClient`` will connect to + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``server_password`` +""""""""""""""""""" + +Optional. Default value is ``None``. + +The password that the ``DatabaseClient`` will use to access the :ref:`DatabaseService`. diff --git a/docs/source/simulation_components/system/applications/dos_bot.rst b/docs/source/simulation_components/system/applications/dos_bot.rst new file mode 100644 index 00000000..6ddbac72 --- /dev/null +++ b/docs/source/simulation_components/system/applications/dos_bot.rst @@ -0,0 +1,160 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DoSBot: + +DoSBot +###### + +The ``DoSBot`` is an implementation of a Denial of Service attack within the PrimAITE simulation. This specifically simulates a `Slow Loris attack `. + +Key features +============ + +- Connects to the :ref:`DatabaseService` via the ``SoftwareManager``. +- Makes many connections to the :ref:`DatabaseService` which ends up using up the available connections. + +Usage +===== + +- Configure with target IP address and optional password. +- use ``run`` to run the application_loop of DoSBot to begin attacks +- DoSBot runs through different actions at each timestep + +Implementation +============== + +- Leverages :ref:`DatabaseClient` to create connections with :ref`DatabaseServer`. +- Extends base Application class. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Install DoSBot on computer + computer.software_manager.install(DoSBot) + dos_bot: DoSBot = computer.software_manager.software.get("DoSBot") + + # Configure the DoSBot + dos_bot.configure( + target_ip_address=IPv4Address("192.168.0.10"), + payload="SPOOF DATA", + repeat=False, + port_scan_p_of_success=0.8, + dos_intensity=1.0, + max_sessions=1000 + ) + + # run DoSBot + dos_bot.run() + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_computer + hostname: example_computer + type: computer + ... + applications: + - ref: dos_bot + type: DoSBot + options: + target_ip_address: 192.168.0.10 + payload: SPOOF DATA + repeat: False + port_scan_p_of_success: 0.8 + dos_intensity: 1.0 + max_sessions: 1000 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DoSBot +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DoSBot`` + +``target_ip_address`` +""""""""""""""""""""" + +IP address of the :ref:`DatabaseService` which the ``DataManipulationBot`` will try to attack. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``target_port`` +""""""""""""""" + +Optional. Default value is ``5432``. + +Port of the target service. + +See :ref:`List of IPProtocols ` for a list of protocols. + +``payload`` +""""""""""" + +Optional. Default value is ``None``. + +The payload that the ``DoSBot`` sends as part of its attack. + +.. include:: ../common/db_payload_list.rst + +``repeat`` +"""""""""" + +Optional. Default value is ``False``. + +If ``True`` the ``DoSBot`` will maintain its attack. + +``port_scan_p_of_success`` +"""""""""""""""""""""""""" + +Optional. Default value is ``0.1``. + +The chance of the ``DoSBot`` to succeed with a port scan (and therefore continue the attack). + +This must be a float value between ``0`` and ``1``. + +``dos_intensity`` +""""""""""""""""" + +Optional. Default value is ``1.0``. + +The intensity of the Denial of Service attack. This is multiplied by the number of ``max_sessions``. + +This must be a float value between ``0`` and ``1``. + +``max_sessions`` +"""""""""""""""" + +Optional. Default value is ``1000``. + +The maximum number of sessions the ``DoSBot`` is able to make. + +This must be an integer value equal to or greater than ``0``. diff --git a/docs/source/simulation_components/system/applications/web_browser.rst b/docs/source/simulation_components/system/applications/web_browser.rst new file mode 100644 index 00000000..c46089ba --- /dev/null +++ b/docs/source/simulation_components/system/applications/web_browser.rst @@ -0,0 +1,111 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _WebBrowser: + +WebBrowser +########## + +The ``WebBrowser`` provides a client interface for connecting to the :ref:`WebServer`. + +Key features +============ + +- Connects to the :ref:`WebServer` via the ``SoftwareManager``. +- Simulates HTTP requests and HTTP packet transfer across a network +- Allows the emulation of HTTP requests between client and server: + - Automatically uses ``DNSClient`` to resolve domain names + - GET: performs an HTTP GET request from client to server +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the ``WebBrowser``. +- Service runs on HTTP port 80 by default. (TODO: HTTPS) +- Execute sending an HTTP GET request with ``get_webpage`` + +Implementation +============== + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for making HTTP requests between an HTTP client and server. +- Extends base Service class. + + +Examples +======== + +Python +"""""" + +The ``WebBrowser`` utilises :ref:`DNSClient` and :ref:`DNSServer` to resolve a URL. + +The :ref:`DNSClient` must be configured to use the :ref:`DNSServer`. The :ref:`DNSServer` should be configured to have the ``WebBrowser`` ``target_url`` within its ``domain_mapping``. + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.applications.web_browser import WebBrowser + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Install WebBrowser on computer + computer.software_manager.install(WebBrowser) + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + web_browser.run() + + # configure the WebBrowser + web_browser.target_url = "arcd.com" + + # once DNS server is configured with the correct domain mapping + # this should work + web_browser.get_webpage() + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_computer + hostname: example_computer + type: computer + ... + applications: + - ref: web_browser + type: WebBrowser + options: + target_url: http://arcd.com/ + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: WebBrowser +.. |SOFTWARE_NAME_BACKTICK| replace:: ``WebBrowser`` + +``target_url`` +"""""""""""""" + +The URL that the ``WebBrowser`` will request when ``get_webpage`` is called without parameters. + +The URL can be in any format so long as the domain is within it e.g. + +The domain ``arcd.com`` can be matched by + +- http://arcd.com/ +- http://arcd.com/users/ +- arcd.com diff --git a/docs/source/simulation_components/system/common/common_configuration.rst b/docs/source/simulation_components/system/common/common_configuration.rst new file mode 100644 index 00000000..27625407 --- /dev/null +++ b/docs/source/simulation_components/system/common/common_configuration.rst @@ -0,0 +1,18 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``ref`` +======= + +Human readable name used as reference for the |SOFTWARE_NAME_BACKTICK|. Not used in code. + +``type`` +======== + +The type of software that should be added. To add |SOFTWARE_NAME| this must be |SOFTWARE_NAME_BACKTICK|. + +``options`` +=========== + +The configuration options are the attributes that fall under the options for an application. diff --git a/docs/source/simulation_components/system/common/db_payload_list.rst b/docs/source/simulation_components/system/common/db_payload_list.rst new file mode 100644 index 00000000..f51227c6 --- /dev/null +++ b/docs/source/simulation_components/system/common/db_payload_list.rst @@ -0,0 +1,11 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _Database Payload List: + +Available Database Payloads: + +- ``SELECT`` +- ``INSERT`` +- ``DELETE`` diff --git a/docs/source/simulation_components/system/internal_frame_processing.rst b/docs/source/simulation_components/system/internal_frame_processing.rst new file mode 100644 index 00000000..9c5356cc --- /dev/null +++ b/docs/source/simulation_components/system/internal_frame_processing.rst @@ -0,0 +1,98 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _internal_frame_processing: + +Internal Frame Processing +========================= + +Inbound +------- + +At the NIC +^^^^^^^^^^ +When a Frame is received on the Node's NIC: + +- The NIC checks if it is enabled. If so, it will process the Frame. +- The Frame's received timestamp is set. +- The Frame is captured by the NIC's PacketCapture if configured. +- The NIC decrements the IP Packet's TTL by 1. +- The NIC calls the Node's ``receive_frame`` method, passing itself as the receiving NIC and the Frame. + + +At the Node +^^^^^^^^^^^ + +When ``receive_frame`` is called on the Node: + +- The source IP address is added to the ARP cache if not already present. +- The Frame's protocol is checked: + - If ARP or ICMP, the Frame is passed to that protocol's handler method. + - Otherwise it is passed to the SessionManager's ``receive_frame`` method. + +At the SessionManager +^^^^^^^^^^^^^^^^^^^^^ + +When ``receive_frame`` is called on the SessionManager: + +- It extracts the key session details from the Frame: + - Protocol (TCP, UDP, etc) + - Source IP + - Destination IP + - Source Port + - Destination Port +- It checks if an existing Session matches these details. +- If no match, a new Session is created to represent this exchange. +- The payload and new/existing Session ID are passed to the SoftwareManager's ``receive_payload_from_session_manager`` method. + +At the SoftwareManager +^^^^^^^^^^^^^^^^^^^^^^ + +Inside ``receive_payload_from_session_manager``: + +- The SoftwareManager checks its port/protocol mapping to find which Service or Application is listening on the destination port and protocol. +- The payload and Session ID are forwarded to that receiver Service/Application instance via their ``receive`` method. +- The Service/Application can then process the payload as needed. + +Outbound +-------- + +At the Service/Application +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a Service or Application needs to send a payload: + +- It calls the SoftwareManager's ``send_payload_to_session_manager`` method. +- Passes the payload, and either destination IP and destination port for new payloads, or session id for existing sessions. + +At the SoftwareManager +^^^^^^^^^^^^^^^^^^^^^^ + +Inside ``send_payload_to_session_manager``: + +- The SoftwareManager forwards the payload and details through to to the SessionManager's ``receive_payload_from_software_manager`` method. + +At the SessionManager +^^^^^^^^^^^^^^^^^^^^^ + +When ``receive_payload_from_software_manager`` is called: + +- If a Session ID was provided, it looks up the Session. +- Gets the destination MAC address by checking the ARP cache. +- If no Session ID was provided, the destination Port, IP address and Mac Address are used along with the outbound IP Address and Mac Address to create a new Session. +- Calls `send_payload_to_nic`` to construct and send the Frame. + +When ``send_payload_to_nic`` is called: + +- It constructs a new Frame with the payload, using the source NIC's MAC, source IP, destination MAC, etc. +- The outbound NIC is looked up via the ARP cache based on destination IP. +- The constructed Frame is passed to the outbound NIC's ``send_frame`` method. + +At the NIC +^^^^^^^^^^ + +When ``send_frame`` is called: + +- The NIC checks if it is enabled before sending. +- If enabled, it sends the Frame out to the connected Link. diff --git a/docs/source/simulation_components/system/list_of_applications.rst b/docs/source/simulation_components/system/list_of_applications.rst new file mode 100644 index 00000000..8f792e4c --- /dev/null +++ b/docs/source/simulation_components/system/list_of_applications.rst @@ -0,0 +1,15 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. toctree:: + :maxdepth: 1 + :glob: + + applications/* + +More info :py:mod:`primaite.game.game.APPLICATION_TYPES_MAPPING` + +.. include:: list_of_system_applications.rst + +.. |SOFTWARE_TYPE| replace:: application diff --git a/docs/source/simulation_components/system/list_of_services.rst b/docs/source/simulation_components/system/list_of_services.rst new file mode 100644 index 00000000..9f1c9fe2 --- /dev/null +++ b/docs/source/simulation_components/system/list_of_services.rst @@ -0,0 +1,15 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. toctree:: + :maxdepth: 1 + :glob: + + services/* + +More info :py:mod:`primaite.game.game.SERVICE_TYPES_MAPPING` + +.. include:: list_of_system_services.rst + +.. |SOFTWARE_TYPE| replace:: service diff --git a/docs/source/simulation_components/system/list_of_system_applications.rst b/docs/source/simulation_components/system/list_of_system_applications.rst new file mode 100644 index 00000000..193b3dc6 --- /dev/null +++ b/docs/source/simulation_components/system/list_of_system_applications.rst @@ -0,0 +1,16 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``system applications`` +""""""""""""""""""""""" + +Some applications are pre installed on nodes - this is similar to how some applications are included with the Operating System. + +The application may not be configured as needed, in which case, see the relevant application page. + +The list of applications that are considered system software are: + +- ``WebBrowser`` + +More info :py:mod:`primaite.simulator.network.hardware.nodes.host.host_node.HostNode.SYSTEM_SOFTWARE` diff --git a/docs/source/simulation_components/system/list_of_system_services.rst b/docs/source/simulation_components/system/list_of_system_services.rst new file mode 100644 index 00000000..5acfc12e --- /dev/null +++ b/docs/source/simulation_components/system/list_of_system_services.rst @@ -0,0 +1,18 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +``system services`` +""""""""""""""""""" + +Some services are pre installed on nodes - this is similar to how some services are included with the Operating System. + +The service may not be configured as needed, in which case, see the relevant service page. + +The list of services that are considered system software are: + +- ``DNSClient`` +- ``FTPClient`` +- ``NTPClient`` + +More info :py:mod:`primaite.simulator.network.hardware.nodes.host.host_node.HostNode.SYSTEM_SOFTWARE` diff --git a/docs/source/simulation_components/system/node_session_software_model_example.png b/docs/source/simulation_components/system/node_session_software_model_example.png new file mode 100644 index 00000000..f4839f14 Binary files /dev/null and b/docs/source/simulation_components/system/node_session_software_model_example.png differ diff --git a/docs/source/simulation_components/system/pcap.rst b/docs/source/simulation_components/system/pcap.rst new file mode 100644 index 00000000..7d7bff0f --- /dev/null +++ b/docs/source/simulation_components/system/pcap.rst @@ -0,0 +1,51 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +PCAP +==== + +The ``packet_capture.py`` module introduces a Packet Capture (PCAP) service within PrimAITE, designed to simulate +packet capturing functionalities for the simulated network environment. This service enables the logging of network +frames as JSON strings, providing valuable insights into the data flowing across the network. + +Overview +-------- + +Packet capture is a crucial tool in network analysis, troubleshooting, and monitoring, allowing for the examination of +packets traversing the network. Within the context of the PrimAITE simulation, the PCAP service enhances the realism +and depth of network simulations by offering detailed visibility into network communications. Notably, PCAP is created +by default at the NetworkInterface level. + +PacketCapture Class +------------------- + +The ``PacketCapture`` class represents the core of the PCAP service, facilitating the capture and logging of network +frames for analysis. + +**Features:** + +- **Automatic Creation:** PCAP is automatically created at the NetworkInterface level, simplifying setup and integration. +- **Inbound and Outbound Frame Capture:** Frames can be captured and logged separately for inbound and outbound + traffic, offering granular insight into network communications. +- **Logging Format:** Captures and logs frames as JSON strings, ensuring that the data is structured and easily + interpretable. +- **File Location:** PCAP logs are saved to a specified directory within the simulation output, organised by hostname + and IP address to facilitate easy retrieval and analysis. + +Usage +----- + +The PCAP service is seamlessly integrated within the simulation, automatically capturing and logging frames for both +inbound and outbound traffic at the NetworkInterface level. This automatic functionality, combined with the ability +to separate traffic directions, significantly enhances network analysis and troubleshooting capabilities. + +This service is particularly useful for: + +- **Network Analysis:** Detailed examination of packet flows and protocols within the simulated environment. +- **Troubleshooting:** Identifying and resolving network issues by analysing packet transmissions and errors. +- **Educational Purposes:** Teaching network principles and diagnostics through hands-on packet analysis. + +The introduction of the ``packet_capture.py`` module significantly enhances the network simulation capabilities of +PrimAITE. By providing a robust tool for packet capture and analysis, PrimAITE allows users to gain deeper insights +into network operations, supporting a wide range of educational, developmental, and research activities. diff --git a/docs/source/simulation_components/system/services/database_service.rst b/docs/source/simulation_components/system/services/database_service.rst new file mode 100644 index 00000000..dd6dec41 --- /dev/null +++ b/docs/source/simulation_components/system/services/database_service.rst @@ -0,0 +1,116 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DatabaseService: + +DatabaseService +############### + +The ``DatabaseService`` provides a SQL database server simulation by extending the base Service class. + +Key capabilities +================ + +- Creates a database file in the ``FileSystem`` of the ``Node`` (which the ``DatabaseService`` is installed on) upon creation. +- Handles connecting clients by maintaining a dictionary of connections mapped to session IDs. +- Authenticates connections using a configurable password. +- Simulates ``SELECT``, ``DELETE`` and ``INSERT`` SQL queries. +- Returns query results and status codes back to clients. +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +===== +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Clients connect, execute queries, and disconnect. +- Service runs on TCP port 5432 by default. + +**Supported queries:** + +* ``SELECT``: As long as the database file is in a ``GOOD`` health state, the db service will respond with a 200 status code. +* ``DELETE``: This query represents an attack, it will cause the database file to enter a ``COMPROMISED`` state, and return a status code 200. +* ``INSERT``: If the database service is in a healthy state, this will return a 200 status, if it's not in a healthy state it will return 404. +* ``SELECT * FROM pg_stat_activity``: This query represents something an admin would send to check the status of the database. If the database service is in a healthy state, it returns a 200 status code, otherwise a 401 status code. + +Implementation +============== + +- Creates the database file within the node's file system. +- Manages client connections in a dictionary by session ID. +- Processes SQL queries. +- Returns results and status codes in a standard dictionary format. +- Extends Service class for integration with ``SoftwareManager``. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.database.database_service import DatabaseService + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install DatabaseService on server + server.software_manager.install(DatabaseService) + db_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_service.start() + + # configure DatabaseService + db_service.configure_backup(IPv4Address("192.168.0.10")) + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: database_service + type: DatabaseService + options: + backup_server_ip: 192.168.0.10 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DatabaseService +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DatabaseService`` + +``backup_server_ip`` +"""""""""""""""""""" + +Optional. Default value is ``None``. + +The IP Address of the backup server that the ``DatabaseService`` will use to create backups of the database. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. + +``password`` +"""""""""""" + +Optional. Default value is ``None``. + +The password that needs to be provided by connecting clients in order to create a successful connection. diff --git a/docs/source/simulation_components/system/services/dns_client.rst b/docs/source/simulation_components/system/services/dns_client.rst new file mode 100644 index 00000000..91461590 --- /dev/null +++ b/docs/source/simulation_components/system/services/dns_client.rst @@ -0,0 +1,99 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DNSClient: + +DNSClient +######### + +The DNSClient provides a client interface for connecting to the :ref:`DNSServer`. + +Key features +============ + +- Connects to the :ref:`DNSServer` via the ``SoftwareManager``. +- Executes DNS lookup requests and keeps a cache of known domain name IP addresses. +- Handles connection to DNSServer and querying for domain name IP addresses. + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on TCP port 53 by default. (TODO: TCP for now, should be UDP in future) +- Execute domain name checks with ``check_domain_exists``. +- ``DNSClient`` will automatically add the IP Address of the domain into its cache + +Implementation +============== + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for Nodes to find IP addresses via domain names. +- Extends base Service class. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.dns.dns_client import DNSClient + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install DNSClient on server + server.software_manager.install(DNSClient) + dns_client: DNSClient = server.software_manager.software.get("DNSClient") + dns_client.start() + + # configure DatabaseService + dns_client.dns_server = IPv4Address("192.168.0.10") + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: dns_client + type: DNSClient + options: + dns_server: 192.168.0.10 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DNSClient +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DNSClient`` + +``dns_server`` +"""""""""""""" + +Optional. Default value is ``None``. + +The IP Address of the :ref:`DNSServer`. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. diff --git a/docs/source/simulation_components/system/services/dns_server.rst b/docs/source/simulation_components/system/services/dns_server.rst new file mode 100644 index 00000000..89ce7fc1 --- /dev/null +++ b/docs/source/simulation_components/system/services/dns_server.rst @@ -0,0 +1,98 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _DNSServer: + +DNSServer +######### + +Also known as a DNS Resolver, the ``DNSServer`` provides a DNS Server simulation by extending the base Service class. + +Key capabilities +================ + +- Simulates DNS requests and DNSPacket transfer across a network +- Registers domain names and the IP Address linked to the domain name +- Returns the IP address for a given domain name within a DNS Packet that a DNS Client can read +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +===== +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on TCP port 53 by default. (TODO: TCP for now, should be UDP in future) + +Implementation +============== + +- DNS request and responses use a ``DNSPacket`` object +- Extends Service class for integration with ``SoftwareManager``. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.dns.dns_server import DNSServer + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install DNSServer on server + server.software_manager.install(DNSServer) + dns_server: DNSServer = server.software_manager.software.get("DNSServer") + dns_server.start() + + # configure DatabaseService + dns_server.dns_register("arcd.com", IPv4Address("192.168.10.10")) + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: dns_server + type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.0.10 + another-example.com: 192.168.10.10 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: DNSServer +.. |SOFTWARE_NAME_BACKTICK| replace:: ``DNSServer`` + +domain_mapping +"""""""""""""" + +Domain mapping takes the domain and IP Addresses as a key-value pairs i.e. + +If the domain is "arcd.com" and the IP Address attributed to the domain is 192.168.0.10, then the value should be ``arcd.com: 192.168.0.10`` + +The key must be a string and the IP Address must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. diff --git a/docs/source/simulation_components/system/services/ftp_client.rst b/docs/source/simulation_components/system/services/ftp_client.rst new file mode 100644 index 00000000..259a626d --- /dev/null +++ b/docs/source/simulation_components/system/services/ftp_client.rst @@ -0,0 +1,91 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _FTPClient: + +FTPClient +######### + +The ``FTPClient`` provides a client interface for connecting to the :ref:`FTPServer`. + +Key features +============ + +- Connects to the :ref:`FTPServer` via the ``SoftwareManager``. +- Simulates FTP requests and FTPPacket transfer across a network +- Allows the emulation of FTP commands between an FTP client and server: + - PORT: specifies the port that server should connect to on the client (currently only uses ``Port.FTP``) + - STOR: stores a file from client to server + - RETR: retrieves a file from the FTP server + - QUIT: disconnect from server +- Leverages the Service base class for install/uninstall, status tracking, etc. +- :ref:`FTPClient` and ``FTPServer`` utilise port 21 (FTP) throughout all file transfer / request + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the FTP client service. +- Service runs on FTP (command) port 21 by default +- Execute sending a file to the FTP server with ``send_file`` +- Execute retrieving a file from the FTP server with ``request_file`` + +Implementation +============== + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for Nodes to transfer files between each other. +- Extends base Service class. + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.ftp.ftp_client import FTPClient + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.10", + start_up_duration=0, + ) + server.power_on() + + # Install FTPClient on server + server.software_manager.install(FTPClient) + ftp_client: FTPClient = server.software_manager.software.get("FTPClient") + ftp_client.start() + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: ftp_client + type: FTPClient + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: FTPClient +.. |SOFTWARE_NAME_BACKTICK| replace:: ``FTPClient`` + +**FTPClient has no configuration options** diff --git a/docs/source/simulation_components/system/services/ftp_server.rst b/docs/source/simulation_components/system/services/ftp_server.rst new file mode 100644 index 00000000..fb57a762 --- /dev/null +++ b/docs/source/simulation_components/system/services/ftp_server.rst @@ -0,0 +1,94 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _FTPServer: + +FTPServer +######### + +Provides a FTP Client-Server simulation by extending the base Service class. + +Key capabilities +================ + +- Simulates FTP requests and FTPPacket transfer across a network +- Allows the emulation of FTP commands between an FTP client and server: + - STOR: stores a file from client to server + - RETR: retrieves a file from the FTP server +- Leverages the Service base class for install/uninstall, status tracking, etc. +- :ref:`FTPClient` and ``FTPServer`` utilise port 21 (FTP) throughout all file transfer / request + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the FTP server service. +- Service runs on FTP (command) port 21 by default + +Implementation +============== + +- FTP request and responses use a ``FTPPacket`` object +- Extends Service class for integration with ``SoftwareManager``. + + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.ftp.ftp_server import FTPServer + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install FTPServer on server + server.software_manager.install(FTPServer) + ftp_server: FTPServer = server.software_manager.software.get("FTPServer") + ftp_server.start() + + ftp_server.server_password = "test" + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: ftp_server + type: FTPServer + options: + server_password: test + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: FTPServer +.. |SOFTWARE_NAME_BACKTICK| replace:: ``FTPServer`` + +``server_password`` +""""""""""""""""""" + +Optional. Default value is ``None``. + +The password that needs to be provided by a connecting :ref:`FTPClient` in order to create a successful connection. diff --git a/docs/source/simulation_components/system/services/ntp_client.rst b/docs/source/simulation_components/system/services/ntp_client.rst new file mode 100644 index 00000000..aaba3261 --- /dev/null +++ b/docs/source/simulation_components/system/services/ntp_client.rst @@ -0,0 +1,95 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _NTPClient: + +NTPClient +######### + +The NTPClient provides a client interface for connecting to the ``NTPServer``. + +Key features +============ + +- Connects to the ``NTPServer`` via the ``SoftwareManager``. + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on UDP port 123 by default. + +Implementation +============== + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for Nodes to find IP addresses via domain names. +- Extends base Service class. + + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.ntp.ntp_client import NTPClient + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install NTPClient on server + server.software_manager.install(NTPClient) + ntp_client: NTPClient = server.software_manager.software.get("NTPClient") + ntp_client.start() + + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.0.10")) + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: ntp_client + type: NTPClient + options: + ntp_server_ip: 192.168.0.10 + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: NTPClient +.. |SOFTWARE_NAME_BACKTICK| replace:: ``NTPClient`` + +``ntp_server_ip`` +""""""""""""""""" + +Optional. Default value is ``None``. + +The IP address of an NTP Server which provides a time that the ``NTPClient`` can synchronise to. + +This must be a valid octet i.e. in the range of ``0.0.0.0`` and ``255.255.255.255``. diff --git a/docs/source/simulation_components/system/services/ntp_server.rst b/docs/source/simulation_components/system/services/ntp_server.rst new file mode 100644 index 00000000..0025b428 --- /dev/null +++ b/docs/source/simulation_components/system/services/ntp_server.rst @@ -0,0 +1,86 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _NTPServer: + +NTPServer +######### + +The ``NTPServer`` provides a NTP Server simulation by extending the base Service class. + +NTP Client +========== + +The ``NTPClient`` provides a NTP Client simulation by extending the base Service class. + +Key capabilities +================ + +- Simulates NTP requests and NTPPacket transfer across a network +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +===== +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on UDP port 123 by default. + +Implementation +============== + +- NTP request and responses use a ``NTPPacket`` object +- Extends Service class for integration with ``SoftwareManager``. + + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.ntp.ntp_server import NTPServer + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install NTPServer on server + server.software_manager.install(NTPServer) + ntp_server: NTPServer = server.software_manager.software.get("NTPServer") + ntp_server.start() + + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: ntp_server + type: NTPServer + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: NTPServer +.. |SOFTWARE_NAME_BACKTICK| replace:: ``NTPServer`` + +**NTPServer has no configuration options** diff --git a/docs/source/simulation_components/system/services/web_server.rst b/docs/source/simulation_components/system/services/web_server.rst new file mode 100644 index 00000000..62b1d090 --- /dev/null +++ b/docs/source/simulation_components/system/services/web_server.rst @@ -0,0 +1,86 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _WebServer: + +WebServer +######### + +Provides a Web Server simulation by extending the base Service class. + +Key capabilities +================ + +- Simulates a web server with the capability to also request data from a database +- Allows the emulation of HTTP requests between client (e.g. a web browser) and server + - GET request sends a get all users request to the database server and returns an HTTP 200 status if the database is responsive +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +===== + +- Install on a Node via the ``SoftwareManager`` to start the `WebServer`. +- Service runs on HTTP port 80 by default. (TODO: HTTPS) +- A :ref:`DatabaseClient` must be installed and configured on the same node as the ``WebServer`` if it is intended to send a users request i.e. + in the case that the :ref:`WebBrowser` sends a request with users in its request path, the ``WebServer`` will utilise the ``DatabaseClient`` to send a request to the ``DatabaseService`` + +Implementation +============== + +- HTTP request uses a ``HttpRequestPacket`` object +- HTTP response uses a ``HttpResponsePacket`` object +- Extends Service class for integration with ``SoftwareManager``. + + +Examples +======== + +Python +"""""" + +.. code-block:: python + + from primaite.simulator.network.hardware.nodes.host.server import Server + from primaite.simulator.system.services.web_server.web_server import WebServer + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Install WebServer on server + server.software_manager.install(WebServer) + web_server: WebServer = server.software_manager.software.get("WebServer") + web_server.start() + +Via Configuration +""""""""""""""""" + +.. code-block:: yaml + + simulation: + network: + nodes: + - ref: example_server + hostname: example_server + type: server + ... + services: + - ref: web_server + type: WebServer + +Configuration +============= + +.. include:: ../common/common_configuration.rst + +.. |SOFTWARE_NAME| replace:: WebServer +.. |SOFTWARE_NAME_BACKTICK| replace:: ``WebServer`` + +**WebServer has no configuration options** diff --git a/docs/source/simulation_components/system/session_and_software_manager.rst b/docs/source/simulation_components/system/session_and_software_manager.rst new file mode 100644 index 00000000..8af96e87 --- /dev/null +++ b/docs/source/simulation_components/system/session_and_software_manager.rst @@ -0,0 +1,92 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +Session and Software Manager +============================ + +The Software Manager and Session Manager are core components of the Node in PrimAITE. These managers orchestrate the +flow of network frames through the Node, ensuring that frames are processed accurately and passed to the relevant +services or applications. + +The following flow diagram illustrates the journey of a network frame as it navigates through various components within +the node. Starting from the network interface, the frame progresses to the node, then to the session manager, and +subsequently to the software manager. From there, it may be directed to one of three potential software destinations: +ARP, ICMP, or the Web Client. This pathway exemplifies the structured processing sequence designed to ensure that +each frame reaches its intended target within the simulated environment. + +.. image:: node_session_software_model_example.png + :width: 500 + :align: center + +Session Manager +--------------- + +The `SessionManager` acts as the intermediary between the Node's hardware-level interactions and higher-level software +processes. It receives frames from the Node and determines the appropriate session or connection context for further +processing. + +**Key Responsibilities:** + +- **Frame Handling:** Receives network frames and identifies the session context based on headers and session state. +- **Protocol Management:** Supports various protocols (e.g., ARP, ICMP) by interpreting protocol-specific information + within frames and facilitating their processing. +- **Session Tracking:** Maintains a record of active sessions and manages their lifecycle, including creation, + maintenance, and termination. + +**Implementation Overview:** + +- Utilises IP and transport layer information to route frames to the correct session. +- Integrates closely with the `SoftwareManager` to ensure seamless transmission of session-specific data to the + application layer. + +Software Manager +---------------- + +The `SoftwareManager` is responsible for the final step in the frame processing pipeline, handling the delivery of +network frames to the appropriate software services or applications within the Node. + +**Key Responsibilities:** + +- **Application Routing:** Determines the target application or service for incoming frames based on protocol and port + information. +- **Software Management:** Oversees the registration, installation, and management of software services and + applications, facilitating communication between network layers and application processes. +- **Frame Dispatching:** Directs frames to their designated applications or services, enabling the processing of + network communications at the application layer. +- **Installation and Uninstallation:** Responsible for the installing and uninstalling of services and applications, + managing the availability of software resources on the Node. + +**Implementation Overview:** + +- Maintains a registry of services and applications, keyed by protocol and port numbers, to efficiently route network + traffic. +- Interacts with the `FileSystem` and other core components to manage application state and data persistence, + supporting complex software interactions within the simulated environment. + +Integration and Workflow +------------------------ + +1. **Initial Port Check:** Upon receiving a network frame at the hardware level, the Node first checks if the + destination port and protocol match any software currently running, as managed by the `SoftwareManager`. This step + determines if the port is open and if the frame's destination is actively listening for incoming traffic on the Node. +2. **Frame Acceptance:** If the frame's destination port and protocol are open on the Node, indicating that there is + software prepared to handle such traffic, the Node accepts the frame. This verification ensures that only relevant + traffic is processed further, enhancing network security and efficiency. +3. **Session Manager Processing:** Accepted frames are then passed to the `SessionManager`, which analyses the frames + within the context of existing sessions or connections. The Session Manager performs protocol-specific handling, + routing the frames based on session state and protocol requirements. +4. **Software Manager Dispatch:** After session processing, frames are dispatched to the `SoftwareManager`, which + routes them to the appropriate services or applications. The Software Manager identifies the target based on the + frame's destination port and protocol, aligning with the initial port check. +5. **Application Processing:** The relevant applications or services process the received frames, completing the + communication pathway within the Node. This step involves the actual handling of frame data by the intended software, + facilitating the intended network operations or communications. + + +Together, the Software Manager and Session Manager form a critical part of the Node's architecture in the PrimAITE, +facilitating a structured and efficient processing pipeline for network frames. This architecture enables the +simulation of realistic network environments, where frames are accurately routed and processed, mirroring the +complexities of real-world network communications. The addition of installation and uninstallation capabilities by +the Software Manager further enhances the Node's functionality, allowing for dynamic software management within the +simulated network. diff --git a/docs/source/simulation_components/system/software.rst b/docs/source/simulation_components/system/software.rst new file mode 100644 index 00000000..2ba8e841 --- /dev/null +++ b/docs/source/simulation_components/system/software.rst @@ -0,0 +1,65 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + + +Software +======== + +------------- +Base Software +------------- + +Software which inherits ``IOSoftware`` installed on a node will not work unless the node has been turned on. + +See :ref:`Node Start up and Shut down` + +.. code-block:: python + + from primaite.simulator.network.hardware.base import Node + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + from primaite.simulator.system.services.service import ServiceOperatingState + from primaite.simulator.system.services.web_server.web_server import WebServer + + node = Node(hostname="pc_a", start_up_duration=0, shut_down_duration=0) + + node.power_on() + assert node.operating_state is NodeOperatingState.ON + + node.software_manager.install(WebServer) + + web_server: WebServer = node.software_manager.software.get("WebServer") + assert web_server.operating_state is ServiceOperatingState.RUNNING # service is immediately ran after install + + node.power_off() + assert node.operating_state is NodeOperatingState.OFF + assert web_server.operating_state is ServiceOperatingState.STOPPED # service stops when node is powered off + + node.power_on() + assert node.operating_state is NodeOperatingState.ON + assert web_server.operating_state is ServiceOperatingState.RUNNING # service turned back on when node is powered on + +.. _List of Applications: + +Applications +############ + +These are a list of applications that are currently available in PrimAITE: + +.. include:: list_of_applications.rst + +.. _List of Services: + +Services +######## + +These are a list of services that are currently available in PrimAITE: + +.. include:: list_of_services.rst + +.. _List of Processes: + +Processes +######### + +`To be implemented` diff --git a/docs/source/simulation_components/system/sys_log.rst b/docs/source/simulation_components/system/sys_log.rst new file mode 100644 index 00000000..a638060c --- /dev/null +++ b/docs/source/simulation_components/system/sys_log.rst @@ -0,0 +1,51 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +SysLog +====== + +The ``sys_log.py`` module introduces a system logging (SysLog) service within PrimAITE, designed to facilitate the +management and recording of system logs for nodes in the simulated network environment. This essential service tracks +system events, assists in debugging, and aids network analysis by providing a structured and accessible log of +activities. + +Overview +-------- + +System logging is vital in network management and diagnostics, offering a timestamped record of events within network +devices. In the PrimAITE simulation context, the SysLog service automatically enables logging at the node level, +enhancing the simulation's analysis and troubleshooting capabilities without manual configuration. + +SysLog Class +------------ + +**Features:** + +- **Automatic Activation:** SysLog is enabled by default at the node level, ensuring comprehensive activity logging + with no additional setup. +- **Log Levels:** Supports various logging levels, including debug, info, error, etc., allowing for detailed + categorisation and severity indication of log messages. +- **Terminal Output:** Logs can be printed to the terminal by setting `to_terminal=True`, offering real-time monitoring + and debugging capabilities. +- **Logging Format:** Records system logs in standard text format for enhanced readability and interpretability. +- **File Location:** Systematically saves logs to a designated directory within the simulation output, organised by + hostname, facilitating log management and retrieval. + +Usage +----- + +SysLog service is seamlessly integrated into the simulation, with automatic activation for each node and support for +various logging levels. The addition of terminal output capabilities further enhances the utility of SysLog for +real-time event monitoring and troubleshooting. + +This service is invaluable for: + +- **Event Tracking:** Documents key system events, configuration changes, and operational status updates. +- **Debugging:** Aids in identifying and resolving simulated network issues by providing a comprehensive event history. +- **Network Analysis:** Offers insights into network node behaviour and interactions. + + +The ``sys_log.py`` module significantly enhances PrimAITE's network simulation capabilities. Providing a robust system +logging tool, automatically enabled at the node level and featuring various log levels and terminal output options, +PrimAITE enables users to conduct in-depth network simulations. diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst new file mode 100644 index 00000000..2804593a --- /dev/null +++ b/docs/source/simulation_structure.rst @@ -0,0 +1,72 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + + +Simulation Structure +==================== + +The simulation is made up of many smaller components which are related to each other in a tree-like structure. At the +top level, there is the :py:meth:`primaite.simulator.sim_container.Simulation`, which keeps track of the physical network +and a domain controller for managing software and users. + +Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. Also, +when a component's ``describe_state()`` method is called, it will include the state of its descendants. The +``apply_request()`` method can be used to act on a component or one of its descendants. The diagram below shows the +relationship between components. + +.. image:: ../../_static/component_relationship.png + :width: 500 + :align: center + :alt: :: The top level simulation object owns a NetworkContainer and a DomainController. The DomainController has a + list of accounts. The network container has links and nodes. Nodes can own switchports, NICs, FileSystem, + Application, Service, and Process. + + +Actions +======= +Agents can interact with the simulation by using actions. Actions are standardised with the +:py:class:`primaite.simulation.core.RequestType` class, which just holds a reference to two special functions. + +1. The request function itself, it must accept a `request` parameters which is a list of strings that describe what the + action should do. It must also accept a `context` dict which can house additional information surrounding the action. + For example, the context will typically include information about which entity intiated the action. +2. A validator function. This function should return a boolean value that decides if the request is permitted or not. + It uses the same paramters as the action function. + +Action Permissions +------------------ +When an agent tries to perform an action on a simulation component, that action will only be executed if the request is +validated. For example, some actions can require that an agent is logged into an admin account. Each action defines its +own permissions using an instance of :py:class:`primaite.simulation.core.ActionPermissionValidator`. The below code +snippet demonstrates usage of the ``ActionPermissionValidator``. + +.. code:: python + + from primaite.simulator.core import Action, RequestManager, SimComponent + from primaite.simulator.domain.controller import AccountGroup, GroupMembershipValidator + + class Smartphone(SimComponent): + name: str + apps = [] + + def _init_request_manager(self) -> RequestManager: + am = super()._init_request_manager() + am.add_request( + "reset_factory_settings", + Action( + func = lambda request, context: self.reset_factory_settings(), + validator = GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), + ) + ) + + def reset_factory_settings(self): + self.apps = [] + + phone = Smartphone(name="phone1") + + # try to wipe the phone as a domain user, this will have no effect + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["DOMAIN_USER"]}) + + # try to wipe the phone as an admin user, this will wipe the phone + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["DOMAIN_ADMIN"]}) diff --git a/docs/source/state_system.rst b/docs/source/state_system.rst new file mode 100644 index 00000000..e31474ea --- /dev/null +++ b/docs/source/state_system.rst @@ -0,0 +1,31 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +Simulation State +================ + +``SimComponent`` objects in the simulation have a method called ``describe_state`` which return a dictionary of the state of the component. This is used to report pertinent data that could impact an agent's actions or rewards. For instance, the name and health status of a node is reported, which can be used by a reward function to punish corrupted or compromised nodes and reward healthy nodes. Each ``SimComponent`` object reports not only its own attributes in the state but also those of its child components. I.e. a computer node will report the state of its ``FileSystem`` and the ``FileSystem`` will report the state of its files and folders. This happens by recursively calling the children's own ``describe_state`` methods. + +The game layer calls ``describe_state`` on the trunk ``SimComponent`` (the top-level parent) and then passes the state to the agents once per simulation step. For this reason, all ``SimComponent`` objects must have a ``describe_state`` method, and they must all be linked to the trunk ``SimComponent``. + +This code snippet demonstrates how the state information is defined within the ``SimComponent`` class: + +.. code-block:: python + + class Node(SimComponent): + operating_state: NodeOperatingState = NodeOperatingState.OFF + services: Dict[str, Service] = {} + + def describe_state(self) -> Dict: + state = super().describe_state() + state["operating_state"] = self.operating_state.value + state["services"] = {uuid: svc.describe_state() for uuid, svc in self.services.items()} + return state + + class Service(SimComponent): + health_state: ServiceHealthState = ServiceHealthState.GOOD + def describe_state(self) -> Dict: + state = super().describe_state() + state["health_state"] = self.health_state.value + return state diff --git a/docs/source/varying_config_files.rst b/docs/source/varying_config_files.rst new file mode 100644 index 00000000..d8f77f64 --- /dev/null +++ b/docs/source/varying_config_files.rst @@ -0,0 +1,49 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +Defining variations in the config files +================ + +PrimAITE supports the ability to use different variations on a scenario at different episodes. This can be used to increase domain randomisation to prevent overfitting, or to set up curriculum learning to train agents to perform more complicated tasks. + +When using a fixed scenario, a single yaml config file is used. However, to use episode schedules, PrimAITE uses a directory with several config files that work together. +Defining variations in the config file. + +Base scenario +************* + +The base scenario is essentially the same as a fixed YAML configuration, but it can contain placeholders that are populated with episode-specific data at runtime. The base scenario contains any network, agent, or settings that remain fixed for the entire training/evaluation session. + +The placeholders are defined as YAML Aliases and they are denoted by an asterisk (*placeholder). + +Variations +********** + +For each variation that could be used in a placeholder, there is a separate yaml file that contains the data that should populate the placeholder. + +The data that fills the placeholder is defined as a YAML Anchor in a separate file, denoted by an ampersand ``&anchor``. + +Learn more about YAML Aliases and Anchors `here `_. + +Schedule +******** + +Users must define which combination of scenario variations should be loaded in each episode. This takes the form of a YAML file with a relative path to the base scenario and a list of paths to be loaded in during each episode. + +It takes the following format: + +.. code-block:: yaml + + base_scenario: base.yaml + schedule: + 0: # list of variations to load in at episode 0 (before the first call to env.reset() happens) + - laydown_1.yaml + - attack_1.yaml + 1: # list of variations to load in at episode 1 (after the first env.reset() call) + - laydown_2.yaml + - attack_2.yaml + +For more information please refer to the ``Using Episode Schedules`` notebook in either :ref:`Executed Notebooks` or run the notebook interactively in ``notebooks/example_notebooks/``. + +For further information around notebooks in general refer to the :ref:`Example Jupyter Notebooks`. diff --git a/pyproject.toml b/pyproject.toml index 9691f65c..290720bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "primaite" description = "PrimAITE (Primary-level AI Training Environment) is a simulation environment for training AI under the ARCD programme." authors = [{name="Defence Science and Technology Laboratory UK", email="oss@dstl.gov.uk"}] license = {file = "LICENSE"} -requires-python = ">=3.8, <3.11" +requires-python = ">=3.8, <3.12" dynamic = ["version", "readme"] classifiers = [ "License :: OSI Approved :: MIT License", @@ -20,11 +20,12 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "gym==0.21.0", + "gymnasium==0.28.1", "jupyterlab==3.6.1", "kaleido==0.2.1", "matplotlib==3.7.1", @@ -32,12 +33,13 @@ dependencies = [ "numpy==1.23.5", "platformdirs==3.5.1", "plotly==5.15.0", - "polars==0.18.4", + "polars==0.20.30", + "prettytable==3.8.0", "PyYAML==6.0", - "ray[rllib]==2.2.0", - "stable-baselines3==1.6.2", - "tensorflow==2.12.0", - "typer[all]==0.9.0" + "typer[all]==0.9.0", + "pydantic==2.7.0", + "ipywidgets", + "deepdiff" ] [tool.setuptools.dynamic] @@ -51,10 +53,16 @@ license-files = ["LICENSE"] [project.optional-dependencies] +rl = [ + "ray[rllib] >= 2.20.0, < 3", + "tensorflow==2.12.0", + "stable-baselines3[extra]==2.1.0", +] dev = [ "build==0.10.0", "flake8==6.0.0", - "furo==2023.3.27", + "flake8-annotations", + "furo==2024.01.29", "gputil==1.4.0", "pip-licenses==4.3.0", "pre-commit==2.20.0", @@ -64,10 +72,10 @@ dev = [ "pytest-cov==4.0.0", "pytest-flake8==1.1.1", "setuptools==66", - "Sphinx==6.1.3", - "sphinx-code-tabs==0.5.3", + "Sphinx==7.1.2", "sphinx-copybutton==0.5.2", - "wheel==0.38.4" + "wheel==0.38.4", + "nbsphinx==0.9.4" ] [project.scripts] @@ -81,3 +89,9 @@ order_by_type = "False" [tool.black] line-length = 120 + +[project.urls] +Homepage = "https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE" +Documentation = "https://Autonomous-Resilient-Cyber-Defence.github.io/PrimAITE/" +Repository = "https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE" +Changelog = "https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE/blob/dev/CHANGELOG.md" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e46aafd6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[metadata] +url = https://github.com/Autonomous-Resilient-Cyber-Defence/PrimAITE +author = Defence Science and Technology Laboratory UK +author_email = oss@dstl.gov.uk diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 227cea21..4a36342f 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -2.0.0 +3.0.0 diff --git a/src/primaite/__init__.py b/src/primaite/__init__.py index a0f5b7fe..98612040 100644 --- a/src/primaite/__init__.py +++ b/src/primaite/__init__.py @@ -1,4 +1,5 @@ # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK +import datetime as datetime import logging import logging.config import shutil @@ -16,6 +17,9 @@ from platformdirs import PlatformDirs with open(Path(__file__).parent.resolve() / "VERSION", "r") as file: __version__ = file.readline().strip() +_PRIMAITE_ROOT: Path = Path(__file__).parent +# TODO: Remove once we integrate the simulation into PrimAITE and it uses the primaite session path + class _PrimaitePaths: """ @@ -24,14 +28,24 @@ class _PrimaitePaths: The PlatformDirs appname is 'primaite' and the version is ``primaite.__version__`. """ - def __init__(self): + def __init__(self) -> None: self._dirs: Final[PlatformDirs] = PlatformDirs(appname="primaite", version=__version__) + self.user_home_path = self.generate_user_home_path() + self.user_sessions_path = self.generate_user_sessions_path() + self.user_config_path = self.generate_user_config_path() + self.user_notebooks_path = self.generate_user_notebooks_path() + self.app_home_path = self.generate_app_home_path() + self.app_config_dir_path = self.generate_app_config_dir_path() + self.app_config_file_path = self.generate_app_config_file_path() + self.app_log_dir_path = self.generate_app_log_dir_path() + self.app_log_file_path = self.generate_app_log_file_path() + self.episode_log_file_path = self.generate_episode_log_file_path() def _get_dirs_properties(self) -> List[str]: class_items = self.__class__.__dict__.items() return [k for k, v in class_items if isinstance(v, property)] - def mkdirs(self): + def mkdirs(self) -> None: """ Creates all Primaite directories. @@ -40,55 +54,47 @@ class _PrimaitePaths: for p in self._get_dirs_properties(): getattr(self, p) - @property - def user_home_path(self) -> Path: + def generate_user_home_path(self) -> Path: """The PrimAITE user home path.""" path = Path.home() / "primaite" / __version__ path.mkdir(exist_ok=True, parents=True) return path - @property - def user_sessions_path(self) -> Path: + def generate_user_sessions_path(self) -> Path: """The PrimAITE user sessions path.""" path = self.user_home_path / "sessions" path.mkdir(exist_ok=True, parents=True) return path - @property - def user_config_path(self) -> Path: + def generate_user_config_path(self) -> Path: """The PrimAITE user config path.""" path = self.user_home_path / "config" path.mkdir(exist_ok=True, parents=True) return path - @property - def user_notebooks_path(self) -> Path: + def generate_user_notebooks_path(self) -> Path: """The PrimAITE user notebooks path.""" path = self.user_home_path / "notebooks" path.mkdir(exist_ok=True, parents=True) return path - @property - def app_home_path(self) -> Path: + def generate_app_home_path(self) -> Path: """The PrimAITE app home path.""" path = self._dirs.user_data_path path.mkdir(exist_ok=True, parents=True) return path - @property - def app_config_dir_path(self) -> Path: + def generate_app_config_dir_path(self) -> Path: """The PrimAITE app config directory path.""" path = self._dirs.user_config_path path.mkdir(exist_ok=True, parents=True) return path - @property - def app_config_file_path(self) -> Path: + def generate_app_config_file_path(self) -> Path: """The PrimAITE app config file path.""" return self.app_config_dir_path / "primaite_config.yaml" - @property - def app_log_dir_path(self) -> Path: + def generate_app_log_dir_path(self) -> Path: """The PrimAITE app log directory path.""" if sys.platform == "win32": path = self.app_home_path / "logs" @@ -97,12 +103,18 @@ class _PrimaitePaths: path.mkdir(exist_ok=True, parents=True) return path - @property - def app_log_file_path(self) -> Path: + def generate_app_log_file_path(self) -> Path: """The PrimAITE app log file path.""" return self.app_log_dir_path / "primaite.log" - def __repr__(self): + def generate_episode_log_file_path(self) -> Path: + """The PrimAITE app episode step log file path.""" + date_string = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + self.episode_log_dir_path = self.app_log_dir_path / date_string + self.episode_log_dir_path.mkdir(exist_ok=True, parents=True) + return self.episode_log_dir_path / "episode.log" + + def __repr__(self) -> str: properties_str = ", ".join([f"{p}='{getattr(self, p)}'" for p in self._get_dirs_properties()]) return f"{self.__class__.__name__}({properties_str})" @@ -110,34 +122,20 @@ class _PrimaitePaths: PRIMAITE_PATHS: Final[_PrimaitePaths] = _PrimaitePaths() -def _host_primaite_config(): - if not PRIMAITE_PATHS.app_config_file_path.exists(): - pkg_config_path = Path(pkg_resources.resource_filename("primaite", "setup/_package_data/primaite_config.yaml")) - shutil.copy2(pkg_config_path, PRIMAITE_PATHS.app_config_file_path) - - -_host_primaite_config() - - def _get_primaite_config() -> Dict: config_path = PRIMAITE_PATHS.app_config_file_path if not config_path.exists(): + # load from package if config does not exist config_path = Path(pkg_resources.resource_filename("primaite", "setup/_package_data/primaite_config.yaml")) + # generate app config + shutil.copy2(config_path, PRIMAITE_PATHS.app_config_file_path) with open(config_path, "r") as file: + # load from config primaite_config = yaml.safe_load(file) - log_level_map = { - "NOTSET": logging.NOTSET, - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARN": logging.WARN, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, - } - primaite_config["log_level"] = log_level_map[primaite_config["logging"]["log_level"]] - return primaite_config + return primaite_config -_PRIMAITE_CONFIG = _get_primaite_config() +PRIMAITE_CONFIG = _get_primaite_config() class _LevelFormatter(Formatter): @@ -164,11 +162,11 @@ class _LevelFormatter(Formatter): _LEVEL_FORMATTER: Final[_LevelFormatter] = _LevelFormatter( { - logging.DEBUG: _PRIMAITE_CONFIG["logging"]["logger_format"]["DEBUG"], - logging.INFO: _PRIMAITE_CONFIG["logging"]["logger_format"]["INFO"], - logging.WARNING: _PRIMAITE_CONFIG["logging"]["logger_format"]["WARNING"], - logging.ERROR: _PRIMAITE_CONFIG["logging"]["logger_format"]["ERROR"], - logging.CRITICAL: _PRIMAITE_CONFIG["logging"]["logger_format"]["CRITICAL"], + logging.DEBUG: PRIMAITE_CONFIG["logging"]["logger_format"]["DEBUG"], + logging.INFO: PRIMAITE_CONFIG["logging"]["logger_format"]["INFO"], + logging.WARNING: PRIMAITE_CONFIG["logging"]["logger_format"]["WARNING"], + logging.ERROR: PRIMAITE_CONFIG["logging"]["logger_format"]["ERROR"], + logging.CRITICAL: PRIMAITE_CONFIG["logging"]["logger_format"]["CRITICAL"], } ) @@ -180,10 +178,10 @@ _FILE_HANDLER: Final[RotatingFileHandler] = RotatingFileHandler( backupCount=9, # Max 100MB of logs encoding="utf8", ) -_STREAM_HANDLER.setLevel(_PRIMAITE_CONFIG["logging"]["log_level"]) -_FILE_HANDLER.setLevel(_PRIMAITE_CONFIG["logging"]["log_level"]) +_STREAM_HANDLER.setLevel(PRIMAITE_CONFIG["logging"]["log_level"]) +_FILE_HANDLER.setLevel(PRIMAITE_CONFIG["logging"]["log_level"]) -_LOG_FORMAT_STR: Final[str] = _PRIMAITE_CONFIG["logging"]["logger_format"] +_LOG_FORMAT_STR: Final[str] = PRIMAITE_CONFIG["logging"]["logger_format"] _STREAM_HANDLER.setFormatter(_LEVEL_FORMATTER) _FILE_HANDLER.setFormatter(_LEVEL_FORMATTER) @@ -202,6 +200,6 @@ def getLogger(name: str) -> Logger: # noqa logging config. """ logger = logging.getLogger(name) - logger.setLevel(_PRIMAITE_CONFIG["log_level"]) + logger.setLevel(PRIMAITE_CONFIG["logging"]["log_level"]) return logger diff --git a/src/primaite/acl/__init__.py b/src/primaite/acl/__init__.py deleted file mode 100644 index 6dc02583..00000000 --- a/src/primaite/acl/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Access Control List. Models firewall functionality.""" diff --git a/src/primaite/acl/access_control_list.py b/src/primaite/acl/access_control_list.py deleted file mode 100644 index 88943f8f..00000000 --- a/src/primaite/acl/access_control_list.py +++ /dev/null @@ -1,198 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""A class that implements the access control list implementation for the network.""" -import logging -from typing import Dict, Final, List, Union - -from primaite.acl.acl_rule import ACLRule -from primaite.common.enums import RulePermissionType - -_LOGGER: Final[logging.Logger] = logging.getLogger(__name__) - - -class AccessControlList: - """Access Control List class.""" - - def __init__(self, implicit_permission: RulePermissionType, max_acl_rules: int) -> None: - """Init.""" - # Implicit ALLOW or DENY firewall spec - self.acl_implicit_permission = implicit_permission - # Implicit rule in ACL list - if self.acl_implicit_permission == RulePermissionType.DENY: - self.acl_implicit_rule = ACLRule(RulePermissionType.DENY, "ANY", "ANY", "ANY", "ANY") - elif self.acl_implicit_permission == RulePermissionType.ALLOW: - self.acl_implicit_rule = ACLRule(RulePermissionType.ALLOW, "ANY", "ANY", "ANY", "ANY") - else: - raise ValueError(f"implicit permission must be ALLOW or DENY, got {self.acl_implicit_permission}") - - # Maximum number of ACL Rules in ACL - self.max_acl_rules: int = max_acl_rules - # A list of ACL Rules - self._acl: List[Union[ACLRule, None]] = [None] * (self.max_acl_rules - 1) - - @property - def acl(self) -> List[Union[ACLRule, None]]: - """Public access method for private _acl.""" - return self._acl + [self.acl_implicit_rule] - - def check_address_match(self, _rule: ACLRule, _source_ip_address: str, _dest_ip_address: str) -> bool: - """Checks for IP address matches. - - :param _rule: The rule object to check - :type _rule: ACLRule - :param _source_ip_address: Source IP address to compare - :type _source_ip_address: str - :param _dest_ip_address: Destination IP address to compare - :type _dest_ip_address: str - :return: True if there is a match, otherwise False. - :rtype: bool - """ - if ( - (_rule.get_source_ip() == _source_ip_address and _rule.get_dest_ip() == _dest_ip_address) - or (_rule.get_source_ip() == "ANY" and _rule.get_dest_ip() == _dest_ip_address) - or (_rule.get_source_ip() == _source_ip_address and _rule.get_dest_ip() == "ANY") - or (_rule.get_source_ip() == "ANY" and _rule.get_dest_ip() == "ANY") - ): - return True - else: - return False - - def is_blocked(self, _source_ip_address: str, _dest_ip_address: str, _protocol: str, _port: str) -> bool: - """ - Checks for rules that block a protocol / port. - - Args: - _source_ip_address: the source IP address to check - _dest_ip_address: the destination IP address to check - _protocol: the protocol to check - _port: the port to check - - Returns: - Indicates block if all conditions are satisfied. - """ - for rule in self.acl: - if isinstance(rule, ACLRule): - if self.check_address_match(rule, _source_ip_address, _dest_ip_address): - if (rule.get_protocol() == _protocol or rule.get_protocol() == "ANY") and ( - str(rule.get_port()) == str(_port) or rule.get_port() == "ANY" - ): - # There's a matching rule. Get the permission - if rule.get_permission() == RulePermissionType.DENY: - return True - elif rule.get_permission() == RulePermissionType.ALLOW: - return False - - # If there has been no rule to allow the IER through, it will return a blocked signal by default - return True - - def add_rule( - self, - _permission: RulePermissionType, - _source_ip: str, - _dest_ip: str, - _protocol: str, - _port: str, - _position: str, - ) -> None: - """ - Adds a new rule. - - Args: - _permission: the permission value (e.g. "ALLOW" or "DENY") - _source_ip: the source IP address - _dest_ip: the destination IP address - _protocol: the protocol - _port: the port - _position: position to insert ACL rule into ACL list (starting from index 1 and NOT 0) - """ - try: - position_index = int(_position) - except TypeError: - _LOGGER.info(f"Position {_position} could not be converted to integer.") - return - - new_rule = ACLRule(_permission, _source_ip, _dest_ip, _protocol, str(_port)) - # Checks position is in correct range - if self.max_acl_rules - 1 > position_index > -1: - try: - _LOGGER.info(f"Position {position_index} is valid.") - # Check to see Agent will not overwrite current ACL in ACL list - if self._acl[position_index] is None: - _LOGGER.info(f"Inserting rule {new_rule} at position {position_index}") - # Adds rule - self._acl[position_index] = new_rule - else: - # Cannot overwrite it - _LOGGER.info(f"Error: inserting rule at non-empty position {position_index}") - return - except Exception: - _LOGGER.info(f"New Rule could NOT be added to list at position {position_index}.") - else: - _LOGGER.info(f"Position {position_index} is an invalid/overwrites implicit firewall rule") - - def remove_rule( - self, _permission: RulePermissionType, _source_ip: str, _dest_ip: str, _protocol: str, _port: str - ) -> None: - """ - Removes a rule. - - Args: - _permission: the permission value (e.g. "ALLOW" or "DENY") - _source_ip: the source IP address - _dest_ip: the destination IP address - _protocol: the protocol - _port: the port - """ - rule_to_delete = ACLRule(_permission, _source_ip, _dest_ip, _protocol, str(_port)) - delete_rule_hash = hash(rule_to_delete) - - for index in range(0, len(self._acl)): - if isinstance(self._acl[index], ACLRule) and hash(self._acl[index]) == delete_rule_hash: - self._acl[index] = None - - def remove_all_rules(self) -> None: - """Removes all rules.""" - for i in range(len(self._acl)): - self._acl[i] = None - - def get_dictionary_hash( - self, _permission: RulePermissionType, _source_ip: str, _dest_ip: str, _protocol: str, _port: str - ) -> int: - """ - Produces a hash value for a rule. - - Args: - _permission: the permission value (e.g. "ALLOW" or "DENY") - _source_ip: the source IP address - _dest_ip: the destination IP address - _protocol: the protocol - _port: the port - - Returns: - Hash value based on rule parameters. - """ - rule = ACLRule(_permission, _source_ip, _dest_ip, _protocol, str(_port)) - hash_value = hash(rule) - return hash_value - - def get_relevant_rules( - self, _source_ip_address: str, _dest_ip_address: str, _protocol: str, _port: str - ) -> Dict[int, ACLRule]: - """Get all ACL rules that relate to the given arguments. - - :param _source_ip_address: the source IP address to check - :param _dest_ip_address: the destination IP address to check - :param _protocol: the protocol to check - :param _port: the port to check - :return: Dictionary of all ACL rules that relate to the given arguments - :rtype: Dict[int, ACLRule] - """ - relevant_rules = {} - for rule in self.acl: - if self.check_address_match(rule, _source_ip_address, _dest_ip_address): - if (rule.get_protocol() == _protocol or rule.get_protocol() == "ANY" or _protocol == "ANY") and ( - str(rule.get_port()) == str(_port) or rule.get_port() == "ANY" or str(_port) == "ANY" - ): - # There's a matching rule. - relevant_rules[self._acl.index(rule)] = rule - - return relevant_rules diff --git a/src/primaite/acl/acl_rule.py b/src/primaite/acl/acl_rule.py deleted file mode 100644 index 9c8deacd..00000000 --- a/src/primaite/acl/acl_rule.py +++ /dev/null @@ -1,87 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""A class that implements an access control list rule.""" -from primaite.common.enums import RulePermissionType - - -class ACLRule: - """Access Control List Rule class.""" - - def __init__( - self, _permission: RulePermissionType, _source_ip: str, _dest_ip: str, _protocol: str, _port: str - ) -> None: - """ - Initialise an ACL Rule. - - :param _permission: The permission (ALLOW or DENY) - :param _source_ip: The source IP address - :param _dest_ip: The destination IP address - :param _protocol: The rule protocol - :param _port: The rule port - """ - self.permission: RulePermissionType = _permission - self.source_ip: str = _source_ip - self.dest_ip: str = _dest_ip - self.protocol: str = _protocol - self.port: str = _port - - def __hash__(self) -> int: - """ - Override the hash function. - - Returns: - Returns hash of core parameters. - """ - return hash( - ( - self.permission, - self.source_ip, - self.dest_ip, - self.protocol, - self.port, - ) - ) - - def get_permission(self) -> str: - """ - Gets the permission attribute. - - Returns: - Returns permission attribute - """ - return self.permission - - def get_source_ip(self) -> str: - """ - Gets the source IP address attribute. - - Returns: - Returns source IP address attribute - """ - return self.source_ip - - def get_dest_ip(self) -> str: - """ - Gets the desintation IP address attribute. - - Returns: - Returns destination IP address attribute - """ - return self.dest_ip - - def get_protocol(self) -> str: - """ - Gets the protocol attribute. - - Returns: - Returns protocol attribute - """ - return self.protocol - - def get_port(self) -> str: - """ - Gets the port attribute. - - Returns: - Returns port attribute - """ - return self.port diff --git a/src/primaite/agents/__init__.py b/src/primaite/agents/__init__.py deleted file mode 100644 index c742daf3..00000000 --- a/src/primaite/agents/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Common interface between RL agents from different libraries and PrimAITE.""" diff --git a/src/primaite/agents/agent_abc.py b/src/primaite/agents/agent_abc.py deleted file mode 100644 index 54c38abf..00000000 --- a/src/primaite/agents/agent_abc.py +++ /dev/null @@ -1,309 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from __future__ import annotations - -import json -from abc import ABC, abstractmethod -from datetime import datetime -from logging import Logger -from pathlib import Path -from typing import Any, Dict, Optional, Union -from uuid import uuid4 - -import primaite -from primaite import getLogger, PRIMAITE_PATHS -from primaite.config import lay_down_config, training_config -from primaite.config.training_config import TrainingConfig -from primaite.data_viz.session_plots import plot_av_reward_per_episode -from primaite.environment.primaite_env import Primaite -from primaite.utils.session_metadata_parser import parse_session_metadata - -_LOGGER: Logger = getLogger(__name__) - - -def get_session_path(session_timestamp: datetime) -> Path: - """ - Get the directory path the session will output to. - - This is set in the format of: - ~/primaite/2.0.0/sessions//_. - - :param session_timestamp: This is the datetime that the session started. - :return: The session directory path. - """ - date_dir = session_timestamp.strftime("%Y-%m-%d") - session_path = session_timestamp.strftime("%Y-%m-%d_%H-%M-%S") - session_path = PRIMAITE_PATHS.user_sessions_path / date_dir / session_path - session_path.mkdir(exist_ok=True, parents=True) - - return session_path - - -class AgentSessionABC(ABC): - """ - An ABC that manages training and/or evaluation of agents in PrimAITE. - - This class cannot be directly instantiated and must be inherited from with all implemented abstract methods - implemented. - """ - - @abstractmethod - def __init__( - self, - training_config_path: Optional[Union[str, Path]] = None, - lay_down_config_path: Optional[Union[str, Path]] = None, - session_path: Optional[Union[str, Path]] = None, - ) -> None: - """ - Initialise an agent session from config files, or load a previous session. - - If training configuration and laydown configuration are provided with a session path, - the session path will be used. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - :param session_path: directory path of the session to load - """ - # initialise variables - self._env: Primaite - self._agent = None - self._can_learn: bool = False - self._can_evaluate: bool = False - self.is_eval = False - - self.session_timestamp: datetime = datetime.now() - - # convert session to path - if session_path is not None: - if not isinstance(session_path, Path): - session_path = Path(session_path) - - # if a session path is provided, load it - if not session_path.exists(): - raise Exception(f"Session could not be loaded. Path does not exist: {session_path}") - - # load session - self.load(session_path) - else: - # set training config path - if not isinstance(training_config_path, Path): - training_config_path = Path(training_config_path) - self._training_config_path: Union[Path, str] = training_config_path - self._training_config: TrainingConfig = training_config.load(self._training_config_path) - - if not isinstance(lay_down_config_path, Path): - lay_down_config_path = Path(lay_down_config_path) - self._lay_down_config_path: Union[Path, str] = lay_down_config_path - self._lay_down_config: Dict = lay_down_config.load(self._lay_down_config_path) - self.sb3_output_verbose_level = self._training_config.sb3_output_verbose_level - - # set random UUID for session - self._uuid = str(uuid4()) - "The session timestamp" - self.session_path = get_session_path(self.session_timestamp) - "The Session path" - - @property - def timestamp_str(self) -> str: - """The session timestamp as a string.""" - return self.session_timestamp.strftime("%Y-%m-%d_%H-%M-%S") - - @property - def learning_path(self) -> Path: - """The learning outputs path.""" - path = self.session_path / "learning" - path.mkdir(exist_ok=True, parents=True) - return path - - @property - def evaluation_path(self) -> Path: - """The evaluation outputs path.""" - path = self.session_path / "evaluation" - path.mkdir(exist_ok=True, parents=True) - return path - - @property - def checkpoints_path(self) -> Path: - """The Session checkpoints path.""" - path = self.learning_path / "checkpoints" - path.mkdir(exist_ok=True, parents=True) - return path - - @property - def uuid(self) -> str: - """The Agent Session UUID.""" - return self._uuid - - def _write_session_metadata_file(self) -> None: - """ - Write the ``session_metadata.json`` file. - - Creates a ``session_metadata.json`` in the ``session_path`` directory - and adds the following key/value pairs: - - - uuid: The UUID assigned to the session upon instantiation. - - start_datetime: The date & time the session started in iso format. - - end_datetime: NULL. - - total_episodes: NULL. - - total_time_steps: NULL. - - env: - - training_config: - - All training config items - - lay_down_config: - - All lay down config items - - """ - metadata_dict = { - "uuid": self.uuid, - "start_datetime": self.session_timestamp.isoformat(), - "end_datetime": None, - "learning": {"total_episodes": None, "total_time_steps": None}, - "evaluation": {"total_episodes": None, "total_time_steps": None}, - "env": { - "training_config": self._training_config.to_dict(json_serializable=True), - "lay_down_config": self._lay_down_config, - }, - } - filepath = self.session_path / "session_metadata.json" - _LOGGER.debug(f"Writing Session Metadata file: {filepath}") - with open(filepath, "w") as file: - json.dump(metadata_dict, file) - _LOGGER.debug("Finished writing session metadata file") - - def _update_session_metadata_file(self) -> None: - """ - Update the ``session_metadata.json`` file. - - Updates the `session_metadata.json`` in the ``session_path`` directory - with the following key/value pairs: - - - end_datetime: The date & time the session ended in iso format. - - total_episodes: The total number of training episodes completed. - - total_time_steps: The total number of training time steps completed. - """ - with open(self.session_path / "session_metadata.json", "r") as file: - metadata_dict = json.load(file) - - metadata_dict["end_datetime"] = datetime.now().isoformat() - if not self.is_eval: - metadata_dict["learning"]["total_episodes"] = self._env.actual_episode_count # noqa - metadata_dict["learning"]["total_time_steps"] = self._env.total_step_count # noqa - else: - metadata_dict["evaluation"]["total_episodes"] = self._env.actual_episode_count # noqa - metadata_dict["evaluation"]["total_time_steps"] = self._env.total_step_count # noqa - - filepath = self.session_path / "session_metadata.json" - _LOGGER.debug(f"Updating Session Metadata file: {filepath}") - with open(filepath, "w") as file: - json.dump(metadata_dict, file) - _LOGGER.debug("Finished updating session metadata file") - - @abstractmethod - def _setup(self) -> None: - _LOGGER.info( - "Welcome to the Primary-level AI Training Environment " f"(PrimAITE) (version: {primaite.__version__})" - ) - _LOGGER.info(f"The output directory for this session is: {self.session_path}") - self._write_session_metadata_file() - self._can_learn = True - self._can_evaluate = False - - @abstractmethod - def _save_checkpoint(self) -> None: - pass - - @abstractmethod - def learn( - self, - **kwargs: Any, - ) -> None: - """ - Train the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - if self._can_learn: - _LOGGER.info("Finished learning") - _LOGGER.debug("Writing transactions") - self._update_session_metadata_file() - self._can_evaluate = True - self.is_eval = False - - @abstractmethod - def evaluate( - self, - **kwargs: Any, - ) -> None: - """ - Evaluate the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - if self._can_evaluate: - self._update_session_metadata_file() - self.is_eval = True - self._plot_av_reward_per_episode(learning_session=False) - _LOGGER.info("Finished evaluation") - - @abstractmethod - def _get_latest_checkpoint(self) -> None: - pass - - def load(self, path: Union[str, Path]) -> None: - """Load an agent from file.""" - md_dict, training_config_path, laydown_config_path = parse_session_metadata(path) - - # set training config path - self._training_config_path: Union[Path, str] = training_config_path - self._training_config: TrainingConfig = training_config.load(self._training_config_path) - self._lay_down_config_path: Union[Path, str] = laydown_config_path - self._lay_down_config: Dict = lay_down_config.load(self._lay_down_config_path) - self.sb3_output_verbose_level = self._training_config.sb3_output_verbose_level - - # set random UUID for session - self._uuid = md_dict["uuid"] - - # set the session path - self.session_path = path - "The Session path" - - @property - def _saved_agent_path(self) -> Path: - file_name = f"{self._training_config.agent_framework}_" f"{self._training_config.agent_identifier}" f".zip" - return self.learning_path / file_name - - @abstractmethod - def save(self) -> None: - """Save the agent.""" - pass - - @abstractmethod - def export(self) -> None: - """Export the agent to transportable file format.""" - pass - - def close(self) -> None: - """Closes the agent.""" - self._env.episode_av_reward_writer.close() # noqa - self._env.transaction_writer.close() # noqa - - def _plot_av_reward_per_episode(self, learning_session: bool = True) -> None: - # self.close() - title = f"PrimAITE Session {self.timestamp_str} " - subtitle = str(self._training_config) - csv_file = f"average_reward_per_episode_{self.timestamp_str}.csv" - image_file = f"average_reward_per_episode_{self.timestamp_str}.png" - if learning_session: - title += "(Learning)" - path = self.learning_path / csv_file - image_path = self.learning_path / image_file - else: - title += "(Evaluation)" - path = self.evaluation_path / csv_file - image_path = self.evaluation_path / image_file - - fig = plot_av_reward_per_episode(path, title, subtitle) - fig.write_image(image_path) - _LOGGER.debug(f"Saved average rewards per episode plot to: {path}") diff --git a/src/primaite/agents/hardcoded_abc.py b/src/primaite/agents/hardcoded_abc.py deleted file mode 100644 index e75edbc5..00000000 --- a/src/primaite/agents/hardcoded_abc.py +++ /dev/null @@ -1,118 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import time -from abc import abstractmethod -from pathlib import Path -from typing import Any, Optional, Union - -import numpy as np - -from primaite import getLogger -from primaite.agents.agent_abc import AgentSessionABC -from primaite.environment.primaite_env import Primaite - -_LOGGER = getLogger(__name__) - - -class HardCodedAgentSessionABC(AgentSessionABC): - """ - An Agent Session ABC for evaluation deterministic agents. - - This class cannot be directly instantiated and must be inherited from with all implemented abstract methods - implemented. - """ - - def __init__( - self, - training_config_path: Optional[Union[str, Path]] = "", - lay_down_config_path: Optional[Union[str, Path]] = "", - session_path: Optional[Union[str, Path]] = None, - ) -> None: - """ - Initialise a hardcoded agent session. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - """ - super().__init__(training_config_path, lay_down_config_path, session_path) - self._setup() - - def _setup(self) -> None: - self._env: Primaite = Primaite( - training_config_path=self._training_config_path, - lay_down_config_path=self._lay_down_config_path, - session_path=self.session_path, - timestamp_str=self.timestamp_str, - ) - super()._setup() - self._can_learn = False - self._can_evaluate = True - - def _save_checkpoint(self) -> None: - pass - - def _get_latest_checkpoint(self) -> None: - pass - - def learn( - self, - **kwargs: Any, - ) -> None: - """ - Train the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - _LOGGER.warning("Deterministic agents cannot learn") - - @abstractmethod - def _calculate_action(self, obs: np.ndarray) -> None: - pass - - def evaluate( - self, - **kwargs: Any, - ) -> None: - """ - Evaluate the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - self._env.set_as_eval() # noqa - self.is_eval = True - - time_steps = self._training_config.num_eval_steps - episodes = self._training_config.num_eval_episodes - - obs = self._env.reset() - for episode in range(episodes): - # Reset env and collect initial observation - for step in range(time_steps): - # Calculate action - action = self._calculate_action(obs) - - # Perform the step - obs, reward, done, info = self._env.step(action) - - if done: - break - - # Introduce a delay between steps - time.sleep(self._training_config.time_delay / 1000) - obs = self._env.reset() - self._env.close() - - @classmethod - def load(cls, path: Union[str, Path] = None) -> None: - """Load an agent from file.""" - _LOGGER.warning("Deterministic agents cannot be loaded") - - def save(self) -> None: - """Save the agent.""" - _LOGGER.warning("Deterministic agents cannot be saved") - - def export(self) -> None: - """Export the agent to transportable file format.""" - _LOGGER.warning("Deterministic agents cannot be exported") diff --git a/src/primaite/agents/hardcoded_acl.py b/src/primaite/agents/hardcoded_acl.py deleted file mode 100644 index 2440da06..00000000 --- a/src/primaite/agents/hardcoded_acl.py +++ /dev/null @@ -1,515 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from typing import Dict, List, Union - -import numpy as np - -from primaite.acl.access_control_list import AccessControlList -from primaite.acl.acl_rule import ACLRule -from primaite.agents.hardcoded_abc import HardCodedAgentSessionABC -from primaite.agents.utils import ( - get_new_action, - get_node_of_ip, - transform_action_acl_enum, - transform_change_obs_readable, -) -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import HardCodedAgentView -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.service_node import ServiceNode -from primaite.pol.ier import IER - - -class HardCodedACLAgent(HardCodedAgentSessionABC): - """An Agent Session class that implements a deterministic ACL agent.""" - - def _calculate_action(self, obs: np.ndarray) -> int: - if self._training_config.hard_coded_agent_view == HardCodedAgentView.BASIC: - # Basic view action using only the current observation - return self._calculate_action_basic_view(obs) - else: - # full view action using observation space, action - # history and reward feedback - return self._calculate_action_full_view(obs) - - def get_blocked_green_iers( - self, green_iers: Dict[str, IER], acl: AccessControlList, nodes: Dict[str, NodeUnion] - ) -> Dict[str, IER]: - """Get blocked green IERs. - - :param green_iers: Green IERs to check for being - :type green_iers: Dict[str, IER] - :param acl: Firewall rules - :type acl: AccessControlList - :param nodes: Nodes in the network - :type nodes: Dict[str,NodeUnion] - :return: Same as `green_iers` input dict, but filtered to only contain the blocked ones. - :rtype: Dict[str, IER] - """ - blocked_green_iers = {} - - for green_ier_id, green_ier in green_iers.items(): - source_node_id = green_ier.get_source_node_id() - source_node_address = nodes[source_node_id].ip_address - dest_node_id = green_ier.get_dest_node_id() - dest_node_address = nodes[dest_node_id].ip_address - protocol = green_ier.get_protocol() # e.g. 'TCP' - port = green_ier.get_port() - - # Can be blocked by an ACL or by default (no allow rule exists) - if acl.is_blocked(source_node_address, dest_node_address, protocol, port): - blocked_green_iers[green_ier_id] = green_ier - - return blocked_green_iers - - def get_matching_acl_rules_for_ier( - self, ier: IER, acl: AccessControlList, nodes: Dict[str, NodeUnion] - ) -> Dict[int, ACLRule]: - """Get list of ACL rules which are relevant to an IER. - - :param ier: Information Exchange Request to query against the ACL list - :type ier: IER - :param acl: Firewall rules - :type acl: AccessControlList - :param nodes: Nodes in the network - :type nodes: Dict[str,NodeUnion] - :return: _description_ - :rtype: _type_ - """ - source_node_id = ier.get_source_node_id() - source_node_address = nodes[source_node_id].ip_address - dest_node_id = ier.get_dest_node_id() - dest_node_address = nodes[dest_node_id].ip_address - protocol = ier.get_protocol() # e.g. 'TCP' - port = ier.get_port() - matching_rules = acl.get_relevant_rules(source_node_address, dest_node_address, protocol, port) - return matching_rules - - def get_blocking_acl_rules_for_ier( - self, ier: IER, acl: AccessControlList, nodes: Dict[str, NodeUnion] - ) -> Dict[int, ACLRule]: - """ - Get blocking ACL rules for an IER. - - .. warning:: - Can return empty dict but IER can still be blocked by default - (No ALLOW rule, therefore blocked). - - :param ier: Information Exchange Request to query against the ACL list - :type ier: IER - :param acl: Firewall rules - :type acl: AccessControlList - :param nodes: Nodes in the network - :type nodes: Dict[str,NodeUnion] - :return: _description_ - :rtype: _type_ - """ - matching_rules = self.get_matching_acl_rules_for_ier(ier, acl, nodes) - - blocked_rules = {} - for rule_key, rule_value in matching_rules.items(): - if rule_value.get_permission() == "DENY": - blocked_rules[rule_key] = rule_value - - return blocked_rules - - def get_allow_acl_rules_for_ier( - self, ier: IER, acl: AccessControlList, nodes: Dict[str, NodeUnion] - ) -> Dict[int, ACLRule]: - """Get all allowing ACL rules for an IER. - - :param ier: Information Exchange Request to query against the ACL list - :type ier: IER - :param acl: Firewall rules - :type acl: AccessControlList - :param nodes: Nodes in the network - :type nodes: Dict[str,NodeUnion] - :return: _description_ - :rtype: _type_ - """ - matching_rules = self.get_matching_acl_rules_for_ier(ier, acl, nodes) - - allowed_rules = {} - for rule_key, rule_value in matching_rules.items(): - if rule_value.get_permission() == "ALLOW": - allowed_rules[rule_key] = rule_value - - return allowed_rules - - def get_matching_acl_rules( - self, - source_node_id: str, - dest_node_id: str, - protocol: str, - port: str, - acl: AccessControlList, - nodes: Dict[str, Union[ServiceNode, ActiveNode]], - services_list: List[str], - ) -> Dict[int, ACLRule]: - """Filter ACL rules to only those which are relevant to the specified nodes. - - :param source_node_id: Source node - :type source_node_id: str - :param dest_node_id: Destination nodes - :type dest_node_id: str - :param protocol: Network protocol - :type protocol: str - :param port: Network port - :type port: str - :param acl: Access Control list which will be filtered - :type acl: AccessControlList - :param nodes: The environment's node directory. - :type nodes: Dict[str, Union[ServiceNode, ActiveNode]] - :param services_list: List of services registered for the environment. - :type services_list: List[str] - :return: Filtered version of 'acl' - :rtype: Dict[str, ACLRule] - """ - if source_node_id != "ANY": - source_node_address = nodes[str(source_node_id)].ip_address - else: - source_node_address = source_node_id - - if dest_node_id != "ANY": - dest_node_address = nodes[str(dest_node_id)].ip_address - else: - dest_node_address = dest_node_id - - if protocol != "ANY": - protocol = services_list[protocol - 1] # -1 as dont have to account for ANY in list of services - # TODO: This should throw an error because protocol is a string - - matching_rules = acl.get_relevant_rules(source_node_address, dest_node_address, protocol, port) - return matching_rules - - def get_allow_acl_rules( - self, - source_node_id: int, - dest_node_id: str, - protocol: int, - port: str, - acl: AccessControlList, - nodes: Dict[str, NodeUnion], - services_list: List[str], - ) -> Dict[int, ACLRule]: - """List ALLOW rules relating to specified nodes. - - :param source_node_id: Source node id - :type source_node_id: int - :param dest_node_id: Destination node - :type dest_node_id: str - :param protocol: Network protocol - :type protocol: int - :param port: Port - :type port: str - :param acl: Firewall ruleset which is applied to the network - :type acl: AccessControlList - :param nodes: The simulation's node store - :type nodes: Dict[str, NodeUnion] - :param services_list: Services list - :type services_list: List[str] - :return: Filtered ACL Rule directory which includes only those rules which affect the specified source and - desination nodes - :rtype: Dict[str, ACLRule] - """ - matching_rules = self.get_matching_acl_rules( - source_node_id, - dest_node_id, - protocol, - port, - acl, - nodes, - services_list, - ) - - allowed_rules = {} - for rule_key, rule_value in matching_rules.items(): - if rule_value.get_permission() == "ALLOW": - allowed_rules[rule_key] = rule_value - - return allowed_rules - - def get_deny_acl_rules( - self, - source_node_id: int, - dest_node_id: str, - protocol: int, - port: str, - acl: AccessControlList, - nodes: Dict[str, NodeUnion], - services_list: List[str], - ) -> Dict[int, ACLRule]: - """List DENY rules relating to specified nodes. - - :param source_node_id: Source node id - :type source_node_id: int - :param dest_node_id: Destination node - :type dest_node_id: str - :param protocol: Network protocol - :type protocol: int - :param port: Port - :type port: str - :param acl: Firewall ruleset which is applied to the network - :type acl: AccessControlList - :param nodes: The simulation's node store - :type nodes: Dict[str, NodeUnion] - :param services_list: Services list - :type services_list: List[str] - :return: Filtered ACL Rule directory which includes only those rules which affect the specified source and - desination nodes - :rtype: Dict[str, ACLRule] - """ - matching_rules = self.get_matching_acl_rules( - source_node_id, - dest_node_id, - protocol, - port, - acl, - nodes, - services_list, - ) - - allowed_rules = {} - for rule_key, rule_value in matching_rules.items(): - if rule_value.get_permission() == "DENY": - allowed_rules[rule_key] = rule_value - - return allowed_rules - - def _calculate_action_full_view(self, obs: np.ndarray) -> int: - """ - Calculate a good acl-based action for the blue agent to take. - - Knowledge of just the observation space is insufficient for a perfect solution, as we need to know: - - - Which ACL rules already exist, - otherwise: - - The agent would perminently get stuck in a loop of performing the same action over and over. - (best action is to block something, but its already blocked but doesn't know this) - - The agent would be unable to interact with existing rules (e.g. how would it know to delete a rule, - if it doesnt know what rules exist) - - The Green IERs (optional) - It often needs to know which traffic it should be allowing. For example - in the default config one of the green IERs is blocked by default, but it has no way of knowing this - based on the observation space. Additionally, potentially in the future, once a node state - has been fixed (no longer compromised), it needs a way to know it should reallow traffic. - A RL agent can learn what the green IERs are on its own - but the rule based agent cannot easily do this. - - There doesn't seem like there's much that can be done if an Operating or OS State is compromised - - If a service node becomes compromised there's a decision to make - do we block that service? - Pros: It cannot launch an attack on another node, so the node will not be able to be OVERWHELMED - Cons: Will block a green IER, decreasing the reward - We decide to block the service. - - Potentially a better solution (for the reward) would be to block the incomming traffic from compromised - nodes once a service becomes overwhelmed. However currently the ACL action space has no way of reversing - an overwhelmed state, so we don't do this. - - :param obs: current observation from the gym environment - :type obs: np.ndarray - :return: Optimal action to take in the environment (chosen from the discrete action space) - :rtype: int - """ - # obs = convert_to_old_obs(obs) - r_obs = transform_change_obs_readable(obs) - _, _, _, *s = r_obs - - if len(r_obs) == 4: # only 1 service - s = [*s] - - # 1. Check if node is compromised. If so we want to block its outwards services - # a. If it is comprimised check if there's an allow rule we should delete. - # cons: might delete a multi-rule from any source node (ANY -> x) - # b. OPTIONAL (Deny rules not needed): Check if there already exists an existing Deny Rule so not to duplicate - # c. OPTIONAL (no allow rule = blocked): Add a DENY rule - found_action = False - for service_num, service_states in enumerate(s): - for x, service_state in enumerate(service_states): - if service_state == "COMPROMISED": - action_source_id = x + 1 # +1 as 0 is any - action_destination_id = "ANY" - action_protocol = service_num + 1 # +1 as 0 is any - action_port = "ANY" - - allow_rules = self.get_allow_acl_rules( - action_source_id, - action_destination_id, - action_protocol, - action_port, - self._env.acl, - self._env.nodes, - self._env.services_list, - ) - deny_rules = self.get_deny_acl_rules( - action_source_id, - action_destination_id, - action_protocol, - action_port, - self._env.acl, - self._env.nodes, - self._env.services_list, - ) - if len(allow_rules) > 0: - # Check if there's an allow rule we should delete - rule = list(allow_rules.values())[0] - action_decision = "DELETE" - action_permission = "ALLOW" - action_source_ip = rule.get_source_ip() - action_source_id = int(get_node_of_ip(action_source_ip, self._env.nodes)) - action_destination_ip = rule.get_dest_ip() - action_destination_id = int(get_node_of_ip(action_destination_ip, self._env.nodes)) - action_protocol_name = rule.get_protocol() - action_protocol = ( - self._env.services_list.index(action_protocol_name) + 1 - ) # convert name e.g. 'TCP' to index - action_port_name = rule.get_port() - action_port = ( - self._env.ports_list.index(action_port_name) + 1 - ) # convert port name e.g. '80' to index - - found_action = True - break - elif len(deny_rules) > 0: - # TODO OPTIONAL - # If there's already a DENY RULE, that blocks EVERYTHING from the source ip we don't need - # to create another - # Check to see if the DENY rule really blocks everything (ANY) or just a specific rule - continue - else: - # TODO OPTIONAL: Add a DENY rule, optional as by default no allow rule == blocked - action_decision = "CREATE" - action_permission = "DENY" - break - if found_action: - break - - # 2. If NO Node is Comprimised, or the node has already been blocked, check the green IERs and - # add an Allow rule if the green IER is being blocked. - # a. OPTIONAL - NOT IMPLEMENTED (optional as a deny rule does not overwrite an allow rule): - # If there's a DENY rule delete it if: - # - There isn't already a deny rule - # - It doesnt allows a comprimised node to become operational. - # b. Add an ALLOW rule if: - # - There isn't already an allow rule - # - It doesnt allows a comprimised node to become operational - - if not found_action: - # Which Green IERS are blocked - blocked_green_iers = self.get_blocked_green_iers(self._env.green_iers, self._env.acl, self._env.nodes) - for ier_key, ier in blocked_green_iers.items(): - # Which ALLOW rules are allowing this IER (none) - allowing_rules = self.get_allow_acl_rules_for_ier(ier, self._env.acl, self._env.nodes) - - # If there are no blocking rules, it may be being blocked by default - # If there is already an allow rule - node_id_to_check = int(ier.get_source_node_id()) - service_name_to_check = ier.get_protocol() - service_id_to_check = self._env.services_list.index(service_name_to_check) - - # Service state of the the source node in the ier - service_state = s[service_id_to_check][node_id_to_check - 1] - - if len(allowing_rules) == 0 and service_state != "COMPROMISED": - action_decision = "CREATE" - action_permission = "ALLOW" - action_source_id = int(ier.get_source_node_id()) - action_destination_id = int(ier.get_dest_node_id()) - action_protocol_name = ier.get_protocol() - action_protocol = ( - self._env.services_list.index(action_protocol_name) + 1 - ) # convert name e.g. 'TCP' to index - action_port_name = ier.get_port() - action_port = ( - self._env.ports_list.index(action_port_name) + 1 - ) # convert port name e.g. '80' to index - - found_action = True - break - - if found_action: - action = [ - action_decision, - action_permission, - action_source_id, - action_destination_id, - action_protocol, - action_port, - ] - action = transform_action_acl_enum(action) - action = get_new_action(action, self._env.action_dict) - else: - # If no good/useful action has been found, just perform a nothing action - action = ["NONE", "ALLOW", "ANY", "ANY", "ANY", "ANY"] - action = transform_action_acl_enum(action) - action = get_new_action(action, self._env.action_dict) - return action - - def _calculate_action_basic_view(self, obs: np.ndarray) -> int: - """ - Calculate a good acl-based action for the blue agent to take. - - Uses ONLY information from the current observation with NO knowledge - of previous actions taken and NO reward feedback. - - We rely on randomness to select the precise action, as we want to - block all traffic originating from a compromised node, without being - able to tell: - 1. Which ACL rules already exist - 2. Which actions the agent has already tried. - - There is a high probability that the correct rule will not be deleted - before the state becomes overwhelmed. - - Currently, a deny rule does not overwrite an allow rule. The allow - rules must be deleted. - - :param obs: current observation from the gym environment - :type obs: np.ndarray - :return: Optimal action to take in the environment (chosen from the discrete action space) - :rtype: int - """ - action_dict = self._env.action_dict - r_obs = transform_change_obs_readable(obs) - _, o, _, *s = r_obs - - if len(r_obs) == 4: # only 1 service - s = [*s] - - number_of_nodes = len([i for i in o if i != "NONE"]) # number of nodes (not links) - for service_num, service_states in enumerate(s): - comprimised_states = [n for n, i in enumerate(service_states) if i == "COMPROMISED"] - if len(comprimised_states) == 0: - # No states are COMPROMISED, try the next service - continue - - compromised_node = np.random.choice(comprimised_states) + 1 # +1 as 0 would be any - action_decision = "DELETE" - action_permission = "ALLOW" - action_source_ip = compromised_node - # Randomly select a destination ID to block - action_destination_ip = np.random.choice(list(range(1, number_of_nodes + 1)) + ["ANY"]) - action_destination_ip = ( - int(action_destination_ip) if action_destination_ip != "ANY" else action_destination_ip - ) - action_protocol = service_num + 1 # +1 as 0 is any - # Randomly select a port to block - # Bad assumption that number of protocols equals number of ports - # AND no rules exist with an ANY port - action_port = np.random.choice(list(range(1, len(s) + 1))) - - action = [ - action_decision, - action_permission, - action_source_ip, - action_destination_ip, - action_protocol, - action_port, - ] - action = transform_action_acl_enum(action) - action = get_new_action(action, action_dict) - # We can only perform 1 action on each step - return action - - # If no good/useful action has been found, just perform a nothing action - nothing_action = ["NONE", "ALLOW", "ANY", "ANY", "ANY", "ANY"] - nothing_action = transform_action_acl_enum(nothing_action) - nothing_action = get_new_action(nothing_action, action_dict) - return nothing_action diff --git a/src/primaite/agents/hardcoded_node.py b/src/primaite/agents/hardcoded_node.py deleted file mode 100644 index b08d8967..00000000 --- a/src/primaite/agents/hardcoded_node.py +++ /dev/null @@ -1,125 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import numpy as np - -from primaite.agents.hardcoded_abc import HardCodedAgentSessionABC -from primaite.agents.utils import get_new_action, transform_action_node_enum, transform_change_obs_readable - - -class HardCodedNodeAgent(HardCodedAgentSessionABC): - """An Agent Session class that implements a deterministic Node agent.""" - - def _calculate_action(self, obs: np.ndarray) -> int: - """ - Calculate a good node-based action for the blue agent to take. - - :param obs: current observation from the gym environment - :type obs: np.ndarray - :return: Optimal action to take in the environment (chosen from the discrete action space) - :rtype: int - """ - action_dict = self._env.action_dict - r_obs = transform_change_obs_readable(obs) - _, o, os, *s = r_obs - - if len(r_obs) == 4: # only 1 service - s = [*s] - - # Check in order of most important states (order doesn't currently - # matter, but it probably should) - # First see if any OS states are compromised - for x, os_state in enumerate(os): - if os_state == "COMPROMISED": - action_node_id = x + 1 - action_node_property = "OS" - property_action = "PATCHING" - action_service_index = 0 # does nothing isn't relevant for os - action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - action = transform_action_node_enum(action) - action = get_new_action(action, action_dict) - # We can only perform 1 action on each step - return action - - # Next, see if any Services are compromised - # We fix the compromised state before overwhelemd state, - # If a compromised entry node is fixed before the overwhelmed state is triggered, instruction is ignored - for service_num, service in enumerate(s): - for x, service_state in enumerate(service): - if service_state == "COMPROMISED": - action_node_id = x + 1 - action_node_property = "SERVICE" - property_action = "PATCHING" - action_service_index = service_num - - action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - action = transform_action_node_enum(action) - action = get_new_action(action, action_dict) - # We can only perform 1 action on each step - return action - - # Next, See if any services are overwhelmed - # perhaps this should be fixed automatically when the compromised PCs issues are also resolved - # Currently there's no reason that an Overwhelmed state cannot be resolved before resolving the compromised PCs - - for service_num, service in enumerate(s): - for x, service_state in enumerate(service): - if service_state == "OVERWHELMED": - action_node_id = x + 1 - action_node_property = "SERVICE" - property_action = "PATCHING" - action_service_index = service_num - - action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - action = transform_action_node_enum(action) - action = get_new_action(action, action_dict) - # We can only perform 1 action on each step - return action - - # Finally, turn on any off nodes - for x, operating_state in enumerate(o): - if os_state == "OFF": - action_node_id = x + 1 - action_node_property = "OPERATING" - property_action = "ON" # Why reset it when we can just turn it on - action_service_index = 0 # does nothing isn't relevant for operating state - action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - # TODO: transform_action_node_enum takes only one argument, not sure why two are given here. - action = transform_action_node_enum(action, action_dict) - action = get_new_action(action, action_dict) - # We can only perform 1 action on each step - return action - - # If no good actions, just go with an action that wont do any harm - action_node_id = 1 - action_node_property = "NONE" - property_action = "ON" - action_service_index = 0 - action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - action = transform_action_node_enum(action) - action = get_new_action(action, action_dict) - - return action diff --git a/src/primaite/agents/rllib.py b/src/primaite/agents/rllib.py deleted file mode 100644 index ab1b3af3..00000000 --- a/src/primaite/agents/rllib.py +++ /dev/null @@ -1,286 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from __future__ import annotations - -import json -import shutil -import zipfile -from datetime import datetime -from logging import Logger -from pathlib import Path -from typing import Any, Callable, Dict, Optional, Union -from uuid import uuid4 - -from ray.rllib.algorithms import Algorithm -from ray.rllib.algorithms.a2c import A2CConfig -from ray.rllib.algorithms.ppo import PPOConfig -from ray.tune.logger import UnifiedLogger -from ray.tune.registry import register_env - -from primaite import getLogger -from primaite.agents.agent_abc import AgentSessionABC -from primaite.common.enums import AgentFramework, AgentIdentifier, SessionType -from primaite.environment.primaite_env import Primaite -from primaite.exceptions import RLlibAgentError - -_LOGGER: Logger = getLogger(__name__) - - -# TODO: verify type of env_config -def _env_creator(env_config: Dict[str, Any]) -> Primaite: - return Primaite( - training_config_path=env_config["training_config_path"], - lay_down_config_path=env_config["lay_down_config_path"], - session_path=env_config["session_path"], - timestamp_str=env_config["timestamp_str"], - ) - - -# TODO: verify type hint return type -def _custom_log_creator(session_path: Path) -> Callable[[Dict], UnifiedLogger]: - logdir = session_path / "ray_results" - logdir.mkdir(parents=True, exist_ok=True) - - def logger_creator(config: Dict) -> UnifiedLogger: - return UnifiedLogger(config, logdir, loggers=None) - - return logger_creator - - -class RLlibAgent(AgentSessionABC): - """An AgentSession class that implements a Ray RLlib agent.""" - - def __init__( - self, - training_config_path: Optional[Union[str, Path]] = "", - lay_down_config_path: Optional[Union[str, Path]] = "", - session_path: Optional[Union[str, Path]] = None, - ) -> None: - """ - Initialise the RLLib Agent training session. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - :raises ValueError: If the training config contains an unexpected value for agent_framework (should be "RLLIB") - :raises ValueError: If the training config contains an unexpected value for agent_identifies (should be `PPO` - or `A2C`) - """ - # TODO: implement RLlib agent loading - if session_path is not None: - msg = "RLlib agent loading has not been implemented yet" - _LOGGER.critical(msg) - raise NotImplementedError(msg) - - super().__init__(training_config_path, lay_down_config_path) - if self._training_config.session_type == SessionType.EVAL: - msg = "Cannot evaluate an RLlib agent that hasn't been through training yet." - _LOGGER.critical(msg) - raise RLlibAgentError(msg) - if not self._training_config.agent_framework == AgentFramework.RLLIB: - msg = f"Expected RLLIB agent_framework, " f"got {self._training_config.agent_framework}" - _LOGGER.error(msg) - raise ValueError(msg) - self._agent_config_class: Union[PPOConfig, A2CConfig] - if self._training_config.agent_identifier == AgentIdentifier.PPO: - self._agent_config_class = PPOConfig - elif self._training_config.agent_identifier == AgentIdentifier.A2C: - self._agent_config_class = A2CConfig - else: - msg = "Expected PPO or A2C agent_identifier, " f"got {self._training_config.agent_identifier.value}" - _LOGGER.error(msg) - raise ValueError(msg) - self._agent_config: Union[PPOConfig, A2CConfig] - - self._current_result: dict - self._setup() - _LOGGER.debug( - f"Created {self.__class__.__name__} using: " - f"agent_framework={self._training_config.agent_framework}, " - f"agent_identifier=" - f"{self._training_config.agent_identifier}, " - f"deep_learning_framework=" - f"{self._training_config.deep_learning_framework}" - ) - self._train_agent = None # Required to capture the learning agent to close after eval - - def _update_session_metadata_file(self) -> None: - """ - Update the ``session_metadata.json`` file. - - Updates the `session_metadata.json`` in the ``session_path`` directory - with the following key/value pairs: - - - end_datetime: The date & time the session ended in iso format. - - total_episodes: The total number of training episodes completed. - - total_time_steps: The total number of training time steps completed. - """ - with open(self.session_path / "session_metadata.json", "r") as file: - metadata_dict = json.load(file) - - metadata_dict["end_datetime"] = datetime.now().isoformat() - if not self.is_eval: - metadata_dict["learning"]["total_episodes"] = self._current_result["episodes_total"] # noqa - metadata_dict["learning"]["total_time_steps"] = self._current_result["timesteps_total"] # noqa - else: - metadata_dict["evaluation"]["total_episodes"] = self._current_result["episodes_total"] # noqa - metadata_dict["evaluation"]["total_time_steps"] = self._current_result["timesteps_total"] # noqa - - filepath = self.session_path / "session_metadata.json" - _LOGGER.debug(f"Updating Session Metadata file: {filepath}") - with open(filepath, "w") as file: - json.dump(metadata_dict, file) - _LOGGER.debug("Finished updating session metadata file") - - def _setup(self) -> None: - super()._setup() - register_env("primaite", _env_creator) - self._agent_config = self._agent_config_class() - - self._agent_config.environment( - env="primaite", - env_config=dict( - training_config_path=self._training_config_path, - lay_down_config_path=self._lay_down_config_path, - session_path=self.session_path, - timestamp_str=self.timestamp_str, - ), - ) - self._agent_config.seed = self._training_config.seed - - self._agent_config.training(train_batch_size=self._training_config.num_train_steps) - self._agent_config.framework(framework="tf") - - self._agent_config.rollouts( - num_rollout_workers=1, - num_envs_per_worker=1, - horizon=self._training_config.num_train_steps, - ) - self._agent: Algorithm = self._agent_config.build(logger_creator=_custom_log_creator(self.learning_path)) - - def _save_checkpoint(self) -> None: - checkpoint_n = self._training_config.checkpoint_every_n_episodes - episode_count = self._current_result["episodes_total"] - save_checkpoint = False - if checkpoint_n: - save_checkpoint = episode_count % checkpoint_n == 0 - if episode_count and save_checkpoint: - self._agent.save(str(self.checkpoints_path)) - - def learn( - self, - **kwargs: Any, - ) -> None: - """ - Evaluate the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - time_steps = self._training_config.num_train_steps - episodes = self._training_config.num_train_episodes - - _LOGGER.info(f"Beginning learning for {episodes} episodes @" f" {time_steps} time steps...") - for i in range(episodes): - self._current_result = self._agent.train() - self._save_checkpoint() - self.save() - super().learn() - # Done this way as the RLlib eval can only be performed if the session hasn't been stopped - if self._training_config.session_type is not SessionType.TRAIN: - self._train_agent = self._agent - else: - self._agent.stop() - self._plot_av_reward_per_episode(learning_session=True) - - def _unpack_saved_agent_into_eval(self) -> Path: - """Unpacks the pre-trained and saved RLlib agent so that it can be reloaded by Ray for eval.""" - agent_restore_path = self.evaluation_path / "agent_restore" - if agent_restore_path.exists(): - shutil.rmtree(agent_restore_path) - agent_restore_path.mkdir() - with zipfile.ZipFile(self._saved_agent_path, "r") as zip_file: - zip_file.extractall(agent_restore_path) - return agent_restore_path - - def _setup_eval(self): - self._can_learn = False - self._can_evaluate = True - self._agent.restore(str(self._unpack_saved_agent_into_eval())) - - def evaluate( - self, - **kwargs, - ): - """ - Evaluate the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - time_steps = self._training_config.num_eval_steps - episodes = self._training_config.num_eval_episodes - - self._setup_eval() - - self._env: Primaite = Primaite( - self._training_config_path, self._lay_down_config_path, self.session_path, self.timestamp_str - ) - - self._env.set_as_eval() - self.is_eval = True - if self._training_config.deterministic: - deterministic_str = "deterministic" - else: - deterministic_str = "non-deterministic" - _LOGGER.info( - f"Beginning {deterministic_str} evaluation for " f"{episodes} episodes @ {time_steps} time steps..." - ) - for episode in range(episodes): - obs = self._env.reset() - for step in range(time_steps): - action = self._agent.compute_single_action(observation=obs, explore=False) - - obs, rewards, done, info = self._env.step(action) - - self._env.reset() - self._env.close() - super().evaluate() - # Now we're safe to close the learning agent and write the mean rewards per episode for it - if self._training_config.session_type is not SessionType.TRAIN: - self._train_agent.stop() - self._plot_av_reward_per_episode(learning_session=True) - # Perform a clean-up of the unpacked agent - if (self.evaluation_path / "agent_restore").exists(): - shutil.rmtree((self.evaluation_path / "agent_restore")) - - def _get_latest_checkpoint(self) -> None: - raise NotImplementedError - - @classmethod - def load(cls, path: Union[str, Path]) -> RLlibAgent: - """Load an agent from file.""" - raise NotImplementedError - - def save(self, overwrite_existing: bool = True) -> None: - """Save the agent.""" - # Make temp dir to save in isolation - temp_dir = self.learning_path / str(uuid4()) - temp_dir.mkdir() - - # Save the agent to the temp dir - self._agent.save(str(temp_dir)) - - # Capture the saved Rllib checkpoint inside the temp directory - for file in temp_dir.iterdir(): - checkpoint_dir = file - break - - # Zip the folder - shutil.make_archive(str(self._saved_agent_path).replace(".zip", ""), "zip", checkpoint_dir) # noqa - - # Drop the temp directory - shutil.rmtree(temp_dir) - - def export(self) -> None: - """Export the agent to transportable file format.""" - raise NotImplementedError diff --git a/src/primaite/agents/sb3.py b/src/primaite/agents/sb3.py deleted file mode 100644 index 783f57eb..00000000 --- a/src/primaite/agents/sb3.py +++ /dev/null @@ -1,196 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from __future__ import annotations - -import json -from logging import Logger -from pathlib import Path -from typing import Any, Optional, Union - -import numpy as np -from stable_baselines3 import A2C, PPO -from stable_baselines3.ppo import MlpPolicy as PPOMlp - -from primaite import getLogger -from primaite.agents.agent_abc import AgentSessionABC -from primaite.common.enums import AgentFramework, AgentIdentifier -from primaite.environment.primaite_env import Primaite - -_LOGGER: Logger = getLogger(__name__) - - -class SB3Agent(AgentSessionABC): - """An AgentSession class that implements a Stable Baselines3 agent.""" - - def __init__( - self, - training_config_path: Optional[Union[str, Path]] = None, - lay_down_config_path: Optional[Union[str, Path]] = None, - session_path: Optional[Union[str, Path]] = None, - ) -> None: - """ - Initialise the SB3 Agent training session. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - :raises ValueError: If the training config contains an unexpected value for agent_framework (should be "SB3") - :raises ValueError: If the training config contains an unexpected value for agent_identifies (should be `PPO` - or `A2C`) - """ - super().__init__(training_config_path, lay_down_config_path, session_path) - if not self._training_config.agent_framework == AgentFramework.SB3: - msg = f"Expected SB3 agent_framework, " f"got {self._training_config.agent_framework}" - _LOGGER.error(msg) - raise ValueError(msg) - self._agent_class: Union[PPO, A2C] - if self._training_config.agent_identifier == AgentIdentifier.PPO: - self._agent_class = PPO - elif self._training_config.agent_identifier == AgentIdentifier.A2C: - self._agent_class = A2C - else: - msg = "Expected PPO or A2C agent_identifier, " f"got {self._training_config.agent_identifier}" - _LOGGER.error(msg) - raise ValueError(msg) - - self._tensorboard_log_path = self.learning_path / "tensorboard_logs" - self._tensorboard_log_path.mkdir(parents=True, exist_ok=True) - - _LOGGER.debug( - f"Created {self.__class__.__name__} using: " - f"agent_framework={self._training_config.agent_framework}, " - f"agent_identifier=" - f"{self._training_config.agent_identifier}" - ) - - self.is_eval = False - - self._setup() - - def _setup(self) -> None: - """Set up the SB3 Agent.""" - self._env = Primaite( - training_config_path=self._training_config_path, - lay_down_config_path=self._lay_down_config_path, - session_path=self.session_path, - timestamp_str=self.timestamp_str, - ) - - # check if there is a zip file that needs to be loaded - load_file = next(self.session_path.rglob("*.zip"), None) - - if not load_file: - # create a new env and agent - - self._agent = self._agent_class( - PPOMlp, - self._env, - verbose=self.sb3_output_verbose_level, - n_steps=self._training_config.num_train_steps, - tensorboard_log=str(self._tensorboard_log_path), - seed=self._training_config.seed, - ) - else: - # set env values from session metadata - with open(self.session_path / "session_metadata.json", "r") as file: - md_dict = json.load(file) - - # load environment values - if self.is_eval: - # evaluation always starts at 0 - self._env.episode_count = 0 - self._env.total_step_count = 0 - else: - # carry on from previous learning sessions - self._env.episode_count = md_dict["learning"]["total_episodes"] - self._env.total_step_count = md_dict["learning"]["total_time_steps"] - - # load the file - self._agent = self._agent_class.load(load_file, env=self._env) - - # set agent values - self._agent.verbose = self.sb3_output_verbose_level - self._agent.tensorboard_log = self.session_path / "learning/tensorboard_logs" - - super()._setup() - - def _save_checkpoint(self) -> None: - checkpoint_n = self._training_config.checkpoint_every_n_episodes - episode_count = self._env.episode_count - save_checkpoint = False - if checkpoint_n: - save_checkpoint = episode_count % checkpoint_n == 0 - if episode_count and save_checkpoint: - checkpoint_path = self.checkpoints_path / f"sb3ppo_{episode_count}.zip" - self._agent.save(checkpoint_path) - _LOGGER.debug(f"Saved agent checkpoint: {checkpoint_path}") - - def _get_latest_checkpoint(self) -> None: - pass - - def learn( - self, - **kwargs: Any, - ) -> None: - """ - Train the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - time_steps = self._training_config.num_train_steps - episodes = self._training_config.num_train_episodes - self.is_eval = False - _LOGGER.info(f"Beginning learning for {episodes} episodes @" f" {time_steps} time steps...") - for i in range(episodes): - self._agent.learn(total_timesteps=time_steps) - self._save_checkpoint() - self._env._write_av_reward_per_episode() # noqa - self.save() - self._env.close() - super().learn() - - # save agent - self.save() - - self._plot_av_reward_per_episode(learning_session=True) - - def evaluate( - self, - **kwargs: Any, - ) -> None: - """ - Evaluate the agent. - - :param kwargs: Any agent-specific key-word args to be passed. - """ - time_steps = self._training_config.num_eval_steps - episodes = self._training_config.num_eval_episodes - self._env.set_as_eval() - self.is_eval = True - if self._training_config.deterministic: - deterministic_str = "deterministic" - else: - deterministic_str = "non-deterministic" - _LOGGER.info( - f"Beginning {deterministic_str} evaluation for " f"{episodes} episodes @ {time_steps} time steps..." - ) - for episode in range(episodes): - obs = self._env.reset() - - for step in range(time_steps): - action, _states = self._agent.predict(obs, deterministic=self._training_config.deterministic) - if isinstance(action, np.ndarray): - action = np.int64(action) - obs, rewards, done, info = self._env.step(action) - self._env._write_av_reward_per_episode() # noqa - self._env.close() - super().evaluate() - - def save(self) -> None: - """Save the agent.""" - self._agent.save(self._saved_agent_path) - - def export(self) -> None: - """Export the agent to transportable file format.""" - raise NotImplementedError diff --git a/src/primaite/agents/simple.py b/src/primaite/agents/simple.py deleted file mode 100644 index bfdff869..00000000 --- a/src/primaite/agents/simple.py +++ /dev/null @@ -1,59 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -import numpy as np - -from primaite.agents.hardcoded_abc import HardCodedAgentSessionABC -from primaite.agents.utils import get_new_action, transform_action_acl_enum, transform_action_node_enum - - -class RandomAgent(HardCodedAgentSessionABC): - """ - A Random Agent. - - Get a completely random action from the action space. - """ - - def _calculate_action(self, obs: np.ndarray) -> int: - return self._env.action_space.sample() - - -class DummyAgent(HardCodedAgentSessionABC): - """ - A Dummy Agent. - - All action spaces setup so dummy action is always 0 regardless of action type used. - """ - - def _calculate_action(self, obs: np.ndarray) -> int: - return 0 - - -class DoNothingACLAgent(HardCodedAgentSessionABC): - """ - A do nothing ACL agent. - - A valid ACL action that has no effect; does nothing. - """ - - def _calculate_action(self, obs: np.ndarray) -> int: - nothing_action = ["NONE", "ALLOW", "ANY", "ANY", "ANY", "ANY"] - nothing_action = transform_action_acl_enum(nothing_action) - nothing_action = get_new_action(nothing_action, self._env.action_dict) - - return nothing_action - - -class DoNothingNodeAgent(HardCodedAgentSessionABC): - """ - A do nothing Node agent. - - A valid Node action that has no effect; does nothing. - """ - - def _calculate_action(self, obs: np.ndarray) -> int: - nothing_action = [1, "NONE", "ON", 0] - nothing_action = transform_action_node_enum(nothing_action) - nothing_action = get_new_action(nothing_action, self._env.action_dict) - # nothing_action should currently always be 0 - - return nothing_action diff --git a/src/primaite/agents/utils.py b/src/primaite/agents/utils.py deleted file mode 100644 index 08d46294..00000000 --- a/src/primaite/agents/utils.py +++ /dev/null @@ -1,450 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from typing import Dict, List, Union - -import numpy as np - -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import ( - HardwareState, - LinkStatus, - NodeHardwareAction, - NodePOLType, - NodeSoftwareAction, - SoftwareState, -) - - -def transform_action_node_readable(action: List[int]) -> List[Union[int, str]]: - """Convert a node action from enumerated format to readable format. - - example: - [1, 3, 1, 0] -> [1, 'SERVICE', 'PATCHING', 0] - - :param action: Agent action, formatted as a list of ints, for more information check out - `primaite.environment.primaite_env.Primaite` - :type action: List[int] - :return: The same action list, but with the encodings translated back into meaningful labels - :rtype: List[Union[int,str]] - """ - action_node_property = NodePOLType(action[1]).name - - if action_node_property == "OPERATING": - property_action = NodeHardwareAction(action[2]).name - elif (action_node_property == "OS" or action_node_property == "SERVICE") and action[2] <= 1: - property_action = NodeSoftwareAction(action[2]).name - else: - property_action = "NONE" - - new_action: list[Union[int, str]] = [action[0], action_node_property, property_action, action[3]] - return new_action - - -def transform_action_acl_readable(action: List[int]) -> List[Union[str, int]]: - """ - Transform an ACL action to a more readable format. - - example: - [0, 1, 2, 5, 0, 1] -> ['NONE', 'ALLOW', 2, 5, 'ANY', 1] - - :param action: Agent action, formatted as a list of ints, for more information check out - `primaite.environment.primaite_env.Primaite` - :type action: List[int] - :return: The same action list, but with the encodings translated back into meaningful labels - :rtype: List[Union[int,str]] - """ - action_decisions = {0: "NONE", 1: "CREATE", 2: "DELETE"} - action_permissions = {0: "DENY", 1: "ALLOW"} - - action_decision = action_decisions[action[0]] - action_permission = action_permissions[action[1]] - - # For IPs, Ports and Protocols, 0 means any, otherwise its just an index - new_action = [action_decision, action_permission] + list(action[2:6]) - for n, val in enumerate(list(action[2:6])): - if val == 0: - new_action[n + 2] = "ANY" - - return new_action - - -def is_valid_node_action(action: List[int]) -> bool: - """ - Is the node action an actual valid action. - - Only uses information about the action to determine if the action has an effect - - Does NOT consider: - - Node ID not valid to perform an operation - e.g. selected node has no service so cannot patch - - Node already being in that state (turning an ON node ON) - - :param action: Agent action, formatted as a list of ints, for more information check out - `primaite.environment.primaite_env.Primaite` - :type action: List[int] - :return: Whether the action is valid - :rtype: bool - """ - action_r = transform_action_node_readable(action) - - node_property = action_r[1] - node_action = action_r[2] - - # print("node property", node_property, "\nnode action", node_action) - - if node_property == "NONE": - return False - if node_action == "NONE": - return False - if node_property == "OPERATING" and node_action == "PATCHING": - # Operating State cannot PATCH - return False - if node_property != "OPERATING" and node_action not in [ - "NONE", - "PATCHING", - ]: - # Software States can only do Nothing or Patch - return False - return True - - -def is_valid_acl_action(action: List[int]) -> bool: - """ - Is the ACL action an actual valid action. - - Only uses information about the action to determine if the action has an effect. - - Does NOT consider: - - Trying to create identical rules - - Trying to create a rule which is a subset of another rule (caused by "ANY") - - :param action: Agent action, formatted as a list of ints, for more information check out - `primaite.environment.primaite_env.Primaite` - :type action: List[int] - :return: Whether the action is valid - :rtype: bool - """ - action_r = transform_action_acl_readable(action) - - action_decision = action_r[0] - action_permission = action_r[1] - action_source_id = action_r[2] - action_destination_id = action_r[3] - - if action_decision == "NONE": - return False - if action_source_id == action_destination_id and action_source_id != "ANY" and action_destination_id != "ANY": - # ACL rule towards itself - return False - if action_permission == "DENY": - # DENY is unnecessary, we can create and delete allow rules instead - # No allow rule = blocked/DENY by feault. ALLOW overrides existing DENY. - return False - - return True - - -def is_valid_acl_action_extra(action: List[int]) -> bool: - """ - Harsher version of valid acl actions, does not allow action. - - :param action: Agent action, formatted as a list of ints, for more information check out - `primaite.environment.primaite_env.Primaite` - :type action: List[int] - :return: Whether the action is valid - :rtype: bool - """ - if is_valid_acl_action(action) is False: - return False - - action_r = transform_action_acl_readable(action) - action_protocol = action_r[4] - action_port = action_r[5] - - # Don't allow protocols or ports to be ANY - # in the future we might want to do the opposite, and only have ANY option for ports and service - if action_protocol == "ANY": - return False - if action_port == "ANY": - return False - - return True - - -def transform_change_obs_readable(obs: np.ndarray) -> List[List[Union[str, int]]]: - """Transform list of transactions to readable list of each observation property. - - example: - np.array([[1,2,1,3],[2,1,1,1]]) -> [[1, 2], ['OFF', 'ON'], ['GOOD', 'GOOD'], ['COMPROMISED', 'GOOD']] - - :param obs: Raw observation from the environment. - :type obs: np.ndarray - :return: The same observation, but the encoded integer values are replaced with readable names. - :rtype: List[List[Union[str, int]]] - """ - ids = [i for i in obs[:, 0]] - operating_states = [HardwareState(i).name for i in obs[:, 1]] - os_states = [SoftwareState(i).name for i in obs[:, 2]] - new_obs = [ids, operating_states, os_states] - - for service in range(4, obs.shape[1]): - # Links bit/s don't have a service state - service_states = [SoftwareState(i).name if i <= 4 else i for i in obs[:, service]] - new_obs.append(service_states) - - return new_obs - - -def transform_obs_readable(obs: np.ndarray) -> List[List[Union[str, int]]]: - """Transform observation to readable format. - - example - np.array([[1,2,1,3],[2,1,1,1]]) -> [[1, 'OFF', 'GOOD', 'COMPROMISED'], [2, 'ON', 'GOOD', 'GOOD']] - - :param obs: Raw observation from the environment. - :type obs: np.ndarray - :return: The same observation, but the encoded integer values are replaced with readable names. - :rtype: List[List[Union[str, int]]] - """ - changed_obs = transform_change_obs_readable(obs) - new_obs = list(zip(*changed_obs)) - # Convert list of tuples to list of lists - new_obs = [list(i) for i in new_obs] - - return new_obs - - -def convert_to_new_obs(obs: np.ndarray, num_nodes: int = 10) -> np.ndarray: - """Convert original gym Box observation space to new multiDiscrete observation space. - - :param obs: observation in the 'old' (NodeLinkTable) format - :type obs: np.ndarray - :param num_nodes: number of nodes in the network, defaults to 10 - :type num_nodes: int, optional - :return: reformatted observation - :rtype: np.ndarray - """ - # Remove ID columns, remove links and flatten to MultiDiscrete observation space - new_obs = obs[:num_nodes, 1:].flatten() - return new_obs - - -def convert_to_old_obs(obs: np.ndarray, num_nodes: int = 10, num_links: int = 10, num_services: int = 1) -> np.ndarray: - """Convert to old observation. - - Links filled with 0's as no information is included in new observation space. - - example: - obs = array([1, 1, 1, 1, 1, 1, 1, 1, 1, ..., 1, 1, 1]) - - new_obs = array([[ 1, 1, 1, 1], - [ 2, 1, 1, 1], - [ 3, 1, 1, 1], - ... - [20, 0, 0, 0]]) - - :param obs: observation in the 'new' (MultiDiscrete) format - :type obs: np.ndarray - :param num_nodes: number of nodes in the network, defaults to 10 - :type num_nodes: int, optional - :param num_links: number of links in the network, defaults to 10 - :type num_links: int, optional - :param num_services: number of services on the network, defaults to 1 - :type num_services: int, optional - :return: 2-d BOX observation space, in the same format as NodeLinkTable - :rtype: np.ndarray - """ - # Convert back to more readable, original format - reshaped_nodes = obs[:-num_links].reshape(num_nodes, num_services + 2) - - # Add empty links back and add node ID back - s = np.zeros( - [reshaped_nodes.shape[0] + num_links, reshaped_nodes.shape[1] + 1], - dtype=np.int64, - ) - s[:, 0] = range(1, num_nodes + num_links + 1) # Adding ID back - s[:num_nodes, 1:] = reshaped_nodes # put values back in - new_obs = s - - # Add links back in - links = obs[-num_links:] - # Links will be added to the last protocol/service slot but they are not specific to that service - new_obs[num_nodes:, -1] = links - - return new_obs - - -def describe_obs_change( - obs1: np.ndarray, obs2: np.ndarray, num_nodes: int = 10, num_links: int = 10, num_services: int = 1 -) -> str: - """Build a string describing the difference between two observations. - - example: - obs_1 = array([[1, 1, 1, 1, 3], [2, 1, 1, 1, 1]]) - obs_2 = array([[1, 1, 1, 1, 1], [2, 1, 1, 1, 1]]) - output = 'ID 1: SERVICE 2 set to GOOD' - - :param obs1: First observation - :type obs1: np.ndarray - :param obs2: Second observation - :type obs2: np.ndarray - :param num_nodes: How many nodes are in the network laydown, defaults to 10 - :type num_nodes: int, optional - :param num_links: How many links are in the network laydown, defaults to 10 - :type num_links: int, optional - :param num_services: How many services are configured for this scenario, defaults to 1 - :type num_services: int, optional - :return: A multi-line string with a human-readable description of the difference. - :rtype: str - """ - obs1 = convert_to_old_obs(obs1, num_nodes, num_links, num_services) - obs2 = convert_to_old_obs(obs2, num_nodes, num_links, num_services) - list_of_changes = [] - for n, row in enumerate(obs1 - obs2): - if row.any() != 0: - relevant_changes = np.where(row != 0, obs2[n], -1) - relevant_changes[0] = obs2[n, 0] # ID is always relevant - is_link = relevant_changes[0] > num_nodes - desc = _describe_obs_change_helper(relevant_changes, is_link) - list_of_changes.append(desc) - - change_string = "\n ".join(list_of_changes) - if len(list_of_changes) > 0: - change_string = "\n " + change_string - return change_string - - -def _describe_obs_change_helper(obs_change: List[int], is_link: bool) -> str: - """ - Helper funcion to describe what has changed. - - example: - [ 1 -1 -1 -1 1] -> "ID 1: Service 1 changed to GOOD" - - Handles multiple changes e.g. 'ID 1: SERVICE 1 changed to PATCHING. SERVICE 2 set to GOOD.' - - :param obs_change: List of integers generated within the `describe_obs_change` function. It should correspond to one - row of the observation table, and have `-1` at locations where the observation hasn't changed, and the new - status where it has changed. - :type obs_change: List[int] - :param is_link: Whether the row of the observation space corresponds to a link. False means it represents a node. - :type is_link: bool - :return: A human-readable description of the difference between the two observation rows. - :rtype: str - """ - # Indexes where a change has occured, not including 0th index - index_changed = [i for i in range(1, len(obs_change)) if obs_change[i] != -1] - # Node pol types, Indexes >= 3 are service nodes - NodePOLTypes = [NodePOLType(i).name if i < 3 else NodePOLType(3).name + " " + str(i - 3) for i in index_changed] - # Account for hardware states, software sattes and links - states = [ - LinkStatus(obs_change[i]).name - if is_link - else HardwareState(obs_change[i]).name - if i == 1 - else SoftwareState(obs_change[i]).name - for i in index_changed - ] - - if not is_link: - desc = f"ID {obs_change[0]}:" - for node_pol_type, state in list(zip(NodePOLTypes, states)): - desc = desc + " " + node_pol_type + " changed to " + state + "." - else: - desc = f"ID {obs_change[0]}: Link traffic changed to {states[0]}." - - return desc - - -def transform_action_node_enum(action: List[Union[str, int]]) -> List[int]: - """Convert a node action from readable string format, to enumerated format. - - example: - [1, 'SERVICE', 'PATCHING', 0] -> [1, 3, 1, 0] - :param action: Action in 'readable' format - :type action: List[Union[str,int]] - :return: Action with verbs encoded as ints - :rtype: List[int] - """ - action_node_id = action[0] - action_node_property = NodePOLType[action[1]].value - - if action[1] == "OPERATING": - property_action = NodeHardwareAction[action[2]].value - elif action[1] == "OS" or action[1] == "SERVICE": - property_action = NodeSoftwareAction[action[2]].value - else: - property_action = 0 - - action_service_index = action[3] - - new_action = [ - action_node_id, - action_node_property, - property_action, - action_service_index, - ] - - return new_action - - -def transform_action_acl_enum(action: List[Union[int, str]]) -> np.ndarray: - """ - Convert acl action from readable str format, to enumerated format. - - :param action: ACL-based action expressed as a list of human-readable ints and strings - :type action: List[Union[int,str]] - :return: The same action but encoded to contain only integers. - :rtype: np.ndarray - """ - action_decisions = {"NONE": 0, "CREATE": 1, "DELETE": 2} - action_permissions = {"DENY": 0, "ALLOW": 1} - - action_decision = action_decisions[action[0]] - action_permission = action_permissions[action[1]] - - # For IPs, Ports and Protocols, ANY has value 0, otherwise its just an index - new_action = [action_decision, action_permission] + list(action[2:6]) - for n, val in enumerate(list(action[2:6])): - if val == "ANY": - new_action[n + 2] = 0 - - new_action = np.array(new_action) - return new_action - - -def get_node_of_ip(ip: str, node_dict: Dict[str, NodeUnion]) -> str: - """Get the node ID of an IP address. - - node_dict: dictionary of nodes where key is ID, and value is the node (can be ontained from env.nodes) - - :param ip: The IP address of the node whose ID is required - :type ip: str - :param node_dict: The environment's node registry dictionary - :type node_dict: Dict[str,NodeUnion] - :return: The key from the registry dict that corresponds to the node with the IP adress provided by `ip` - :rtype: str - """ - for node_key, node_value in node_dict.items(): - node_ip = node_value.ip_address - if node_ip == ip: - return node_key - - -def get_new_action(old_action: np.ndarray, action_dict: Dict[int, List]) -> int: - """ - Get new action (e.g. 32) from old action e.g. [1,1,1,0]. - - Old_action can be either node or acl action type - - :param old_action: Action expressed as a list of choices, eg. [1,1,1,0] - :type old_action: np.ndarray - :param action_dict: Dictionary for translating the multidiscrete actions into the list-based actions. - :type action_dict: Dict[int,List] - :return: Action key correspoinding to the input `old_action` - :rtype: int - """ - for key, val in action_dict.items(): - if list(val) == list(old_action): - return key - # Not all possible actions are included in dict, only valid action are - # if action is not in the dict, its an invalid action so return 0 - return 0 diff --git a/src/primaite/cli.py b/src/primaite/cli.py index 4e37f75c..e2e5f8f6 100644 --- a/src/primaite/cli.py +++ b/src/primaite/cli.py @@ -2,25 +2,29 @@ """Provides a CLI using Typer as an entry point.""" import logging import os +import shutil from enum import Enum +from pathlib import Path from typing import Optional +import pkg_resources import typer import yaml from typing_extensions import Annotated from primaite import PRIMAITE_PATHS -from primaite.data_viz import PlotlyTemplate +from primaite.utils.cli import dev_cli -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) +app.add_typer(dev_cli.dev, name="dev-mode") @app.command() def build_dirs() -> None: """Build the PrimAITE app directories.""" - from primaite.setup import setup_app_dirs + from primaite import PRIMAITE_PATHS - setup_app_dirs.run() + PRIMAITE_PATHS.mkdirs() @app.command() @@ -81,14 +85,6 @@ def log_level(level: Annotated[Optional[_LogLevel], typer.Argument()] = None) -> print(f"PrimAITE Log Level: {level}") -@app.command() -def notebooks() -> None: - """Start Jupyter Lab in the users PrimAITE notebooks directory.""" - from primaite.notebooks import start_jupyter_session - - start_jupyter_session() - - @app.command() def version() -> None: """Get the installed PrimAITE version number.""" @@ -98,101 +94,31 @@ def version() -> None: @app.command() -def clean_up() -> None: - """Cleans up left over files from previous version installations.""" - from primaite.setup import old_installation_clean_up - - old_installation_clean_up.run() - - -@app.command() -def setup(overwrite_existing: bool = True) -> None: +def setup(overwrite_existing: bool = False) -> None: """ Perform the PrimAITE first-time setup. WARNING: All user-data will be lost. """ from primaite import getLogger - from primaite.setup import old_installation_clean_up, reset_demo_notebooks, reset_example_configs + from primaite.setup import reset_demo_notebooks, reset_example_configs _LOGGER = getLogger(__name__) _LOGGER.info("Performing the PrimAITE first-time setup...") - _LOGGER.info("Building primaite_config.yaml...") - _LOGGER.info("Building the PrimAITE app directories...") PRIMAITE_PATHS.mkdirs() + _LOGGER.info("Building primaite_config.yaml...") + if overwrite_existing: + pkg_config_path = Path(pkg_resources.resource_filename("primaite", "setup/_package_data/primaite_config.yaml")) + shutil.copy(pkg_config_path, PRIMAITE_PATHS.app_config_file_path) + _LOGGER.info("Rebuilding the demo notebooks...") reset_demo_notebooks.run(overwrite_existing=True) _LOGGER.info("Rebuilding the example notebooks...") reset_example_configs.run(overwrite_existing=True) - _LOGGER.info("Performing a clean-up of previous PrimAITE installations...") - old_installation_clean_up.run() - _LOGGER.info("PrimAITE setup complete!") - - -@app.command() -def session(tc: Optional[str] = None, ldc: Optional[str] = None, load: Optional[str] = None) -> None: - """ - Run a PrimAITE session. - - tc: The training config filepath. Optional. If no value is passed then - example default training config is used from: - ~/primaite/2.0.0/config/example_config/training/training_config_main.yaml. - - ldc: The lay down config file path. Optional. If no value is passed then - example default lay down config is used from: - ~/primaite/2.0.0/config/example_config/lay_down/lay_down_config_3_doc_very_basic.yaml. - - load: The directory of a previous session. Optional. If no value is passed, then the session - will use the default training config and laydown config. Inversely, if a training config and laydown config - is passed while a session directory is passed, PrimAITE will load the session and ignore the training config - and laydown config. - """ - from primaite.config.lay_down_config import dos_very_basic_config_path - from primaite.config.training_config import main_training_config_path - from primaite.main import run - - if load is not None: - # run a loaded session - run(session_path=load) - - else: - # start a new session using tc and ldc - if not tc: - tc = main_training_config_path() - - if not ldc: - ldc = dos_very_basic_config_path() - - run(training_config_path=tc, lay_down_config_path=ldc) - - -@app.command() -def plotly_template(template: Annotated[Optional[PlotlyTemplate], typer.Argument()] = None) -> None: - """ - View or set the plotly template for Session plots. - - To View, simply call: primaite plotly-template - - To set, call: primaite plotly-template - - For example, to set as plotly_dark, call: primaite plotly-template PLOTLY_DARK - """ - if PRIMAITE_PATHS.app_config_file_path.exists(): - with open(PRIMAITE_PATHS.app_config_file_path, "r") as file: - primaite_config = yaml.safe_load(file) - - if template: - primaite_config["session"]["outputs"]["plots"]["template"] = template.value - with open(PRIMAITE_PATHS.app_config_file_path, "w") as file: - yaml.dump(primaite_config, file) - print(f"PrimAITE plotly template: {template.value}") - else: - template = primaite_config["session"]["outputs"]["plots"]["template"] - print(f"PrimAITE plotly template: {template}") diff --git a/src/primaite/common/__init__.py b/src/primaite/common/__init__.py deleted file mode 100644 index 5770bcbc..00000000 --- a/src/primaite/common/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Objects which are shared between many PrimAITE modules.""" diff --git a/src/primaite/common/custom_typing.py b/src/primaite/common/custom_typing.py deleted file mode 100644 index 4130e71a..00000000 --- a/src/primaite/common/custom_typing.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Union - -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.passive_node import PassiveNode -from primaite.nodes.service_node import ServiceNode - -NodeUnion = Union[ActiveNode, PassiveNode, ServiceNode] -"""A Union of ActiveNode, PassiveNode, and ServiceNode.""" diff --git a/src/primaite/common/enums.py b/src/primaite/common/enums.py deleted file mode 100644 index 006301f1..00000000 --- a/src/primaite/common/enums.py +++ /dev/null @@ -1,208 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Enumerations for APE.""" - -from enum import Enum, IntEnum - - -class NodeType(Enum): - """Node type enumeration.""" - - CCTV = 1 - SWITCH = 2 - COMPUTER = 3 - LINK = 4 - MONITOR = 5 - PRINTER = 6 - LOP = 7 - RTU = 8 - ACTUATOR = 9 - SERVER = 10 - - -class Priority(Enum): - """Node priority enumeration.""" - - P1 = 1 - P2 = 2 - P3 = 3 - P4 = 4 - P5 = 5 - - -class HardwareState(Enum): - """Node hardware state enumeration.""" - - NONE = 0 - ON = 1 - OFF = 2 - RESETTING = 3 - SHUTTING_DOWN = 4 - BOOTING = 5 - - -class SoftwareState(Enum): - """Software or Service state enumeration.""" - - NONE = 0 - GOOD = 1 - PATCHING = 2 - COMPROMISED = 3 - OVERWHELMED = 4 - - -class NodePOLType(Enum): - """Node Pattern of Life type enumeration.""" - - NONE = 0 - OPERATING = 1 - OS = 2 - SERVICE = 3 - FILE = 4 - - -class NodePOLInitiator(Enum): - """Node Pattern of Life initiator enumeration.""" - - DIRECT = 1 - IER = 2 - SERVICE = 3 - - -class Protocol(Enum): - """Service protocol enumeration.""" - - LDAP = 0 - FTP = 1 - HTTPS = 2 - SMTP = 3 - RTP = 4 - IPP = 5 - TCP = 6 - NONE = 7 - - -class SessionType(Enum): - """The type of PrimAITE Session to be run.""" - - TRAIN = 1 - "Train an agent" - EVAL = 2 - "Evaluate an agent" - TRAIN_EVAL = 3 - "Train then evaluate an agent" - - -class AgentFramework(Enum): - """The agent algorithm framework/package.""" - - CUSTOM = 0 - "Custom Agent" - SB3 = 1 - "Stable Baselines3" - RLLIB = 2 - "Ray RLlib" - - -class DeepLearningFramework(Enum): - """The deep learning framework.""" - - TF = "tf" - "Tensorflow" - TF2 = "tf2" - "Tensorflow 2.x" - TORCH = "torch" - "PyTorch" - - -class AgentIdentifier(Enum): - """The Red Agent algo/class.""" - - A2C = 1 - "Advantage Actor Critic" - PPO = 2 - "Proximal Policy Optimization" - HARDCODED = 3 - "The Hardcoded agents" - DO_NOTHING = 4 - "The DoNothing agents" - RANDOM = 5 - "The RandomAgent" - DUMMY = 6 - "The DummyAgent" - - -class HardCodedAgentView(Enum): - """The view the deterministic hard-coded agent has of the environment.""" - - BASIC = 1 - "The current observation space only" - FULL = 2 - "Full environment view with actions taken and reward feedback" - - -class ActionType(Enum): - """Action type enumeration.""" - - NODE = 0 - ACL = 1 - ANY = 2 - - -# TODO: this is not used anymore, write a ticket to delete it. -class ObservationType(Enum): - """Observation type enumeration.""" - - BOX = 0 - MULTIDISCRETE = 1 - - -class FileSystemState(Enum): - """File System State.""" - - GOOD = 1 - CORRUPT = 2 - DESTROYED = 3 - REPAIRING = 4 - RESTORING = 5 - - -class NodeHardwareAction(Enum): - """Node hardware action.""" - - NONE = 0 - ON = 1 - OFF = 2 - RESET = 3 - - -class NodeSoftwareAction(Enum): - """Node software action.""" - - NONE = 0 - PATCHING = 1 - - -class LinkStatus(Enum): - """Link traffic status.""" - - NONE = 0 - LOW = 1 - MEDIUM = 2 - HIGH = 3 - OVERLOAD = 4 - - -class SB3OutputVerboseLevel(IntEnum): - """The Stable Baselines3 learn/eval output verbosity level.""" - - NONE = 0 - INFO = 1 - DEBUG = 2 - - -class RulePermissionType(Enum): - """Any firewall rule type.""" - - NONE = 0 - DENY = 1 - ALLOW = 2 diff --git a/src/primaite/common/protocol.py b/src/primaite/common/protocol.py deleted file mode 100644 index 6940ba3f..00000000 --- a/src/primaite/common/protocol.py +++ /dev/null @@ -1,47 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The protocol class.""" - - -class Protocol(object): - """Protocol class.""" - - def __init__(self, _name: str) -> None: - """ - Initialise a protocol. - - :param _name: The name of the protocol - :type _name: str - """ - self.name: str = _name - self.load: int = 0 # bps - - def get_name(self) -> str: - """ - Gets the protocol name. - - Returns: - The protocol name - """ - return self.name - - def get_load(self) -> int: - """ - Gets the protocol load. - - Returns: - The protocol load (bps) - """ - return self.load - - def add_load(self, _load: int) -> None: - """ - Adds load to the protocol. - - Args: - _load: The load to add - """ - self.load += _load - - def clear_load(self) -> None: - """Clears the load on this protocol.""" - self.load = 0 diff --git a/src/primaite/common/service.py b/src/primaite/common/service.py deleted file mode 100644 index 956815e8..00000000 --- a/src/primaite/common/service.py +++ /dev/null @@ -1,28 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The Service class.""" - -from primaite.common.enums import SoftwareState - - -class Service(object): - """Service class.""" - - def __init__(self, name: str, port: str, software_state: SoftwareState) -> None: - """ - Initialise a service. - - :param name: The service name. - :param port: The service port. - :param software_state: The service SoftwareState. - """ - self.name: str = name - self.port: str = port - self.software_state: SoftwareState = software_state - self.patching_count: int = 0 - - def reduce_patching_count(self) -> None: - """Reduces the patching count for the service.""" - self.patching_count -= 1 - if self.patching_count <= 0: - self.patching_count = 0 - self.software_state = SoftwareState.GOOD diff --git a/src/primaite/config/_package_data/basic_lan_network_example.yaml b/src/primaite/config/_package_data/basic_lan_network_example.yaml new file mode 100644 index 00000000..9490ff00 --- /dev/null +++ b/src/primaite/config/_package_data/basic_lan_network_example.yaml @@ -0,0 +1,65 @@ +game: + ports: + - ARP + protocols: + - ICMP + - TCP + - UDP + +simulation: + network: + nodes: + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: pc_2 + type: computer + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + + - hostname: switch_1 + type: switch + num_ports: 4 + + - hostname: router_1 + type: router + num_ports: 1 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 10: + action: PERMIT + src_ip: 192.168.1.0 + src_wildcard_mask: 0.0.0.255 + dst_ip: 192.168.1.1 + dst_wildcard_mask: 0.0.0.0 + + links: + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + - endpoint_a_hostname: pc_2 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 + - endpoint_a_hostname: server_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 3 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 4 diff --git a/src/primaite/config/_package_data/client_server_p2p_network_example.yaml b/src/primaite/config/_package_data/client_server_p2p_network_example.yaml new file mode 100644 index 00000000..798dd318 --- /dev/null +++ b/src/primaite/config/_package_data/client_server_p2p_network_example.yaml @@ -0,0 +1,26 @@ +game: + ports: + - ARP + protocols: + - ICMP + - TCP + - UDP + +simulation: + network: + nodes: + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + + links: + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: server_1 + endpoint_b_port: 1 diff --git a/src/primaite/config/_package_data/data_manipulation.yaml b/src/primaite/config/_package_data/data_manipulation.yaml new file mode 100644 index 00000000..e3d68706 --- /dev/null +++ b/src/primaite/config/_package_data/data_manipulation.yaml @@ -0,0 +1,938 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + sys_log_level: WARNING + + +game: + max_episode_length: 128 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_1 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_1 + + + + + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + - node_name: client_2 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_CHECKHASH" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + + + agent_settings: + flatten_obs: true + + + + + +simulation: + network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE + nodes: + + - hostname: router_1 + type: router + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + 18: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 19: + action: PERMIT + src_port: DNS + dst_port: DNS + 20: + action: PERMIT + src_port: FTP + dst_port: FTP + 21: + action: PERMIT + src_port: HTTP + dst_port: HTTP + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - hostname: switch_1 + type: switch + num_ports: 8 + + - hostname: switch_2 + type: switch + num_ports: 8 + + - hostname: domain_controller + type: server + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - hostname: web_server + type: server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - hostname: database_server + type: server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + options: + backup_server_ip: 192.168.1.16 + - type: FTPClient + + - hostname: backup_server + type: server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - hostname: security_suite + type: server + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/src/primaite/config/_package_data/data_manipulation_marl.yaml b/src/primaite/config/_package_data/data_manipulation_marl.yaml new file mode 100644 index 00000000..45779036 --- /dev/null +++ b/src/primaite/config/_package_data/data_manipulation_marl.yaml @@ -0,0 +1,1517 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: true + + +game: + max_episode_length: 128 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_1 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_1 + + + + + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + - type: NODE_FILE_DELETE + - type: NODE_FILE_CORRUPT + - type: NODE_OS_SCAN + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + - node_name: client_2 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender_1 + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + + agent_settings: + flatten_obs: true + + - ref: defender_2 + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + options: + target_router_nodename: router_1 + - type: ROUTER_ACL_REMOVERULE + options: + target_router_nodename: router_1 + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + + agent_settings: + flatten_obs: true + + + + +simulation: + network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE + nodes: + + - hostname: router_1 + type: router + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + 18: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 19: + action: PERMIT + src_port: DNS + dst_port: DNS + 20: + action: PERMIT + src_port: FTP + dst_port: FTP + 21: + action: PERMIT + src_port: HTTP + dst_port: HTTP + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - hostname: switch_1 + type: switch + num_ports: 8 + + - hostname: switch_2 + type: switch + num_ports: 8 + + - hostname: domain_controller + type: server + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - hostname: web_server + type: server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - hostname: database_server + type: server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + options: + backup_server_ip: 192.168.1.16 + - type: FTPClient + + - hostname: backup_server + type: server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - hostname: security_suite + type: server + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/src/primaite/config/_package_data/lay_down/lay_down_config_1_DDOS_basic.yaml b/src/primaite/config/_package_data/lay_down/lay_down_config_1_DDOS_basic.yaml deleted file mode 100644 index dad0ff4b..00000000 --- a/src/primaite/config/_package_data/lay_down/lay_down_config_1_DDOS_basic.yaml +++ /dev/null @@ -1,166 +0,0 @@ -- item_type: PORTS - ports_list: - - port: '80' -- item_type: SERVICES - service_list: - - name: TCP -- item_type: NODE - node_id: '1' - name: PC1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.2 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '2' - name: SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.3 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '3' - name: PC2 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.4 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '4' - name: SWITCH1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.5 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '5' - name: SWITCH2 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.6 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '6' - name: SWITCH3 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.7 - software_state: GOOD - file_system_state: GOOD -- item_type: LINK - id: '7' - name: link1 - bandwidth: 1000000000 - source: '1' - destination: '4' -- item_type: LINK - id: '8' - name: link2 - bandwidth: 1000000000 - source: '4' - destination: '2' -- item_type: LINK - id: '9' - name: link3 - bandwidth: 1000000000 - source: '2' - destination: '5' -- item_type: LINK - id: '10' - name: link4 - bandwidth: 1000000000 - source: '2' - destination: '6' -- item_type: LINK - id: '11' - name: link5 - bandwidth: 1000000000 - source: '5' - destination: '3' -- item_type: LINK - id: '12' - name: link6 - bandwidth: 1000000000 - source: '6' - destination: '3' -- item_type: GREEN_IER - id: '13' - start_step: 1 - end_step: 128 - load: 100000 - protocol: TCP - port: '80' - source: '3' - destination: '2' - mission_criticality: 5 -- item_type: RED_POL - id: '14' - start_step: 50 - end_step: 50 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: TCP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_IER - id: '15' - start_step: 60 - end_step: 100 - load: 1000000 - protocol: TCP - port: '80' - source: '1' - destination: '2' - mission_criticality: 0 -- item_type: RED_POL - id: '16' - start_step: 80 - end_step: 80 - targetNodeId: '2' - initiator: IER - type: SERVICE - protocol: TCP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: ACL_RULE - id: '17' - permission: ALLOW - source: ANY - destination: ANY - protocol: ANY - port: ANY - position: 0 diff --git a/src/primaite/config/_package_data/lay_down/lay_down_config_2_DDOS_basic.yaml b/src/primaite/config/_package_data/lay_down/lay_down_config_2_DDOS_basic.yaml deleted file mode 100644 index e91859d2..00000000 --- a/src/primaite/config/_package_data/lay_down/lay_down_config_2_DDOS_basic.yaml +++ /dev/null @@ -1,366 +0,0 @@ -- item_type: PORTS - ports_list: - - port: '80' -- item_type: SERVICES - service_list: - - name: TCP -- item_type: NODE - node_id: '1' - name: PC1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.11 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '2' - name: PC2 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.12 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '3' - name: PC3 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.13 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '4' - name: PC4 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.20.14 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '5' - name: SWITCH1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.2 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '6' - name: IDS - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.4 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '7' - name: SWITCH2 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.3 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '8' - name: LOP1 - node_class: SERVICE - node_type: LOP - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.12 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '9' - name: SERVER1 - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.14 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '10' - name: SERVER2 - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.20.15 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: LINK - id: '11' - name: link1 - bandwidth: 1000000000 - source: '1' - destination: '5' -- item_type: LINK - id: '12' - name: link2 - bandwidth: 1000000000 - source: '2' - destination: '5' -- item_type: LINK - id: '13' - name: link3 - bandwidth: 1000000000 - source: '3' - destination: '5' -- item_type: LINK - id: '14' - name: link4 - bandwidth: 1000000000 - source: '4' - destination: '5' -- item_type: LINK - id: '15' - name: link5 - bandwidth: 1000000000 - source: '5' - destination: '6' -- item_type: LINK - id: '16' - name: link6 - bandwidth: 1000000000 - source: '5' - destination: '8' -- item_type: LINK - id: '17' - name: link7 - bandwidth: 1000000000 - source: '6' - destination: '7' -- item_type: LINK - id: '18' - name: link8 - bandwidth: 1000000000 - source: '8' - destination: '7' -- item_type: LINK - id: '19' - name: link9 - bandwidth: 1000000000 - source: '7' - destination: '9' -- item_type: LINK - id: '20' - name: link10 - bandwidth: 1000000000 - source: '7' - destination: '10' -- item_type: GREEN_IER - id: '21' - start_step: 1 - end_step: 128 - load: 100000 - protocol: TCP - port: '80' - source: '1' - destination: '9' - mission_criticality: 2 -- item_type: GREEN_IER - id: '22' - start_step: 1 - end_step: 128 - load: 100000 - protocol: TCP - port: '80' - source: '2' - destination: '9' - mission_criticality: 2 -- item_type: GREEN_IER - id: '23' - start_step: 1 - end_step: 128 - load: 100000 - protocol: TCP - port: '80' - source: '9' - destination: '3' - mission_criticality: 5 -- item_type: GREEN_IER - id: '24' - start_step: 1 - end_step: 128 - load: 100000 - protocol: TCP - port: '80' - source: '4' - destination: '10' - mission_criticality: 2 -- item_type: ACL_RULE - id: '25' - permission: ALLOW - source: 192.168.10.11 - destination: 192.168.10.14 - protocol: TCP - port: 80 - position: 0 -- item_type: ACL_RULE - id: '26' - permission: ALLOW - source: 192.168.10.12 - destination: 192.168.10.14 - protocol: TCP - port: 80 - position: 1 -- item_type: ACL_RULE - id: '27' - permission: ALLOW - source: 192.168.10.13 - destination: 192.168.10.14 - protocol: TCP - port: 80 - position: 2 -- item_type: ACL_RULE - id: '28' - permission: ALLOW - source: 192.168.20.14 - destination: 192.168.20.15 - protocol: TCP - port: 80 - position: 3 -- item_type: ACL_RULE - id: '29' - permission: ALLOW - source: 192.168.10.14 - destination: 192.168.10.13 - protocol: TCP - port: 80 - position: 4 -- item_type: ACL_RULE - id: '30' - permission: DENY - source: 192.168.10.11 - destination: 192.168.20.15 - protocol: TCP - port: 80 - position: 5 -- item_type: ACL_RULE - id: '31' - permission: DENY - source: 192.168.10.12 - destination: 192.168.20.15 - protocol: TCP - port: 80 - position: 6 -- item_type: ACL_RULE - id: '32' - permission: DENY - source: 192.168.10.13 - destination: 192.168.20.15 - protocol: TCP - port: 80 - position: 7 -- item_type: ACL_RULE - id: '33' - permission: DENY - source: 192.168.20.14 - destination: 192.168.10.14 - protocol: TCP - port: 80 - position: 8 -- item_type: RED_POL - id: '34' - start_step: 20 - end_step: 20 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: TCP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '35' - start_step: 20 - end_step: 20 - targetNodeId: '2' - initiator: DIRECT - type: SERVICE - protocol: TCP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_IER - id: '36' - start_step: 30 - end_step: 128 - load: 440000000 - protocol: TCP - port: '80' - source: '1' - destination: '9' - mission_criticality: 0 -- item_type: RED_IER - id: '37' - start_step: 30 - end_step: 128 - load: 440000000 - protocol: TCP - port: '80' - source: '2' - destination: '9' - mission_criticality: 0 -- item_type: RED_POL - id: '38' - start_step: 30 - end_step: 30 - targetNodeId: '9' - initiator: IER - type: SERVICE - protocol: TCP - state: OVERWHELMED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA diff --git a/src/primaite/config/_package_data/lay_down/lay_down_config_3_DOS_very_basic.yaml b/src/primaite/config/_package_data/lay_down/lay_down_config_3_DOS_very_basic.yaml deleted file mode 100644 index 453b6abb..00000000 --- a/src/primaite/config/_package_data/lay_down/lay_down_config_3_DOS_very_basic.yaml +++ /dev/null @@ -1,164 +0,0 @@ -- item_type: PORTS - ports_list: - - port: '80' -- item_type: SERVICES - service_list: - - name: TCP -- item_type: NODE - node_id: '1' - name: PC1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.2 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '2' - name: PC2 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.3 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '3' - name: SWITCH1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.1 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '4' - name: SERVER1 - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.4 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: LINK - id: '5' - name: link1 - bandwidth: 1000000000 - source: '1' - destination: '3' -- item_type: LINK - id: '6' - name: link2 - bandwidth: 1000000000 - source: '2' - destination: '3' -- item_type: LINK - id: '7' - name: link3 - bandwidth: 1000000000 - source: '3' - destination: '4' -- item_type: GREEN_IER - id: '8' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '1' - destination: '4' - mission_criticality: 1 -- item_type: GREEN_IER - id: '9' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '2' - destination: '4' - mission_criticality: 1 -- item_type: GREEN_IER - id: '10' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '4' - destination: '2' - mission_criticality: 5 -- item_type: ACL_RULE - id: '11' - permission: ALLOW - source: 192.168.1.2 - destination: 192.168.1.4 - protocol: TCP - port: 80 - position: 0 -- item_type: ACL_RULE - id: '12' - permission: ALLOW - source: 192.168.1.3 - destination: 192.168.1.4 - protocol: TCP - port: 80 - position: 1 -- item_type: ACL_RULE - id: '13' - permission: ALLOW - source: 192.168.1.4 - destination: 192.168.1.3 - protocol: TCP - port: 80 - position: 2 -- item_type: RED_POL - id: '14' - start_step: 20 - end_step: 20 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: TCP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_IER - id: '15' - start_step: 30 - end_step: 256 - load: 10000000 - protocol: TCP - port: '80' - source: '1' - destination: '4' - mission_criticality: 0 -- item_type: RED_POL - id: '16' - start_step: 40 - end_step: 40 - targetNodeId: '4' - initiator: IER - type: SERVICE - protocol: TCP - state: OVERWHELMED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA diff --git a/src/primaite/config/_package_data/lay_down/lay_down_config_5_data_manipulation.yaml b/src/primaite/config/_package_data/lay_down/lay_down_config_5_data_manipulation.yaml deleted file mode 100644 index 96596514..00000000 --- a/src/primaite/config/_package_data/lay_down/lay_down_config_5_data_manipulation.yaml +++ /dev/null @@ -1,546 +0,0 @@ -- item_type: PORTS - ports_list: - - port: '80' - - port: '1433' - - port: '53' -- item_type: SERVICES - service_list: - - name: TCP - - name: TCP_SQL - - name: UDP -- item_type: NODE - node_id: '1' - name: CLIENT_1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.11 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: UDP - port: '53' - state: GOOD -- item_type: NODE - node_id: '2' - name: CLIENT_2 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.10.12 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: NODE - node_id: '3' - name: SWITCH_1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.10.1 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '4' - name: SECURITY_SUITE - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.10 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: UDP - port: '53' - state: GOOD -- item_type: NODE - node_id: '5' - name: MANAGEMENT_CONSOLE - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.12 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: UDP - port: '53' - state: GOOD -- item_type: NODE - node_id: '6' - name: SWITCH_2 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.2.1 - software_state: GOOD - file_system_state: GOOD -- item_type: NODE - node_id: '7' - name: WEB_SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.2.10 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: TCP_SQL - port: '1433' - state: GOOD -- item_type: NODE - node_id: '8' - name: DATABASE_SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.2.14 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: TCP_SQL - port: '1433' - state: GOOD - - name: UDP - port: '53' - state: GOOD -- item_type: NODE - node_id: '9' - name: BACKUP_SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.2.16 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD -- item_type: LINK - id: '10' - name: LINK_1 - bandwidth: 1000000000 - source: '1' - destination: '3' -- item_type: LINK - id: '11' - name: LINK_2 - bandwidth: 1000000000 - source: '2' - destination: '3' -- item_type: LINK - id: '12' - name: LINK_3 - bandwidth: 1000000000 - source: '3' - destination: '4' -- item_type: LINK - id: '13' - name: LINK_4 - bandwidth: 1000000000 - source: '3' - destination: '5' -- item_type: LINK - id: '14' - name: LINK_5 - bandwidth: 1000000000 - source: '4' - destination: '6' -- item_type: LINK - id: '15' - name: LINK_6 - bandwidth: 1000000000 - source: '5' - destination: '6' -- item_type: LINK - id: '16' - name: LINK_7 - bandwidth: 1000000000 - source: '6' - destination: '7' -- item_type: LINK - id: '17' - name: LINK_8 - bandwidth: 1000000000 - source: '6' - destination: '8' -- item_type: LINK - id: '18' - name: LINK_9 - bandwidth: 1000000000 - source: '6' - destination: '9' -- item_type: GREEN_IER - id: '19' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '1' - destination: '7' - mission_criticality: 5 -- item_type: GREEN_IER - id: '20' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '7' - destination: '1' - mission_criticality: 5 -- item_type: GREEN_IER - id: '21' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '2' - destination: '7' - mission_criticality: 5 -- item_type: GREEN_IER - id: '22' - start_step: 1 - end_step: 256 - load: 10000 - protocol: TCP - port: '80' - source: '7' - destination: '2' - mission_criticality: 5 -- item_type: GREEN_IER - id: '23' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP_SQL - port: '1433' - source: '7' - destination: '8' - mission_criticality: 5 -- item_type: GREEN_IER - id: '24' - start_step: 1 - end_step: 256 - load: 100000 - protocol: TCP_SQL - port: '1433' - source: '8' - destination: '7' - mission_criticality: 5 -- item_type: GREEN_IER - id: '25' - start_step: 1 - end_step: 256 - load: 50000 - protocol: TCP - port: '80' - source: '1' - destination: '9' - mission_criticality: 2 -- item_type: GREEN_IER - id: '26' - start_step: 1 - end_step: 256 - load: 50000 - protocol: TCP - port: '80' - source: '2' - destination: '9' - mission_criticality: 2 -- item_type: GREEN_IER - id: '27' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '5' - destination: '7' - mission_criticality: 1 -- item_type: GREEN_IER - id: '28' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '7' - destination: '5' - mission_criticality: 1 -- item_type: GREEN_IER - id: '29' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '5' - destination: '8' - mission_criticality: 1 -- item_type: GREEN_IER - id: '30' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '8' - destination: '5' - mission_criticality: 1 -- item_type: GREEN_IER - id: '31' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '5' - destination: '9' - mission_criticality: 1 -- item_type: GREEN_IER - id: '32' - start_step: 1 - end_step: 256 - load: 5000 - protocol: TCP - port: '80' - source: '9' - destination: '5' - mission_criticality: 1 -- item_type: ACL_RULE - id: '33' - permission: ALLOW - source: 192.168.10.11 - destination: 192.168.2.10 - protocol: ANY - port: ANY - position: 0 -- item_type: ACL_RULE - id: '34' - permission: ALLOW - source: 192.168.10.11 - destination: 192.168.2.14 - protocol: ANY - port: ANY - position: 1 -- item_type: ACL_RULE - id: '35' - permission: ALLOW - source: 192.168.10.12 - destination: 192.168.2.14 - protocol: ANY - port: ANY - position: 2 -- item_type: ACL_RULE - id: '36' - permission: ALLOW - source: 192.168.10.12 - destination: 192.168.2.10 - protocol: ANY - port: ANY - position: 3 -- item_type: ACL_RULE - id: '37' - permission: ALLOW - source: 192.168.2.10 - destination: 192.168.10.11 - protocol: ANY - port: ANY - position: 4 -- item_type: ACL_RULE - id: '38' - permission: ALLOW - source: 192.168.2.10 - destination: 192.168.10.12 - protocol: ANY - port: ANY - position: 5 -- item_type: ACL_RULE - id: '39' - permission: ALLOW - source: 192.168.2.10 - destination: 192.168.2.14 - protocol: ANY - port: ANY - position: 6 -- item_type: ACL_RULE - id: '40' - permission: ALLOW - source: 192.168.2.14 - destination: 192.168.2.10 - protocol: ANY - port: ANY - position: 7 -- item_type: ACL_RULE - id: '41' - permission: ALLOW - source: 192.168.10.11 - destination: 192.168.2.16 - protocol: ANY - port: ANY - position: 8 -- item_type: ACL_RULE - id: '42' - permission: ALLOW - source: 192.168.10.12 - destination: 192.168.2.16 - protocol: ANY - port: ANY - position: 9 -- item_type: ACL_RULE - id: '43' - permission: ALLOW - source: 192.168.1.12 - destination: 192.168.2.10 - protocol: ANY - port: ANY - position: 10 -- item_type: ACL_RULE - id: '44' - permission: ALLOW - source: 192.168.1.12 - destination: 192.168.2.14 - protocol: ANY - port: ANY - position: 11 -- item_type: ACL_RULE - id: '45' - permission: ALLOW - source: 192.168.1.12 - destination: 192.168.2.16 - protocol: ANY - port: ANY - position: 12 -- item_type: ACL_RULE - id: '46' - permission: ALLOW - source: 192.168.2.10 - destination: 192.168.1.12 - protocol: ANY - port: ANY - position: 13 -- item_type: ACL_RULE - id: '47' - permission: ALLOW - source: 192.168.2.14 - destination: 192.168.1.12 - protocol: ANY - port: ANY - position: 14 -- item_type: ACL_RULE - id: '48' - permission: ALLOW - source: 192.168.2.16 - destination: 192.168.1.12 - protocol: ANY - port: ANY - position: 15 -- item_type: ACL_RULE - id: '49' - permission: DENY - source: ANY - destination: ANY - protocol: ANY - port: ANY - position: 16 -- item_type: RED_POL - id: '50' - start_step: 50 - end_step: 50 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: UDP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_IER - id: '51' - start_step: 75 - end_step: 105 - load: 10000 - protocol: UDP - port: '53' - source: '1' - destination: '8' - mission_criticality: 0 -- item_type: RED_POL - id: '52' - start_step: 100 - end_step: 100 - targetNodeId: '8' - initiator: IER - type: SERVICE - protocol: UDP - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '53' - start_step: 105 - end_step: 105 - targetNodeId: '8' - initiator: SERVICE - type: FILE - protocol: NA - state: CORRUPT - sourceNodeId: '8' - sourceNodeService: UDP - sourceNodeServiceState: COMPROMISED -- item_type: RED_POL - id: '54' - start_step: 105 - end_step: 105 - targetNodeId: '8' - initiator: SERVICE - type: SERVICE - protocol: TCP_SQL - state: COMPROMISED - sourceNodeId: '8' - sourceNodeService: UDP - sourceNodeServiceState: COMPROMISED -- item_type: RED_POL - id: '55' - start_step: 125 - end_step: 125 - targetNodeId: '7' - initiator: SERVICE - type: SERVICE - protocol: TCP - state: OVERWHELMED - sourceNodeId: '8' - sourceNodeService: TCP_SQL - sourceNodeServiceState: COMPROMISED diff --git a/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml b/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml new file mode 100644 index 00000000..09e85d03 --- /dev/null +++ b/src/primaite/config/_package_data/multi_lan_internet_network_example.yaml @@ -0,0 +1,440 @@ +game: + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +simulation: + network: + nodes: + # Home/Office Network + - hostname: pc_1 + type: computer + ip_address: 192.168.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: pc_2 + type: computer + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: server_1 + type: server + ip_address: 192.168.1.13 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 8.8.8.2 + + - hostname: switch_1 + type: switch + num_ports: 4 + + - hostname: router_1 + type: router + num_ports: 2 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 43.35.240.2 + subnet_mask: 255.255.255.252 + acl: + 10: + action: PERMIT + default_route: # Default route to all external networks + next_hop_ip_address: 43.35.240.1 # NI 1 on icp_router + + # ISP Network + - hostname: isp_rt + type: router + num_ports: 3 + ports: + 1: + ip_address: 43.35.240.1 + subnet_mask: 255.255.255.252 + 2: + ip_address: 94.10.180.1 + subnet_mask: 255.255.255.252 + 3: + ip_address: 8.8.8.1 + subnet_mask: 255.255.255.252 + acl: + 10: + action: PERMIT + routes: + - address: 192.168.1.0 # Route to the Home/Office LAN + subnet_mask: 255.255.255.0 + next_hop_ip_address: 43.35.240.2 # NI 2 on router_1 + - address: 10.10.0.0 # Route to the SomeTech internal network + subnet_mask: 255.255.0.0 + next_hop_ip_address: 94.10.180.2 # NI ext on some_tech_fw + - address: 94.10.180.6 # Route to the Web Server in the SomeTech DMZ + subnet_mask: 255.255.255.255 + next_hop_ip_address: 94.10.180.2 # NI ext on some_tech_fw + + - hostname: isp_dns_srv + type: server + ip_address: 8.8.8.2 + subnet_mask: 255.255.255.252 + default_gateway: 8.8.8.1 + services: + - ref: dns_server + type: DNSServer + options: + domain_mapping: + sometech.ai: 94.10.180.6 + + # SomeTech Network + - hostname: some_tech_fw + type: firewall + ports: + external_port: # port 1 + ip_address: 94.10.180.2 + subnet_mask: 255.255.255.252 + internal_port: # port 2 + ip_address: 10.10.4.2 + subnet_mask: 255.255.255.252 + dmz_port: # port 3 + ip_address: 94.10.180.5 + subnet_mask: 255.255.255.252 + acl: + internal_inbound_acl: + 8: # Permit some_tech_web_srv to connect to Database service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 10.10.1.11 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 9: # Permit SomeTech to use HTTP + action: PERMIT + src_port: HTTP + 10: # Permit SomeTech to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + internal_outbound_acl: + 10: # Permit all internal outbound traffic + action: PERMIT + dmz_inbound_acl: + 7: # Permit Database service on some_tech_db_srv to respond to some_tech_web_srv + action: PERMIT + src_ip: 10.10.1.11 + src_port: POSTGRES_SERVER + src_wildcard_mask: 0.0.0.0 + dst_ip: 94.10.180.6 + dst_port: POSTGRES_SERVER + dst_wildcard_mask: 0.0.0.0 + 8: # Permit SomeTech DMZ to use ARP + action: PERMIT + src_port: ARP + dst_port: ARP + 9: # Permit SomeTech DMZ to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + 10: # Permit all inbound HTTP requests + action: PERMIT + dst_port: HTTP + dmz_outbound_acl: + 7: # Permit some_tech_web_srv to connect to Database service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_port: POSTGRES_SERVER + src_wildcard_mask: 0.0.0.0 + dst_ip: 10.10.1.11 + dst_port: POSTGRES_SERVER + dst_wildcard_mask: 0.0.0.0 + 8: # Permit SomeTech DMZ to use ARP + action: PERMIT + src_port: ARP + dst_port: ARP + 9: # Permit SomeTech DMZ to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + 10: # Permit all outbound HTTP requests + action: PERMIT + src_port: HTTP + default_route: # Default route to all external networks + next_hop_ip_address: 94.10.180.1 # NI 2 on isp_rt + routes: + - address: 10.10.0.0 # Route to the SomeTech internal LAN + subnet_mask: 255.255.0.0 + next_hop_ip_address: 10.10.4.1 # NI 1 on some_tech_rt + + + - hostname: some_tech_web_srv + type: server + ip_address: 94.10.180.6 + subnet_mask: 255.255.255.252 + default_gateway: 94.10.180.5 + dns_server: 8.8.8.2 + services: + - ref: web_server + type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + + - hostname: some_tech_rt + type: router + num_ports: 4 + ports: + 1: + ip_address: 10.10.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 10.10.4.1 + subnet_mask: 255.255.255.252 + 3: + ip_address: 10.10.3.1 + subnet_mask: 255.255.255.0 + 4: + ip_address: 10.10.2.1 + subnet_mask: 255.255.255.0 + + acl: + 2: # Allow the some_tech_web_srv to connect to the Database Service on some_tech_db_srv + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 10.10.1.11 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 3: # Allow the Database Service on some_tech_db_srv to respond to some_tech_web_srv + action: PERMIT + src_ip: 10.10.1.11 + src_wildcard_mask: 0.0.0.0 + src_port: POSTGRES_SERVER + dst_ip: 94.10.180.6 + dst_wildcard_mask: 0.0.0.0 + dst_port: POSTGRES_SERVER + 4: # Prevent the Junior engineer from downloading files from the some_tech_storage_srv over FTP + action: DENY + src_ip: 10.10.2.12 + src_wildcard_mask: 0.0.0.0 + src_port: FTP + dst_ip: 10.10.1.12 + dst_wildcard_mask: 0.0.0.0 + dst_port: FTP + 5: # Allow communication between Engineering and the DB & Storage subnet + action: PERMIT + src_ip: 10.10.2.0 + src_wildcard_mask: 0.0.0.255 + dst_ip: 10.10.1.0 + dst_wildcard_mask: 0.0.0.255 + 6: # Allow communication between the DB & Storage subnet and Engineering + action: PERMIT + src_ip: 10.10.1.0 + src_wildcard_mask: 0.0.0.255 + dst_ip: 10.10.2.0 + dst_wildcard_mask: 0.0.0.255 + 7: # Allow the SomeTech network to use HTTP + action: PERMIT + src_port: HTTP + dst_port: HTTP + 8: # Allow the SomeTech internal network to use ARP + action: PERMIT + src_ip: 10.10.0.0 + src_wildcard_mask: 0.0.255.255 + src_port: ARP + 9: # Allow the SomeTech internal network to use ICMP + action: PERMIT + src_ip: 10.10.0.0 + src_wildcard_mask: 0.0.255.255 + protocol: ICMP + 10: + action: PERMIT + src_ip: 94.10.180.6 + src_wildcard_mask: 0.0.0.0 + src_port: HTTP + dst_ip: 10.10.0.0 + dst_wildcard_mask: 0.0.255.255 + dst_port: HTTP + 11: # Permit SomeTech to use DNS + action: PERMIT + src_port: DNS + dst_port: DNS + default_route: # Default route to all external networks + next_hop_ip_address: 10.10.4.2 # NI int on some_tech_fw + + + - hostname: some_tech_data_sw + type: switch + num_ports: 3 + + - hostname: some_tech_hr_sw + type: switch + num_ports: 2 + + - hostname: some_tech_eng_sw + type: switch + num_ports: 3 + + - hostname: some_tech_db_srv + type: server + ip_address: 10.10.1.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.1.1 + dns_server: 8.8.8.2 + services: + - type: DatabaseService + options: + backup_server_ip: 10.10.1.12 # The some_tech_storage_srv server + - type: FTPClient + + - hostname: some_tech_storage_srv + type: server + ip_address: 10.10.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.1.1 + dns_server: 8.8.8.2 + services: + - type: FTPServer + + - hostname: some_tech_hr_1 + type: computer + ip_address: 10.10.3.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.3.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: some_tech_snr_dev_pc + type: computer + ip_address: 10.10.2.11 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.2.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + - hostname: some_tech_jnr_dev_pc + type: computer + ip_address: 10.10.2.12 + subnet_mask: 255.255.255.0 + default_gateway: 10.10.2.1 + dns_server: 8.8.8.2 + applications: + - type: DatabaseClient + options: + db_server_ip: 10.10.1.11 + - type: WebBrowser + options: + target_url: http://sometech.ai + + links: + # Home/Office Lan Links + - endpoint_a_hostname: pc_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + bandwidth: 200 + - endpoint_a_hostname: pc_2 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 + - endpoint_a_hostname: server_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 3 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 4 + + # ISP Links + - endpoint_a_hostname: isp_rt + endpoint_a_port: 1 + endpoint_b_hostname: router_1 + endpoint_b_port: 2 + - endpoint_a_hostname: isp_rt + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_fw + endpoint_b_port: 1 + - endpoint_a_hostname: isp_rt + endpoint_a_port: 3 + endpoint_b_hostname: isp_dns_srv + endpoint_b_port: 1 + + + # SomeTech LAN Links + - endpoint_a_hostname: some_tech_fw + endpoint_a_port: 3 + endpoint_b_hostname: some_tech_web_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_fw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_rt + endpoint_b_port: 2 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_data_sw + endpoint_b_port: 3 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 3 + endpoint_b_hostname: some_tech_hr_sw + endpoint_b_port: 2 + - endpoint_a_hostname: some_tech_rt + endpoint_a_port: 4 + endpoint_b_hostname: some_tech_eng_sw + endpoint_b_port: 3 + - endpoint_a_hostname: some_tech_data_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_db_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_data_sw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_storage_srv + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_hr_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_hr_1 + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_eng_sw + endpoint_a_port: 1 + endpoint_b_hostname: some_tech_snr_dev_pc + endpoint_b_port: 1 + - endpoint_a_hostname: some_tech_eng_sw + endpoint_a_port: 2 + endpoint_b_hostname: some_tech_jnr_dev_pc + endpoint_b_port: 1 diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/greens_0.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/greens_0.yaml new file mode 100644 index 00000000..f31c52fa --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/greens_0.yaml @@ -0,0 +1,2 @@ +# No green agents present +greens: &greens [] diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/greens_1.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/greens_1.yaml new file mode 100644 index 00000000..98d2392a --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/greens_1.yaml @@ -0,0 +1,34 @@ +agents: &greens + - ref: green_A + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.2 + 1: 0.8 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DatabaseClient + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + + reward_function: + reward_components: + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 1.0 + options: + node_hostname: client diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/greens_2.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/greens_2.yaml new file mode 100644 index 00000000..17a5977b --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/greens_2.yaml @@ -0,0 +1,34 @@ +agents: &greens + - ref: green_B + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.95 + 1: 0.05 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DatabaseClient + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + + reward_function: + reward_components: + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 1.0 + options: + node_hostname: client diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/reds_0.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/reds_0.yaml new file mode 100644 index 00000000..878aba97 --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/reds_0.yaml @@ -0,0 +1,2 @@ +# No red agents present +reds: &reds [] diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/reds_1.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/reds_1.yaml new file mode 100644 index 00000000..31675a0b --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/reds_1.yaml @@ -0,0 +1,26 @@ +reds: &reds + - ref: red_A + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DataManipulationBot + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 10 + frequency: 10 + variance: 0 diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/reds_2.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/reds_2.yaml new file mode 100644 index 00000000..c5572b89 --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/reds_2.yaml @@ -0,0 +1,26 @@ +reds: &reds + - ref: red_B + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DataManipulationBot + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 3 + frequency: 2 + variance: 1 diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml new file mode 100644 index 00000000..81848b2d --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/scenario.yaml @@ -0,0 +1,168 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + + +game: + max_episode_length: 128 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - *greens + - *reds + + - ref: defender + team: BLUE + type: ProxyAgent + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + routers: [] + hosts: + - hostname: client + - hostname: server + num_services: 1 + num_applications: 1 + num_folders: 1 + num_files: 1 + num_nics: 1 + include_num_access: false + include_nmne: true + + - type: LINKS + label: LINKS + options: + link_references: + - client:eth-1<->switch_1:eth-1 + - server:eth-1<->switch_1:eth-2 + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_SHUTDOWN + options: + node_id: 0 + 2: + action: NODE_SHUTDOWN + options: + node_id: 1 + 3: + action: NODE_STARTUP + options: + node_id: 0 + 4: + action: NODE_STARTUP + options: + node_id: 1 + 5: + action: HOST_NIC_DISABLE + options: + node_id: 0 + nic_id: 0 + 6: + action: HOST_NIC_DISABLE + options: + node_id: 1 + nic_id: 0 + 7: + action: HOST_NIC_ENABLE + options: + node_id: 0 + nic_id: 0 + 8: + action: HOST_NIC_ENABLE + options: + node_id: 1 + nic_id: 0 + options: + nodes: + - node_name: client + - node_name: server + + max_folders_per_node: 0 + max_files_per_folder: 0 + max_services_per_node: 0 + max_nics_per_node: 1 + max_acl_rules: 0 + ip_list: + - 192.168.1.2 + - 192.168.1.3 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + agent_settings: + flatten_obs: false + + +simulation: + network: + nodes: + - hostname: client + type: computer + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.3 + - type: DataManipulationBot + options: + server_ip: 192.168.1.3 + payload: "DELETE" + + - hostname: switch_1 + type: switch + num_ports: 2 + + - hostname: server + type: server + ip_address: 192.168.1.3 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DatabaseService + + links: + - endpoint_a_hostname: client + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + + - endpoint_a_hostname: server + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 diff --git a/src/primaite/config/_package_data/scenario_with_placeholders/schedule.yaml b/src/primaite/config/_package_data/scenario_with_placeholders/schedule.yaml new file mode 100644 index 00000000..07ee4e50 --- /dev/null +++ b/src/primaite/config/_package_data/scenario_with_placeholders/schedule.yaml @@ -0,0 +1,14 @@ +base_scenario: scenario.yaml +schedule: + 0: + - greens_0.yaml + - reds_0.yaml + 1: + - greens_0.yaml + - reds_1.yaml + 2: + - greens_1.yaml + - reds_1.yaml + 3: + - greens_2.yaml + - reds_2.yaml diff --git a/src/primaite/config/_package_data/training/training_config_main.yaml b/src/primaite/config/_package_data/training/training_config_main.yaml deleted file mode 100644 index db4ed692..00000000 --- a/src/primaite/config/_package_data/training/training_config_main.yaml +++ /dev/null @@ -1,168 +0,0 @@ -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: null - -# Set whether the agent evaluation will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# observation space -observation_space: - flatten: true - components: - - name: NODE_LINK_TABLE - - name: NODE_STATUSES - - name: LINK_TRAFFIC_LEVELS - - name: ACCESS_CONTROL_LIST - -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 1 - -# Number of time_steps for evaluation per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 10 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# Implicit ACL firewall rule at end of ACL list to be the default action (ALLOW or DENY) -implicit_acl_rule: DENY -# Total number of ACL rules allowed in the environment -max_number_acl_rules: 30 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -0.001 -off_should_be_resetting: -0.0005 -on_should_be_off: -0.0002 -on_should_be_resetting: -0.0005 -resetting_should_be_on: -0.0005 -resetting_should_be_off: -0.0002 -resetting: -0.0003 -# Node Software or Service State -good_should_be_patching: 0.0002 -good_should_be_compromised: 0.0005 -good_should_be_overwhelmed: 0.0005 -patching_should_be_good: -0.0005 -patching_should_be_compromised: 0.0002 -patching_should_be_overwhelmed: 0.0002 -patching: -0.0003 -compromised_should_be_good: -0.002 -compromised_should_be_patching: -0.002 -compromised_should_be_overwhelmed: -0.002 -compromised: -0.002 -overwhelmed_should_be_good: -0.002 -overwhelmed_should_be_patching: -0.002 -overwhelmed_should_be_compromised: -0.002 -overwhelmed: -0.002 -# Node File System State -good_should_be_repairing: 0.0002 -good_should_be_restoring: 0.0002 -good_should_be_corrupt: 0.0005 -good_should_be_destroyed: 0.001 -repairing_should_be_good: -0.0005 -repairing_should_be_restoring: 0.0002 -repairing_should_be_corrupt: 0.0002 -repairing_should_be_destroyed: 0.0000 -repairing: -0.0003 -restoring_should_be_good: -0.001 -restoring_should_be_repairing: -0.0002 -restoring_should_be_corrupt: 0.0001 -restoring_should_be_destroyed: 0.0002 -restoring: -0.0006 -corrupt_should_be_good: -0.001 -corrupt_should_be_repairing: -0.001 -corrupt_should_be_restoring: -0.001 -corrupt_should_be_destroyed: 0.0002 -corrupt: -0.001 -destroyed_should_be_good: -0.002 -destroyed_should_be_repairing: -0.002 -destroyed_should_be_restoring: -0.002 -destroyed_should_be_corrupt: -0.002 -destroyed: -0.002 -scanning: -0.0002 -# IER status -red_ier_running: -0.0005 -green_ier_blocked: -0.001 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/src/primaite/config/lay_down_config.py b/src/primaite/config/lay_down_config.py deleted file mode 100644 index 65ca7e91..00000000 --- a/src/primaite/config/lay_down_config.py +++ /dev/null @@ -1,112 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from logging import Logger -from pathlib import Path -from typing import Any, Dict, Final, Union - -import yaml - -from primaite import getLogger, PRIMAITE_PATHS - -_LOGGER: Logger = getLogger(__name__) - -_EXAMPLE_LAY_DOWN: Final[Path] = PRIMAITE_PATHS.user_config_path / "example_config" / "lay_down" - - -def convert_legacy_lay_down_config_dict(legacy_config_dict: Dict[str, Any]) -> Dict[str, Any]: - """ - Convert a legacy lay down config dict to the new format. - - :param legacy_config_dict: A legacy lay down config dict. - """ - _LOGGER.warning("Legacy lay down config conversion not yet implemented") - return legacy_config_dict - - -def load(file_path: Union[str, Path], legacy_file: bool = False) -> Dict: - """ - Read in a lay down config yaml file. - - :param file_path: The config file path. - :param legacy_file: True if the config file is legacy format, otherwise False. - :return: The lay down config as a dict. - :raises ValueError: If the file_path does not exist. - """ - if not isinstance(file_path, Path): - file_path = Path(file_path) - if file_path.exists(): - with open(file_path, "r") as file: - config = yaml.safe_load(file) - _LOGGER.debug(f"Loading lay down config file: {file_path}") - if legacy_file: - try: - config = convert_legacy_lay_down_config_dict(config) - except KeyError: - msg = ( - f"Failed to convert lay down config file {file_path} " - f"from legacy format. Attempting to use file as is." - ) - _LOGGER.error(msg) - return config - msg = f"Cannot load the lay down config as it does not exist: {file_path}" - _LOGGER.error(msg) - raise ValueError(msg) - - -def ddos_basic_one_config_path() -> Path: - """ - The path to the example lay_down_config_1_DDOS_basic.yaml file. - - :return: The file path. - """ - path = _EXAMPLE_LAY_DOWN / "lay_down_config_1_DDOS_basic.yaml" - if not path.exists(): - msg = "Example config not found. Please run 'primaite setup'" - _LOGGER.critical(msg) - raise FileNotFoundError(msg) - - return path - - -def ddos_basic_two_config_path() -> Path: - """ - The path to the example lay_down_config_2_DDOS_basic.yaml file. - - :return: The file path. - """ - path = _EXAMPLE_LAY_DOWN / "lay_down_config_2_DDOS_basic.yaml" - if not path.exists(): - msg = "Example config not found. Please run 'primaite setup'" - _LOGGER.critical(msg) - raise FileNotFoundError(msg) - - return path - - -def dos_very_basic_config_path() -> Path: - """ - The path to the example lay_down_config_3_DOS_very_basic.yaml file. - - :return: The file path. - """ - path = _EXAMPLE_LAY_DOWN / "lay_down_config_3_DOS_very_basic.yaml" - if not path.exists(): - msg = "Example config not found. Please run 'primaite setup'" - _LOGGER.critical(msg) - raise FileNotFoundError(msg) - - return path - - -def data_manipulation_config_path() -> Path: - """ - The path to the example lay_down_config_5_data_manipulation.yaml file. - - :return: The file path. - """ - path = _EXAMPLE_LAY_DOWN / "lay_down_config_5_data_manipulation.yaml" - if not path.exists(): - msg = "Example config not found. Please run 'primaite setup'" - _LOGGER.critical(msg) - raise FileNotFoundError(msg) - - return path diff --git a/src/primaite/config/load.py b/src/primaite/config/load.py new file mode 100644 index 00000000..d5acd690 --- /dev/null +++ b/src/primaite/config/load.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Dict, Final, Union + +import yaml + +from primaite import getLogger, PRIMAITE_PATHS + +_LOGGER = getLogger(__name__) + +_EXAMPLE_CFG: Final[Path] = PRIMAITE_PATHS.user_config_path / "example_config" + + +def load(file_path: Union[str, Path]) -> Dict: + """ + Read a YAML file and return the contents as a dictionary. + + :param file_path: Path to the YAML file. + :type file_path: Union[str, Path] + :return: Config dictionary + :rtype: Dict + """ + if not isinstance(file_path, Path): + file_path = Path(file_path) + if not file_path.exists(): + _LOGGER.error(f"File does not exist: {file_path}") + raise FileNotFoundError(f"File does not exist: {file_path}") + with open(file_path, "r") as file: + config = yaml.safe_load(file) + _LOGGER.debug(f"Loaded config from {file_path}") + return config + + +def data_manipulation_config_path() -> Path: + """ + Get the path to the example config. + + :return: Path to the example config. + :rtype: Path + """ + path = _EXAMPLE_CFG / "data_manipulation.yaml" + if not path.exists(): + msg = f"Example config does not exist: {path}. Have you run `primaite setup`?" + _LOGGER.error(msg) + raise FileNotFoundError(msg) + return path diff --git a/src/primaite/config/training_config.py b/src/primaite/config/training_config.py deleted file mode 100644 index ebfee09a..00000000 --- a/src/primaite/config/training_config.py +++ /dev/null @@ -1,424 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from __future__ import annotations - -from dataclasses import dataclass, field -from logging import Logger -from pathlib import Path -from typing import Any, Dict, Final, Optional, Union - -import yaml - -from primaite import getLogger, PRIMAITE_PATHS -from primaite.common.enums import ( - ActionType, - AgentFramework, - AgentIdentifier, - DeepLearningFramework, - HardCodedAgentView, - RulePermissionType, - SB3OutputVerboseLevel, - SessionType, -) - -_LOGGER: Logger = getLogger(__name__) - -_EXAMPLE_TRAINING: Final[Path] = PRIMAITE_PATHS.user_config_path / "example_config" / "training" - - -def main_training_config_path() -> Path: - """ - The path to the example training_config_main.yaml file. - - :return: The file path. - """ - path = _EXAMPLE_TRAINING / "training_config_main.yaml" - if not path.exists(): - msg = "Example config not found. Please run 'primaite setup'" - _LOGGER.critical(msg) - raise FileNotFoundError(msg) - - return path - - -@dataclass() -class TrainingConfig: - """The Training Config class.""" - - agent_framework: AgentFramework = AgentFramework.SB3 - "The AgentFramework" - - deep_learning_framework: DeepLearningFramework = DeepLearningFramework.TF - "The DeepLearningFramework" - - agent_identifier: AgentIdentifier = AgentIdentifier.PPO - "The AgentIdentifier" - - hard_coded_agent_view: HardCodedAgentView = HardCodedAgentView.FULL - "The view the deterministic hard-coded agent has of the environment" - - random_red_agent: bool = False - "Creates Random Red Agent Attacks" - - action_type: ActionType = ActionType.ANY - "The ActionType to use" - - num_train_episodes: int = 10 - "The number of episodes to train over during an training session" - - num_train_steps: int = 256 - "The number of steps in an episode during an training session" - - num_eval_episodes: int = 1 - "The number of episodes to train over during an evaluation session" - - num_eval_steps: int = 256 - "The number of steps in an episode during an evaluation session" - - checkpoint_every_n_episodes: int = 5 - "The agent will save a checkpoint every n episodes" - - observation_space: dict = field(default_factory=lambda: {"components": [{"name": "NODE_LINK_TABLE"}]}) - "The observation space config dict" - - time_delay: int = 10 - "The delay between steps (ms). Applies to generic agents only" - - # file - session_type: SessionType = SessionType.TRAIN - "The type of PrimAITE session to run" - - load_agent: bool = False - "Determine whether to load an agent from file" - - agent_load_file: Optional[str] = None - "File path and file name of agent if you're loading one in" - - # Environment - observation_space_high_value: int = 1000000000 - "The high value for the observation space" - - sb3_output_verbose_level: SB3OutputVerboseLevel = SB3OutputVerboseLevel.NONE - "Stable Baselines3 learn/eval output verbosity level" - - implicit_acl_rule: RulePermissionType = RulePermissionType.DENY - "ALLOW or DENY implicit firewall rule to go at the end of list of ACL list." - - max_number_acl_rules: int = 30 - "Sets a limit for number of acl rules allowed in the list and environment." - - # Reward values - # Generic - all_ok: float = 0 - - # Node Hardware State - off_should_be_on: float = -0.001 - off_should_be_resetting: float = -0.0005 - on_should_be_off: float = -0.0002 - on_should_be_resetting: float = -0.0005 - resetting_should_be_on: float = -0.0005 - resetting_should_be_off: float = -0.0002 - resetting: float = -0.0003 - - # Node Software or Service State - good_should_be_patching: float = 0.0002 - good_should_be_compromised: float = 0.0005 - good_should_be_overwhelmed: float = 0.0005 - patching_should_be_good: float = -0.0005 - patching_should_be_compromised: float = 0.0002 - patching_should_be_overwhelmed: float = 0.0002 - patching: float = -0.0003 - compromised_should_be_good: float = -0.002 - compromised_should_be_patching: float = -0.002 - compromised_should_be_overwhelmed: float = -0.002 - compromised: float = -0.002 - overwhelmed_should_be_good: float = -0.002 - overwhelmed_should_be_patching: float = -0.002 - overwhelmed_should_be_compromised: float = -0.002 - overwhelmed: float = -0.002 - - # Node File System State - good_should_be_repairing: float = 0.0002 - good_should_be_restoring: float = 0.0002 - good_should_be_corrupt: float = 0.0005 - good_should_be_destroyed: float = 0.001 - repairing_should_be_good: float = -0.0005 - repairing_should_be_restoring: float = 0.0002 - repairing_should_be_corrupt: float = 0.0002 - repairing_should_be_destroyed: float = 0.0000 - repairing: float = -0.0003 - restoring_should_be_good: float = -0.001 - restoring_should_be_repairing: float = -0.0002 - restoring_should_be_corrupt: float = 0.0001 - restoring_should_be_destroyed: float = 0.0002 - restoring: float = -0.0006 - corrupt_should_be_good: float = -0.001 - corrupt_should_be_repairing: float = -0.001 - corrupt_should_be_restoring: float = -0.001 - corrupt_should_be_destroyed: float = 0.0002 - corrupt: float = -0.001 - destroyed_should_be_good: float = -0.002 - destroyed_should_be_repairing: float = -0.002 - destroyed_should_be_restoring: float = -0.002 - destroyed_should_be_corrupt: float = -0.002 - destroyed: float = -0.002 - scanning: float = -0.0002 - - # IER status - red_ier_running: float = -0.0005 - green_ier_blocked: float = -0.001 - - # Patching / Reset durations - os_patching_duration: int = 5 - "The time taken to patch the OS" - - node_reset_duration: int = 5 - "The time taken to reset a node (hardware)" - - node_booting_duration: int = 3 - "The Time taken to turn on the node" - - node_shutdown_duration: int = 2 - "The time taken to turn off the node" - - service_patching_duration: int = 5 - "The time taken to patch a service" - - file_system_repairing_limit: int = 5 - "The time take to repair the file system" - - file_system_restoring_limit: int = 5 - "The time take to restore the file system" - - file_system_scanning_limit: int = 5 - "The time taken to scan the file system" - - deterministic: bool = False - "If true, the training will be deterministic" - - seed: Optional[int] = None - "The random number generator seed to be used while training the agent" - - @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> TrainingConfig: - """ - Create an instance of TrainingConfig from a dict. - - :param config_dict: The training config dict. - :return: The instance of TrainingConfig. - """ - field_enum_map = { - "agent_framework": AgentFramework, - "deep_learning_framework": DeepLearningFramework, - "agent_identifier": AgentIdentifier, - "action_type": ActionType, - "session_type": SessionType, - "sb3_output_verbose_level": SB3OutputVerboseLevel, - "hard_coded_agent_view": HardCodedAgentView, - "implicit_acl_rule": RulePermissionType, - } - - # convert the string representation of enums into the actual enum values themselves? - for key, value in field_enum_map.items(): - if key in config_dict: - config_dict[key] = value[config_dict[key]] - - return TrainingConfig(**config_dict) - - def to_dict(self, json_serializable: bool = True) -> Dict: - """ - Serialise the ``TrainingConfig`` as dict. - - :param json_serializable: If True, Enums are converted to their - string name. - :return: The ``TrainingConfig`` as a dict. - """ - data = self.__dict__ - if json_serializable: - data["agent_framework"] = self.agent_framework.name - data["deep_learning_framework"] = self.deep_learning_framework.name - data["agent_identifier"] = self.agent_identifier.name - data["action_type"] = self.action_type.name - data["sb3_output_verbose_level"] = self.sb3_output_verbose_level.name - data["session_type"] = self.session_type.name - data["hard_coded_agent_view"] = self.hard_coded_agent_view.name - data["implicit_acl_rule"] = self.implicit_acl_rule.name - - return data - - def __str__(self) -> str: - obs_str = ",".join([c["name"] for c in self.observation_space["components"]]) - tc = f"{self.agent_framework}, " - if self.agent_framework is AgentFramework.RLLIB: - tc += f"{self.deep_learning_framework}, " - tc += f"{self.agent_identifier}, " - if self.agent_identifier is AgentIdentifier.HARDCODED: - tc += f"{self.hard_coded_agent_view}, " - tc += f"{self.action_type}, " - tc += f"observation_space={obs_str}, " - if self.session_type is SessionType.TRAIN: - tc += f"{self.num_train_episodes} episodes @ " - tc += f"{self.num_train_steps} steps" - elif self.session_type is SessionType.EVAL: - tc += f"{self.num_eval_episodes} episodes @ " - tc += f"{self.num_eval_steps} steps" - else: - tc += f"Training: {self.num_eval_episodes} episodes @ " - tc += f"{self.num_eval_steps} steps" - tc += f"Evaluation: {self.num_eval_episodes} episodes @ " - tc += f"{self.num_eval_steps} steps" - return tc - - -def load(file_path: Union[str, Path], legacy_file: bool = False) -> TrainingConfig: - """ - Read in a training config yaml file. - - :param file_path: The config file path. - :param legacy_file: True if the config file is legacy format, otherwise - False. - :return: An instance of - :class:`~primaite.config.training_config.TrainingConfig`. - :raises ValueError: If the file_path does not exist. - :raises TypeError: When the TrainingConfig object cannot be created - using the values from the config file read from ``file_path``. - """ - if not isinstance(file_path, Path): - file_path = Path(file_path) - if file_path.exists(): - with open(file_path, "r") as file: - config = yaml.safe_load(file) - _LOGGER.debug(f"Loading training config file: {file_path}") - if legacy_file: - try: - config = convert_legacy_training_config_dict(config) - except KeyError: - msg = ( - f"Failed to convert training config file {file_path} " - f"from legacy format. Attempting to use file as is." - ) - _LOGGER.error(msg) - try: - return TrainingConfig.from_dict(config) - except TypeError as e: - msg = f"Error when creating an instance of {TrainingConfig} " f"from the training config file {file_path}" - _LOGGER.critical(msg, exc_info=True) - raise e - msg = f"Cannot load the training config as it does not exist: {file_path}" - _LOGGER.error(msg) - raise ValueError(msg) - - -def convert_legacy_training_config_dict( - legacy_config_dict: Dict[str, Any], - agent_framework: AgentFramework = AgentFramework.SB3, - agent_identifier: AgentIdentifier = AgentIdentifier.PPO, - action_type: ActionType = ActionType.ANY, - num_train_steps: int = 256, -) -> Dict[str, Any]: - """ - Convert a legacy training config dict to the new format. - - :param legacy_config_dict: A legacy training config dict. - :param agent_framework: The agent framework to use as legacy training - configs don't have agent_framework values. - :param agent_identifier: The red agent identifier to use as legacy - training configs don't have agent_identifier values. - :param action_type: The action space type to set as legacy training configs - don't have action_type values. - :param num_train_steps: The number of steps to set as legacy training configs - don't have num_train_steps values. - :return: The converted training config dict. - """ - config_dict = { - "agent_framework": agent_framework.name, - "agent_identifier": agent_identifier.name, - "action_type": action_type.name, - "num_train_steps": num_train_steps, - "sb3_output_verbose_level": SB3OutputVerboseLevel.INFO.name, - } - session_type_map = {"TRAINING": "TRAIN", "EVALUATION": "EVAL"} - legacy_config_dict["sessionType"] = session_type_map[legacy_config_dict["sessionType"]] - for legacy_key, value in legacy_config_dict.items(): - new_key = _get_new_key_from_legacy(legacy_key) - if new_key: - config_dict[new_key] = value - return config_dict - - -def _get_new_key_from_legacy(legacy_key: str) -> Optional[str]: - """ - Maps legacy training config keys to the new format keys. - - :param legacy_key: A legacy training config key. - :return: The mapped key. - """ - key_mapping = { - "agentIdentifier": None, - "numEpisodes": "num_train_episodes", - "numSteps": "num_train_steps", - "timeDelay": "time_delay", - "configFilename": None, - "sessionType": "session_type", - "loadAgent": "load_agent", - "agentLoadFile": "agent_load_file", - "observationSpaceHighValue": "observation_space_high_value", - "allOk": "all_ok", - "offShouldBeOn": "off_should_be_on", - "offShouldBeResetting": "off_should_be_resetting", - "onShouldBeOff": "on_should_be_off", - "onShouldBeResetting": "on_should_be_resetting", - "resettingShouldBeOn": "resetting_should_be_on", - "resettingShouldBeOff": "resetting_should_be_off", - "resetting": "resetting", - "goodShouldBePatching": "good_should_be_patching", - "goodShouldBeCompromised": "good_should_be_compromised", - "goodShouldBeOverwhelmed": "good_should_be_overwhelmed", - "patchingShouldBeGood": "patching_should_be_good", - "patchingShouldBeCompromised": "patching_should_be_compromised", - "patchingShouldBeOverwhelmed": "patching_should_be_overwhelmed", - "patching": "patching", - "compromisedShouldBeGood": "compromised_should_be_good", - "compromisedShouldBePatching": "compromised_should_be_patching", - "compromisedShouldBeOverwhelmed": "compromised_should_be_overwhelmed", - "compromised": "compromised", - "overwhelmedShouldBeGood": "overwhelmed_should_be_good", - "overwhelmedShouldBePatching": "overwhelmed_should_be_patching", - "overwhelmedShouldBeCompromised": "overwhelmed_should_be_compromised", - "overwhelmed": "overwhelmed", - "goodShouldBeRepairing": "good_should_be_repairing", - "goodShouldBeRestoring": "good_should_be_restoring", - "goodShouldBeCorrupt": "good_should_be_corrupt", - "goodShouldBeDestroyed": "good_should_be_destroyed", - "repairingShouldBeGood": "repairing_should_be_good", - "repairingShouldBeRestoring": "repairing_should_be_restoring", - "repairingShouldBeCorrupt": "repairing_should_be_corrupt", - "repairingShouldBeDestroyed": "repairing_should_be_destroyed", - "repairing": "repairing", - "restoringShouldBeGood": "restoring_should_be_good", - "restoringShouldBeRepairing": "restoring_should_be_repairing", - "restoringShouldBeCorrupt": "restoring_should_be_corrupt", - "restoringShouldBeDestroyed": "restoring_should_be_destroyed", - "restoring": "restoring", - "corruptShouldBeGood": "corrupt_should_be_good", - "corruptShouldBeRepairing": "corrupt_should_be_repairing", - "corruptShouldBeRestoring": "corrupt_should_be_restoring", - "corruptShouldBeDestroyed": "corrupt_should_be_destroyed", - "corrupt": "corrupt", - "destroyedShouldBeGood": "destroyed_should_be_good", - "destroyedShouldBeRepairing": "destroyed_should_be_repairing", - "destroyedShouldBeRestoring": "destroyed_should_be_restoring", - "destroyedShouldBeCorrupt": "destroyed_should_be_corrupt", - "destroyed": "destroyed", - "scanning": "scanning", - "redIerRunning": "red_ier_running", - "greenIerBlocked": "green_ier_blocked", - "osPatchingDuration": "os_patching_duration", - "nodeResetDuration": "node_reset_duration", - "nodeBootingDuration": "node_booting_duration", - "nodeShutdownDuration": "node_shutdown_duration", - "servicePatchingDuration": "service_patching_duration", - "fileSystemRepairingLimit": "file_system_repairing_limit", - "fileSystemRestoringLimit": "file_system_restoring_limit", - "fileSystemScanningLimit": "file_system_scanning_limit", - } - return key_mapping[legacy_key] diff --git a/src/primaite/data_viz/__init__.py b/src/primaite/data_viz/__init__.py deleted file mode 100644 index 260579da..00000000 --- a/src/primaite/data_viz/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Utility to generate plots of sessions metrics after PrimAITE.""" -from enum import Enum - - -class PlotlyTemplate(Enum): - """The built-in plotly templates.""" - - PLOTLY = "plotly" - PLOTLY_WHITE = "plotly_white" - PLOTLY_DARK = "plotly_dark" - GGPLOT2 = "ggplot2" - SEABORN = "seaborn" - SIMPLE_WHITE = "simple_white" - NONE = "none" diff --git a/src/primaite/data_viz/session_plots.py b/src/primaite/data_viz/session_plots.py deleted file mode 100644 index 37750353..00000000 --- a/src/primaite/data_viz/session_plots.py +++ /dev/null @@ -1,73 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -from pathlib import Path -from typing import Dict, Optional, Union - -import plotly.graph_objects as go -import polars as pl -import yaml -from plotly.graph_objs import Figure - -from primaite import PRIMAITE_PATHS - - -def get_plotly_config() -> Dict: - """Get the plotly config from primaite_config.yaml.""" - with open(PRIMAITE_PATHS.app_config_file_path, "r") as file: - primaite_config = yaml.safe_load(file) - return primaite_config["session"]["outputs"]["plots"] - - -def plot_av_reward_per_episode( - av_reward_per_episode_csv: Union[str, Path], - title: Optional[str] = None, - subtitle: Optional[str] = None, -) -> Figure: - """ - Plot the average reward per episode from a csv session output. - - :param av_reward_per_episode_csv: The average reward per episode csv - file path. - :param title: The plot title. This is optional. - :param subtitle: The plot subtitle. This is optional. - :return: The plot as an instance of ``plotly.graph_objs._figure.Figure``. - """ - df = pl.read_csv(av_reward_per_episode_csv) - - if title: - if subtitle: - title = f"{title}
{subtitle}" - else: - if subtitle: - title = subtitle - - config = get_plotly_config() - layout = go.Layout( - autosize=config["size"]["auto_size"], - width=config["size"]["width"], - height=config["size"]["height"], - ) - # Create the line graph with a colored line - fig = go.Figure(layout=layout) - fig.update_layout(template=config["template"]) - fig.add_trace( - go.Scatter( - x=df["Episode"], - y=df["Average Reward"], - mode="lines", - name="Mean Reward per Episode", - ) - ) - - # Set the layout of the graph - fig.update_layout( - xaxis={ - "title": "Episode", - "type": "linear", - "rangeslider": {"visible": config["range_slider"]}, - }, - yaxis={"title": "Average Reward"}, - title=title, - showlegend=False, - ) - - return fig diff --git a/src/primaite/environment/__init__.py b/src/primaite/environment/__init__.py deleted file mode 100644 index f0fd21b9..00000000 --- a/src/primaite/environment/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Gym/Gymnasium environment for RL agents consisting of a simulated computer network.""" diff --git a/src/primaite/environment/observations.py b/src/primaite/environment/observations.py deleted file mode 100644 index 383a9b5a..00000000 --- a/src/primaite/environment/observations.py +++ /dev/null @@ -1,735 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Module for handling configurable observation spaces in PrimAITE.""" -import logging -from abc import ABC, abstractmethod -from logging import Logger -from typing import Dict, Final, List, Tuple, TYPE_CHECKING, Union - -import numpy as np -from gym import spaces - -from primaite.acl.acl_rule import ACLRule -from primaite.common.enums import FileSystemState, HardwareState, RulePermissionType, SoftwareState -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.service_node import ServiceNode - -# This dependency is only needed for type hints, -# TYPE_CHECKING is False at runtime and True when typecheckers are performing typechecking -# Therefore, this avoids circular dependency problem. -if TYPE_CHECKING: - from primaite.environment.primaite_env import Primaite - - -_LOGGER: Logger = logging.getLogger(__name__) - - -class AbstractObservationComponent(ABC): - """Represents a part of the PrimAITE observation space.""" - - @abstractmethod - def __init__(self, env: "Primaite") -> None: - """ - Initialise observation component. - - :param env: Primaite training environment. - :type env: Primaite - """ - _LOGGER.info(f"Initialising {self} observation component") - self.env: "Primaite" = env - self.space: spaces.Space - self.current_observation: np.ndarray # type might be too restrictive? - self.structure: List[str] - return NotImplemented - - @abstractmethod - def update(self) -> None: - """Update the observation based on the current state of the environment.""" - self.current_observation = NotImplemented - - @abstractmethod - def generate_structure(self) -> List[str]: - """Return a list of labels for the components of the flattened observation space.""" - return NotImplemented - - -class NodeLinkTable(AbstractObservationComponent): - """ - Table with nodes and links as rows and hardware/software status as cols. - - This will create the observation space formatted as a table of integers. - There is one row per node, followed by one row per link. - The number of columns is 4 plus one per service. They are: - - * node/link ID - * node hardware status / 0 for links - * node operating system status (if active/service) / 0 for links - * node file system status (active/service only) / 0 for links - * node service1 status / traffic load from that service for links - * node service2 status / traffic load from that service for links - * ... - * node serviceN status / traffic load from that service for links - - For example if the environment has 5 nodes, 7 links, and 3 services, the observation space shape will be - ``(12, 7)`` - """ - - _FIXED_PARAMETERS: int = 4 - _MAX_VAL: int = 1_000_000_000 - _DATA_TYPE: type = np.int64 - - def __init__(self, env: "Primaite") -> None: - """ - Initialise a NodeLinkTable observation space component. - - :param env: Training environment. - :type env: Primaite - """ - super().__init__(env) - - # 1. Define the shape of your observation space component - num_items = self.env.num_links + self.env.num_nodes - num_columns = self.env.num_services + self._FIXED_PARAMETERS - observation_shape = (num_items, num_columns) - - # 2. Create Observation space - self.space = spaces.Box( - low=0, - high=self._MAX_VAL, - shape=observation_shape, - dtype=self._DATA_TYPE, - ) - - # 3. Initialise Observation with zeroes - self.current_observation = np.zeros(observation_shape, dtype=self._DATA_TYPE) - - self.structure = self.generate_structure() - - def update(self) -> None: - """ - Update the observation based on current environment state. - - The structure of the observation space is described in :class:`.NodeLinkTable` - """ - item_index = 0 - nodes = self.env.nodes - links = self.env.links - # Do nodes first - for _, node in nodes.items(): - self.current_observation[item_index][0] = int(node.node_id) - self.current_observation[item_index][1] = node.hardware_state.value - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - self.current_observation[item_index][2] = node.software_state.value - self.current_observation[item_index][3] = node.file_system_state_observed.value - else: - self.current_observation[item_index][2] = 0 - self.current_observation[item_index][3] = 0 - service_index = 4 - if isinstance(node, ServiceNode): - for service in self.env.services_list: - if node.has_service(service): - self.current_observation[item_index][service_index] = node.get_service_state(service).value - else: - self.current_observation[item_index][service_index] = 0 - service_index += 1 - else: - # Not a service node - for service in self.env.services_list: - self.current_observation[item_index][service_index] = 0 - service_index += 1 - item_index += 1 - - # Now do links - for _, link in links.items(): - self.current_observation[item_index][0] = int(link.get_id()) - self.current_observation[item_index][1] = 0 - self.current_observation[item_index][2] = 0 - self.current_observation[item_index][3] = 0 - protocol_list = link.get_protocol_list() - protocol_index = 0 - for protocol in protocol_list: - self.current_observation[item_index][protocol_index + 4] = protocol.get_load() - protocol_index += 1 - item_index += 1 - - def generate_structure(self) -> List[str]: - """Return a list of labels for the components of the flattened observation space.""" - nodes = self.env.nodes.values() - links = self.env.links.values() - - structure = [] - - for i, node in enumerate(nodes): - node_id = node.node_id - node_labels = [ - f"node_{node_id}_id", - f"node_{node_id}_hardware_status", - f"node_{node_id}_os_status", - f"node_{node_id}_fs_status", - ] - for j, serv in enumerate(self.env.services_list): - node_labels.append(f"node_{node_id}_service_{serv}_status") - - structure.extend(node_labels) - - for i, link in enumerate(links): - link_id = link.id - link_labels = [ - f"link_{link_id}_id", - f"link_{link_id}_n/a", - f"link_{link_id}_n/a", - f"link_{link_id}_n/a", - ] - for j, serv in enumerate(self.env.services_list): - link_labels.append(f"link_{link_id}_service_{serv}_load") - - structure.extend(link_labels) - return structure - - -class NodeStatuses(AbstractObservationComponent): - """ - Flat list of nodes' hardware, OS, file system, and service states. - - The MultiDiscrete observation space can be though of as a one-dimensional vector of discrete states, represented by - integers. - Each node has 3 elements plus 1 per service. It will have the following structure: - .. code-block:: - - [ - node1 hardware state, - node1 OS state, - node1 file system state, - node1 service1 state, - node1 service2 state, - node1 serviceN state (one for each service), - node2 hardware state, - node2 OS state, - node2 file system state, - node2 service1 state, - node2 service2 state, - node2 serviceN state (one for each service), - ... - ] - """ - - _DATA_TYPE: type = np.int64 - - def __init__(self, env: "Primaite") -> None: - """ - Initialise a NodeStatuses observation component. - - :param env: Training environment. - :type env: Primaite - """ - super().__init__(env) - - # 1. Define the shape of your observation space component - node_shape = [ - len(HardwareState) + 1, - len(SoftwareState) + 1, - len(FileSystemState) + 1, - ] - services_shape = [len(SoftwareState) + 1] * self.env.num_services - node_shape = node_shape + services_shape - - shape = node_shape * self.env.num_nodes - # 2. Create Observation space - self.space = spaces.MultiDiscrete(shape) - - # 3. Initialise observation with zeroes - self.current_observation = np.zeros(len(shape), dtype=self._DATA_TYPE) - self.structure = self.generate_structure() - - def update(self) -> None: - """ - Update the observation based on current environment state. - - The structure of the observation space is described in :class:`.NodeStatuses` - """ - obs = [] - for _, node in self.env.nodes.items(): - hardware_state = node.hardware_state.value - software_state = 0 - file_system_state = 0 - service_states = [0] * self.env.num_services - - if isinstance(node, ActiveNode): - software_state = node.software_state.value - file_system_state = node.file_system_state_observed.value - - if isinstance(node, ServiceNode): - for i, service in enumerate(self.env.services_list): - if node.has_service(service): - service_states[i] = node.get_service_state(service).value - obs.extend( - [ - hardware_state, - software_state, - file_system_state, - *service_states, - ] - ) - self.current_observation[:] = obs - - def generate_structure(self) -> List[str]: - """Return a list of labels for the components of the flattened observation space.""" - services = self.env.services_list - - structure = [] - - for _, node in self.env.nodes.items(): - node_id = node.node_id - structure.append(f"node_{node_id}_hardware_state_NONE") - for state in HardwareState: - structure.append(f"node_{node_id}_hardware_state_{state.name}") - structure.append(f"node_{node_id}_software_state_NONE") - for state in SoftwareState: - structure.append(f"node_{node_id}_software_state_{state.name}") - structure.append(f"node_{node_id}_file_system_state_NONE") - for state in FileSystemState: - structure.append(f"node_{node_id}_file_system_state_{state.name}") - for service in services: - structure.append(f"node_{node_id}_service_{service}_state_NONE") - for state in SoftwareState: - structure.append(f"node_{node_id}_service_{service}_state_{state.name}") - return structure - - -class LinkTrafficLevels(AbstractObservationComponent): - """ - Flat list of traffic levels encoded into banded categories. - - For each link, total traffic or traffic per service is encoded into a categorical value. - For example, if ``quantisation_levels=5``, the traffic levels represent these values: - - * 0 = No traffic (0% of bandwidth) - * 1 = No traffic (0%-33% of bandwidth) - * 2 = No traffic (33%-66% of bandwidth) - * 3 = No traffic (66%-100% of bandwidth) - * 4 = No traffic (100% of bandwidth) - - .. note:: - The lowest category always corresponds to no traffic and the highest category to the link being at max capacity. - Any amount of traffic between 0% and 100% (exclusive) is divided evenly into the remaining categories. - - """ - - _DATA_TYPE: type = np.int64 - - def __init__( - self, - env: "Primaite", - combine_service_traffic: bool = False, - quantisation_levels: int = 5, - ) -> None: - """ - Initialise a LinkTrafficLevels observation component. - - :param env: The environment that forms the basis of the observations - :type env: Primaite - :param combine_service_traffic: Whether to consider total traffic on the link, or each protocol individually, - defaults to False - :type combine_service_traffic: bool, optional - :param quantisation_levels: How many bands to consider when converting the traffic amount to a categorical - value, defaults to 5 - :type quantisation_levels: int, optional - """ - if quantisation_levels < 3: - _msg = ( - f"quantisation_levels must be 3 or more because the lowest and highest levels are " - f"reserved for 0% and 100% link utilisation, got {quantisation_levels} instead. " - f"Resetting to default value (5)" - ) - _LOGGER.warning(_msg) - quantisation_levels = 5 - - super().__init__(env) - - self._combine_service_traffic: bool = combine_service_traffic - self._quantisation_levels: int = quantisation_levels - self._entries_per_link: int = 1 - - if not self._combine_service_traffic: - self._entries_per_link = self.env.num_services - - # 1. Define the shape of your observation space component - shape = [self._quantisation_levels] * self.env.num_links * self._entries_per_link - - # 2. Create Observation space - self.space = spaces.MultiDiscrete(shape) - - # 3. Initialise observation with zeroes - self.current_observation = np.zeros(len(shape), dtype=self._DATA_TYPE) - - self.structure = self.generate_structure() - - def update(self) -> None: - """ - Update the observation based on current environment state. - - The structure of the observation space is described in :class:`.LinkTrafficLevels` - """ - obs = [] - for _, link in self.env.links.items(): - bandwidth = link.bandwidth - if self._combine_service_traffic: - loads = [link.get_current_load()] - else: - loads = [protocol.get_load() for protocol in link.protocol_list] - - for load in loads: - if load <= 0: - traffic_level = 0 - elif load >= bandwidth: - traffic_level = self._quantisation_levels - 1 - else: - traffic_level = (load / bandwidth) // (1 / (self._quantisation_levels - 2)) + 1 - - obs.append(int(traffic_level)) - - self.current_observation[:] = obs - - def generate_structure(self) -> List[str]: - """Return a list of labels for the components of the flattened observation space.""" - structure = [] - for _, link in self.env.links.items(): - link_id = link.id - if self._combine_service_traffic: - protocols = ["overall"] - else: - protocols = [protocol.name for protocol in link.protocol_list] - - for p in protocols: - for i in range(self._quantisation_levels): - structure.append(f"link_{link_id}_{p}_traffic_level_{i}") - return structure - - -class AccessControlList(AbstractObservationComponent): - """Flat list of all the Access Control Rules in the Access Control List. - - The MultiDiscrete observation space can be though of as a one-dimensional vector of discrete states, represented by - integers. - - Each ACL Rule has 6 elements. It will have the following structure: - .. code-block:: - [ - acl_rule1 permission, - acl_rule1 source_ip, - acl_rule1 dest_ip, - acl_rule1 protocol, - acl_rule1 port, - acl_rule1 position, - acl_rule2 permission, - acl_rule2 source_ip, - acl_rule2 dest_ip, - acl_rule2 protocol, - acl_rule2 port, - acl_rule2 position, - ... - ] - - - Terms (for ACL Observation Space): - [0, 1, 2] - Permission (0 = NA, 1 = DENY, 2 = ALLOW) - [0, num nodes] - Source IP (0 = NA, 1 = any, then 2 -> x resolving to Node IDs) - [0, num nodes] - Dest IP (0 = NA, 1 = any, then 2 -> x resolving to Node IDs) - [0, num services] - Protocol (0 = NA, 1 = any, then 2 -> x resolving to protocol) - [0, num ports] - Port (0 = NA, 1 = any, then 2 -> x resolving to port) - [0, max acl rules - 1] - Position (0 = NA, 1 = first index, then 2 -> x index resolving to acl rule in acl list) - - NOTE: NA is Non-Applicable - this means the ACL Rule in the list is a NoneType and NOT an ACLRule object. - """ - - _DATA_TYPE: type = np.int64 - - def __init__(self, env: "Primaite"): - """ - Initialise an AccessControlList observation component. - - :param env: The environment that forms the basis of the observations - :type env: Primaite - """ - super().__init__(env) - - # 1. Define the shape of your observation space component - # The NA and ANY types means that there are 2 extra items for Nodes, Services and Ports. - # Number of ACL rules incremented by 1 for positions starting at index 0. - acl_shape = [ - len(RulePermissionType), - len(env.nodes) + 2, - len(env.nodes) + 2, - len(env.services_list) + 2, - len(env.ports_list) + 2, - env.max_number_acl_rules, - ] - shape = acl_shape * self.env.max_number_acl_rules - - # 2. Create Observation space - self.space = spaces.MultiDiscrete(shape) - - # 3. Initialise observation with zeroes - self.current_observation = np.zeros(len(shape), dtype=self._DATA_TYPE) - - self.structure = self.generate_structure() - - def update(self) -> None: - """Update the observation based on current environment state. - - The structure of the observation space is described in :class:`.AccessControlList` - """ - obs = [] - - for index in range(0, len(self.env.acl.acl)): - acl_rule = self.env.acl.acl[index] - if isinstance(acl_rule, ACLRule): - permission = acl_rule.permission - source_ip = acl_rule.source_ip - dest_ip = acl_rule.dest_ip - protocol = acl_rule.protocol - port = acl_rule.port - position = index - # Map each ACL attribute from what it was to an integer to fit the observation space - source_ip_int = None - dest_ip_int = None - if permission == RulePermissionType.DENY: - permission_int = 1 - else: - permission_int = 2 - if source_ip == "ANY": - source_ip_int = 1 - else: - # Map Node ID (+ 1) to source IP address - nodes = list(self.env.nodes.values()) - for node in nodes: - if ( - isinstance(node, ServiceNode) or isinstance(node, ActiveNode) - ) and node.ip_address == source_ip: - source_ip_int = int(node.node_id) + 1 - break - if dest_ip == "ANY": - dest_ip_int = 1 - else: - # Map Node ID (+ 1) to dest IP address - # Index of Nodes start at 1 so + 1 is needed so NA can be added. - nodes = list(self.env.nodes.values()) - for node in nodes: - if ( - isinstance(node, ServiceNode) or isinstance(node, ActiveNode) - ) and node.ip_address == dest_ip: - dest_ip_int = int(node.node_id) + 1 - if protocol == "ANY": - protocol_int = 1 - else: - # Index of protocols and ports start from 0 so + 2 is needed to add NA and ANY - try: - protocol_int = self.env.services_list.index(protocol) + 2 - except AttributeError: - _LOGGER.info(f"Service {protocol} could not be found") - protocol_int = None - if port == "ANY": - port_int = 1 - else: - if port in self.env.ports_list: - port_int = self.env.ports_list.index(port) + 2 - else: - _LOGGER.info(f"Port {port} could not be found.") - port_int = None - # Add to current obs - obs.extend( - [ - permission_int, - source_ip_int, - dest_ip_int, - protocol_int, - port_int, - position, - ] - ) - - else: - # The Nothing or NA representation of 'NONE' ACL rules - obs.extend([0, 0, 0, 0, 0, 0]) - - self.current_observation[:] = obs - - def generate_structure(self) -> List[str]: - """Return a list of labels for the components of the flattened observation space.""" - structure = [] - for acl_rule in self.env.acl.acl: - acl_rule_id = self.env.acl.acl.index(acl_rule) - - for permission in RulePermissionType: - structure.append(f"acl_rule_{acl_rule_id}_permission_{permission.name}") - - structure.append(f"acl_rule_{acl_rule_id}_source_ip_ANY") - for node in self.env.nodes.keys(): - structure.append(f"acl_rule_{acl_rule_id}_source_ip_{node}") - - structure.append(f"acl_rule_{acl_rule_id}_dest_ip_ANY") - for node in self.env.nodes.keys(): - structure.append(f"acl_rule_{acl_rule_id}_dest_ip_{node}") - - structure.append(f"acl_rule_{acl_rule_id}_service_ANY") - for service in self.env.services_list: - structure.append(f"acl_rule_{acl_rule_id}_service_{service}") - - structure.append(f"acl_rule_{acl_rule_id}_port_ANY") - for port in self.env.ports_list: - structure.append(f"acl_rule_{acl_rule_id}_port_{port}") - - return structure - - -class ObservationsHandler: - """ - Component-based observation space handler. - - This allows users to configure observation spaces by mixing and matching components. Each component can also define - further parameters to make them more flexible. - """ - - _REGISTRY: Final[Dict[str, type]] = { - "NODE_LINK_TABLE": NodeLinkTable, - "NODE_STATUSES": NodeStatuses, - "LINK_TRAFFIC_LEVELS": LinkTrafficLevels, - "ACCESS_CONTROL_LIST": AccessControlList, - } - - def __init__(self) -> None: - """Initialise the observation handler.""" - self.registered_obs_components: List[AbstractObservationComponent] = [] - - # internal the observation space (unflattened version of space if flatten=True) - self._space: spaces.Space - # flattened version of the observation space - self._flat_space: spaces.Space - - self._observation: Union[Tuple[np.ndarray], np.ndarray] - # used for transactions and when flatten=true - self._flat_observation: np.ndarray - - def update_obs(self) -> None: - """Fetch fresh information about the environment.""" - current_obs = [] - for obs in self.registered_obs_components: - obs.update() - current_obs.append(obs.current_observation) - - if len(current_obs) == 1: - self._observation = current_obs[0] - else: - self._observation = tuple(current_obs) - self._flat_observation = spaces.flatten(self._space, self._observation) - - def register(self, obs_component: AbstractObservationComponent) -> None: - """ - Add a component for this handler to track. - - :param obs_component: The component to add. - :type obs_component: AbstractObservationComponent - """ - self.registered_obs_components.append(obs_component) - self.update_space() - - def deregister(self, obs_component: AbstractObservationComponent) -> None: - """ - Remove a component from this handler. - - :param obs_component: Which component to remove. It must exist within this object's - ``registered_obs_components`` attribute. - :type obs_component: AbstractObservationComponent - """ - self.registered_obs_components.remove(obs_component) - self.update_space() - - def update_space(self) -> None: - """Rebuild the handler's composite observation space from its components.""" - component_spaces = [] - for obs_comp in self.registered_obs_components: - component_spaces.append(obs_comp.space) - - # if there are multiple components, build a composite tuple space - if len(component_spaces) == 1: - self._space = component_spaces[0] - else: - self._space = spaces.Tuple(component_spaces) - if len(component_spaces) > 0: - self._flat_space = spaces.flatten_space(self._space) - else: - self._flat_space = spaces.Box(0, 1, (0,)) - - @property - def space(self) -> spaces.Space: - """Observation space, return the flattened version if flatten is True.""" - if len(self.registered_obs_components) > 1: - return self._flat_space - else: - return self._space - - @property - def current_observation(self) -> Union[np.ndarray, Tuple[np.ndarray]]: - """Current observation, return the flattened version if flatten is True.""" - if len(self.registered_obs_components) > 1: - return self._flat_observation - else: - return self._observation - - @classmethod - def from_config(cls, env: "Primaite", obs_space_config: dict) -> "ObservationsHandler": - """ - Parse a config dictinary, return a new observation handler populated with new observation component objects. - - The expected format for the config dictionary is: - - .. code-block:: python - - config = { - components: [ - { - "name": "" - }, - { - "name": "" - "options": {"opt1": val1, "opt2": val2} - }, - { - ... - }, - ] - } - - :return: Observation handler - :rtype: primaite.environment.observations.ObservationsHandler - """ - # Instantiate the handler - handler = cls() - - for component_cfg in obs_space_config["components"]: - # Figure out which class can instantiate the desired component - comp_type = component_cfg["name"] - comp_class = cls._REGISTRY[comp_type] - - # Create the component with options from the YAML - options = component_cfg.get("options") or {} - component = comp_class(env, **options) - - handler.register(component) - - handler.update_obs() - return handler - - def describe_structure(self) -> List[str]: - """ - Create a list of names for the features of the obs space. - - The order of labels follows the flattened version of the space. - """ - # as it turns out it's not possible to take the gym flattening function and apply it to our labels so we have - # to fake it. each component has to just hard-code the expected label order after flattening... - - labels = [] - for obs_comp in self.registered_obs_components: - labels.extend(obs_comp.structure) - - return labels diff --git a/src/primaite/environment/primaite_env.py b/src/primaite/environment/primaite_env.py deleted file mode 100644 index cde586ed..00000000 --- a/src/primaite/environment/primaite_env.py +++ /dev/null @@ -1,1403 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Main environment module containing the PRIMmary AI Training Evironment (Primaite) class.""" -import copy -import logging -import uuid as uuid -from logging import Logger -from pathlib import Path -from random import choice, randint, sample, uniform -from typing import Any, Dict, Final, List, Tuple, Union - -import networkx as nx -import numpy as np -import yaml -from gym import Env, spaces -from matplotlib import pyplot as plt - -from primaite import getLogger -from primaite.acl.access_control_list import AccessControlList -from primaite.agents.utils import is_valid_acl_action_extra, is_valid_node_action -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import ( - ActionType, - AgentFramework, - AgentIdentifier, - FileSystemState, - HardwareState, - NodePOLInitiator, - NodePOLType, - NodeType, - ObservationType, - Priority, - SessionType, - SoftwareState, -) -from primaite.common.service import Service -from primaite.config import training_config -from primaite.config.training_config import TrainingConfig -from primaite.environment.observations import ObservationsHandler -from primaite.environment.reward import calculate_reward_function -from primaite.links.link import Link -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.node import Node -from primaite.nodes.node_state_instruction_green import NodeStateInstructionGreen -from primaite.nodes.node_state_instruction_red import NodeStateInstructionRed -from primaite.nodes.passive_node import PassiveNode -from primaite.nodes.service_node import ServiceNode -from primaite.pol.green_pol import apply_iers, apply_node_pol -from primaite.pol.ier import IER -from primaite.pol.red_agent_pol import apply_red_agent_iers, apply_red_agent_node_pol -from primaite.transactions.transaction import Transaction -from primaite.utils.session_output_writer import SessionOutputWriter - -_LOGGER: Logger = getLogger(__name__) - - -class Primaite(Env): - """PRIMmary AI Training Evironment (Primaite) class.""" - - # Action Space contants - ACTION_SPACE_NODE_PROPERTY_VALUES: int = 5 - ACTION_SPACE_NODE_ACTION_VALUES: int = 4 - ACTION_SPACE_ACL_ACTION_VALUES: int = 3 - ACTION_SPACE_ACL_PERMISSION_VALUES: int = 2 - - def __init__( - self, - training_config_path: Union[str, Path], - lay_down_config_path: Union[str, Path], - session_path: Path, - timestamp_str: str, - ) -> None: - """ - The Primaite constructor. - - :param training_config_path: The training config filepath. - :param lay_down_config_path: The lay down config filepath. - :param session_path: The directory path the session is writing to. - :param timestamp_str: The session timestamp in the format: _. - """ - self.session_path: Final[Path] = session_path - self.timestamp_str: Final[str] = timestamp_str - self._training_config_path: Union[str, Path] = training_config_path - self._lay_down_config_path: Union[str, Path] = lay_down_config_path - - self.training_config: TrainingConfig = training_config.load(training_config_path) - _LOGGER.info(f"Using: {str(self.training_config)}") - - # Number of steps in an episode - self.episode_steps: int - if self.training_config.session_type == SessionType.TRAIN: - self.episode_steps = self.training_config.num_train_steps - elif self.training_config.session_type == SessionType.EVAL: - self.episode_steps = self.training_config.num_eval_steps - else: - self.episode_steps = self.training_config.num_train_steps - - super(Primaite, self).__init__() - - # The agent in use - self.agent_identifier: AgentIdentifier = self.training_config.agent_identifier - - # Create a dictionary to hold all the nodes - self.nodes: Dict[str, NodeUnion] = {} - - # Create a dictionary to hold a reference set of nodes - self.nodes_reference: Dict[str, NodeUnion] = {} - - # Create a dictionary to hold all the links - self.links: Dict[str, Link] = {} - - # Create a dictionary to hold a reference set of links - self.links_reference: Dict[str, Link] = {} - - # Create a dictionary to hold all the green IERs (this will come from an external source) - self.green_iers: Dict[str, IER] = {} - self.green_iers_reference: Dict[str, IER] = {} - - # Create a dictionary to hold all the node PoLs (this will come from an external source) - self.node_pol: Dict[str, NodeStateInstructionGreen] = {} - - # Create a dictionary to hold all the red agent IERs (this will come from an external source) - self.red_iers: Dict[str, IER] = {} - - # Create a dictionary to hold all the red agent node PoLs (this will come from an external source) - self.red_node_pol: Dict[str, NodeStateInstructionRed] = {} - - # Create the Access Control List - self.acl: AccessControlList = AccessControlList( - self.training_config.implicit_acl_rule, - self.training_config.max_number_acl_rules, - ) - # Sets limit for number of ACL rules in environment - self.max_number_acl_rules: int = self.training_config.max_number_acl_rules - - # Create a list of services (enums) - self.services_list: List[str] = [] - - # Create a list of ports - self.ports_list: List[str] = [] - - # Create graph (network) - self.network: nx.Graph = nx.MultiGraph() - - # Create a graph (network) reference - self.network_reference: nx.Graph = nx.MultiGraph() - - # Create step count - self.step_count: int = 0 - - self.total_step_count: int = 0 - """The total number of time steps completed.""" - - # Create step info dictionary - self.step_info: Dict[Any] = {} - - # Total reward - self.total_reward: float = 0 - - # Average reward - self.average_reward: float = 0 - - # Episode count - self.episode_count: int = 0 - - # Number of nodes - gets a value by examining the nodes dictionary after it's been populated - self.num_nodes: int = 0 - - # Number of links - gets a value by examining the links dictionary after it's been populated - self.num_links: int = 0 - - # Number of services - gets a value when config is loaded - self.num_services: int = 0 - - # Number of ports - gets a value when config is loaded - self.num_ports: int = 0 - - # The action type - # TODO: confirm type - self.action_type: int = 0 - - # TODO fix up with TrainingConfig - # stores the observation config from the yaml, default is NODE_LINK_TABLE - self.obs_config: dict = {"components": [{"name": "NODE_LINK_TABLE"}]} - if self.training_config.observation_space is not None: - self.obs_config = self.training_config.observation_space - - # Observation Handler manages the user-configurable observation space. - # It will be initialised later. - self.obs_handler: ObservationsHandler - - self._obs_space_description: List[str] = None - "The env observation space description for transactions writing" - - # Open the config file and build the environment laydown - with open(self._lay_down_config_path, "r") as file: - # Open the config file and build the environment laydown - self.lay_down_config = yaml.safe_load(file) - self.load_lay_down_config() - - # Store the node objects as node attributes - # (This is so we can access them as objects) - for node in self.network: - self.network.nodes[node]["self"] = node - - for node in self.network_reference: - self.network_reference.nodes[node]["self"] = node - - self.num_nodes = len(self.nodes) - self.num_links = len(self.links) - - # Visualise in PNG - try: - plt.tight_layout() - nx.draw_networkx(self.network, with_labels=True) - - file_path = session_path / f"network_{timestamp_str}.png" - plt.savefig(file_path, format="PNG") - plt.clf() - except Exception: - _LOGGER.error("Could not save network diagram", exc_info=True) - - # Initiate observation space - self.observation_space: spaces.Space - self.env_obs: np.ndarray - self.observation_space, self.env_obs = self.init_observations() - - # Define Action Space - depends on action space type (Node or ACL) - self.action_dict: Dict[int, List[int]] - self.action_space: spaces.Space - if self.training_config.action_type == ActionType.NODE: - _LOGGER.debug("Action space type NODE selected") - # Terms (for node action space): - # [0, num nodes] - node ID (0 = nothing, node ID) - # [0, 4] - what property it's acting on (0 = nothing, state, SoftwareState, service state, file system state) # noqa - # [0, 3] - action on property (0 = nothing, On / Scan, Off / Repair, Reset / Patch / Restore) # noqa - # [0, num services] - resolves to service ID (0 = nothing, resolves to service) # noqa - self.action_dict = self.create_node_action_dict() - self.action_space = spaces.Discrete(len(self.action_dict)) - elif self.training_config.action_type == ActionType.ACL: - _LOGGER.debug("Action space type ACL selected") - # Terms (for ACL action space): - # [0, 2] - Action (0 = do nothing, 1 = create rule, 2 = delete rule) - # [0, 1] - Permission (0 = DENY, 1 = ALLOW) - # [0, num nodes] - Source IP (0 = any, then 1 -> x resolving to IP addresses) - # [0, num nodes] - Dest IP (0 = any, then 1 -> x resolving to IP addresses) - # [0, num services] - Protocol (0 = any, then 1 -> x resolving to protocol) - # [0, num ports] - Port (0 = any, then 1 -> x resolving to port) - self.action_dict = self.create_acl_action_dict() - self.action_space = spaces.Discrete(len(self.action_dict)) - elif self.training_config.action_type == ActionType.ANY: - _LOGGER.debug("Action space type ANY selected - Node + ACL") - self.action_dict = self.create_node_and_acl_action_dict() - self.action_space = spaces.Discrete(len(self.action_dict)) - else: - _LOGGER.error(f"Invalid action type selected: {self.training_config.action_type}") - - self.episode_av_reward_writer: SessionOutputWriter = SessionOutputWriter( - self, transaction_writer=False, learning_session=True - ) - self.transaction_writer: SessionOutputWriter = SessionOutputWriter( - self, transaction_writer=True, learning_session=True - ) - - self.is_eval = False - - @property - def actual_episode_count(self) -> int: - """Shifts the episode_count by -1 for RLlib learning session.""" - if self.training_config.agent_framework is AgentFramework.RLLIB and not self.is_eval: - return self.episode_count - 1 - return self.episode_count - - def set_as_eval(self) -> None: - """Set the writers to write to eval directories.""" - self.episode_av_reward_writer = SessionOutputWriter(self, transaction_writer=False, learning_session=False) - self.transaction_writer = SessionOutputWriter(self, transaction_writer=True, learning_session=False) - self.episode_count = 0 - self.step_count = 0 - self.total_step_count = 0 - self.episode_steps = self.training_config.num_eval_steps - self.is_eval = True - - def _write_av_reward_per_episode(self) -> None: - if self.actual_episode_count > 0: - csv_data = self.actual_episode_count, self.average_reward - self.episode_av_reward_writer.write(csv_data) - - def reset(self) -> np.ndarray: - """ - AI Gym Reset function. - - Returns: - Environment observation space (reset) - """ - self._write_av_reward_per_episode() - self.episode_count += 1 - - # Don't need to reset links, as they are cleared and recalculated every - # step - - # Clear the ACL - self.init_acl() - - # Reset the node statuses and recreate the ACL from config - # Does this for both live and reference nodes - self.reset_environment() - - # Create a random red agent to use for this episode - if self.training_config.random_red_agent: - self._create_random_red_agent() - - # Reset counters and totals - self.total_reward = 0.0 - self.step_count = 0 - self.average_reward = 0.0 - - # Update observations space and return - self.update_environent_obs() - - return self.env_obs - - def step(self, action: int) -> Tuple[np.ndarray, float, bool, Dict]: - """ - AI Gym Step function. - - Args: - action: Action space from agent - - Returns: - env_obs: Observation space - reward: Reward value for this step - done: Indicates episode is complete if True - step_info: Additional information relating to this step - """ - # TEMP - done = False - self.step_count += 1 - self.total_step_count += 1 - - # Need to clear traffic on all links first - for link_key, link_value in self.links.items(): - link_value.clear_traffic() - - for link in self.links_reference.values(): - link.clear_traffic() - - # Create a Transaction (metric) object for this step - transaction = Transaction(self.agent_identifier, self.actual_episode_count, self.step_count) - # Load the initial observation space into the transaction - transaction.obs_space = self.obs_handler._flat_observation - - # Set the transaction obs space description - transaction.obs_space_description = self._obs_space_description - - # Load the action space into the transaction - transaction.action_space = copy.deepcopy(action) - - # 1. Implement Blue Action - self.interpret_action_and_apply(action) - # Take snapshots of nodes and links - self.nodes_post_blue = copy.deepcopy(self.nodes) - self.links_post_blue = copy.deepcopy(self.links) - - # 2. Perform any time-based activities (e.g. a component moving from patching to good) - self.apply_time_based_updates() - - # 3. Apply PoL - apply_node_pol(self.nodes, self.node_pol, self.step_count) # Node PoL - apply_iers( - self.network, - self.nodes, - self.links, - self.green_iers, - self.acl, - self.step_count, - ) # Network PoL - # Take snapshots of nodes and links - self.nodes_post_pol = copy.deepcopy(self.nodes) - self.links_post_pol = copy.deepcopy(self.links) - # Reference - apply_node_pol(self.nodes_reference, self.node_pol, self.step_count) # Node PoL - apply_iers( - self.network_reference, - self.nodes_reference, - self.links_reference, - self.green_iers_reference, - self.acl, - self.step_count, - ) # Network PoL - - # 4. Implement Red Action - apply_red_agent_iers( - self.network, - self.nodes, - self.links, - self.red_iers, - self.acl, - self.step_count, - ) - apply_red_agent_node_pol(self.nodes, self.red_iers, self.red_node_pol, self.step_count) - # Take snapshots of nodes and links - self.nodes_post_red = copy.deepcopy(self.nodes) - self.links_post_red = copy.deepcopy(self.links) - - # 5. Calculate reward signal (for RL) - reward = calculate_reward_function( - self.nodes_post_pol, - self.nodes_post_red, - self.nodes_reference, - self.green_iers, - self.green_iers_reference, - self.red_iers, - self.step_count, - self.training_config, - ) - _LOGGER.debug(f"Episode: {self.actual_episode_count}, " f"Step {self.step_count}, " f"Reward: {reward}") - self.total_reward += reward - if self.step_count == self.episode_steps: - self.average_reward = self.total_reward / self.step_count - if self.training_config.session_type is SessionType.EVAL: - # For evaluation, need to trigger the done value = True when - # step count is reached in order to prevent neverending episode - done = True - _LOGGER.info(f"Episode: {self.actual_episode_count}, " f"Average Reward: {self.average_reward}") - # Load the reward into the transaction - transaction.reward = reward - - # 6. Output Verbose - # self.output_link_status() - - # 7. Update env_obs - self.update_environent_obs() - - # Write transaction to file - if self.actual_episode_count > 0: - self.transaction_writer.write(transaction) - - # Return - return self.env_obs, reward, done, self.step_info - - def close(self) -> None: - """Override parent close and close writers.""" - # Close files if last episode/step - # if self.can_finish: - super().close() - - self.transaction_writer.close() - self.episode_av_reward_writer.close() - - def init_acl(self) -> None: - """Initialise the Access Control List.""" - self.acl.remove_all_rules() - - def output_link_status(self) -> None: - """Output the link status of all links to the console.""" - for link_key, link_value in self.links.items(): - _LOGGER.debug("Link ID: " + link_value.get_id()) - for protocol in link_value.protocol_list: - print(" Protocol: " + protocol.get_name().name + ", Load: " + str(protocol.get_load())) - - def interpret_action_and_apply(self, _action: int) -> None: - """ - Applies agent actions to the nodes and Access Control List. - - Args: - _action: The action space from the agent - """ - # At the moment, actions are only affecting nodes - if self.training_config.action_type == ActionType.NODE: - self.apply_actions_to_nodes(_action) - elif self.training_config.action_type == ActionType.ACL: - self.apply_actions_to_acl(_action) - elif len(self.action_dict[_action]) == 7: # ACL actions in multidiscrete form have len 7 - self.apply_actions_to_acl(_action) - elif len(self.action_dict[_action]) == 4: # Node actions in multdiscrete (array) from have len 4 - self.apply_actions_to_nodes(_action) - else: - logging.error("Invalid action type found") - - def apply_actions_to_nodes(self, _action: int) -> None: - """ - Applies agent actions to the nodes. - - Args: - _action: The action space from the agent - """ - readable_action = self.action_dict[_action] - node_id = readable_action[0] - node_property = readable_action[1] - property_action = readable_action[2] - service_index = readable_action[3] - - # Check that the action is requesting a valid node - try: - node = self.nodes[str(node_id)] - except Exception: - return - - if node_property == 0: - # This is the do nothing action - return - elif node_property == 1: - # This is an action on the node Hardware State - if property_action == 0: - # Do nothing - return - elif property_action == 1: - # Turn on (only applicable if it's OFF, not if it's patching) - if node.hardware_state == HardwareState.OFF: - node.turn_on() - elif property_action == 2: - # Turn off - node.turn_off() - elif property_action == 3: - # Reset (only applicable if it's ON) - if node.hardware_state == HardwareState.ON: - node.reset() - else: - return - elif node_property == 2: - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - # This is an action on the node Software State - if property_action == 0: - # Do nothing - return - elif property_action == 1: - # Patch (valid action if it's good or compromised) - node.software_state = SoftwareState.PATCHING - else: - # Node is not of Active or Service Type - return - elif node_property == 3: - # This is an action on a node Service State - if isinstance(node, ServiceNode): - # This is an action on a node Service State - if property_action == 0: - # Do nothing - return - elif property_action == 1: - # Patch (valid action if it's good or compromised) - node.set_service_state(self.services_list[service_index], SoftwareState.PATCHING) - else: - # Node is not of Service Type - return - elif node_property == 4: - # This is an action on a node file system state - if isinstance(node, ActiveNode): - if property_action == 0: - # Do nothing - return - elif property_action == 1: - # Scan - node.start_file_system_scan() - elif property_action == 2: - # Repair - # You cannot repair a destroyed file system - it needs restoring - if node.file_system_state_actual != FileSystemState.DESTROYED: - node.set_file_system_state(FileSystemState.REPAIRING) - elif property_action == 3: - # Restore - node.set_file_system_state(FileSystemState.RESTORING) - else: - # Node is not of Active Type - return - else: - return - - def apply_actions_to_acl(self, _action: int) -> None: - """ - Applies agent actions to the Access Control List [TO DO]. - - Args: - _action: The action space from the agent - """ - # Convert discrete value back to multidiscrete - readable_action = self.action_dict[_action] - - action_decision = readable_action[0] - action_permission = readable_action[1] - action_source_ip = readable_action[2] - action_destination_ip = readable_action[3] - action_protocol = readable_action[4] - action_port = readable_action[5] - acl_rule_position = readable_action[6] - - if action_decision == 0: - # It's decided to do nothing - return - else: - # It's decided to create a new ACL rule or remove an existing rule - # Permission value - if action_permission == 0: - acl_rule_permission = "DENY" - else: - acl_rule_permission = "ALLOW" - # Source IP value - if action_source_ip == 0: - acl_rule_source = "ANY" - else: - node = list(self.nodes.values())[action_source_ip - 1] - if isinstance(node, ServiceNode) or isinstance(node, ActiveNode): - acl_rule_source = node.ip_address - else: - return - # Destination IP value - if action_destination_ip == 0: - acl_rule_destination = "ANY" - else: - node = list(self.nodes.values())[action_destination_ip - 1] - if isinstance(node, ServiceNode) or isinstance(node, ActiveNode): - acl_rule_destination = node.ip_address - else: - return - # Protocol value - if action_protocol == 0: - acl_rule_protocol = "ANY" - else: - acl_rule_protocol = self.services_list[action_protocol - 1] - # Port value - if action_port == 0: - acl_rule_port = "ANY" - else: - acl_rule_port = self.ports_list[action_port - 1] - - # Now add or remove - if action_decision == 1: - # Add the rule - self.acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_rule_position, - ) - elif action_decision == 2: - # Remove the rule - self.acl.remove_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - ) - else: - return - - def apply_time_based_updates(self) -> None: - """ - Updates anything that needs to count down and then change state. - - e.g. reset / patching status - """ - for node_key, node in self.nodes.items(): - if node.hardware_state == HardwareState.RESETTING: - node.update_resetting_status() - else: - pass - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - node.update_file_system_state() - if node.software_state == SoftwareState.PATCHING: - node.update_os_patching_status() - else: - pass - else: - pass - if isinstance(node, ServiceNode): - node.update_services_patching_status() - else: - pass - - for node_key, node in self.nodes_reference.items(): - if node.hardware_state == HardwareState.RESETTING: - node.update_resetting_status() - else: - pass - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - node.update_file_system_state() - if node.software_state == SoftwareState.PATCHING: - node.update_os_patching_status() - else: - pass - else: - pass - if isinstance(node, ServiceNode): - node.update_services_patching_status() - else: - pass - - def init_observations(self) -> Tuple[spaces.Space, np.ndarray]: - """ - Create the environment's observation handler. - - :return: The observation space, initial observation (zeroed out array with the correct shape) - :rtype: Tuple[spaces.Space, np.ndarray] - """ - self.obs_handler = ObservationsHandler.from_config(self, self.obs_config) - - if not self._obs_space_description: - self._obs_space_description = self.obs_handler.describe_structure() - - return self.obs_handler.space, self.obs_handler.current_observation - - def update_environent_obs(self) -> None: - """Updates the observation space based on the node and link status.""" - self.obs_handler.update_obs() - self.env_obs = self.obs_handler.current_observation - - def load_lay_down_config(self) -> None: - """Loads config data in order to build the environment configuration.""" - for item in self.lay_down_config: - if item["item_type"] == "NODE": - # Create a node - self.create_node(item) - elif item["item_type"] == "LINK": - # Create a link - self.create_link(item) - elif item["item_type"] == "GREEN_IER": - # Create a Green IER - self.create_green_ier(item) - elif item["item_type"] == "GREEN_POL": - # Create a Green PoL - self.create_green_pol(item) - elif item["item_type"] == "RED_IER": - # Create a Red IER - self.create_red_ier(item) - elif item["item_type"] == "RED_POL": - # Create a Red PoL - self.create_red_pol(item) - elif item["item_type"] == "ACL_RULE": - # Create an ACL rule - self.create_acl_rule(item) - elif item["item_type"] == "SERVICES": - # Create the list of services - self.create_services_list(item) - elif item["item_type"] == "PORTS": - # Create the list of ports - self.create_ports_list(item) - else: - item_type = item["item_type"] - _LOGGER.error(f"Invalid item_type: {item_type}") - pass - - _LOGGER.info("Environment configuration loaded") - print("Environment configuration loaded") - - def create_node(self, item: Dict) -> None: - """ - Creates a node from config data. - - Args: - item: A config data item - """ - # All nodes have these parameters - node_id = item["node_id"] - node_name = item["name"] - node_class = item["node_class"] - node_type = NodeType[item["node_type"]] - node_priority = Priority[item["priority"]] - node_hardware_state = HardwareState[item["hardware_state"]] - - if node_class == "PASSIVE": - node = PassiveNode( - node_id, - node_name, - node_type, - node_priority, - node_hardware_state, - self.training_config, - ) - elif node_class == "ACTIVE": - # Active nodes have IP address, Software State and file system state - node_ip_address = item["ip_address"] - node_software_state = SoftwareState[item["software_state"]] - node_file_system_state = FileSystemState[item["file_system_state"]] - node = ActiveNode( - node_id, - node_name, - node_type, - node_priority, - node_hardware_state, - node_ip_address, - node_software_state, - node_file_system_state, - self.training_config, - ) - elif node_class == "SERVICE": - # Service nodes have IP address, Software State, file system state and list of services - node_ip_address = item["ip_address"] - node_software_state = SoftwareState[item["software_state"]] - node_file_system_state = FileSystemState[item["file_system_state"]] - node = ServiceNode( - node_id, - node_name, - node_type, - node_priority, - node_hardware_state, - node_ip_address, - node_software_state, - node_file_system_state, - self.training_config, - ) - node_services = item["services"] - for service in node_services: - service_protocol = service["name"] - service_port = service["port"] - service_state = SoftwareState[service["state"]] - node.add_service(Service(service_protocol, service_port, service_state)) - else: - # Bad formatting - pass - - # Copy the node for the reference version - node_ref = copy.deepcopy(node) - - # Add node to node dictionary - self.nodes[node_id] = node - - # Add reference node to reference node dictionary - self.nodes_reference[node_id] = node_ref - - # Add node to network - self.network.add_nodes_from([node]) - - # Add node to network (reference) - self.network_reference.add_nodes_from([node_ref]) - - def create_link(self, item: Dict) -> None: - """ - Creates a link from config data. - - Args: - item: A config data item - """ - link_id = item["id"] - link_name = item["name"] - link_bandwidth = item["bandwidth"] - link_source = item["source"] - link_destination = item["destination"] - - source_node: Node = self.nodes[link_source] - dest_node: Node = self.nodes[link_destination] - - # Add link to network - self.network.add_edge(source_node, dest_node, id=link_name) - - # Add link to link dictionary - self.links[link_name] = Link( - link_id, - link_bandwidth, - source_node.name, - dest_node.name, - self.services_list, - ) - - # Reference - source_node_ref: Node = self.nodes_reference[link_source] - dest_node_ref: Node = self.nodes_reference[link_destination] - - # Add link to network (reference) - self.network_reference.add_edge(source_node_ref, dest_node_ref, id=link_name) - - # Add link to link dictionary (reference) - self.links_reference[link_name] = Link( - link_id, - link_bandwidth, - source_node_ref.name, - dest_node_ref.name, - self.services_list, - ) - - def create_green_ier(self, item: Dict) -> None: - """ - Creates a green IER from config data. - - Args: - item: A config data item - """ - ier_id = item["id"] - ier_start_step = item["start_step"] - ier_end_step = item["end_step"] - ier_load = item["load"] - ier_protocol = item["protocol"] - ier_port = item["port"] - ier_source = item["source"] - ier_destination = item["destination"] - ier_mission_criticality = item["mission_criticality"] - - # Create IER and add to green IER dictionary - self.green_iers[ier_id] = IER( - ier_id, - ier_start_step, - ier_end_step, - ier_load, - ier_protocol, - ier_port, - ier_source, - ier_destination, - ier_mission_criticality, - ) - self.green_iers_reference[ier_id] = IER( - ier_id, - ier_start_step, - ier_end_step, - ier_load, - ier_protocol, - ier_port, - ier_source, - ier_destination, - ier_mission_criticality, - ) - - def create_red_ier(self, item: Dict) -> None: - """ - Creates a red IER from config data. - - Args: - item: A config data item - """ - ier_id = item["id"] - ier_start_step = item["start_step"] - ier_end_step = item["end_step"] - ier_load = item["load"] - ier_protocol = item["protocol"] - ier_port = item["port"] - ier_source = item["source"] - ier_destination = item["destination"] - ier_mission_criticality = item["mission_criticality"] - - # Create IER and add to red IER dictionary - self.red_iers[ier_id] = IER( - ier_id, - ier_start_step, - ier_end_step, - ier_load, - ier_protocol, - ier_port, - ier_source, - ier_destination, - ier_mission_criticality, - ) - - def create_green_pol(self, item: Dict) -> None: - """ - Creates a green PoL object from config data. - - Args: - item: A config data item - """ - pol_id = item["id"] - pol_start_step = item["start_step"] - pol_end_step = item["end_step"] - pol_node = item["nodeId"] - pol_type = NodePOLType[item["type"]] - - # State depends on whether this is Operating, Software, file system or Service PoL type - if pol_type == NodePOLType.OPERATING: - pol_state = HardwareState[item["state"]] - pol_protocol = "" - elif pol_type == NodePOLType.FILE: - pol_state = FileSystemState[item["state"]] - pol_protocol = "" - else: - pol_protocol = item["protocol"] - pol_state = SoftwareState[item["state"]] - - self.node_pol[pol_id] = NodeStateInstructionGreen( - pol_id, - pol_start_step, - pol_end_step, - pol_node, - pol_type, - pol_protocol, - pol_state, - ) - - def create_red_pol(self, item: Dict) -> None: - """ - Creates a red PoL object from config data. - - Args: - item: A config data item - """ - pol_id = item["id"] - pol_start_step = item["start_step"] - pol_end_step = item["end_step"] - pol_target_node_id = item["targetNodeId"] - pol_initiator = NodePOLInitiator[item["initiator"]] - pol_type = NodePOLType[item["type"]] - pol_protocol = item["protocol"] - - # State depends on whether this is Operating, Software, file system or Service PoL type - if pol_type == NodePOLType.OPERATING: - pol_state = HardwareState[item["state"]] - elif pol_type == NodePOLType.FILE: - pol_state = FileSystemState[item["state"]] - else: - pol_state = SoftwareState[item["state"]] - - pol_source_node_id = item["sourceNodeId"] - pol_source_node_service = item["sourceNodeService"] - pol_source_node_service_state = item["sourceNodeServiceState"] - - self.red_node_pol[pol_id] = NodeStateInstructionRed( - pol_id, - pol_start_step, - pol_end_step, - pol_target_node_id, - pol_initiator, - pol_type, - pol_protocol, - pol_state, - pol_source_node_id, - pol_source_node_service, - pol_source_node_service_state, - ) - - def create_acl_rule(self, item: Dict) -> None: - """ - Creates an ACL rule from config data. - - Args: - item: A config data item - """ - acl_rule_permission = item["permission"] - acl_rule_source = item["source"] - acl_rule_destination = item["destination"] - acl_rule_protocol = item["protocol"] - acl_rule_port = item["port"] - acl_rule_position = item["position"] - - self.acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_rule_position, - ) - - # TODO: confirm typehint using runtime - def create_services_list(self, services: Dict) -> None: - """ - Creates a list of services (enum) from config data. - - Args: - item: A config data item representing the services - """ - service_list = services["service_list"] - - for service in service_list: - service_name = service["name"] - self.services_list.append(service_name) - - # Set the number of services - self.num_services = len(self.services_list) - - def create_ports_list(self, ports: Dict) -> None: - """ - Creates a list of ports from config data. - - Args: - item: A config data item representing the ports - """ - ports_list = ports["ports_list"] - - for port in ports_list: - port_value = port["port"] - self.ports_list.append(port_value) - - # Set the number of ports - self.num_ports = len(self.ports_list) - - # TODO: this is not used anymore, write a ticket to delete it - def get_observation_info(self, observation_info: Dict) -> None: - """ - Extracts observation_info. - - :param observation_info: Config item that defines which type of observation space to use - :type observation_info: str - """ - self.observation_type = ObservationType[observation_info["type"]] - - # TODO: this is not used anymore, write a ticket to delete it. - def get_action_info(self, action_info: Dict) -> None: - """ - Extracts action_info. - - Args: - item: A config data item representing action info - """ - self.action_type = ActionType[action_info["type"]] - - def save_obs_config(self, obs_config: dict) -> None: - """ - Cache the config for the observation space. - - This is necessary as the observation space can't be built while reading the config, - it must be done after all the nodes, links, and services have been initialised. - - :param obs_config: Parsed config relating to the observation space. The format is described in - :py:meth:`primaite.environment.observations.ObservationsHandler.from_config` - :type obs_config: dict - """ - self.obs_config = obs_config - - def reset_environment(self) -> None: - """ - Resets environment. - - Uses config data config data in order to build the environment configuration. - """ - for item in self.lay_down_config: - if item["item_type"] == "NODE": - # Reset a node's state (normal and reference) - self.reset_node(item) - elif item["item_type"] == "ACL_RULE": - # Create an ACL rule (these are cleared on reset, so just need to recreate them) - self.create_acl_rule(item) - else: - # Do nothing (bad formatting or not relevant to reset) - pass - - # Reset the IER status so they are not running initially - # Green IERs - for ier_key, ier_value in self.green_iers.items(): - ier_value.set_is_running(False) - # Red IERs - for ier_key, ier_value in self.red_iers.items(): - ier_value.set_is_running(False) - - def reset_node(self, item: Dict) -> None: - """ - Resets the statuses of a node. - - Args: - item: A config data item - """ - # All nodes have these parameters - node_id = item["node_id"] - node_class = item["node_class"] - node_hardware_state: HardwareState = HardwareState[item["hardware_state"]] - - node: NodeUnion = self.nodes[node_id] - node_ref = self.nodes_reference[node_id] - - # Reset the hardware state (common for all node types) - node.hardware_state = node_hardware_state - node_ref.hardware_state = node_hardware_state - - if node_class == "ACTIVE": - # Active nodes have Software State - node_software_state = SoftwareState[item["software_state"]] - node_file_system_state = FileSystemState[item["file_system_state"]] - node.software_state = node_software_state - node_ref.software_state = node_software_state - node.set_file_system_state(node_file_system_state) - node_ref.set_file_system_state(node_file_system_state) - elif node_class == "SERVICE": - # Service nodes have Software State and list of services - node_software_state = SoftwareState[item["software_state"]] - node_file_system_state = FileSystemState[item["file_system_state"]] - node.software_state = node_software_state - node_ref.software_state = node_software_state - node.set_file_system_state(node_file_system_state) - node_ref.set_file_system_state(node_file_system_state) - # Update service states - node_services = item["services"] - for service in node_services: - service_protocol = service["name"] - service_state = SoftwareState[service["state"]] - # Update node service state - node.set_service_state(service_protocol, service_state) - # Update reference node service state - node_ref.set_service_state(service_protocol, service_state) - else: - # Bad formatting - pass - - def create_node_action_dict(self) -> Dict[int, List[int]]: - """ - Creates a dictionary mapping each possible discrete action to more readable multidiscrete action. - - Note: Only actions that have the potential to change the state exist in the mapping (except for key 0) - - example return: - {0: [1, 0, 0, 0], - 1: [1, 1, 1, 0], - 2: [1, 1, 2, 0], - 3: [1, 1, 3, 0], - 4: [1, 2, 1, 0], - 5: [1, 3, 1, 0], - ... - } - """ - # Terms (for node action space): - # [0, num nodes] - node ID (0 = nothing, node ID) - # [0, 4] - what property it's acting on (0 = nothing, state, SoftwareState, service state, file system state) # noqa - # [0, 3] - action on property (0 = nothing, On / Scan, Off / Repair, Reset / Patch / Restore) # noqa - # [0, num services] - resolves to service ID (0 = nothing, resolves to service) # noqa - # reserve 0 action to be a nothing action - actions = {0: [1, 0, 0, 0]} - action_key = 1 - for node in range(1, self.num_nodes + 1): - # 4 node properties (NONE, OPERATING, OS, SERVICE) - for node_property in range(4): - # Node Actions either: - # (NONE, ON, OFF, RESET) - operating state OR (NONE, PATCH) - OS/service state - # Use MAX to ensure we get them all - for node_action in range(4): - for service_state in range(self.num_services): - action = [node, node_property, node_action, service_state] - # check to see if it's a nothing action (has no effect) - if is_valid_node_action(action): - actions[action_key] = action - action_key += 1 - - return actions - - def create_acl_action_dict(self) -> Dict[int, List[int]]: - """Creates a dictionary mapping each possible discrete action to more readable multidiscrete action.""" - # Terms (for ACL action space): - # [0, 2] - Action (0 = do nothing, 1 = create rule, 2 = delete rule) - # [0, 1] - Permission (0 = DENY, 1 = ALLOW) - # [0, num nodes] - Source IP (0 = any, then 1 -> x resolving to IP addresses) - # [0, num nodes] - Dest IP (0 = any, then 1 -> x resolving to IP addresses) - # [0, num services] - Protocol (0 = any, then 1 -> x resolving to protocol) - # [0, num ports] - Port (0 = any, then 1 -> x resolving to port) - # [0, max acl rules - 1] - Position (0 = first index, then 1 -> x index resolving to acl rule in acl list) - # reserve 0 action to be a nothing action - actions = {0: [0, 0, 0, 0, 0, 0, 0]} - - action_key = 1 - # 3 possible action decisions, 0=NOTHING, 1=CREATE, 2=DELETE - for action_decision in range(3): - # 2 possible action permissions 0 = DENY, 1 = CREATE - for action_permission in range(2): - # Number of nodes + 1 (for any) - for source_ip in range(self.num_nodes + 1): - for dest_ip in range(self.num_nodes + 1): - for protocol in range(self.num_services + 1): - for port in range(self.num_ports + 1): - for position in range(self.max_number_acl_rules - 1): - action = [ - action_decision, - action_permission, - source_ip, - dest_ip, - protocol, - port, - position, - ] - # Check to see if it is an action we want to include as possible - # i.e. not a nothing action - if is_valid_acl_action_extra(action): - actions[action_key] = action - action_key += 1 - - return actions - - def create_node_and_acl_action_dict(self) -> Dict[int, List[int]]: - """ - Create a dictionary mapping each possible discrete action to a more readable mutlidiscrete action. - - The dictionary contains actions of both Node and ACL action types. - """ - node_action_dict = self.create_node_action_dict() - acl_action_dict = self.create_acl_action_dict() - - # Change node keys to not overlap with acl keys - # Only 1 nothing action (key 0) is required, remove the other - new_node_action_dict = {k + len(acl_action_dict) - 1: v for k, v in node_action_dict.items() if k != 0} - - # Combine the Node dict and ACL dict - combined_action_dict = {**acl_action_dict, **new_node_action_dict} - return combined_action_dict - - def _create_random_red_agent(self) -> None: - """Decide on random red agent for the episode to be called in env.reset().""" - # Reset the current red iers and red node pol - self.red_iers = {} - self.red_node_pol = {} - - # Decide how many nodes become compromised - node_list = list(self.nodes.values()) - computers = [node for node in node_list if node.node_type == NodeType.COMPUTER] - max_num_nodes_compromised = len(computers) # only computers can become compromised - # random select between 1 and max_num_nodes_compromised - num_nodes_to_compromise = randint(1, max_num_nodes_compromised) - - # Decide which of the nodes to compromise - nodes_to_be_compromised = sample(computers, num_nodes_to_compromise) - - # choose a random compromise node to be source of attacks - source_node = choice(nodes_to_be_compromised) - - # For each of the nodes to be compromised decide which step they become compromised - max_step_compromised = self.episode_steps // 2 # always compromise in first half of episode - - # Bandwidth for all links - bandwidths = [i.get_bandwidth() for i in list(self.links.values())] - - if len(bandwidths) < 1: - msg = "Random red agent cannot be used on a network without any links" - _LOGGER.error(msg) - raise Exception(msg) - - servers = [node for node in node_list if node.node_type == NodeType.SERVER] - - for n, node in enumerate(nodes_to_be_compromised): - # 1: Use Node PoL to set node to compromised - - _id = str(uuid.uuid4()) - _start_step = randint(2, max_step_compromised + 1) # step compromised - pol_service_name = choice(list(node.services.keys())) - - source_node_service = choice(list(source_node.services.values())) - - red_pol = NodeStateInstructionRed( - _id=_id, - _start_step=_start_step, - _end_step=_start_step, # only run for 1 step - _target_node_id=node.node_id, - _pol_initiator="DIRECT", - _pol_type=NodePOLType["SERVICE"], - pol_protocol=pol_service_name, - _pol_state=SoftwareState.COMPROMISED, - _pol_source_node_id=source_node.node_id, - _pol_source_node_service=source_node_service.name, - _pol_source_node_service_state=source_node_service.software_state, - ) - - self.red_node_pol[_id] = red_pol - - # 2: Launch the attack from compromised node - set the IER - - ier_id = str(uuid.uuid4()) - # Launch the attack after node is compromised, and not right at the end of the episode - ier_start_step = randint(_start_step + 2, int(self.episode_steps * 0.8)) - ier_end_step = self.episode_steps - - # Randomise the load, as a percentage of a random link bandwith - ier_load = uniform(0.4, 0.8) * choice(bandwidths) - ier_protocol = pol_service_name # Same protocol as compromised node - ier_service = node.services[pol_service_name] - ier_port = ier_service.port - ier_mission_criticality = 0 # Red IER will never be important to green agent success - # We choose a node to attack based on the first that applies: - # a. Green IERs, select dest node of the red ier based on dest node of green IER - # b. Attack a random server that doesn't have a DENY acl rule in default config - # c. Attack a random server - possible_ier_destinations = [ - ier.get_dest_node_id() - for ier in list(self.green_iers.values()) - if ier.get_source_node_id() == node.node_id - ] - if len(possible_ier_destinations) < 1: - for server in servers: - if not self.acl.is_blocked( - node.ip_address, - server.ip_address, - ier_service, - ier_port, - ): - possible_ier_destinations.append(server.node_id) - if len(possible_ier_destinations) < 1: - # If still none found choose from all servers - possible_ier_destinations = [server.node_id for server in servers] - ier_dest = choice(possible_ier_destinations) - self.red_iers[ier_id] = IER( - ier_id, - ier_start_step, - ier_end_step, - ier_load, - ier_protocol, - ier_port, - node.node_id, - ier_dest, - ier_mission_criticality, - ) - - overwhelm_pol = red_pol - overwhelm_pol.id = str(uuid.uuid4()) - overwhelm_pol.end_step = self.episode_steps - - # 3: Make sure the targetted node can be set to overwhelmed - with node pol - # # TODO remove duplicate red pol for same targetted service - must take into account start step - - o_pol_id = str(uuid.uuid4()) - o_red_pol = NodeStateInstructionRed( - _id=o_pol_id, - _start_step=ier_start_step, - _end_step=self.episode_steps, - _target_node_id=ier_dest, - _pol_initiator="DIRECT", - _pol_type=NodePOLType["SERVICE"], - pol_protocol=ier_protocol, - _pol_state=SoftwareState.OVERWHELMED, - _pol_source_node_id=source_node.node_id, - _pol_source_node_service=source_node_service.name, - _pol_source_node_service_state=source_node_service.software_state, - ) - self.red_node_pol[o_pol_id] = o_red_pol diff --git a/src/primaite/environment/reward.py b/src/primaite/environment/reward.py deleted file mode 100644 index aa9dc97d..00000000 --- a/src/primaite/environment/reward.py +++ /dev/null @@ -1,386 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Implements reward function.""" -from logging import Logger -from typing import Dict, TYPE_CHECKING, Union - -from primaite import getLogger -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import FileSystemState, HardwareState, SoftwareState -from primaite.common.service import Service -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.service_node import ServiceNode - -if TYPE_CHECKING: - from primaite.config.training_config import TrainingConfig - from primaite.pol.ier import IER - -_LOGGER: Logger = getLogger(__name__) - - -def calculate_reward_function( - initial_nodes: Dict[str, NodeUnion], - final_nodes: Dict[str, NodeUnion], - reference_nodes: Dict[str, NodeUnion], - green_iers: Dict[str, "IER"], - green_iers_reference: Dict[str, "IER"], - red_iers: Dict[str, "IER"], - step_count: int, - config_values: "TrainingConfig", -) -> float: - """ - Compares the states of the initial and final nodes/links to get a reward. - - Args: - initial_nodes: The nodes before red and blue agents take effect - final_nodes: The nodes after red and blue agents take effect - reference_nodes: The nodes if there had been no red or blue effect - green_iers: The green IERs (should be running) - red_iers: Should be stopeed (ideally) by the blue agent - step_count: current step - config_values: Config values - """ - reward_value: float = 0.0 - - # For each node, compare hardware state, SoftwareState, service states - for node_key, final_node in final_nodes.items(): - initial_node = initial_nodes[node_key] - reference_node = reference_nodes[node_key] - - # Hardware State - reward_value += score_node_operating_state(final_node, initial_node, reference_node, config_values) - - # Software State - if isinstance(final_node, ActiveNode) or isinstance(final_node, ServiceNode): - reward_value += score_node_os_state(final_node, initial_node, reference_node, config_values) - - # Service State - if isinstance(final_node, ServiceNode): - reward_value += score_node_service_state(final_node, initial_node, reference_node, config_values) - - # File System State - if isinstance(final_node, ActiveNode): - reward_value += score_node_file_system(final_node, initial_node, reference_node, config_values) - - # Go through each red IER - penalise if it is running - for ier_key, ier_value in red_iers.items(): - start_step = ier_value.get_start_step() - stop_step = ier_value.get_end_step() - if step_count >= start_step and step_count <= stop_step: - if ier_value.get_is_running(): - reward_value += config_values.red_ier_running - - # Go through each green IER - penalise if it's not running (weighted) - # but only if it's supposed to be running (it's running in reference) - for ier_key, ier_value in green_iers.items(): - reference_ier = green_iers_reference[ier_key] - start_step = ier_value.get_start_step() - stop_step = ier_value.get_end_step() - if step_count >= start_step and step_count <= stop_step: - reference_blocked = not reference_ier.get_is_running() - live_blocked = not ier_value.get_is_running() - ier_reward = config_values.green_ier_blocked * ier_value.get_mission_criticality() - - if live_blocked and not reference_blocked: - reward_value += ier_reward - elif live_blocked and reference_blocked: - _LOGGER.debug( - ( - f"IER {ier_key} is blocked in the reference and live environments. " - f"Penalty of {ier_reward} was NOT applied." - ) - ) - elif not live_blocked and reference_blocked: - _LOGGER.debug( - ( - f"IER {ier_key} is blocked in the reference env but not in the live one. " - f"Penalty of {ier_reward} was NOT applied." - ) - ) - return reward_value - - -def score_node_operating_state( - final_node: NodeUnion, initial_node: NodeUnion, reference_node: NodeUnion, config_values: "TrainingConfig" -) -> float: - """ - Calculates score relating to the hardware state of a node. - - Args: - final_node: The node after red and blue agents take effect - initial_node: The node before red and blue agents take effect - reference_node: The node if there had been no red or blue effect - config_values: Config values - """ - score: float = 0.0 - final_node_operating_state = final_node.hardware_state - reference_node_operating_state = reference_node.hardware_state - - if final_node_operating_state == reference_node_operating_state: - # All is well - we're no different from the reference situation - score += config_values.all_ok - else: - # We're different from the reference situation - # Need to compare reference and final (current) state of node (i.e. at every step) - if reference_node_operating_state == HardwareState.ON: - if final_node_operating_state == HardwareState.OFF: - score += config_values.off_should_be_on - elif final_node_operating_state == HardwareState.RESETTING: - score += config_values.resetting_should_be_on - else: - pass - elif reference_node_operating_state == HardwareState.OFF: - if final_node_operating_state == HardwareState.ON: - score += config_values.on_should_be_off - elif final_node_operating_state == HardwareState.RESETTING: - score += config_values.resetting_should_be_off - else: - pass - elif reference_node_operating_state == HardwareState.RESETTING: - if final_node_operating_state == HardwareState.ON: - score += config_values.on_should_be_resetting - elif final_node_operating_state == HardwareState.OFF: - score += config_values.off_should_be_resetting - elif final_node_operating_state == HardwareState.RESETTING: - score += config_values.resetting - else: - pass - else: - pass - - return score - - -def score_node_os_state( - final_node: Union[ActiveNode, ServiceNode], - initial_node: Union[ActiveNode, ServiceNode], - reference_node: Union[ActiveNode, ServiceNode], - config_values: "TrainingConfig", -) -> float: - """ - Calculates score relating to the Software State of a node. - - Args: - final_node: The node after red and blue agents take effect - initial_node: The node before red and blue agents take effect - reference_node: The node if there had been no red or blue effect - config_values: Config values - """ - score: float = 0.0 - final_node_os_state = final_node.software_state - reference_node_os_state = reference_node.software_state - - if final_node_os_state == reference_node_os_state: - # All is well - we're no different from the reference situation - score += config_values.all_ok - else: - # We're different from the reference situation - # Need to compare reference and final (current) state of node (i.e. at every step) - if reference_node_os_state == SoftwareState.GOOD: - if final_node_os_state == SoftwareState.PATCHING: - score += config_values.patching_should_be_good - elif final_node_os_state == SoftwareState.COMPROMISED: - score += config_values.compromised_should_be_good - else: - pass - elif reference_node_os_state == SoftwareState.PATCHING: - if final_node_os_state == SoftwareState.GOOD: - score += config_values.good_should_be_patching - elif final_node_os_state == SoftwareState.COMPROMISED: - score += config_values.compromised_should_be_patching - elif final_node_os_state == SoftwareState.PATCHING: - score += config_values.patching - else: - pass - elif reference_node_os_state == SoftwareState.COMPROMISED: - if final_node_os_state == SoftwareState.GOOD: - score += config_values.good_should_be_compromised - elif final_node_os_state == SoftwareState.PATCHING: - score += config_values.patching_should_be_compromised - elif final_node_os_state == SoftwareState.COMPROMISED: - score += config_values.compromised - else: - pass - else: - pass - - return score - - -def score_node_service_state( - final_node: ServiceNode, initial_node: ServiceNode, reference_node: ServiceNode, config_values: "TrainingConfig" -) -> float: - """ - Calculates score relating to the service state(s) of a node. - - Args: - final_node: The node after red and blue agents take effect - initial_node: The node before red and blue agents take effect - reference_node: The node if there had been no red or blue effect - config_values: Config values - """ - score: float = 0.0 - final_node_services: Dict[str, Service] = final_node.services - reference_node_services: Dict[str, Service] = reference_node.services - - for service_key, final_service in final_node_services.items(): - reference_service = reference_node_services[service_key] - final_service = final_node_services[service_key] - - if final_service.software_state == reference_service.software_state: - # All is well - we're no different from the reference situation - score += config_values.all_ok - else: - # We're different from the reference situation - # Need to compare reference and final state of node (i.e. at every step) - if reference_service.software_state == SoftwareState.GOOD: - if final_service.software_state == SoftwareState.PATCHING: - score += config_values.patching_should_be_good - elif final_service.software_state == SoftwareState.COMPROMISED: - score += config_values.compromised_should_be_good - elif final_service.software_state == SoftwareState.OVERWHELMED: - score += config_values.overwhelmed_should_be_good - else: - pass - elif reference_service.software_state == SoftwareState.PATCHING: - if final_service.software_state == SoftwareState.GOOD: - score += config_values.good_should_be_patching - elif final_service.software_state == SoftwareState.COMPROMISED: - score += config_values.compromised_should_be_patching - elif final_service.software_state == SoftwareState.OVERWHELMED: - score += config_values.overwhelmed_should_be_patching - elif final_service.software_state == SoftwareState.PATCHING: - score += config_values.patching - else: - pass - elif reference_service.software_state == SoftwareState.COMPROMISED: - if final_service.software_state == SoftwareState.GOOD: - score += config_values.good_should_be_compromised - elif final_service.software_state == SoftwareState.PATCHING: - score += config_values.patching_should_be_compromised - elif final_service.software_state == SoftwareState.COMPROMISED: - score += config_values.compromised - elif final_service.software_state == SoftwareState.OVERWHELMED: - score += config_values.overwhelmed_should_be_compromised - else: - pass - elif reference_service.software_state == SoftwareState.OVERWHELMED: - if final_service.software_state == SoftwareState.GOOD: - score += config_values.good_should_be_overwhelmed - elif final_service.software_state == SoftwareState.PATCHING: - score += config_values.patching_should_be_overwhelmed - elif final_service.software_state == SoftwareState.COMPROMISED: - score += config_values.compromised_should_be_overwhelmed - elif final_service.software_state == SoftwareState.OVERWHELMED: - score += config_values.overwhelmed - else: - pass - else: - pass - - return score - - -def score_node_file_system( - final_node: Union[ActiveNode, ServiceNode], - initial_node: Union[ActiveNode, ServiceNode], - reference_node: Union[ActiveNode, ServiceNode], - config_values: "TrainingConfig", -) -> float: - """ - Calculates score relating to the file system state of a node. - - Args: - final_node: The node after red and blue agents take effect - initial_node: The node before red and blue agents take effect - reference_node: The node if there had been no red or blue effect - """ - score: float = 0.0 - final_node_file_system_state = final_node.file_system_state_actual - reference_node_file_system_state = reference_node.file_system_state_actual - - final_node_scanning_state = final_node.file_system_scanning - reference_node_scanning_state = reference_node.file_system_scanning - - # File System State - if final_node_file_system_state == reference_node_file_system_state: - # All is well - we're no different from the reference situation - score += config_values.all_ok - else: - # We're different from the reference situation - # Need to compare reference and final state of node (i.e. at every step) - if reference_node_file_system_state == FileSystemState.GOOD: - if final_node_file_system_state == FileSystemState.REPAIRING: - score += config_values.repairing_should_be_good - elif final_node_file_system_state == FileSystemState.RESTORING: - score += config_values.restoring_should_be_good - elif final_node_file_system_state == FileSystemState.CORRUPT: - score += config_values.corrupt_should_be_good - elif final_node_file_system_state == FileSystemState.DESTROYED: - score += config_values.destroyed_should_be_good - else: - pass - elif reference_node_file_system_state == FileSystemState.REPAIRING: - if final_node_file_system_state == FileSystemState.GOOD: - score += config_values.good_should_be_repairing - elif final_node_file_system_state == FileSystemState.RESTORING: - score += config_values.restoring_should_be_repairing - elif final_node_file_system_state == FileSystemState.CORRUPT: - score += config_values.corrupt_should_be_repairing - elif final_node_file_system_state == FileSystemState.DESTROYED: - score += config_values.destroyed_should_be_repairing - elif final_node_file_system_state == FileSystemState.REPAIRING: - score += config_values.repairing - else: - pass - elif reference_node_file_system_state == FileSystemState.RESTORING: - if final_node_file_system_state == FileSystemState.GOOD: - score += config_values.good_should_be_restoring - elif final_node_file_system_state == FileSystemState.REPAIRING: - score += config_values.repairing_should_be_restoring - elif final_node_file_system_state == FileSystemState.CORRUPT: - score += config_values.corrupt_should_be_restoring - elif final_node_file_system_state == FileSystemState.DESTROYED: - score += config_values.destroyed_should_be_restoring - elif final_node_file_system_state == FileSystemState.RESTORING: - score += config_values.restoring - else: - pass - elif reference_node_file_system_state == FileSystemState.CORRUPT: - if final_node_file_system_state == FileSystemState.GOOD: - score += config_values.good_should_be_corrupt - elif final_node_file_system_state == FileSystemState.REPAIRING: - score += config_values.repairing_should_be_corrupt - elif final_node_file_system_state == FileSystemState.RESTORING: - score += config_values.restoring_should_be_corrupt - elif final_node_file_system_state == FileSystemState.DESTROYED: - score += config_values.destroyed_should_be_corrupt - elif final_node_file_system_state == FileSystemState.CORRUPT: - score += config_values.corrupt - else: - pass - elif reference_node_file_system_state == FileSystemState.DESTROYED: - if final_node_file_system_state == FileSystemState.GOOD: - score += config_values.good_should_be_destroyed - elif final_node_file_system_state == FileSystemState.REPAIRING: - score += config_values.repairing_should_be_destroyed - elif final_node_file_system_state == FileSystemState.RESTORING: - score += config_values.restoring_should_be_destroyed - elif final_node_file_system_state == FileSystemState.CORRUPT: - score += config_values.corrupt_should_be_destroyed - elif final_node_file_system_state == FileSystemState.DESTROYED: - score += config_values.destroyed - else: - pass - else: - pass - - # Scanning State - if final_node_scanning_state == reference_node_scanning_state: - # All is well - we're no different from the reference situation - score += config_values.all_ok - else: - # We're different from the reference situation - # We're scanning the file system which incurs a penalty (as it slows down systems) - score += config_values.scanning - - return score diff --git a/src/primaite/exceptions.py b/src/primaite/exceptions.py index 3b4058ac..ad9e6e5b 100644 --- a/src/primaite/exceptions.py +++ b/src/primaite/exceptions.py @@ -1,11 +1,11 @@ # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK class PrimaiteError(Exception): - """The root PrimAITe Error.""" + """The root PrimAITE Error.""" pass -class RLlibAgentError(PrimaiteError): - """Raised when there is a generic error with a RLlib agent that is specific to PRimAITE.""" +class NetworkError(PrimaiteError): + """Raised when an error occurs at the network level.""" pass diff --git a/src/primaite/game/__init__.py b/src/primaite/game/__init__.py new file mode 100644 index 00000000..5d7a721f --- /dev/null +++ b/src/primaite/game/__init__.py @@ -0,0 +1 @@ +"""PrimAITE Game Layer.""" diff --git a/src/primaite/game/agent/__init__.py b/src/primaite/game/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py new file mode 100644 index 00000000..7707df2b --- /dev/null +++ b/src/primaite/game/agent/actions.py @@ -0,0 +1,1347 @@ +""" +This module contains the ActionManager class which belongs to the Agent class. + +An agent's action space is made up of a collection of actions. Each action is an instance of a subclass of +AbstractAction. The ActionManager is responsible for: + 1. Creating the action space from a list of action types. + 2. Converting an integer action choice into a specific action and parameter choice. + 3. Converting an action and parameter choice into a request which can be ingested by the PrimAITE simulation. This + ensures that requests conform to the simulator's request format. +""" +import itertools +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING + +from gymnasium import spaces + +from primaite import getLogger + +_LOGGER = getLogger(__name__) + +if TYPE_CHECKING: + from primaite.game.game import PrimaiteGame + + +class AbstractAction(ABC): + """Base class for actions.""" + + @abstractmethod + def __init__(self, manager: "ActionManager", **kwargs) -> None: + """ + Init method for action. + + All action init functions should accept **kwargs as a way of ignoring extra arguments. + + Since many parameters are defined for the action space as a whole (such as max files per folder, max services + per node), we need to pass those options to every action that gets created. To prevent verbosity, these + parameters are just broadcasted to all actions and the actions can pay attention to the ones that apply. + """ + self.name: str = "" + """Human-readable action identifier used for printing, logging, and reporting.""" + self.shape: Dict[str, int] = {} + """Dictionary describing the number of options for each parameter of this action. The keys of this dict must + align with the keyword args of the form_request method.""" + self.manager: ActionManager = manager + """Reference to the ActionManager which created this action. This is used to access the game and simulation + objects.""" + + @abstractmethod + def form_request(self) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return [] + + +class DoNothingAction(AbstractAction): + """Action which does nothing. This is here to allow agents to be idle if they choose to.""" + + def __init__(self, manager: "ActionManager", **kwargs) -> None: + super().__init__(manager=manager) + self.name = "DONOTHING" + self.shape: Dict[str, int] = { + "dummy": 1, + } + # This action does not accept any parameters, therefore it technically has a gymnasium shape of Discrete(1), + # i.e. a choice between one option. To make enumerating this action easier, we are adding a 'dummy' paramter + # with one option. This just aids the Action Manager to enumerate all possibilities. + + def form_request(self, **kwargs) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return ["do_nothing"] + + +class NodeServiceAbstractAction(AbstractAction): + """ + Base class for service actions. + + Any action which applies to a service and uses node_id and service_id as its only two parameters can inherit from + this base class. + """ + + @abstractmethod + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes, "service_id": num_services} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int, service_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + service_name = self.manager.get_service_name_by_idx(node_id, service_id) + if node_name is None or service_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "service", service_name, self.verb] + + +class NodeServiceScanAction(NodeServiceAbstractAction): + """Action which scans a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "scan" + + +class NodeServiceStopAction(NodeServiceAbstractAction): + """Action which stops a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "stop" + + +class NodeServiceStartAction(NodeServiceAbstractAction): + """Action which starts a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "start" + + +class NodeServicePauseAction(NodeServiceAbstractAction): + """Action which pauses a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "pause" + + +class NodeServiceResumeAction(NodeServiceAbstractAction): + """Action which resumes a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "resume" + + +class NodeServiceRestartAction(NodeServiceAbstractAction): + """Action which restarts a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "restart" + + +class NodeServiceDisableAction(NodeServiceAbstractAction): + """Action which disables a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "disable" + + +class NodeServiceEnableAction(NodeServiceAbstractAction): + """Action which enables a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "enable" + + +class NodeServiceFixAction(NodeServiceAbstractAction): + """Action which fixes a service.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_services: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_services=num_services) + self.verb: str = "fix" + + +class NodeApplicationAbstractAction(AbstractAction): + """ + Base class for application actions. + + Any action which applies to an application and uses node_id and application_id as its only two parameters can + inherit from this base class. + """ + + @abstractmethod + def __init__(self, manager: "ActionManager", num_nodes: int, num_applications: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes, "application_id": num_applications} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int, application_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + application_name = self.manager.get_application_name_by_idx(node_id, application_id) + if node_name is None or application_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "application", application_name, self.verb] + + +class NodeApplicationExecuteAction(NodeApplicationAbstractAction): + """Action which executes an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_applications: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_applications=num_applications) + self.verb: str = "execute" + + +class NodeApplicationScanAction(NodeApplicationAbstractAction): + """Action which scans an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_applications: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_applications=num_applications) + self.verb: str = "scan" + + +class NodeApplicationCloseAction(NodeApplicationAbstractAction): + """Action which closes an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_applications: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_applications=num_applications) + self.verb: str = "close" + + +class NodeApplicationFixAction(NodeApplicationAbstractAction): + """Action which fixes an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_applications: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, num_applications=num_applications) + self.verb: str = "fix" + + +class NodeApplicationInstallAction(AbstractAction): + """Action which installs an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes} + + def form_request(self, node_id: int, application_name: str, ip_address: str) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + return [ + "network", + "node", + node_name, + "software_manager", + "application", + "install", + application_name, + ip_address, + ] + + +class NodeApplicationRemoveAction(AbstractAction): + """Action which removes/uninstalls an application.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes} + + def form_request(self, node_id: int, application_name: str) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "software_manager", "application", "uninstall", application_name] + + +class NodeFolderAbstractAction(AbstractAction): + """ + Base class for folder actions. + + Any action which applies to a folder and uses node_id and folder_id as its only two parameters can inherit from + this base class. + """ + + @abstractmethod + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes, "folder_id": num_folders} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int, folder_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + folder_name = self.manager.get_folder_name_by_idx(node_idx=node_id, folder_idx=folder_id) + if node_name is None or folder_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "folder", folder_name, self.verb] + + +class NodeFolderScanAction(NodeFolderAbstractAction): + """Action which scans a folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "scan" + + +class NodeFolderCheckhashAction(NodeFolderAbstractAction): + """Action which checks the hash of a folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "checkhash" + + +class NodeFolderRepairAction(NodeFolderAbstractAction): + """Action which repairs a folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "repair" + + +class NodeFolderRestoreAction(NodeFolderAbstractAction): + """Action which restores a folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "restore" + + +class NodeFileCreateAction(AbstractAction): + """Action which creates a new file in a given folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "create" + + def form_request(self, node_id: int, folder_name: str, file_name: str) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None or folder_name is None or file_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "create", "file", folder_name, file_name] + + +class NodeFolderCreateAction(AbstractAction): + """Action which creates a new folder.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "create" + + def form_request(self, node_id: int, folder_name: str) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None or folder_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "create", "folder", folder_name] + + +class NodeFileAbstractAction(AbstractAction): + """Abstract base class for file actions. + + Any action which applies to a file and uses node_id, folder_id, and file_id as its only three parameters can inherit + from this base class. + """ + + @abstractmethod + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes, "folder_id": num_folders, "file_id": num_files} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int, folder_id: int, file_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + folder_name = self.manager.get_folder_name_by_idx(node_idx=node_id, folder_idx=folder_id) + file_name = self.manager.get_file_name_by_idx(node_idx=node_id, folder_idx=folder_id, file_idx=file_id) + if node_name is None or folder_name is None or file_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "folder", folder_name, "file", file_name, self.verb] + + +class NodeFileScanAction(NodeFileAbstractAction): + """Action which scans a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "scan" + + +class NodeFileCheckhashAction(NodeFileAbstractAction): + """Action which checks the hash of a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "checkhash" + + +class NodeFileDeleteAction(NodeFileAbstractAction): + """Action which deletes a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "delete" + + def form_request(self, node_id: int, folder_id: int, file_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + folder_name = self.manager.get_folder_name_by_idx(node_idx=node_id, folder_idx=folder_id) + file_name = self.manager.get_file_name_by_idx(node_idx=node_id, folder_idx=folder_id, file_idx=file_id) + if node_name is None or folder_name is None or file_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "delete", "file", folder_name, file_name] + + +class NodeFileRepairAction(NodeFileAbstractAction): + """Action which repairs a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "repair" + + +class NodeFileRestoreAction(NodeFileAbstractAction): + """Action which restores a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "restore" + + +class NodeFileCorruptAction(NodeFileAbstractAction): + """Action which corrupts a file.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, num_files: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) + self.verb: str = "corrupt" + + +class NodeFileAccessAction(AbstractAction): + """Action which increases a file's access count.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, num_folders: int, **kwargs) -> None: + super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, **kwargs) + self.verb: str = "access" + + def form_request(self, node_id: int, folder_name: str, file_name: str) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + if node_name is None or folder_name is None or file_name is None: + return ["do_nothing"] + return ["network", "node", node_name, "file_system", "access", folder_name, file_name] + + +class NodeAbstractAction(AbstractAction): + """ + Abstract base class for node actions. + + Any action which applies to a node and uses node_id as its only parameter can inherit from this base class. + """ + + @abstractmethod + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_id) + return ["network", "node", node_name, self.verb] + + +class NodeOSScanAction(NodeAbstractAction): + """Action which scans a node's OS.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes) + self.verb: str = "scan" + + +class NodeShutdownAction(NodeAbstractAction): + """Action which shuts down a node.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes) + self.verb: str = "shutdown" + + +class NodeStartupAction(NodeAbstractAction): + """Action which starts up a node.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes) + self.verb: str = "startup" + + +class NodeResetAction(NodeAbstractAction): + """Action which resets a node.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes) + self.verb: str = "reset" + + +class RouterACLAddRuleAction(AbstractAction): + """Action which adds a rule to a router's ACL.""" + + def __init__( + self, + manager: "ActionManager", + max_acl_rules: int, + num_ips: int, + num_ports: int, + num_protocols: int, + **kwargs, + ) -> None: + """Init method for RouterACLAddRuleAction. + + :param manager: Reference to the ActionManager which created this action. + :type manager: ActionManager + :param max_acl_rules: Maximum number of ACL rules that can be added to the router. + :type max_acl_rules: int + :param num_ips: Number of IP addresses in the simulation. + :type num_ips: int + :param num_ports: Number of ports in the simulation. + :type num_ports: int + :param num_protocols: Number of protocols in the simulation. + :type num_protocols: int + """ + super().__init__(manager=manager) + num_permissions = 3 + self.shape: Dict[str, int] = { + "position": max_acl_rules, + "permission": num_permissions, + "source_ip_id": num_ips, + "dest_ip_id": num_ips, + "source_port_id": num_ports, + "dest_port_id": num_ports, + "protocol_id": num_protocols, + } + + def form_request( + self, + target_router_nodename: str, + position: int, + permission: int, + source_ip_id: int, + source_wildcard_id: int, + dest_ip_id: int, + dest_wildcard_id: int, + source_port_id: int, + dest_port_id: int, + protocol_id: int, + ) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + if permission == 0: + permission_str = "UNUSED" + return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS + elif permission == 1: + permission_str = "PERMIT" + elif permission == 2: + permission_str = "DENY" + else: + _LOGGER.warning(f"{self.__class__} received permission {permission}, expected 0 or 1.") + + if protocol_id == 0: + return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS + + if protocol_id == 1: + protocol = "ALL" + else: + protocol = self.manager.get_internet_protocol_by_idx(protocol_id - 2) + # subtract 2 to account for UNUSED=0 and ALL=1. + + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif source_ip_id == 1: + src_ip = "ALL" + else: + src_ip = self.manager.get_ip_address_by_idx(source_ip_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + src_wildcard = self.manager.get_wildcard_by_idx(source_wildcard_id) + if source_port_id == 0: + return ["do_nothing"] # invalid formulation + elif source_port_id == 1: + src_port = "ALL" + else: + src_port = self.manager.get_port_by_idx(source_port_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + + if dest_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_ip_id == 1: + dst_ip = "ALL" + else: + dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + dst_wildcard = self.manager.get_wildcard_by_idx(dest_wildcard_id) + + if dest_port_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_port_id == 1: + dst_port = "ALL" + else: + dst_port = self.manager.get_port_by_idx(dest_port_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + + return [ + "network", + "node", + target_router_nodename, + "acl", + "add_rule", + permission_str, + protocol, + str(src_ip), + src_wildcard, + src_port, + str(dst_ip), + dst_wildcard, + dst_port, + position, + ] + + +class RouterACLRemoveRuleAction(AbstractAction): + """Action which removes a rule from a router's ACL.""" + + def __init__(self, manager: "ActionManager", max_acl_rules: int, **kwargs) -> None: + """Init method for RouterACLRemoveRuleAction. + + :param manager: Reference to the ActionManager which created this action. + :type manager: ActionManager + :param max_acl_rules: Maximum number of ACL rules that can be added to the router. + :type max_acl_rules: int + """ + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"position": max_acl_rules} + + def form_request(self, target_router_nodename: str, position: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return ["network", "node", target_router_nodename, "acl", "remove_rule", position] + + +class FirewallACLAddRuleAction(AbstractAction): + """Action which adds a rule to a firewall port's ACL.""" + + def __init__( + self, + manager: "ActionManager", + max_acl_rules: int, + num_ips: int, + num_ports: int, + num_protocols: int, + **kwargs, + ) -> None: + """Init method for FirewallACLAddRuleAction. + + :param manager: Reference to the ActionManager which created this action. + :type manager: ActionManager + :param max_acl_rules: Maximum number of ACL rules that can be added to the router. + :type max_acl_rules: int + :param num_ips: Number of IP addresses in the simulation. + :type num_ips: int + :param num_ports: Number of ports in the simulation. + :type num_ports: int + :param num_protocols: Number of protocols in the simulation. + :type num_protocols: int + """ + super().__init__(manager=manager) + num_permissions = 3 + self.shape: Dict[str, int] = { + "position": max_acl_rules, + "permission": num_permissions, + "source_ip_id": num_ips, + "dest_ip_id": num_ips, + "source_port_id": num_ports, + "dest_port_id": num_ports, + "protocol_id": num_protocols, + } + + def form_request( + self, + target_firewall_nodename: str, + firewall_port_name: str, + firewall_port_direction: str, + position: int, + permission: int, + source_ip_id: int, + source_wildcard_id: int, + dest_ip_id: int, + dest_wildcard_id: int, + source_port_id: int, + dest_port_id: int, + protocol_id: int, + ) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + if permission == 0: + permission_str = "UNUSED" + return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS + elif permission == 1: + permission_str = "PERMIT" + elif permission == 2: + permission_str = "DENY" + else: + _LOGGER.warning(f"{self.__class__} received permission {permission}, expected 0 or 1.") + + if protocol_id == 0: + return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS + + if protocol_id == 1: + protocol = "ALL" + else: + protocol = self.manager.get_internet_protocol_by_idx(protocol_id - 2) + # subtract 2 to account for UNUSED=0 and ALL=1. + + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif source_ip_id == 1: + src_ip = "ALL" + else: + src_ip = self.manager.get_ip_address_by_idx(source_ip_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + + if source_port_id == 0: + return ["do_nothing"] # invalid formulation + elif source_port_id == 1: + src_port = "ALL" + else: + src_port = self.manager.get_port_by_idx(source_port_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + + if dest_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_ip_id == 1: + dst_ip = "ALL" + else: + dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + + if dest_port_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_port_id == 1: + dst_port = "ALL" + else: + dst_port = self.manager.get_port_by_idx(dest_port_id - 2) + # subtract 2 to account for UNUSED=0, and ALL=1 + src_wildcard = self.manager.get_wildcard_by_idx(source_wildcard_id) + dst_wildcard = self.manager.get_wildcard_by_idx(dest_wildcard_id) + + return [ + "network", + "node", + target_firewall_nodename, + firewall_port_name, + firewall_port_direction, + "acl", + "add_rule", + permission_str, + protocol, + str(src_ip), + src_wildcard, + src_port, + str(dst_ip), + dst_wildcard, + dst_port, + position, + ] + + +class FirewallACLRemoveRuleAction(AbstractAction): + """Action which removes a rule from a firewall port's ACL.""" + + def __init__(self, manager: "ActionManager", max_acl_rules: int, **kwargs) -> None: + """Init method for RouterACLRemoveRuleAction. + + :param manager: Reference to the ActionManager which created this action. + :type manager: ActionManager + :param max_acl_rules: Maximum number of ACL rules that can be added to the router. + :type max_acl_rules: int + """ + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"position": max_acl_rules} + + def form_request( + self, target_firewall_nodename: str, firewall_port_name: str, firewall_port_direction: str, position: int + ) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + return [ + "network", + "node", + target_firewall_nodename, + firewall_port_name, + firewall_port_direction, + "acl", + "remove_rule", + position, + ] + + +class HostNICAbstractAction(AbstractAction): + """ + Abstract base class for NIC actions. + + Any action which applies to a NIC and uses node_id and nic_id as its only two parameters can inherit from this base + class. + """ + + def __init__(self, manager: "ActionManager", num_nodes: int, max_nics_per_node: int, **kwargs) -> None: + """Init method for HostNICAbstractAction. + + :param manager: Reference to the ActionManager which created this action. + :type manager: ActionManager + :param num_nodes: Number of nodes in the simulation. + :type num_nodes: int + :param max_nics_per_node: Maximum number of NICs per node. + :type max_nics_per_node: int + """ + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"node_id": num_nodes, "nic_id": max_nics_per_node} + self.verb: str # define but don't initialise: defends against children classes not defining this + + def form_request(self, node_id: int, nic_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_name = self.manager.get_node_name_by_idx(node_idx=node_id) + nic_num = self.manager.get_nic_num_by_idx(node_idx=node_id, nic_idx=nic_id) + if node_name is None or nic_num is None: + return ["do_nothing"] + return ["network", "node", node_name, "network_interface", nic_num, self.verb] + + +class HostNICEnableAction(HostNICAbstractAction): + """Action which enables a NIC.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, max_nics_per_node: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, max_nics_per_node=max_nics_per_node, **kwargs) + self.verb: str = "enable" + + +class HostNICDisableAction(HostNICAbstractAction): + """Action which disables a NIC.""" + + def __init__(self, manager: "ActionManager", num_nodes: int, max_nics_per_node: int, **kwargs) -> None: + super().__init__(manager=manager, num_nodes=num_nodes, max_nics_per_node=max_nics_per_node, **kwargs) + self.verb: str = "disable" + + +class NetworkPortEnableAction(AbstractAction): + """Action which enables are port on a router or a firewall.""" + + def __init__(self, manager: "ActionManager", max_nics_per_node: int, **kwargs) -> None: + """Init method for NetworkPortEnableAction. + + :param max_nics_per_node: Maximum number of NICs per node. + :type max_nics_per_node: int + """ + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"port_id": max_nics_per_node} + + def form_request(self, target_nodename: str, port_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + if target_nodename is None or port_id is None: + return ["do_nothing"] + return ["network", "node", target_nodename, "network_interface", port_id, "enable"] + + +class NetworkPortDisableAction(AbstractAction): + """Action which disables are port on a router or a firewall.""" + + def __init__(self, manager: "ActionManager", max_nics_per_node: int, **kwargs) -> None: + """Init method for NetworkPortDisableAction. + + :param max_nics_per_node: Maximum number of NICs per node. + :type max_nics_per_node: int + """ + super().__init__(manager=manager) + self.shape: Dict[str, int] = {"port_id": max_nics_per_node} + + def form_request(self, target_nodename: str, port_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + if target_nodename is None or port_id is None: + return ["do_nothing"] + return ["network", "node", target_nodename, "network_interface", port_id, "disable"] + + +class ActionManager: + """Class which manages the action space for an agent.""" + + act_class_identifiers: Dict[str, type] = { + "DONOTHING": DoNothingAction, + "NODE_SERVICE_SCAN": NodeServiceScanAction, + "NODE_SERVICE_STOP": NodeServiceStopAction, + "NODE_SERVICE_START": NodeServiceStartAction, + "NODE_SERVICE_PAUSE": NodeServicePauseAction, + "NODE_SERVICE_RESUME": NodeServiceResumeAction, + "NODE_SERVICE_RESTART": NodeServiceRestartAction, + "NODE_SERVICE_DISABLE": NodeServiceDisableAction, + "NODE_SERVICE_ENABLE": NodeServiceEnableAction, + "NODE_SERVICE_FIX": NodeServiceFixAction, + "NODE_APPLICATION_EXECUTE": NodeApplicationExecuteAction, + "NODE_APPLICATION_SCAN": NodeApplicationScanAction, + "NODE_APPLICATION_CLOSE": NodeApplicationCloseAction, + "NODE_APPLICATION_FIX": NodeApplicationFixAction, + "NODE_APPLICATION_INSTALL": NodeApplicationInstallAction, + "NODE_APPLICATION_REMOVE": NodeApplicationRemoveAction, + "NODE_FILE_SCAN": NodeFileScanAction, + "NODE_FILE_CREATE": NodeFileCreateAction, + "NODE_FILE_CHECKHASH": NodeFileCheckhashAction, + "NODE_FILE_DELETE": NodeFileDeleteAction, + "NODE_FILE_REPAIR": NodeFileRepairAction, + "NODE_FILE_RESTORE": NodeFileRestoreAction, + "NODE_FILE_CORRUPT": NodeFileCorruptAction, + "NODE_FILE_ACCESS": NodeFileAccessAction, + "NODE_FOLDER_CREATE": NodeFolderCreateAction, + "NODE_FOLDER_SCAN": NodeFolderScanAction, + "NODE_FOLDER_CHECKHASH": NodeFolderCheckhashAction, + "NODE_FOLDER_REPAIR": NodeFolderRepairAction, + "NODE_FOLDER_RESTORE": NodeFolderRestoreAction, + "NODE_OS_SCAN": NodeOSScanAction, + "NODE_SHUTDOWN": NodeShutdownAction, + "NODE_STARTUP": NodeStartupAction, + "NODE_RESET": NodeResetAction, + "ROUTER_ACL_ADDRULE": RouterACLAddRuleAction, + "ROUTER_ACL_REMOVERULE": RouterACLRemoveRuleAction, + "FIREWALL_ACL_ADDRULE": FirewallACLAddRuleAction, + "FIREWALL_ACL_REMOVERULE": FirewallACLRemoveRuleAction, + "HOST_NIC_ENABLE": HostNICEnableAction, + "HOST_NIC_DISABLE": HostNICDisableAction, + "NETWORK_PORT_ENABLE": NetworkPortEnableAction, + "NETWORK_PORT_DISABLE": NetworkPortDisableAction, + } + """Dictionary which maps action type strings to the corresponding action class.""" + + def __init__( + self, + actions: List[Dict], # stores list of actions available to agent + nodes: List[Dict], # extra configuration for each node + max_folders_per_node: int = 2, # allows calculating shape + max_files_per_folder: int = 2, # allows calculating shape + max_services_per_node: int = 2, # allows calculating shape + max_applications_per_node: int = 2, # allows calculating shape + max_nics_per_node: int = 8, # allows calculating shape + max_acl_rules: int = 10, # allows calculating shape + protocols: List[str] = ["TCP", "UDP", "ICMP"], # allow mapping index to protocol + ports: List[str] = ["HTTP", "DNS", "ARP", "FTP", "NTP"], # allow mapping index to port + ip_list: List[str] = [], # to allow us to map an index to an ip address. + wildcard_list: List[str] = [], # to allow mapping from wildcard index to + act_map: Optional[Dict[int, Dict]] = None, # allows restricting set of possible actions + ) -> None: + """Init method for ActionManager. + + :param game: Reference to the game to which the agent belongs. + :type game: PrimaiteGame + :param actions: List of action specs which should be made available to the agent. The keys of each spec are: + 'type' and 'options' for passing any options to the action class's init method + :type actions: List[dict] + :param nodes: Extra configuration for each node. + :type nodes: List[Dict] + :param max_folders_per_node: Maximum number of folders per node. Used for calculating action shape. + :type max_folders_per_node: int + :param max_files_per_folder: Maximum number of files per folder. Used for calculating action shape. + :type max_files_per_folder: int + :param max_services_per_node: Maximum number of services per node. Used for calculating action shape. + :type max_services_per_node: int + :param max_nics_per_node: Maximum number of NICs per node. Used for calculating action shape. + :type max_nics_per_node: int + :param max_acl_rules: Maximum number of ACL rules per router. Used for calculating action shape. + :type max_acl_rules: int + :param protocols: List of protocols that are available in the simulation. Used for calculating action shape. + :type protocols: List[str] + :param ports: List of ports that are available in the simulation. Used for calculating action shape. + :type ports: List[str] + :param ip_list: List of IP addresses that known to this agent. Used for calculating action shape. + :type ip_list: Optional[List[str]] + :param act_map: Action map which maps integers to actions. Used for restricting the set of possible actions. + :type act_map: Optional[Dict[int, Dict]] + """ + self.node_names: List[str] = [n["node_name"] for n in nodes] + """List of node names in this action space. The list order is the mapping between node index and node name.""" + self.application_names: List[List[str]] = [] + """ + List of applications per node. The list order gives the two-index mapping between (node_id, app_id) to app name. + The first index corresponds to node id, the second index is the app id on that particular node. + For instance, self.application_names[0][2] is the name of the third application on the first node. + """ + self.service_names: List[List[str]] = [] + """ + List of services per node. The list order gives the two-index mapping between (node_id, svc_id) to svc name. + The first index corresponds to node id, the second index is the service id on that particular node. + For instance, self.service_names[0][2] is the name of the third service on the first node. + """ + self.folder_names: List[List[str]] = [] + """ + List of folders per node. The list order gives the two-index mapping between (node_id, folder_id) to folder + name. The first index corresponds to node id, the second index is the folder id on that particular node. + For instance, self.folder_names[0][2] is the name of the third folder on the first node. + """ + self.file_names: List[List[List[str]]] = [] + """ + List of files per folder per node. The list order gives the three-index mapping between + (node_id, folder_id, file_id) to file name. The first index corresponds to node id, the second index is the + folder id on that particular node, and the third index is the file id in that particular folder. + For instance, self.file_names[0][2][1] is the name of the second file in the third folder on the first node. + """ + + # Populate lists of apps, services, files, folders, etc on nodes. + for node in nodes: + app_list = [a["application_name"] for a in node.get("applications", [])] + while len(app_list) < max_applications_per_node: + app_list.append(None) + self.application_names.append(app_list) + + svc_list = [s["service_name"] for s in node.get("services", [])] + while len(svc_list) < max_services_per_node: + svc_list.append(None) + self.service_names.append(svc_list) + + folder_list = [f["folder_name"] for f in node.get("folders", [])] + while len(folder_list) < max_folders_per_node: + folder_list.append(None) + self.folder_names.append(folder_list) + + file_sublist = [] + for folder in node.get("folders", [{"files": []}]): + file_list = [f["file_name"] for f in folder.get("files", [])] + while len(file_list) < max_files_per_folder: + file_list.append(None) + file_sublist.append(file_list) + while len(file_sublist) < max_folders_per_node: + file_sublist.append([None] * max_files_per_folder) + self.file_names.append(file_sublist) + self.protocols: List[str] = protocols + self.ports: List[str] = ports + + self.ip_address_list: List[str] = ip_list + self.wildcard_list: List[str] = wildcard_list + if self.wildcard_list == []: + self.wildcard_list = ["NONE"] + # action_args are settings which are applied to the action space as a whole. + global_action_args = { + "num_nodes": len(self.node_names), + "num_folders": max_folders_per_node, + "num_files": max_files_per_folder, + "num_services": max_services_per_node, + "num_applications": max_applications_per_node, + "num_nics": max_nics_per_node, + "num_acl_rules": max_acl_rules, + "num_protocols": len(self.protocols), + "num_ports": len(self.protocols), + "num_ips": len(self.ip_address_list), + "max_acl_rules": max_acl_rules, + "max_nics_per_node": max_nics_per_node, + } + self.actions: Dict[str, AbstractAction] = {} + for act_spec in actions: + # each action is provided into the action space config like this: + # - type: ACTION_TYPE + # options: + # option_1: value1 + # option_2: value2 + # where `type` decides which AbstractAction subclass should be used + # and `options` is an optional dict of options to pass to the init method of the action class + act_type = act_spec.get("type") + act_options = act_spec.get("options", {}) + self.actions[act_type] = self.act_class_identifiers[act_type](self, **global_action_args, **act_options) + + self.action_map: Dict[int, Tuple[str, Dict]] = {} + """ + Action mapping that converts an integer to a specific action and parameter choice. + + For example : + {0: ("NODE_SERVICE_SCAN", {node_id:0, service_id:2})} + """ + if act_map is None: + # raise RuntimeError("Action map must be specified in the config file.") + pass + else: + self.action_map = {i: (a["action"], a["options"]) for i, a in act_map.items()} + # make sure all numbers between 0 and N are represented as dict keys in action map + assert all([i in self.action_map.keys() for i in range(len(self.action_map))]) + + def _enumerate_actions( + self, + ) -> Dict[int, Tuple[str, Dict]]: + """Generate a list of all the possible actions that could be taken. + + This enumerates all actions all combinations of parameters you could choose for those actions. The output + of this function is intended to populate the self.action_map parameter in the situation where the user provides + a list of action types, but doesn't specify any subset of actions that should be made available to the agent. + + The enumeration relies on the Actions' `shape` attribute. + + :return: An action map maps consecutive integers to a combination of Action type and parameter choices. + An example output could be: + {0: ("DONOTHING", {'dummy': 0}), + 1: ("NODE_OS_SCAN", {'node_id': 0}), + 2: ("NODE_OS_SCAN", {'node_id': 1}), + 3: ("NODE_FOLDER_SCAN", {'node_id:0, folder_id:0}), + ... #etc... + } + :rtype: Dict[int, Tuple[AbstractAction, Dict]] + """ + all_action_possibilities = [] + for act_name, action in self.actions.items(): + param_names = list(action.shape.keys()) + num_possibilities = list(action.shape.values()) + possibilities = [range(n) for n in num_possibilities] + + param_combinations = list(itertools.product(*possibilities)) + all_action_possibilities.extend( + [ + (act_name, {param_names[i]: param_combinations[j][i] for i in range(len(param_names))}) + for j in range(len(param_combinations)) + ] + ) + + return {i: p for i, p in enumerate(all_action_possibilities)} + + def get_action(self, action: int) -> Tuple[str, Dict]: + """Produce action in CAOS format.""" + """the agent chooses an action (as an integer), this is converted into an action in CAOS format""" + """The CAOS format is basically a action identifier, followed by parameters stored in a dictionary""" + act_identifier, act_options = self.action_map[action] + return act_identifier, act_options + + def form_request(self, action_identifier: str, action_options: Dict) -> List[str]: + """Take action in CAOS format and use the execution definition to change it into PrimAITE request format.""" + act_obj = self.actions[action_identifier] + return act_obj.form_request(**action_options) + + @property + def space(self) -> spaces.Space: + """Return the gymnasium action space for this agent.""" + return spaces.Discrete(len(self.action_map)) + + def get_node_name_by_idx(self, node_idx: int) -> str: + """ + Get the node name corresponding to the given index. + + :param node_idx: The index of the node to retrieve. + :type node_idx: int + :return: The node hostname. + :rtype: str + """ + if not node_idx < len(self.node_names): + msg = ( + f"Error: agent attempted to perform an action on node {node_idx}, but its action space only" + f"has {len(self.node_names)} nodes." + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.node_names[node_idx] + + def get_folder_name_by_idx(self, node_idx: int, folder_idx: int) -> Optional[str]: + """ + Get the folder name corresponding to the given node and folder indices. + + :param node_idx: The index of the node. + :type node_idx: int + :param folder_idx: The index of the folder on the node. + :type folder_idx: int + :return: The name of the folder. Or None if the node has fewer folders than the given index. + :rtype: Optional[str] + """ + if node_idx >= len(self.folder_names) or folder_idx >= len(self.folder_names[node_idx]): + msg = ( + f"Error: agent attempted to perform an action on node {node_idx} and folder {folder_idx}, but this" + f" is out of range for its action space. Folder on each node: {self.folder_names}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.folder_names[node_idx][folder_idx] + + def get_file_name_by_idx(self, node_idx: int, folder_idx: int, file_idx: int) -> Optional[str]: + """Get the file name corresponding to the given node, folder, and file indices. + + :param node_idx: The index of the node. + :type node_idx: int + :param folder_idx: The index of the folder on the node. + :type folder_idx: int + :param file_idx: The index of the file in the folder. + :type file_idx: int + :return: The name of the file. Or None if the node has fewer folders than the given index, or the folder has + fewer files than the given index. + :rtype: Optional[str] + """ + if ( + node_idx >= len(self.file_names) + or folder_idx >= len(self.file_names[node_idx]) + or file_idx >= len(self.file_names[node_idx][folder_idx]) + ): + msg = ( + f"Error: agent attempted to perform an action on node {node_idx} folder {folder_idx} file {file_idx}" + f" but this is out of range for its action space. Files on each node: {self.file_names}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.file_names[node_idx][folder_idx][file_idx] + + def get_service_name_by_idx(self, node_idx: int, service_idx: int) -> Optional[str]: + """Get the service name corresponding to the given node and service indices. + + :param node_idx: The index of the node. + :type node_idx: int + :param service_idx: The index of the service on the node. + :type service_idx: int + :return: The name of the service. Or None if the node has fewer services than the given index. + :rtype: Optional[str] + """ + if node_idx >= len(self.service_names) or service_idx >= len(self.service_names[node_idx]): + msg = ( + f"Error: agent attempted to perform an action on node {node_idx} and service {service_idx}, but this" + f" is out of range for its action space. Services on each node: {self.service_names}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.service_names[node_idx][service_idx] + + def get_application_name_by_idx(self, node_idx: int, application_idx: int) -> Optional[str]: + """Get the application name corresponding to the given node and service indices. + + :param node_idx: The index of the node. + :type node_idx: int + :param application_idx: The index of the service on the node. + :type application_idx: int + :return: The name of the service. Or None if the node has fewer services than the given index. + :rtype: Optional[str] + """ + if node_idx >= len(self.application_names) or application_idx >= len(self.application_names[node_idx]): + msg = ( + f"Error: agent attempted to perform an action on node {node_idx} and app {application_idx}, but " + f"this is out of range for its action space. Applications on each node: {self.application_names}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.application_names[node_idx][application_idx] + + def get_internet_protocol_by_idx(self, protocol_idx: int) -> str: + """Get the internet protocol corresponding to the given index. + + :param protocol_idx: The index of the protocol to retrieve. + :type protocol_idx: int + :return: The protocol. + :rtype: str + """ + if protocol_idx >= len(self.protocols): + msg = ( + f"Error: agent attempted to perform an action on protocol {protocol_idx} but this" + f" is out of range for its action space. Protocols: {self.protocols}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.protocols[protocol_idx] + + def get_ip_address_by_idx(self, ip_idx: int) -> str: + """ + Get the IP address corresponding to the given index. + + :param ip_idx: The index of the IP address to retrieve. + :type ip_idx: int + :return: The IP address. + :rtype: str + """ + if ip_idx >= len(self.ip_address_list): + msg = ( + f"Error: agent attempted to perform an action on ip address {ip_idx} but this" + f" is out of range for its action space. IP address list: {self.ip_address_list}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.ip_address_list[ip_idx] + + def get_wildcard_by_idx(self, wildcard_idx: int) -> str: + """ + Get the IP wildcard corresponding to the given index. + + :param ip_idx: The index of the IP wildcard to retrieve. + :type ip_idx: int + :return: The wildcard address. + :rtype: str + """ + if wildcard_idx >= len(self.wildcard_list): + msg = ( + f"Error: agent attempted to perform an action on ip wildcard {wildcard_idx} but this" + f" is out of range for its action space. Wildcard list: {self.wildcard_list}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.wildcard_list[wildcard_idx] + + def get_port_by_idx(self, port_idx: int) -> str: + """ + Get the port corresponding to the given index. + + :param port_idx: The index of the port to retrieve. + :type port_idx: int + :return: The port. + :rtype: str + """ + if port_idx >= len(self.ports): + msg = ( + f"Error: agent attempted to perform an action on port {port_idx} but this" + f" is out of range for its action space. Port list: {self.ip_address_list}" + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + return self.ports[port_idx] + + def get_nic_num_by_idx(self, node_idx: int, nic_idx: int) -> int: + """ + Get the NIC number corresponding to the given node and NIC indices. + + :param node_idx: The index of the node. + :type node_idx: int + :param nic_idx: The index of the NIC on the node. + :type nic_idx: int + :return: The NIC number. + :rtype: int + """ + return nic_idx + 1 + + @classmethod + def from_config(cls, game: "PrimaiteGame", cfg: Dict) -> "ActionManager": + """ + Construct an ActionManager from a config definition. + + The action space config supports the following three sections: + 1. ``action_list`` + ``action_list`` contains a list action components which need to be included in the action space. + Each action component has a ``type`` which maps to a subclass of AbstractAction, and additional options + which will be passed to the action class's __init__ method during initialisation. + 2. ``action_map`` + Since the agent uses a discrete action space which acts as a flattened version of the component-based + action space, action_map provides a mapping between an integer (chosen by the agent) and a meaningful + action and values of parameters. For example action 0 can correspond to do nothing, action 1 can + correspond to "NODE_SERVICE_SCAN" with ``node_id=1`` and ``service_id=1``, action 2 can be " + 3. ``options`` + ``options`` contains a dictionary of options which are passed to the ActionManager's __init__ method. + These options are used to calculate the shape of the action space, and to provide additional information + to the ActionManager which is required to convert the agent's action choice into a CAOS request. + + :param game: The Primaite Game to which the agent belongs. + :type game: PrimaiteGame + :param cfg: The action space config. + :type cfg: Dict + :return: The constructed ActionManager. + :rtype: ActionManager + """ + if "ip_list" not in cfg["options"]: + cfg["options"]["ip_list"] = [] + + obj = cls( + actions=cfg["action_list"], + **cfg["options"], + protocols=game.options.protocols, + ports=game.options.ports, + act_map=cfg.get("action_map"), + ) + + return obj diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py new file mode 100644 index 00000000..444aa4f7 --- /dev/null +++ b/src/primaite/game/agent/interface.py @@ -0,0 +1,225 @@ +"""Interface for agents.""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from gymnasium.core import ActType, ObsType +from pydantic import BaseModel, model_validator + +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.observations.observation_manager import ObservationManager +from primaite.game.agent.rewards import RewardFunction +from primaite.interface.request import RequestFormat, RequestResponse + +if TYPE_CHECKING: + pass + + +class AgentHistoryItem(BaseModel): + """One entry of an agent's action log - what the agent did and how the simulator responded in 1 step.""" + + timestep: int + """Timestep of this action.""" + + action: str + """CAOS Action name.""" + + parameters: Dict[str, Any] + """CAOS parameters for the given action.""" + + request: RequestFormat + """The request that was sent to the simulation based on the CAOS action chosen.""" + + response: RequestResponse + """The response sent back by the simulator for this action.""" + + reward: Optional[float] = None + + +class AgentStartSettings(BaseModel): + """Configuration values for when an agent starts performing actions.""" + + start_step: int = 5 + "The timestep at which an agent begins performing it's actions" + frequency: int = 5 + "The number of timesteps to wait between performing actions" + variance: int = 0 + "The amount the frequency can randomly change to" + + @model_validator(mode="after") + def check_variance_lt_frequency(self) -> "AgentStartSettings": + """ + Make sure variance is equal to or lower than frequency. + + This is because the calculation for the next execution time is now + (frequency +- variance). If variance were + greater than frequency, sometimes the bracketed term would be negative and the attack would never happen again. + """ + if self.variance > self.frequency: + raise ValueError( + f"Agent start settings error: variance must be lower than frequency " + f"{self.variance=}, {self.frequency=}" + ) + return self + + +class AgentSettings(BaseModel): + """Settings for configuring the operation of an agent.""" + + start_settings: Optional[AgentStartSettings] = None + "Configuration for when an agent begins performing it's actions" + flatten_obs: bool = True + "Whether to flatten the observation space before passing it to the agent. True by default." + + @classmethod + def from_config(cls, config: Optional[Dict]) -> "AgentSettings": + """Construct agent settings from a config dictionary. + + :param config: A dict of options for the agent settings. + :type config: Dict + :return: The agent settings. + :rtype: AgentSettings + """ + if config is None: + return cls() + + return cls(**config) + + +class AbstractAgent(ABC): + """Base class for scripted and RL agents.""" + + def __init__( + self, + agent_name: Optional[str], + action_space: Optional[ActionManager], + observation_space: Optional[ObservationManager], + reward_function: Optional[RewardFunction], + agent_settings: Optional[AgentSettings] = None, + ) -> None: + """ + Initialize an agent. + + :param agent_name: Unique string identifier for the agent, for reporting and multi-agent purposes. + :type agent_name: Optional[str] + :param action_space: Action space for the agent. + :type action_space: Optional[ActionManager] + :param observation_space: Observation space for the agent. + :type observation_space: Optional[ObservationSpace] + :param reward_function: Reward function for the agent. + :type reward_function: Optional[RewardFunction] + """ + self.agent_name: str = agent_name or "unnamed_agent" + self.action_manager: Optional[ActionManager] = action_space + self.observation_manager: Optional[ObservationManager] = observation_space + self.reward_function: Optional[RewardFunction] = reward_function + self.agent_settings = agent_settings or AgentSettings() + self.history: List[AgentHistoryItem] = [] + + def update_observation(self, state: Dict) -> ObsType: + """ + Convert a state from the simulator into an observation for the agent using the observation space. + + state : dict state directly from simulation.describe_state + output : dict state according to CAOS. + """ + return self.observation_manager.update(state) + + def update_reward(self, state: Dict) -> float: + """ + Use the reward function to calculate a reward from the state. + + :param state: State of the environment. + :type state: Dict + :return: Reward from the state. + :rtype: float + """ + return self.reward_function.update(state=state, last_action_response=self.history[-1]) + + @abstractmethod + def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: + """ + Return an action to be taken in the environment. + + Subclasses should implement agent logic here. It should use the observation as input to decide best next action. + + :param obs: Observation of the environment. + :type obs: ObsType + :param timestep: The current timestep in the simulation, used for non-RL agents. Optional + :type timestep: int + :return: Action to be taken in the environment. + :rtype: Tuple[str, Dict] + """ + # in RL agent, this method will send CAOS observation to RL agent, then receive a int 0-39, + # then use a bespoke conversion to take 1-40 int back into CAOS action + return ("DO_NOTHING", {}) + + def format_request(self, action: Tuple[str, Dict], options: Dict[str, int]) -> List[str]: + # this will take something like APPLICATION.EXECUTE and add things like target_ip_address in simulator. + # therefore the execution definition needs to be a mapping from CAOS into SIMULATOR + """Format action into format expected by the simulator, and apply execution definition if applicable.""" + request = self.action_manager.form_request(action_identifier=action, action_options=options) + return request + + def process_action_response( + self, timestep: int, action: str, parameters: Dict[str, Any], request: RequestFormat, response: RequestResponse + ) -> None: + """Process the response from the most recent action.""" + self.history.append( + AgentHistoryItem( + timestep=timestep, action=action, parameters=parameters, request=request, response=response + ) + ) + + def save_reward_to_history(self) -> None: + """Update the most recent history item with the reward value.""" + self.history[-1].reward = self.reward_function.current_reward + + +class AbstractScriptedAgent(AbstractAgent): + """Base class for actors which generate their own behaviour.""" + + @abstractmethod + def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: + """Return an action to be taken in the environment.""" + return super().get_action(obs=obs, timestep=timestep) + + +class ProxyAgent(AbstractAgent): + """Agent that sends observations to an RL model and receives actions from that model.""" + + def __init__( + self, + agent_name: Optional[str], + action_space: Optional[ActionManager], + observation_space: Optional[ObservationManager], + reward_function: Optional[RewardFunction], + agent_settings: Optional[AgentSettings] = None, + ) -> None: + super().__init__( + agent_name=agent_name, + action_space=action_space, + observation_space=observation_space, + reward_function=reward_function, + ) + self.most_recent_action: ActType + self.flatten_obs: bool = agent_settings.flatten_obs if agent_settings else False + + def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: + """ + Return the agent's most recent action, formatted in CAOS format. + + :param obs: Observation for the agent. Not used by ProxyAgents, but required by the interface. + :type obs: ObsType + :param timestep: Current simulation timestep. Not used by ProxyAgents, bur required for the interface. + :type timestep: int + :return: Action to be taken in CAOS format. + :rtype: Tuple[str, Dict] + """ + return self.action_manager.get_action(self.most_recent_action) + + def store_action(self, action: ActType): + """ + Store the most recent action taken by the agent. + + The environment is responsible for calling this method when it receives an action from the agent policy. + """ + self.most_recent_action = action diff --git a/src/primaite/game/agent/observations/__init__.py b/src/primaite/game/agent/observations/__init__.py new file mode 100644 index 00000000..15fdf7ed --- /dev/null +++ b/src/primaite/game/agent/observations/__init__.py @@ -0,0 +1,20 @@ +# flake8: noqa +# Pre-import all the observations when we load up the observations module so that they can be resolved by the parser. +from primaite.game.agent.observations.acl_observation import ACLObservation +from primaite.game.agent.observations.file_system_observations import FileObservation, FolderObservation +from primaite.game.agent.observations.firewall_observation import FirewallObservation +from primaite.game.agent.observations.host_observations import HostObservation +from primaite.game.agent.observations.link_observation import LinkObservation, LinksObservation +from primaite.game.agent.observations.nic_observations import NICObservation, PortObservation +from primaite.game.agent.observations.node_observations import NodesObservation +from primaite.game.agent.observations.observation_manager import NestedObservation, NullObservation, ObservationManager +from primaite.game.agent.observations.observations import AbstractObservation +from primaite.game.agent.observations.router_observation import RouterObservation +from primaite.game.agent.observations.software_observation import ApplicationObservation, ServiceObservation + +# fmt: off +__all__ = [ + "ACLObservation", "FileObservation", "FolderObservation", "FirewallObservation", "HostObservation", + "LinksObservation", "NICObservation", "PortObservation", "NodesObservation", "NestedObservation", + "ObservationManager", "ApplicationObservation", "ServiceObservation",] +# fmt: on diff --git a/src/primaite/game/agent/observations/acl_observation.py b/src/primaite/game/agent/observations/acl_observation.py new file mode 100644 index 00000000..934d688e --- /dev/null +++ b/src/primaite/game/agent/observations/acl_observation.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +_LOGGER = getLogger(__name__) + + +class ACLObservation(AbstractObservation, identifier="ACL"): + """ACL observation, provides information about access control lists within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for ACLObservation.""" + + ip_list: Optional[List[IPv4Address]] = None + """List of IP addresses.""" + wildcard_list: Optional[List[str]] = None + """List of wildcard strings.""" + port_list: Optional[List[int]] = None + """List of port numbers.""" + protocol_list: Optional[List[str]] = None + """List of protocol names.""" + num_rules: Optional[int] = None + """Number of ACL rules.""" + + def __init__( + self, + where: WhereType, + num_rules: int, + ip_list: List[IPv4Address], + wildcard_list: List[str], + port_list: List[int], + protocol_list: List[str], + ) -> None: + """ + Initialise an ACL observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this ACL. + :type where: WhereType + :param num_rules: Number of ACL rules. + :type num_rules: int + :param ip_list: List of IP addresses. + :type ip_list: List[IPv4Address] + :param wildcard_list: List of wildcard strings. + :type wildcard_list: List[str] + :param port_list: List of port numbers. + :type port_list: List[int] + :param protocol_list: List of protocol names. + :type protocol_list: List[str] + """ + self.where = where + self.num_rules: int = num_rules + self.ip_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(ip_list)} + self.wildcard_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(wildcard_list)} + self.port_to_id: Dict[int, int] = {p: i + 2 for i, p in enumerate(port_list)} + self.protocol_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(protocol_list)} + self.default_observation: Dict = { + i + + 1: { + "position": i, + "permission": 0, + "source_ip_id": 0, + "source_wildcard_id": 0, + "source_port_id": 0, + "dest_ip_id": 0, + "dest_wildcard_id": 0, + "dest_port_id": 0, + "protocol_id": 0, + } + for i in range(self.num_rules) + } + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing ACL rules. + :rtype: ObsType + """ + acl_state: Dict = access_from_nested_dict(state, self.where) + if acl_state is NOT_PRESENT_IN_STATE: + return self.default_observation + obs = {} + acl_items = dict(acl_state.items()) + i = 1 # don't show rule 0 for compatibility reasons. + while i < self.num_rules + 1: + rule_state = acl_items[i] + if rule_state is None: + obs[i] = { + "position": i - 1, + "permission": 0, + "source_ip_id": 0, + "source_wildcard_id": 0, + "source_port_id": 0, + "dest_ip_id": 0, + "dest_wildcard_id": 0, + "dest_port_id": 0, + "protocol_id": 0, + } + else: + src_ip = rule_state["src_ip_address"] + src_node_id = 1 if src_ip is None else self.ip_to_id[src_ip] + dst_ip = rule_state["dst_ip_address"] + dst_node_id = 1 if dst_ip is None else self.ip_to_id[dst_ip] + src_wildcard = rule_state["src_wildcard_mask"] + src_wildcard_id = self.wildcard_to_id.get(src_wildcard, 1) + dst_wildcard = rule_state["dst_wildcard_mask"] + dst_wildcard_id = self.wildcard_to_id.get(dst_wildcard, 1) + src_port = rule_state["src_port"] + src_port_id = self.port_to_id.get(src_port, 1) + dst_port = rule_state["dst_port"] + dst_port_id = self.port_to_id.get(dst_port, 1) + protocol = rule_state["protocol"] + protocol_id = self.protocol_to_id.get(protocol, 1) + obs[i] = { + "position": i - 1, + "permission": rule_state["action"], + "source_ip_id": src_node_id, + "source_wildcard_id": src_wildcard_id, + "source_port_id": src_port_id, + "dest_ip_id": dst_node_id, + "dest_wildcard_id": dst_wildcard_id, + "dest_port_id": dst_port_id, + "protocol_id": protocol_id, + } + i += 1 + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for ACL rules. + :rtype: spaces.Space + """ + return spaces.Dict( + { + i + + 1: spaces.Dict( + { + "position": spaces.Discrete(self.num_rules), + "permission": spaces.Discrete(3), + # adding two to lengths is to account for reserved values 0 (unused) and 1 (any) + "source_ip_id": spaces.Discrete(len(self.ip_to_id) + 2), + "source_wildcard_id": spaces.Discrete(len(self.wildcard_to_id) + 2), + "source_port_id": spaces.Discrete(len(self.port_to_id) + 2), + "dest_ip_id": spaces.Discrete(len(self.ip_to_id) + 2), + "dest_wildcard_id": spaces.Discrete(len(self.wildcard_to_id) + 2), + "dest_port_id": spaces.Discrete(len(self.port_to_id) + 2), + "protocol_id": spaces.Discrete(len(self.protocol_to_id) + 2), + } + ) + for i in range(self.num_rules) + } + ) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> ACLObservation: + """ + Create an ACL observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the ACL observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this ACL's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed ACL observation instance. + :rtype: ACLObservation + """ + return cls( + where=parent_where + ["acl", "acl"], + num_rules=config.num_rules, + ip_list=config.ip_list, + wildcard_list=config.wildcard_list, + port_list=config.port_list, + protocol_list=config.protocol_list, + ) diff --git a/src/primaite/game/agent/observations/file_system_observations.py b/src/primaite/game/agent/observations/file_system_observations.py new file mode 100644 index 00000000..baf27660 --- /dev/null +++ b/src/primaite/game/agent/observations/file_system_observations.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from typing import Dict, Iterable, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +_LOGGER = getLogger(__name__) + + +class FileObservation(AbstractObservation, identifier="FILE"): + """File observation, provides status information about a file within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for FileObservation.""" + + file_name: str + """Name of the file, used for querying simulation state dictionary.""" + include_num_access: Optional[bool] = None + """Whether to include the number of accesses to the file in the observation.""" + + def __init__(self, where: WhereType, include_num_access: bool) -> None: + """ + Initialise a file observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this file. + A typical location for a file might be + ['network', 'nodes', , 'file_system', 'folder', , 'files', ]. + :type where: WhereType + :param include_num_access: Whether to include the number of accesses to the file in the observation. + :type include_num_access: bool + """ + self.where: WhereType = where + self.include_num_access: bool = include_num_access + + self.default_observation: ObsType = {"health_status": 0} + if self.include_num_access: + self.default_observation["num_access"] = 0 + + # TODO: allow these to be configured in yaml + self.high_threshold = 10 + self.med_threshold = 5 + self.low_threshold = 0 + + def _categorise_num_access(self, num_access: int) -> int: + """ + Represent number of file accesses as a categorical variable. + + :param num_access: Number of file accesses. + :return: Bin number corresponding to the number of accesses. + """ + if num_access > self.high_threshold: + return 3 + elif num_access > self.med_threshold: + return 2 + elif num_access > self.low_threshold: + return 1 + return 0 + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the health status of the file and optionally the number of accesses. + :rtype: ObsType + """ + file_state = access_from_nested_dict(state, self.where) + if file_state is NOT_PRESENT_IN_STATE: + return self.default_observation + obs = {"health_status": file_state["visible_status"]} + if self.include_num_access: + obs["num_access"] = self._categorise_num_access(file_state["num_access"]) + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for file status. + :rtype: spaces.Space + """ + space = {"health_status": spaces.Discrete(6)} + if self.include_num_access: + space["num_access"] = spaces.Discrete(4) + return spaces.Dict(space) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> FileObservation: + """ + Create a file observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the file observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this file's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed file observation instance. + :rtype: FileObservation + """ + return cls(where=parent_where + ["files", config.file_name], include_num_access=config.include_num_access) + + +class FolderObservation(AbstractObservation, identifier="FOLDER"): + """Folder observation, provides status information about a folder within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for FolderObservation.""" + + folder_name: str + """Name of the folder, used for querying simulation state dictionary.""" + files: List[FileObservation.ConfigSchema] = [] + """List of file configurations within the folder.""" + num_files: Optional[int] = None + """Number of spaces for file observations in this folder.""" + include_num_access: Optional[bool] = None + """Whether files in this folder should include the number of accesses in their observation.""" + + def __init__( + self, where: WhereType, files: Iterable[FileObservation], num_files: int, include_num_access: bool + ) -> None: + """ + Initialise a folder observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this folder. + A typical location for a folder might be ['network', 'nodes', , 'folders', ]. + :type where: WhereType + :param files: List of file observation instances within the folder. + :type files: Iterable[FileObservation] + :param num_files: Number of files expected in the folder. + :type num_files: int + :param include_num_access: Whether to include the number of accesses to files in the observation. + :type include_num_access: bool + """ + self.where: WhereType = where + + self.files: List[FileObservation] = files + while len(self.files) < num_files: + self.files.append(FileObservation(where=None, include_num_access=include_num_access)) + while len(self.files) > num_files: + truncated_file = self.files.pop() + msg = f"Too many files in folder observation. Truncating file {truncated_file}" + _LOGGER.warning(msg) + + self.default_observation = { + "health_status": 0, + } + if self.files: + self.default_observation["FILES"] = {i + 1: f.default_observation for i, f in enumerate(self.files)} + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the health status of the folder and status of files within the folder. + :rtype: ObsType + """ + folder_state = access_from_nested_dict(state, self.where) + if folder_state is NOT_PRESENT_IN_STATE: + return self.default_observation + + health_status = folder_state["health_status"] + + obs = {} + + obs["health_status"] = health_status + if self.files: + obs["FILES"] = {i + 1: file.observe(state) for i, file in enumerate(self.files)} + + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for folder status. + :rtype: spaces.Space + """ + shape = {"health_status": spaces.Discrete(6)} + if self.files: + shape["FILES"] = spaces.Dict({i + 1: f.space for i, f in enumerate(self.files)}) + return spaces.Dict(shape) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> FolderObservation: + """ + Create a folder observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the folder observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this folder's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed folder observation instance. + :rtype: FolderObservation + """ + where = parent_where + ["file_system", "folders", config.folder_name] + + # pass down shared/common config items + for file_config in config.files: + file_config.include_num_access = config.include_num_access + + files = [FileObservation.from_config(config=f, parent_where=where) for f in config.files] + return cls(where=where, files=files, num_files=config.num_files, include_num_access=config.include_num_access) diff --git a/src/primaite/game/agent/observations/firewall_observation.py b/src/primaite/game/agent/observations/firewall_observation.py new file mode 100644 index 00000000..97a8f814 --- /dev/null +++ b/src/primaite/game/agent/observations/firewall_observation.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.acl_observation import ACLObservation +from primaite.game.agent.observations.nic_observations import PortObservation +from primaite.game.agent.observations.observations import AbstractObservation, WhereType + +_LOGGER = getLogger(__name__) + + +class FirewallObservation(AbstractObservation, identifier="FIREWALL"): + """Firewall observation, provides status information about a firewall within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for FirewallObservation.""" + + hostname: str + """Hostname of the firewall node, used for querying simulation state dictionary.""" + ip_list: Optional[List[str]] = None + """List of IP addresses for encoding ACLs.""" + wildcard_list: Optional[List[str]] = None + """List of IP wildcards for encoding ACLs.""" + port_list: Optional[List[int]] = None + """List of ports for encoding ACLs.""" + protocol_list: Optional[List[str]] = None + """List of protocols for encoding ACLs.""" + num_rules: Optional[int] = None + """Number of rules ACL rules to show.""" + + def __init__( + self, + where: WhereType, + ip_list: List[str], + wildcard_list: List[str], + port_list: List[int], + protocol_list: List[str], + num_rules: int, + ) -> None: + """ + Initialise a firewall observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this firewall. + A typical location for a firewall might be ['network', 'nodes', ]. + :type where: WhereType + :param ip_list: List of IP addresses. + :type ip_list: List[str] + :param wildcard_list: List of wildcard rules. + :type wildcard_list: List[str] + :param port_list: List of port numbers. + :type port_list: List[int] + :param protocol_list: List of protocol types. + :type protocol_list: List[str] + :param num_rules: Number of rules configured in the firewall. + :type num_rules: int + """ + self.where: WhereType = where + + self.ports: List[PortObservation] = [ + PortObservation(where=self.where + ["NICs", port_num]) for port_num in (1, 2, 3) + ] + # TODO: check what the port nums are for firewall. + + self.internal_inbound_acl = ACLObservation( + where=self.where + ["internal_inbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + self.internal_outbound_acl = ACLObservation( + where=self.where + ["internal_outbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + self.dmz_inbound_acl = ACLObservation( + where=self.where + ["dmz_inbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + self.dmz_outbound_acl = ACLObservation( + where=self.where + ["dmz_outbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + self.external_inbound_acl = ACLObservation( + where=self.where + ["external_inbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + self.external_outbound_acl = ACLObservation( + where=self.where + ["external_outbound_acl", "acl"], + num_rules=num_rules, + ip_list=ip_list, + wildcard_list=wildcard_list, + port_list=port_list, + protocol_list=protocol_list, + ) + + self.default_observation = { + "PORTS": {i + 1: p.default_observation for i, p in enumerate(self.ports)}, + "ACL": { + "INTERNAL": { + "INBOUND": self.internal_inbound_acl.default_observation, + "OUTBOUND": self.internal_outbound_acl.default_observation, + }, + "DMZ": { + "INBOUND": self.dmz_inbound_acl.default_observation, + "OUTBOUND": self.dmz_outbound_acl.default_observation, + }, + "EXTERNAL": { + "INBOUND": self.external_inbound_acl.default_observation, + "OUTBOUND": self.external_outbound_acl.default_observation, + }, + }, + } + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the status of ports and ACLs for internal, DMZ, and external traffic. + :rtype: ObsType + """ + obs = { + "PORTS": {i + 1: p.observe(state) for i, p in enumerate(self.ports)}, + "ACL": { + "INTERNAL": { + "INBOUND": self.internal_inbound_acl.observe(state), + "OUTBOUND": self.internal_outbound_acl.observe(state), + }, + "DMZ": { + "INBOUND": self.dmz_inbound_acl.observe(state), + "OUTBOUND": self.dmz_outbound_acl.observe(state), + }, + "EXTERNAL": { + "INBOUND": self.external_inbound_acl.observe(state), + "OUTBOUND": self.external_outbound_acl.observe(state), + }, + }, + } + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for firewall status. + :rtype: spaces.Space + """ + space = spaces.Dict( + { + "PORTS": spaces.Dict({i + 1: p.space for i, p in enumerate(self.ports)}), + "ACL": spaces.Dict( + { + "INTERNAL": spaces.Dict( + { + "INBOUND": self.internal_inbound_acl.space, + "OUTBOUND": self.internal_outbound_acl.space, + } + ), + "DMZ": spaces.Dict( + { + "INBOUND": self.dmz_inbound_acl.space, + "OUTBOUND": self.dmz_outbound_acl.space, + } + ), + "EXTERNAL": spaces.Dict( + { + "INBOUND": self.external_inbound_acl.space, + "OUTBOUND": self.external_outbound_acl.space, + } + ), + } + ), + } + ) + return space + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> FirewallObservation: + """ + Create a firewall observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the firewall observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this firewall's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed firewall observation instance. + :rtype: FirewallObservation + """ + return cls( + where=parent_where + [config.hostname], + ip_list=config.ip_list, + wildcard_list=config.wildcard_list, + port_list=config.port_list, + protocol_list=config.protocol_list, + num_rules=config.num_rules, + ) diff --git a/src/primaite/game/agent/observations/host_observations.py b/src/primaite/game/agent/observations/host_observations.py new file mode 100644 index 00000000..02c0d17f --- /dev/null +++ b/src/primaite/game/agent/observations/host_observations.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.file_system_observations import FolderObservation +from primaite.game.agent.observations.nic_observations import NICObservation +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.observations.software_observation import ApplicationObservation, ServiceObservation +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +_LOGGER = getLogger(__name__) + + +class HostObservation(AbstractObservation, identifier="HOST"): + """Host observation, provides status information about a host within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for HostObservation.""" + + hostname: str + """Hostname of the host, used for querying simulation state dictionary.""" + services: List[ServiceObservation.ConfigSchema] = [] + """List of services to observe on the host.""" + applications: List[ApplicationObservation.ConfigSchema] = [] + """List of applications to observe on the host.""" + folders: List[FolderObservation.ConfigSchema] = [] + """List of folders to observe on the host.""" + network_interfaces: List[NICObservation.ConfigSchema] = [] + """List of network interfaces to observe on the host.""" + num_services: Optional[int] = None + """Number of spaces for service observations on this host.""" + num_applications: Optional[int] = None + """Number of spaces for application observations on this host.""" + num_folders: Optional[int] = None + """Number of spaces for folder observations on this host.""" + num_files: Optional[int] = None + """Number of spaces for file observations on this host.""" + num_nics: Optional[int] = None + """Number of spaces for network interface observations on this host.""" + include_nmne: Optional[bool] = None + """Whether network interface observations should include number of malicious network events.""" + include_num_access: Optional[bool] = None + """Whether to include the number of accesses to files observations on this host.""" + + def __init__( + self, + where: WhereType, + services: List[ServiceObservation], + applications: List[ApplicationObservation], + folders: List[FolderObservation], + network_interfaces: List[NICObservation], + num_services: int, + num_applications: int, + num_folders: int, + num_files: int, + num_nics: int, + include_nmne: bool, + include_num_access: bool, + ) -> None: + """ + Initialise a host observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this host. + A typical location for a host might be ['network', 'nodes', ]. + :type where: WhereType + :param services: List of service observations on the host. + :type services: List[ServiceObservation] + :param applications: List of application observations on the host. + :type applications: List[ApplicationObservation] + :param folders: List of folder observations on the host. + :type folders: List[FolderObservation] + :param network_interfaces: List of network interface observations on the host. + :type network_interfaces: List[NICObservation] + :param num_services: Number of services to observe. + :type num_services: int + :param num_applications: Number of applications to observe. + :type num_applications: int + :param num_folders: Number of folders to observe. + :type num_folders: int + :param num_files: Number of files. + :type num_files: int + :param num_nics: Number of network interfaces. + :type num_nics: int + :param include_nmne: Flag to include network metrics and errors. + :type include_nmne: bool + :param include_num_access: Flag to include the number of accesses to files. + :type include_num_access: bool + """ + self.where: WhereType = where + + self.include_num_access = include_num_access + + # Ensure lists have lengths equal to specified counts by truncating or padding + self.services: List[ServiceObservation] = services + while len(self.services) < num_services: + self.services.append(ServiceObservation(where=None)) + while len(self.services) > num_services: + truncated_service = self.services.pop() + msg = f"Too many services in Node observation space for node. Truncating service {truncated_service.where}" + _LOGGER.warning(msg) + + self.applications: List[ApplicationObservation] = applications + while len(self.applications) < num_applications: + self.applications.append(ApplicationObservation(where=None)) + while len(self.applications) > num_applications: + truncated_application = self.applications.pop() + msg = f"Too many applications in Node observation space for node. Truncating {truncated_application.where}" + _LOGGER.warning(msg) + + self.folders: List[FolderObservation] = folders + while len(self.folders) < num_folders: + self.folders.append( + FolderObservation(where=None, files=[], num_files=num_files, include_num_access=include_num_access) + ) + while len(self.folders) > num_folders: + truncated_folder = self.folders.pop() + msg = f"Too many folders in Node observation space for node. Truncating folder {truncated_folder.where}" + _LOGGER.warning(msg) + + self.nics: List[NICObservation] = network_interfaces + while len(self.nics) < num_nics: + self.nics.append(NICObservation(where=None, include_nmne=include_nmne)) + while len(self.nics) > num_nics: + truncated_nic = self.nics.pop() + msg = f"Too many network_interfaces in Node observation space for node. Truncating {truncated_nic.where}" + _LOGGER.warning(msg) + + self.default_observation: ObsType = { + "operating_status": 0, + } + if self.services: + self.default_observation["SERVICES"] = {i + 1: s.default_observation for i, s in enumerate(self.services)} + if self.applications: + self.default_observation["APPLICATIONS"] = { + i + 1: a.default_observation for i, a in enumerate(self.applications) + } + if self.folders: + self.default_observation["FOLDERS"] = {i + 1: f.default_observation for i, f in enumerate(self.folders)} + if self.nics: + self.default_observation["NICS"] = {i + 1: n.default_observation for i, n in enumerate(self.nics)} + if self.include_num_access: + self.default_observation["num_file_creations"] = 0 + self.default_observation["num_file_deletions"] = 0 + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the status information about the host. + :rtype: ObsType + """ + node_state = access_from_nested_dict(state, self.where) + if node_state is NOT_PRESENT_IN_STATE: + return self.default_observation + + obs = {} + obs["operating_status"] = node_state["operating_state"] + if self.services: + obs["SERVICES"] = {i + 1: service.observe(state) for i, service in enumerate(self.services)} + if self.applications: + obs["APPLICATIONS"] = {i + 1: app.observe(state) for i, app in enumerate(self.applications)} + if self.folders: + obs["FOLDERS"] = {i + 1: folder.observe(state) for i, folder in enumerate(self.folders)} + if self.nics: + obs["NICS"] = {i + 1: nic.observe(state) for i, nic in enumerate(self.nics)} + if self.include_num_access: + obs["num_file_creations"] = node_state["file_system"]["num_file_creations"] + obs["num_file_deletions"] = node_state["file_system"]["num_file_deletions"] + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for host status. + :rtype: spaces.Space + """ + shape = { + "operating_status": spaces.Discrete(5), + } + if self.services: + shape["SERVICES"] = spaces.Dict({i + 1: service.space for i, service in enumerate(self.services)}) + if self.applications: + shape["APPLICATIONS"] = spaces.Dict({i + 1: app.space for i, app in enumerate(self.applications)}) + if self.folders: + shape["FOLDERS"] = spaces.Dict({i + 1: folder.space for i, folder in enumerate(self.folders)}) + if self.nics: + shape["NICS"] = spaces.Dict({i + 1: nic.space for i, nic in enumerate(self.nics)}) + if self.include_num_access: + shape["num_file_creations"] = spaces.Discrete(4) + shape["num_file_deletions"] = spaces.Discrete(4) + return spaces.Dict(shape) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> HostObservation: + """ + Create a host observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the host observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this host. + A typical location might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed host observation instance. + :rtype: HostObservation + """ + if parent_where == []: + where = ["network", "nodes", config.hostname] + else: + where = parent_where + [config.hostname] + + # Pass down shared/common config items + for folder_config in config.folders: + folder_config.include_num_access = config.include_num_access + folder_config.num_files = config.num_files + for nic_config in config.network_interfaces: + nic_config.include_nmne = config.include_nmne + + services = [ServiceObservation.from_config(config=c, parent_where=where) for c in config.services] + applications = [ApplicationObservation.from_config(config=c, parent_where=where) for c in config.applications] + folders = [FolderObservation.from_config(config=c, parent_where=where) for c in config.folders] + nics = [NICObservation.from_config(config=c, parent_where=where) for c in config.network_interfaces] + # If list of network interfaces is not defined, assume we want to + # monitor the first N interfaces. Network interface numbering starts at 1. + count = 1 + while len(nics) < config.num_nics: + nic_config = NICObservation.ConfigSchema(nic_num=count, include_nmne=config.include_nmne) + nics.append(NICObservation.from_config(config=nic_config, parent_where=where)) + count += 1 + + return cls( + where=where, + services=services, + applications=applications, + folders=folders, + network_interfaces=nics, + num_services=config.num_services, + num_applications=config.num_applications, + num_folders=config.num_folders, + num_files=config.num_files, + num_nics=config.num_nics, + include_nmne=config.include_nmne, + include_num_access=config.include_num_access, + ) diff --git a/src/primaite/game/agent/observations/link_observation.py b/src/primaite/game/agent/observations/link_observation.py new file mode 100644 index 00000000..50dc1105 --- /dev/null +++ b/src/primaite/game/agent/observations/link_observation.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +_LOGGER = getLogger(__name__) + + +class LinkObservation(AbstractObservation, identifier="LINK"): + """Link observation, providing information about a specific link within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for LinkObservation.""" + + link_reference: str + """Reference identifier for the link.""" + + def __init__(self, where: WhereType) -> None: + """ + Initialise a link observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this link. + A typical location for a link might be ['network', 'links', ]. + :type where: WhereType + """ + self.where = where + self.default_observation: ObsType = {"PROTOCOLS": {"ALL": 0}} + + def observe(self, state: Dict) -> Any: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing information about the link. + :rtype: Any + """ + link_state = access_from_nested_dict(state, self.where) + if link_state is NOT_PRESENT_IN_STATE: + self.where[-1] = "<->".join(self.where[-1].split("<->")[::-1]) # try swapping endpoint A and B + link_state = access_from_nested_dict(state, self.where) + if link_state is NOT_PRESENT_IN_STATE: + return self.default_observation + + bandwidth = link_state["bandwidth"] + load = link_state["current_load"] + if load == 0: + utilisation_category = 0 + else: + utilisation_fraction = load / bandwidth + utilisation_category = int(utilisation_fraction * 9) + 1 + + return {"PROTOCOLS": {"ALL": min(utilisation_category, 10)}} + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for link status. + :rtype: spaces.Space + """ + return spaces.Dict({"PROTOCOLS": spaces.Dict({"ALL": spaces.Discrete(11)})}) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> LinkObservation: + """ + Create a link observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the link observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this link. + A typical location might be ['network', 'links', ]. + :type parent_where: WhereType, optional + :return: Constructed link observation instance. + :rtype: LinkObservation + """ + link_reference = config.link_reference + if parent_where == []: + where = ["network", "links", link_reference] + else: + where = parent_where + ["links", link_reference] + return cls(where=where) + + +class LinksObservation(AbstractObservation, identifier="LINKS"): + """Collection of link observations representing multiple links within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for LinksObservation.""" + + link_references: List[str] + """List of reference identifiers for the links.""" + + def __init__(self, where: WhereType, links: List[LinkObservation]) -> None: + """ + Initialise a links observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for these links. + A typical location for links might be ['network', 'links']. + :type where: WhereType + :param links: List of link observations. + :type links: List[LinkObservation] + """ + self.where: WhereType = where + self.links: List[LinkObservation] = links + self.default_observation: ObsType = {i + 1: l.default_observation for i, l in enumerate(self.links)} + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing information about multiple links. + :rtype: ObsType + """ + return {i + 1: l.observe(state) for i, l in enumerate(self.links)} + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for multiple links. + :rtype: spaces.Space + """ + return spaces.Dict({i + 1: l.space for i, l in enumerate(self.links)}) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> LinksObservation: + """ + Create a links observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the links observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about these links. + A typical location might be ['network']. + :type parent_where: WhereType, optional + :return: Constructed links observation instance. + :rtype: LinksObservation + """ + where = parent_where + ["network"] + link_cfgs = [LinkObservation.ConfigSchema(link_reference=ref) for ref in config.link_references] + links = [LinkObservation.from_config(c, parent_where=where) for c in link_cfgs] + return cls(where=where, links=links) diff --git a/src/primaite/game/agent/observations/nic_observations.py b/src/primaite/game/agent/observations/nic_observations.py new file mode 100644 index 00000000..afce9095 --- /dev/null +++ b/src/primaite/game/agent/observations/nic_observations.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from typing import Dict, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + + +class NICObservation(AbstractObservation, identifier="NETWORK_INTERFACE"): + """Status information about a network interface within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for NICObservation.""" + + nic_num: int + """Number of the network interface.""" + include_nmne: Optional[bool] = None + """Whether to include number of malicious network events (NMNE) in the observation.""" + + def __init__( + self, + where: WhereType, + include_nmne: bool, + ) -> None: + """ + Initialise a network interface observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this interface. + A typical location for a network interface might be + ['network', 'nodes', , 'NICs', ]. + :type where: WhereType + :param include_nmne: Flag to determine whether to include NMNE information in the observation. + :type include_nmne: bool + """ + self.where = where + self.include_nmne: bool = include_nmne + + self.default_observation: ObsType = {"nic_status": 0} + if self.include_nmne: + self.default_observation.update({"NMNE": {"inbound": 0, "outbound": 0}}) + self.nmne_inbound_last_step: int = 0 + self.nmne_outbound_last_step: int = 0 + + # TODO: allow these to be configured in yaml + self.high_nmne_threshold = 10 + self.med_nmne_threshold = 5 + self.low_nmne_threshold = 0 + + def _categorise_mne_count(self, nmne_count: int) -> int: + """ + Categorise the number of Malicious Network Events (NMNEs) into discrete bins. + + This helps in classifying the severity or volume of MNEs into manageable levels for the agent. + + Bins are defined as follows: + - 0: No MNEs detected (0 events). + - 1: Low number of MNEs (default 1-5 events). + - 2: Moderate number of MNEs (default 6-10 events). + - 3: High number of MNEs (default more than 10 events). + + :param nmne_count: Number of MNEs detected. + :return: Bin number corresponding to the number of MNEs. Returns 0, 1, 2, or 3 based on the detected MNE count. + """ + if nmne_count > self.high_nmne_threshold: + return 3 + elif nmne_count > self.med_nmne_threshold: + return 2 + elif nmne_count > self.low_nmne_threshold: + return 1 + return 0 + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the status of the network interface and optionally NMNE information. + :rtype: ObsType + """ + nic_state = access_from_nested_dict(state, self.where) + + if nic_state is NOT_PRESENT_IN_STATE: + return self.default_observation + + obs = {"nic_status": 1 if nic_state["enabled"] else 2} + if self.include_nmne: + obs.update({"NMNE": {}}) + direction_dict = nic_state["nmne"].get("direction", {}) + inbound_keywords = direction_dict.get("inbound", {}).get("keywords", {}) + inbound_count = inbound_keywords.get("*", 0) + outbound_keywords = direction_dict.get("outbound", {}).get("keywords", {}) + outbound_count = outbound_keywords.get("*", 0) + obs["NMNE"]["inbound"] = self._categorise_mne_count(inbound_count - self.nmne_inbound_last_step) + obs["NMNE"]["outbound"] = self._categorise_mne_count(outbound_count - self.nmne_outbound_last_step) + self.nmne_inbound_last_step = inbound_count + self.nmne_outbound_last_step = outbound_count + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for network interface status and NMNE information. + :rtype: spaces.Space + """ + space = spaces.Dict({"nic_status": spaces.Discrete(3)}) + + if self.include_nmne: + space["NMNE"] = spaces.Dict({"inbound": spaces.Discrete(4), "outbound": spaces.Discrete(4)}) + + return space + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> NICObservation: + """ + Create a network interface observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the network interface observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this NIC's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed network interface observation instance. + :rtype: NICObservation + """ + return cls(where=parent_where + ["NICs", config.nic_num], include_nmne=config.include_nmne) + + +class PortObservation(AbstractObservation, identifier="PORT"): + """Port observation, provides status information about a network port within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for PortObservation.""" + + port_id: int + """Identifier of the port, used for querying simulation state dictionary.""" + + def __init__(self, where: WhereType) -> None: + """ + Initialise a port observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this port. + A typical location for a port might be ['network', 'nodes', , 'NICs', ]. + :type where: WhereType + """ + self.where = where + self.default_observation: ObsType = {"operating_status": 0} + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the operating status of the port. + :rtype: ObsType + """ + port_state = access_from_nested_dict(state, self.where) + if port_state is NOT_PRESENT_IN_STATE: + return self.default_observation + return {"operating_status": 1 if port_state["enabled"] else 2} + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for port status. + :rtype: spaces.Space + """ + return spaces.Dict({"operating_status": spaces.Discrete(3)}) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> PortObservation: + """ + Create a port observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the port observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this port's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed port observation instance. + :rtype: PortObservation + """ + return cls(where=parent_where + ["NICs", config.port_id]) diff --git a/src/primaite/game/agent/observations/node_observations.py b/src/primaite/game/agent/observations/node_observations.py new file mode 100644 index 00000000..8f7ac0fc --- /dev/null +++ b/src/primaite/game/agent/observations/node_observations.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType +from pydantic import model_validator + +from primaite import getLogger +from primaite.game.agent.observations.firewall_observation import FirewallObservation +from primaite.game.agent.observations.host_observations import HostObservation +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.observations.router_observation import RouterObservation + +_LOGGER = getLogger(__name__) + + +class NodesObservation(AbstractObservation, identifier="NODES"): + """Nodes observation, provides status information about nodes within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for NodesObservation.""" + + hosts: List[HostObservation.ConfigSchema] = [] + """List of configurations for host observations.""" + routers: List[RouterObservation.ConfigSchema] = [] + """List of configurations for router observations.""" + firewalls: List[FirewallObservation.ConfigSchema] = [] + """List of configurations for firewall observations.""" + num_services: Optional[int] = None + """Number of services.""" + num_applications: Optional[int] = None + """Number of applications.""" + num_folders: Optional[int] = None + """Number of folders.""" + num_files: Optional[int] = None + """Number of files.""" + num_nics: Optional[int] = None + """Number of network interface cards (NICs).""" + include_nmne: Optional[bool] = None + """Flag to include nmne.""" + include_num_access: Optional[bool] = None + """Flag to include the number of accesses.""" + num_ports: Optional[int] = None + """Number of ports.""" + ip_list: Optional[List[str]] = None + """List of IP addresses for encoding ACLs.""" + wildcard_list: Optional[List[str]] = None + """List of IP wildcards for encoding ACLs.""" + port_list: Optional[List[int]] = None + """List of ports for encoding ACLs.""" + protocol_list: Optional[List[str]] = None + """List of protocols for encoding ACLs.""" + num_rules: Optional[int] = None + """Number of rules ACL rules to show.""" + + @model_validator(mode="after") + def force_optional_fields(self) -> NodesObservation.ConfigSchema: + """Check that options are specified only if they are needed for the nodes that are part of the config.""" + # check for hosts: + host_fields = ( + self.num_services, + self.num_applications, + self.num_folders, + self.num_files, + self.num_nics, + self.include_nmne, + self.include_num_access, + ) + router_fields = ( + self.num_ports, + self.ip_list, + self.wildcard_list, + self.port_list, + self.protocol_list, + self.num_rules, + ) + firewall_fields = (self.ip_list, self.wildcard_list, self.port_list, self.protocol_list, self.num_rules) + if len(self.hosts) > 0 and any([x is None for x in host_fields]): + raise ValueError("Configuration error: Host observation options were not fully specified.") + if len(self.routers) > 0 and any([x is None for x in router_fields]): + raise ValueError("Configuration error: Router observation options were not fully specified.") + if len(self.firewalls) > 0 and any([x is None for x in firewall_fields]): + raise ValueError("Configuration error: Firewall observation options were not fully specified.") + return self + + def __init__( + self, + where: WhereType, + hosts: List[HostObservation], + routers: List[RouterObservation], + firewalls: List[FirewallObservation], + ) -> None: + """ + Initialise a nodes observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for nodes. + A typical location for nodes might be ['network', 'nodes']. + :type where: WhereType + :param hosts: List of host observations. + :type hosts: List[HostObservation] + :param routers: List of router observations. + :type routers: List[RouterObservation] + :param firewalls: List of firewall observations. + :type firewalls: List[FirewallObservation] + """ + self.where: WhereType = where + + self.hosts: List[HostObservation] = hosts + self.routers: List[RouterObservation] = routers + self.firewalls: List[FirewallObservation] = firewalls + + self.default_observation = { + **{f"HOST{i}": host.default_observation for i, host in enumerate(self.hosts)}, + **{f"ROUTER{i}": router.default_observation for i, router in enumerate(self.routers)}, + **{f"FIREWALL{i}": firewall.default_observation for i, firewall in enumerate(self.firewalls)}, + } + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing status information about nodes. + :rtype: ObsType + """ + obs = { + **{f"HOST{i}": host.observe(state) for i, host in enumerate(self.hosts)}, + **{f"ROUTER{i}": router.observe(state) for i, router in enumerate(self.routers)}, + **{f"FIREWALL{i}": firewall.observe(state) for i, firewall in enumerate(self.firewalls)}, + } + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for nodes. + :rtype: spaces.Space + """ + space = spaces.Dict( + { + **{f"HOST{i}": host.space for i, host in enumerate(self.hosts)}, + **{f"ROUTER{i}": router.space for i, router in enumerate(self.routers)}, + **{f"FIREWALL{i}": firewall.space for i, firewall in enumerate(self.firewalls)}, + } + ) + return space + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> NodesObservation: + """ + Create a nodes observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for nodes observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about nodes. + A typical location for nodes might be ['network', 'nodes']. + :type parent_where: WhereType, optional + :return: Constructed nodes observation instance. + :rtype: NodesObservation + """ + if not parent_where: + where = ["network", "nodes"] + else: + where = parent_where + ["nodes"] + + for host_config in config.hosts: + if host_config.num_services is None: + host_config.num_services = config.num_services + if host_config.num_applications is None: + host_config.num_applications = config.num_applications + if host_config.num_folders is None: + host_config.num_folders = config.num_folders + if host_config.num_files is None: + host_config.num_files = config.num_files + if host_config.num_nics is None: + host_config.num_nics = config.num_nics + if host_config.include_nmne is None: + host_config.include_nmne = config.include_nmne + if host_config.include_num_access is None: + host_config.include_num_access = config.include_num_access + + for router_config in config.routers: + if router_config.num_ports is None: + router_config.num_ports = config.num_ports + if router_config.ip_list is None: + router_config.ip_list = config.ip_list + if router_config.wildcard_list is None: + router_config.wildcard_list = config.wildcard_list + if router_config.port_list is None: + router_config.port_list = config.port_list + if router_config.protocol_list is None: + router_config.protocol_list = config.protocol_list + if router_config.num_rules is None: + router_config.num_rules = config.num_rules + + for firewall_config in config.firewalls: + if firewall_config.ip_list is None: + firewall_config.ip_list = config.ip_list + if firewall_config.wildcard_list is None: + firewall_config.wildcard_list = config.wildcard_list + if firewall_config.port_list is None: + firewall_config.port_list = config.port_list + if firewall_config.protocol_list is None: + firewall_config.protocol_list = config.protocol_list + if firewall_config.num_rules is None: + firewall_config.num_rules = config.num_rules + + hosts = [HostObservation.from_config(config=c, parent_where=where) for c in config.hosts] + routers = [RouterObservation.from_config(config=c, parent_where=where) for c in config.routers] + firewalls = [FirewallObservation.from_config(config=c, parent_where=where) for c in config.firewalls] + + return cls(where=where, hosts=hosts, routers=routers, firewalls=firewalls) diff --git a/src/primaite/game/agent/observations/observation_manager.py b/src/primaite/game/agent/observations/observation_manager.py new file mode 100644 index 00000000..352003d6 --- /dev/null +++ b/src/primaite/game/agent/observations/observation_manager.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType +from pydantic import BaseModel, ConfigDict, model_validator, ValidationError + +from primaite.game.agent.observations.observations import AbstractObservation, WhereType + + +class NestedObservation(AbstractObservation, identifier="CUSTOM"): + """Observation type that allows combining other observations into a gymnasium.spaces.Dict space.""" + + class NestedObservationItem(BaseModel): + """One list item of the config.""" + + model_config = ConfigDict(extra="forbid") + type: str + """Select observation class. It maps to the identifier of the obs class by checking the registry.""" + label: str + """Dict key in the final observation space.""" + options: Dict + """Options to pass to the observation class from_config method.""" + + @model_validator(mode="after") + def check_model(self) -> "NestedObservation.NestedObservationItem": + """Make sure tha the config options match up with the selected observation type.""" + obs_subclass_name = self.type + obs_options = self.options + if obs_subclass_name not in AbstractObservation._registry: + raise ValueError(f"Observation of type {obs_subclass_name} could not be found.") + obs_schema = AbstractObservation._registry[obs_subclass_name].ConfigSchema + try: + obs_schema(**obs_options) + except ValidationError as e: + raise ValueError(f"Observation options did not match schema, got this error: {e}") + return self + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for NestedObservation.""" + + components: List[NestedObservation.NestedObservationItem] = [] + """List of observation components to be part of this space.""" + + def __init__(self, components: Dict[str, AbstractObservation]) -> None: + """Initialise nested observation.""" + self.components: Dict[str, AbstractObservation] = components + """Maps label: observation object""" + + self.default_observation = {label: obs.default_observation for label, obs in self.components.items()} + """Default observation is just the default observations of constituents.""" + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the status information about the host. + :rtype: ObsType + """ + return {label: obs.observe(state) for label, obs in self.components.items()} + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the nested observation space. + :rtype: spaces.Space + """ + return spaces.Dict({label: obs.space for label, obs in self.components.items()}) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> NestedObservation: + """ + Read the Nested observation config and create all defined subcomponents. + + Example configuration that utilises NestedObservation: + This lets us have different options for different types of hosts. + + ```yaml + observation_space: + - type: CUSTOM + options: + components: + + - type: HOSTS + label: COMPUTERS # What is the dictionary key called + options: + hosts: + - client_1 + - client_2 + num_services: 0 + num_applications: 5 + ... # other options + + - type: HOSTS + label: SERVERS # What is the dictionary key called + options: + hosts: + - hostname: database_server + - hostname: web_server + num_services: 4 + num_applications: 0 + num_folders: 2 + num_files: 2 + + ``` + """ + instances = dict() + for component in config.components: + obs_class = AbstractObservation._registry[component.type] + obs_instance = obs_class.from_config(config=obs_class.ConfigSchema(**component.options)) + instances[component.label] = obs_instance + return cls(components=instances) + + +class NullObservation(AbstractObservation, identifier="NONE"): + """Empty observation that acts as a placeholder.""" + + def __init__(self) -> None: + """Initialise the empty observation.""" + self.default_observation = 0 + + def observe(self, state: Dict) -> Any: + """Simply return 0.""" + return 0 + + @property + def space(self) -> spaces.Space: + """Essentially empty space.""" + return spaces.Discrete(1) + + @classmethod + def from_config(cls, config: NullObservation.ConfigSchema, parent_where: WhereType = []) -> NullObservation: + """Instantiate a NullObservation. Accepts parameters to comply with API.""" + return cls() + + +class ObservationManager: + """ + Manage the observations of an Agent. + + The observation space has the purpose of: + 1. Reading the outputted state from the PrimAITE Simulation. + 2. Selecting parts of the simulation state that are requested by the simulation config + 3. Formatting this information so an agent can use it to make decisions. + """ + + def __init__(self, obs: AbstractObservation) -> None: + """Initialise observation space. + + :param observation: Observation object + :type observation: AbstractObservation + """ + self.obs: AbstractObservation = obs + self.current_observation: ObsType + """Cached copy of the observation at the time it was most recently calculated.""" + + def update(self, state: Dict) -> Dict: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary + :type state: Dict + """ + self.current_observation = self.obs.observe(state) + return self.current_observation + + @property + def space(self) -> None: + """Gymnasium space object describing the observation space shape.""" + return self.obs.space + + @classmethod + def from_config(cls, config: Optional[Dict]) -> "ObservationManager": + """ + Create observation space from a config. + + :param config: Dictionary containing the configuration for this observation space. + If None, a blank observation space is created. + Otherwise, this must be a Dict with a type field and options field. + type: string that corresponds to one of the observation identifiers that are provided when subclassing + AbstractObservation + options: this must adhere to the chosen observation type's ConfigSchema nested class. + :type config: Dict + """ + if config is None: + return cls(NullObservation()) + obs_type = config["type"] + obs_class = AbstractObservation._registry[obs_type] + observation = obs_class.from_config(config=obs_class.ConfigSchema(**config["options"])) + obs_manager = cls(observation) + return obs_manager diff --git a/src/primaite/game/agent/observations/observations.py b/src/primaite/game/agent/observations/observations.py new file mode 100644 index 00000000..1ba87a30 --- /dev/null +++ b/src/primaite/game/agent/observations/observations.py @@ -0,0 +1,68 @@ +"""Manages the observation space for the agent.""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Iterable, Optional, Type, Union + +from gymnasium import spaces +from gymnasium.core import ObsType +from pydantic import BaseModel, ConfigDict + +from primaite import getLogger + +_LOGGER = getLogger(__name__) +WhereType = Optional[Iterable[Union[str, int]]] + + +class AbstractObservation(ABC): + """Abstract class for an observation space component.""" + + class ConfigSchema(ABC, BaseModel): + """Config schema for observations.""" + + model_config = ConfigDict(extra="forbid") + + _registry: Dict[str, Type["AbstractObservation"]] = {} + """Registry of observation components, with their name as key. + + Automatically populated when subclasses are defined. Used for defining from_config. + """ + + def __init__(self) -> None: + """Initialise an observation. This method must be overwritten.""" + self.default_observation: ObsType + + def __init_subclass__(cls, identifier: str, **kwargs: Any) -> None: + """ + Register an observation type. + + :param identifier: Identifier used to uniquely specify observation component types. + :type identifier: str + :raises ValueError: When attempting to create a component with a name that is already in use. + """ + super().__init_subclass__(**kwargs) + if identifier in cls._registry: + raise ValueError(f"Duplicate observation component type {identifier}") + cls._registry[identifier] = cls + + @abstractmethod + def observe(self, state: Dict) -> Any: + """ + Return an observation based on the current state of the simulation. + + :param state: Simulation state dictionary + :type state: Dict + :return: Observation + :rtype: Any + """ + pass + + @property + @abstractmethod + def space(self) -> spaces.Space: + """Gymnasium space object describing the observation space.""" + pass + + @classmethod + @abstractmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> "AbstractObservation": + """Create this observation space component form a serialised format.""" + return cls() diff --git a/src/primaite/game/agent/observations/router_observation.py b/src/primaite/game/agent/observations/router_observation.py new file mode 100644 index 00000000..3f7e6494 --- /dev/null +++ b/src/primaite/game/agent/observations/router_observation.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite import getLogger +from primaite.game.agent.observations.acl_observation import ACLObservation +from primaite.game.agent.observations.nic_observations import PortObservation +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +_LOGGER = getLogger(__name__) + + +class RouterObservation(AbstractObservation, identifier="ROUTER"): + """Router observation, provides status information about a router within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for RouterObservation.""" + + hostname: str + """Hostname of the router, used for querying simulation state dictionary.""" + ports: Optional[List[PortObservation.ConfigSchema]] = None + """Configuration of port observations for this router.""" + num_ports: Optional[int] = None + """Number of port observations configured for this router.""" + acl: Optional[ACLObservation.ConfigSchema] = None + """Configuration of ACL observation on this router.""" + ip_list: Optional[List[str]] = None + """List of IP addresses for encoding ACLs.""" + wildcard_list: Optional[List[str]] = None + """List of IP wildcards for encoding ACLs.""" + port_list: Optional[List[int]] = None + """List of ports for encoding ACLs.""" + protocol_list: Optional[List[str]] = None + """List of protocols for encoding ACLs.""" + num_rules: Optional[int] = None + """Number of rules ACL rules to show.""" + + def __init__( + self, + where: WhereType, + ports: List[PortObservation], + num_ports: int, + acl: ACLObservation, + ) -> None: + """ + Initialise a router observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this router. + A typical location for a router might be ['network', 'nodes', ]. + :type where: WhereType + :param ports: List of port observations representing the ports of the router. + :type ports: List[PortObservation] + :param num_ports: Number of ports for the router. + :type num_ports: int + :param acl: ACL observation representing the access control list of the router. + :type acl: ACLObservation + """ + self.where: WhereType = where + self.ports: List[PortObservation] = ports + self.acl: ACLObservation = acl + self.num_ports: int = num_ports + + while len(self.ports) < num_ports: + self.ports.append(PortObservation(where=None)) + while len(self.ports) > num_ports: + self.ports.pop() + msg = "Too many ports in router observation. Truncating." + _LOGGER.warning(msg) + + self.default_observation = { + "ACL": self.acl.default_observation, + } + if self.ports: + self.default_observation["PORTS"] = {i + 1: p.default_observation for i, p in enumerate(self.ports)} + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the status of ports and ACL configuration of the router. + :rtype: ObsType + """ + router_state = access_from_nested_dict(state, self.where) + if router_state is NOT_PRESENT_IN_STATE: + return self.default_observation + + obs = {} + obs["ACL"] = self.acl.observe(state) + if self.ports: + obs["PORTS"] = {i + 1: p.observe(state) for i, p in enumerate(self.ports)} + return obs + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for router status. + :rtype: spaces.Space + """ + shape = {"ACL": self.acl.space} + if self.ports: + shape["PORTS"] = spaces.Dict({i + 1: p.space for i, p in enumerate(self.ports)}) + return spaces.Dict(shape) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> RouterObservation: + """ + Create a router observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the router observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this router's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed router observation instance. + :rtype: RouterObservation + """ + where = parent_where + [config.hostname] + + if config.acl is None: + config.acl = ACLObservation.ConfigSchema() + if config.acl.num_rules is None: + config.acl.num_rules = config.num_rules + if config.acl.ip_list is None: + config.acl.ip_list = config.ip_list + if config.acl.wildcard_list is None: + config.acl.wildcard_list = config.wildcard_list + if config.acl.port_list is None: + config.acl.port_list = config.port_list + if config.acl.protocol_list is None: + config.acl.protocol_list = config.protocol_list + + if config.ports is None: + config.ports = [PortObservation.ConfigSchema(port_id=i + 1) for i in range(config.num_ports)] + + ports = [PortObservation.from_config(config=c, parent_where=where) for c in config.ports] + acl = ACLObservation.from_config(config=config.acl, parent_where=where) + return cls(where=where, ports=ports, num_ports=config.num_ports, acl=acl) diff --git a/src/primaite/game/agent/observations/software_observation.py b/src/primaite/game/agent/observations/software_observation.py new file mode 100644 index 00000000..f943f540 --- /dev/null +++ b/src/primaite/game/agent/observations/software_observation.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import Dict + +from gymnasium import spaces +from gymnasium.core import ObsType + +from primaite.game.agent.observations.observations import AbstractObservation, WhereType +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + + +class ServiceObservation(AbstractObservation, identifier="SERVICE"): + """Service observation, shows status of a service in the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for ServiceObservation.""" + + service_name: str + """Name of the service, used for querying simulation state dictionary""" + + def __init__(self, where: WhereType) -> None: + """ + Initialise a service observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this service. + A typical location for a service might be ['network', 'nodes', , 'services', ]. + :type where: WhereType + """ + self.where = where + self.default_observation = {"operating_status": 0, "health_status": 0} + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Observation containing the operating status and health status of the service. + :rtype: ObsType + """ + service_state = access_from_nested_dict(state, self.where) + if service_state is NOT_PRESENT_IN_STATE: + return self.default_observation + return { + "operating_status": service_state["operating_state"], + "health_status": service_state["health_state_visible"], + } + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for service status. + :rtype: spaces.Space + """ + return spaces.Dict({"operating_status": spaces.Discrete(7), "health_status": spaces.Discrete(5)}) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> ServiceObservation: + """ + Create a service observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the service observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this service's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed service observation instance. + :rtype: ServiceObservation + """ + return cls(where=parent_where + ["services", config.service_name]) + + +class ApplicationObservation(AbstractObservation, identifier="APPLICATION"): + """Application observation, shows the status of an application within the simulation environment.""" + + class ConfigSchema(AbstractObservation.ConfigSchema): + """Configuration schema for ApplicationObservation.""" + + application_name: str + """Name of the application, used for querying simulation state dictionary""" + + def __init__(self, where: WhereType) -> None: + """ + Initialise an application observation instance. + + :param where: Where in the simulation state dictionary to find the relevant information for this application. + A typical location for an application might be + ['network', 'nodes', , 'applications', ]. + :type where: WhereType + """ + self.where = where + self.default_observation = {"operating_status": 0, "health_status": 0, "num_executions": 0} + + # TODO: allow these to be configured in yaml + self.high_threshold = 10 + self.med_threshold = 5 + self.low_threshold = 0 + + def _categorise_num_executions(self, num_executions: int) -> int: + """ + Represent number of file accesses as a categorical variable. + + :param num_access: Number of file accesses. + :return: Bin number corresponding to the number of accesses. + """ + if num_executions > self.high_threshold: + return 3 + elif num_executions > self.med_threshold: + return 2 + elif num_executions > self.low_threshold: + return 1 + return 0 + + def observe(self, state: Dict) -> ObsType: + """ + Generate observation based on the current state of the simulation. + + :param state: Simulation state dictionary. + :type state: Dict + :return: Obs containing the operating status, health status, and number of executions of the application. + :rtype: ObsType + """ + application_state = access_from_nested_dict(state, self.where) + if application_state is NOT_PRESENT_IN_STATE: + return self.default_observation + return { + "operating_status": application_state["operating_state"], + "health_status": application_state["health_state_visible"], + "num_executions": self._categorise_num_executions(application_state["num_executions"]), + } + + @property + def space(self) -> spaces.Space: + """ + Gymnasium space object describing the observation space shape. + + :return: Gymnasium space representing the observation space for application status. + :rtype: spaces.Space + """ + return spaces.Dict( + { + "operating_status": spaces.Discrete(7), + "health_status": spaces.Discrete(5), + "num_executions": spaces.Discrete(4), + } + ) + + @classmethod + def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> ApplicationObservation: + """ + Create an application observation from a configuration schema. + + :param config: Configuration schema containing the necessary information for the application observation. + :type config: ConfigSchema + :param parent_where: Where in the simulation state dictionary to find the information about this application's + parent node. A typical location for a node might be ['network', 'nodes', ]. + :type parent_where: WhereType, optional + :return: Constructed application observation instance. + :rtype: ApplicationObservation + """ + return cls(where=parent_where + ["applications", config.application_name]) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py new file mode 100644 index 00000000..d77640d1 --- /dev/null +++ b/src/primaite/game/agent/rewards.py @@ -0,0 +1,423 @@ +""" +Manages the reward function for the agent. + +Each agent is equipped with a RewardFunction, which is made up of a list of reward components. The components are +designed to calculate a reward value based on the current state of the simulation. The overall reward function is a +weighed sum of the components. + +The reward function is typically specified using a config yaml file or a config dictionary. The following example shows +the structure: +```yaml + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_name: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_name: web_server + service_ref: web_server_database_client +``` +""" +from abc import abstractmethod +from typing import Callable, Dict, Iterable, List, Optional, Tuple, Type, TYPE_CHECKING, Union + +from typing_extensions import Never + +from primaite import getLogger +from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE + +if TYPE_CHECKING: + from primaite.game.agent.interface import AgentHistoryItem + +_LOGGER = getLogger(__name__) +WhereType = Optional[Iterable[Union[str, int]]] + + +class AbstractReward: + """Base class for reward function components.""" + + @abstractmethod + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Calculate the reward for the current state.""" + return 0.0 + + @classmethod + @abstractmethod + def from_config(cls, config: dict) -> "AbstractReward": + """Create a reward function component from a config dictionary. + + :param config: dict of options for the reward component's constructor + :type config: dict + :return: The reward component. + :rtype: AbstractReward + """ + return cls() + + +class DummyReward(AbstractReward): + """Dummy reward function component which always returns 0.""" + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Calculate the reward for the current state.""" + return 0.0 + + @classmethod + def from_config(cls, config: dict) -> "DummyReward": + """Create a reward function component from a config dictionary. + + :param config: dict of options for the reward component's constructor. Should be empty. + :type config: dict + :return: The reward component. + :rtype: DummyReward + """ + return cls() + + +class DatabaseFileIntegrity(AbstractReward): + """Reward function component which rewards the agent for maintaining the integrity of a database file.""" + + def __init__(self, node_hostname: str, folder_name: str, file_name: str) -> None: + """Initialise the reward component. + + :param node_hostname: Hostname of the node which contains the database file. + :type node_hostname: str + :param folder_name: folder which contains the database file. + :type folder_name: str + :param file_name: name of the database file. + :type file_name: str + """ + self.location_in_state = [ + "network", + "nodes", + node_hostname, + "file_system", + "folders", + folder_name, + "files", + file_name, + ] + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Calculate the reward for the current state. + + :param state: The current state of the simulation. + :type state: Dict + """ + database_file_state = access_from_nested_dict(state, self.location_in_state) + if database_file_state is NOT_PRESENT_IN_STATE: + _LOGGER.debug( + f"Could not calculate {self.__class__} reward because " + "simulation state did not contain enough information." + ) + return 0.0 + + health_status = database_file_state["health_status"] + if health_status == 2: + return -1 + elif health_status == 1: + return 1 + else: + return 0 + + @classmethod + def from_config(cls, config: Dict) -> "DatabaseFileIntegrity": + """Create a reward function component from a config dictionary. + + :param config: dict of options for the reward component's constructor + :type config: Dict + :return: The reward component. + :rtype: DatabaseFileIntegrity + """ + node_hostname = config.get("node_hostname") + folder_name = config.get("folder_name") + file_name = config.get("file_name") + if not (node_hostname and folder_name and file_name): + msg = f"{cls.__name__} could not be initialised with parameters {config}" + _LOGGER.error(msg) + raise ValueError(msg) + + return cls(node_hostname=node_hostname, folder_name=folder_name, file_name=file_name) + + +class WebServer404Penalty(AbstractReward): + """Reward function component which penalises the agent when the web server returns a 404 error.""" + + def __init__(self, node_hostname: str, service_name: str) -> None: + """Initialise the reward component. + + :param node_hostname: Hostname of the node which contains the web server service. + :type node_hostname: str + :param service_name: Name of the web server service. + :type service_name: str + """ + self.location_in_state = ["network", "nodes", node_hostname, "services", service_name] + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Calculate the reward for the current state. + + :param state: The current state of the simulation. + :type state: Dict + """ + web_service_state = access_from_nested_dict(state, self.location_in_state) + if web_service_state is NOT_PRESENT_IN_STATE: + return 0.0 + most_recent_return_code = web_service_state["last_response_status_code"] + # TODO: reward needs to use the current web state. Observation should return web state at the time of last scan. + if most_recent_return_code == 200: + return 1.0 + elif most_recent_return_code == 404: + return -1.0 + else: + return 0.0 + + @classmethod + def from_config(cls, config: Dict) -> "WebServer404Penalty": + """Create a reward function component from a config dictionary. + + :param config: dict of options for the reward component's constructor + :type config: Dict + :return: The reward component. + :rtype: WebServer404Penalty + """ + node_hostname = config.get("node_hostname") + service_name = config.get("service_name") + if not (node_hostname and service_name): + msg = ( + f"{cls.__name__} could not be initialised from config because node_name and service_ref were not " + "found in reward config." + ) + _LOGGER.warning(msg) + raise ValueError(msg) + + return cls(node_hostname=node_hostname, service_name=service_name) + + +class WebpageUnavailablePenalty(AbstractReward): + """Penalises the agent when the web browser fails to fetch a webpage.""" + + def __init__(self, node_hostname: str) -> None: + """ + Initialise the reward component. + + :param node_hostname: Hostname of the node which has the web browser. + :type node_hostname: str + """ + self._node: str = node_hostname + self.location_in_state: List[str] = ["network", "nodes", node_hostname, "applications", "WebBrowser"] + self._last_request_failed: bool = False + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """ + Calculate the reward based on current simulation state, and the recent agent action. + + When the green agent requests to execute the browser application, and that request fails, this reward + component will keep track of that information. In that case, it doesn't matter whether the last webpage + had a 200 status code, because there has been an unsuccessful request since. + """ + if last_action_response.request == ["network", "node", self._node, "application", "WebBrowser", "execute"]: + self._last_request_failed = last_action_response.response.status != "success" + + # if agent couldn't even get as far as sending the request (because for example the node was off), then + # apply a penalty + if self._last_request_failed: + return -1.0 + + # If the last request did actually go through, then check if the webpage also loaded + web_browser_state = access_from_nested_dict(state, self.location_in_state) + if web_browser_state is NOT_PRESENT_IN_STATE or "history" not in web_browser_state: + _LOGGER.debug( + "Web browser reward could not be calculated because the web browser history on node", + f"{self._node} was not reported in the simulation state. Returning 0.0", + ) + return 0.0 # 0 if the web browser cannot be found + if not web_browser_state["history"]: + return 0.0 # 0 if no requests have been attempted yet + outcome = web_browser_state["history"][-1]["outcome"] + if outcome == "PENDING": + return 0.0 # 0 if a request was attempted but not yet resolved + elif outcome == 200: + return 1.0 # 1 for successful request + else: # includes failure codes and SERVER_UNREACHABLE + return -1.0 # -1 for failure + + @classmethod + def from_config(cls, config: dict) -> AbstractReward: + """ + Build the reward component object from config. + + :param config: Configuration dictionary. + :type config: Dict + """ + node_hostname = config.get("node_hostname") + return cls(node_hostname=node_hostname) + + +class GreenAdminDatabaseUnreachablePenalty(AbstractReward): + """Penalises the agent when the green db clients fail to connect to the database.""" + + def __init__(self, node_hostname: str) -> None: + """ + Initialise the reward component. + + :param node_hostname: Hostname of the node where the database client sits. + :type node_hostname: str + """ + self._node: str = node_hostname + self.location_in_state: List[str] = ["network", "nodes", node_hostname, "applications", "DatabaseClient"] + self._last_request_failed: bool = False + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """ + Calculate the reward based on current simulation state, and the recent agent action. + + When the green agent requests to execute the database client application, and that request fails, this reward + component will keep track of that information. In that case, it doesn't matter whether the last successful + request returned was able to connect to the database server, because there has been an unsuccessful request + since. + """ + if last_action_response.request == ["network", "node", self._node, "application", "DatabaseClient", "execute"]: + self._last_request_failed = last_action_response.response.status != "success" + + # if agent couldn't even get as far as sending the request (because for example the node was off), then + # apply a penalty + if self._last_request_failed: + return -1.0 + + # If the last request was actually sent, then check if the connection was established. + db_state = access_from_nested_dict(state, self.location_in_state) + if db_state is NOT_PRESENT_IN_STATE or "last_connection_successful" not in db_state: + _LOGGER.debug(f"Can't calculate reward for {self.__class__.__name__}") + return 0.0 + last_connection_successful = db_state["last_connection_successful"] + if last_connection_successful is False: + return -1.0 + elif last_connection_successful is True: + return 1.0 + return 0.0 + + @classmethod + def from_config(cls, config: Dict) -> AbstractReward: + """ + Build the reward component object from config. + + :param config: Configuration dictionary. + :type config: Dict + """ + node_hostname = config.get("node_hostname") + return cls(node_hostname=node_hostname) + + +class SharedReward(AbstractReward): + """Adds another agent's reward to the overall reward.""" + + def __init__(self, agent_name: Optional[str] = None) -> None: + """ + Initialise the shared reward. + + The agent_name is a placeholder value. It starts off as none, but it must be set before this reward can work + correctly. + + :param agent_name: The name whose reward is an input + :type agent_name: Optional[str] + """ + self.agent_name = agent_name + """Agent whose reward to track.""" + + def default_callback(agent_name: str) -> Never: + """ + Default callback to prevent calling this reward until it's properly initialised. + + SharedReward should not be used until the game layer replaces self.callback with a reference to the + function that retrieves the desired agent's reward. Therefore, we define this default callback that raises + an error. + """ + raise RuntimeError("Attempted to calculate SharedReward but it was not initialised properly.") + + self.callback: Callable[[str], float] = default_callback + """Method that retrieves an agent's current reward given the agent's name.""" + + def calculate(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Simply access the other agent's reward and return it.""" + return self.callback(self.agent_name) + + @classmethod + def from_config(cls, config: Dict) -> "SharedReward": + """ + Build the SharedReward object from config. + + :param config: Configuration dictionary + :type config: Dict + """ + agent_name = config.get("agent_name") + return cls(agent_name=agent_name) + + +class RewardFunction: + """Manages the reward function for the agent.""" + + rew_class_identifiers: Dict[str, Type[AbstractReward]] = { + "DUMMY": DummyReward, + "DATABASE_FILE_INTEGRITY": DatabaseFileIntegrity, + "WEB_SERVER_404_PENALTY": WebServer404Penalty, + "WEBPAGE_UNAVAILABLE_PENALTY": WebpageUnavailablePenalty, + "GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY": GreenAdminDatabaseUnreachablePenalty, + "SHARED_REWARD": SharedReward, + } + """List of reward class identifiers.""" + + def __init__(self): + """Initialise the reward function object.""" + self.reward_components: List[Tuple[AbstractReward, float]] = [] + "attribute reward_components keeps track of reward components and the weights assigned to each." + self.current_reward: float = 0.0 + self.total_reward: float = 0.0 + + def register_component(self, component: AbstractReward, weight: float = 1.0) -> None: + """Add a reward component to the reward function. + + :param component: Instance of a reward component. + :type component: AbstractReward + :param weight: Relative weight of the reward component, defaults to 1.0 + :type weight: float, optional + """ + self.reward_components.append((component, weight)) + + def update(self, state: Dict, last_action_response: "AgentHistoryItem") -> float: + """Calculate the overall reward for the current state. + + :param state: The current state of the simulation. + :type state: Dict + """ + total = 0.0 + for comp_and_weight in self.reward_components: + comp = comp_and_weight[0] + weight = comp_and_weight[1] + total += weight * comp.calculate(state=state, last_action_response=last_action_response) + self.current_reward = total + return self.current_reward + + @classmethod + def from_config(cls, config: Dict) -> "RewardFunction": + """Create a reward function from a config dictionary. + + :param config: dict of options for the reward manager's constructor + :type config: Dict + :return: The reward manager. + :rtype: RewardFunction + """ + new = cls() + + for rew_component_cfg in config["reward_components"]: + rew_type = rew_component_cfg["type"] + weight = rew_component_cfg.get("weight", 1.0) + rew_class = cls.rew_class_identifiers[rew_type] + rew_instance = rew_class.from_config(config=rew_component_cfg.get("options", {})) + new.register_component(component=rew_instance, weight=weight) + return new diff --git a/src/primaite/game/agent/scripted_agents/__init__.py b/src/primaite/game/agent/scripted_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/game/agent/scripted_agents/data_manipulation_bot.py b/src/primaite/game/agent/scripted_agents/data_manipulation_bot.py new file mode 100644 index 00000000..d3ec19cb --- /dev/null +++ b/src/primaite/game/agent/scripted_agents/data_manipulation_bot.py @@ -0,0 +1,55 @@ +import random +from typing import Dict, Tuple + +from gymnasium.core import ObsType + +from primaite.game.agent.interface import AbstractScriptedAgent + + +class DataManipulationAgent(AbstractScriptedAgent): + """Agent that uses a DataManipulationBot to perform an SQL injection attack.""" + + next_execution_timestep: int = 0 + starting_node_idx: int = 0 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setup_agent() + + def _set_next_execution_timestep(self, timestep: int) -> None: + """Set the next execution timestep with a configured random variance. + + :param timestep: The timestep to add variance to. + """ + random_timestep_increment = random.randint( + -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance + ) + self.next_execution_timestep = timestep + random_timestep_increment + + def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]: + """Waits until a specific timestep, then attempts to execute its data manipulation application. + + :param obs: Current observation for this agent, not used in DataManipulationAgent + :type obs: ObsType + :param timestep: The current simulation timestep, used for scheduling actions + :type timestep: int + :return: Action formatted in CAOS format + :rtype: Tuple[str, Dict] + """ + if timestep < self.next_execution_timestep: + return "DONOTHING", {} + + self._set_next_execution_timestep(timestep + self.agent_settings.start_settings.frequency) + + return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0} + + def setup_agent(self) -> None: + """Set the next execution timestep when the episode resets.""" + self._select_start_node() + self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) + + def _select_start_node(self) -> None: + """Set the starting starting node of the agent to be a random node from this agent's action manager.""" + # we are assuming that every node in the node manager has a data manipulation application at idx 0 + num_nodes = len(self.action_manager.node_names) + self.starting_node_idx = random.randint(0, num_nodes - 1) diff --git a/src/primaite/game/agent/scripted_agents/probabilistic_agent.py b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py new file mode 100644 index 00000000..9cddc978 --- /dev/null +++ b/src/primaite/game/agent/scripted_agents/probabilistic_agent.py @@ -0,0 +1,87 @@ +"""Agents with predefined behaviours.""" +from typing import Dict, Optional, Tuple + +import numpy as np +import pydantic +from gymnasium.core import ObsType + +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.interface import AbstractScriptedAgent +from primaite.game.agent.observations.observation_manager import ObservationManager +from primaite.game.agent.rewards import RewardFunction + + +class ProbabilisticAgent(AbstractScriptedAgent): + """Scripted agent which randomly samples its action space with prescribed probabilities for each action.""" + + class Settings(pydantic.BaseModel): + """Config schema for Probabilistic agent settings.""" + + model_config = pydantic.ConfigDict(extra="forbid") + """Strict validation.""" + action_probabilities: Dict[int, float] + """Probability to perform each action in the action map. The sum of probabilities should sum to 1.""" + random_seed: Optional[int] = None + """Random seed. If set, each episode the agent will choose the same random sequence of actions.""" + # TODO: give the option to still set a random seed, but have it vary each episode in a predictable way + # for example if the user sets seed 123, have it be 123 + episode_num, so that each ep it's the next seed. + + @pydantic.field_validator("action_probabilities", mode="after") + @classmethod + def probabilities_sum_to_one(cls, v: Dict[int, float]) -> Dict[int, float]: + """Make sure the probabilities sum to 1.""" + if not abs(sum(v.values()) - 1) < 1e-6: + raise ValueError("Green action probabilities must sum to 1") + return v + + @pydantic.field_validator("action_probabilities", mode="after") + @classmethod + def action_map_covered_correctly(cls, v: Dict[int, float]) -> Dict[int, float]: + """Ensure that the keys of the probability dictionary cover all integers from 0 to N.""" + if not all((i in v) for i in range(len(v))): + raise ValueError( + "Green action probabilities must be defined as a mapping where the keys are consecutive integers " + "from 0 to N." + ) + return v + + def __init__( + self, + agent_name: str, + action_space: Optional[ActionManager], + observation_space: Optional[ObservationManager], + reward_function: Optional[RewardFunction], + settings: Dict = {}, + ) -> None: + # If the action probabilities are not specified, create equal probabilities for all actions + if "action_probabilities" not in settings: + num_actions = len(action_space.action_map) + settings = {"action_probabilities": {i: 1 / num_actions for i in range(num_actions)}} + + # If seed not specified, set it to None so that numpy chooses a random one. + settings.setdefault("random_seed") + + self.settings = ProbabilisticAgent.Settings(**settings) + + self.rng = np.random.default_rng(self.settings.random_seed) + + # convert probabilities from + self.probabilities = np.asarray(list(self.settings.action_probabilities.values())) + + super().__init__(agent_name, action_space, observation_space, reward_function) + + def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: + """ + Sample the action space randomly. + + The probability of each action is given by the corresponding index in ``self.probabilities``. + + :param obs: Current observation for this agent, not used in ProbabilisticAgent + :type obs: ObsType + :param timestep: The current simulation timestep, not used in ProbabilisticAgent + :type timestep: int + :return: Action formatted in CAOS format + :rtype: Tuple[str, Dict] + """ + choice = self.rng.choice(len(self.action_manager.action_map), p=self.probabilities) + return self.action_manager.get_action(choice) diff --git a/src/primaite/game/agent/scripted_agents/random_agent.py b/src/primaite/game/agent/scripted_agents/random_agent.py new file mode 100644 index 00000000..5021a832 --- /dev/null +++ b/src/primaite/game/agent/scripted_agents/random_agent.py @@ -0,0 +1,83 @@ +import random +from typing import Dict, Optional, Tuple + +from gymnasium.core import ObsType +from pydantic import BaseModel + +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.interface import AbstractScriptedAgent +from primaite.game.agent.observations.observation_manager import ObservationManager +from primaite.game.agent.rewards import RewardFunction + + +class RandomAgent(AbstractScriptedAgent): + """Agent that ignores its observation and acts completely at random.""" + + def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]: + """Sample the action space randomly. + + :param obs: Current observation for this agent, not used in RandomAgent + :type obs: ObsType + :param timestep: The current simulation timestep, not used in RandomAgent + :type timestep: int + :return: Action formatted in CAOS format + :rtype: Tuple[str, Dict] + """ + return self.action_manager.get_action(self.action_manager.space.sample()) + + +class PeriodicAgent(AbstractScriptedAgent): + """Agent that does nothing most of the time, but executes application at regular intervals (with variance).""" + + class Settings(BaseModel): + """Configuration values for when an agent starts performing actions.""" + + start_step: int = 20 + "The timestep at which an agent begins performing it's actions." + start_variance: int = 5 + "Deviation around the start step." + frequency: int = 5 + "The number of timesteps to wait between performing actions." + variance: int = 0 + "The amount the frequency can randomly change to." + max_executions: int = 999999 + "Maximum number of times the agent can execute its action." + + def __init__( + self, + agent_name: str, + action_space: ActionManager, + observation_space: ObservationManager, + reward_function: RewardFunction, + settings: Optional[Settings] = None, + ) -> None: + """Initialise PeriodicAgent.""" + super().__init__( + agent_name=agent_name, + action_space=action_space, + observation_space=observation_space, + reward_function=reward_function, + ) + self.settings = settings or PeriodicAgent.Settings() + self._set_next_execution_timestep(timestep=self.settings.start_step, variance=self.settings.start_variance) + self.num_executions = 0 + + def _set_next_execution_timestep(self, timestep: int, variance: int) -> None: + """Set the next execution timestep with a configured random variance. + + :param timestep: The timestep when the next execute action should be taken. + :type timestep: int + :param variance: Uniform random variance applied to the timestep + :type variance: int + """ + random_increment = random.randint(-variance, variance) + self.next_execution_timestep = timestep + random_increment + + def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]: + """Do nothing, unless the current timestep is the next execution timestep, in which case do the action.""" + if timestep == self.next_execution_timestep and self.num_executions < self.settings.max_executions: + self.num_executions += 1 + self._set_next_execution_timestep(timestep + self.settings.frequency, self.settings.variance) + return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0} + + return "DONOTHING", {} diff --git a/src/primaite/game/agent/scripted_agents/tap001.py b/src/primaite/game/agent/scripted_agents/tap001.py new file mode 100644 index 00000000..88fa37cf --- /dev/null +++ b/src/primaite/game/agent/scripted_agents/tap001.py @@ -0,0 +1,78 @@ +import random +from typing import Dict, Tuple + +from gymnasium.core import ObsType + +from primaite.game.agent.interface import AbstractScriptedAgent + + +class TAP001(AbstractScriptedAgent): + """ + TAP001 | Mobile Malware -- Ransomware Variant. + + Scripted Red Agent. Capable of one action; launching the kill-chain (Ransomware Application) + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setup_agent() + + next_execution_timestep: int = 0 + starting_node_idx: int = 0 + installed: bool = False + + def _set_next_execution_timestep(self, timestep: int) -> None: + """Set the next execution timestep with a configured random variance. + + :param timestep: The timestep to add variance to. + """ + random_timestep_increment = random.randint( + -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance + ) + self.next_execution_timestep = timestep + random_timestep_increment + + def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]: + """Waits until a specific timestep, then attempts to execute the ransomware application. + + This application acts a wrapper around the kill-chain, similar to green-analyst and + the previous UC2 data manipulation bot. + + :param obs: Current observation for this agent. + :type obs: ObsType + :param timestep: The current simulation timestep, used for scheduling actions + :type timestep: int + :return: Action formatted in CAOS format + :rtype: Tuple[str, Dict] + """ + if timestep < self.next_execution_timestep: + return "DONOTHING", {} + + self._set_next_execution_timestep(timestep + self.agent_settings.start_settings.frequency) + + if not self.installed: + self.installed = True + return "NODE_APPLICATION_INSTALL", { + "node_id": self.starting_node_idx, + "application_name": "RansomwareScript", + "ip_address": self.ip_address, + } + + return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0} + + def setup_agent(self) -> None: + """Set the next execution timestep when the episode resets.""" + self._select_start_node() + self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) + for n, act in self.action_manager.action_map.items(): + if not act[0] == "NODE_APPLICATION_INSTALL": + continue + if act[1]["node_id"] == self.starting_node_idx: + self.ip_address = act[1]["ip_address"] + return + raise RuntimeError("TAP001 agent could not find database server ip address in action map") + + def _select_start_node(self) -> None: + """Set the starting starting node of the agent to be a random node from this agent's action manager.""" + # we are assuming that every node in the node manager has a data manipulation application at idx 0 + num_nodes = len(self.action_manager.node_names) + self.starting_node_idx = random.randint(0, num_nodes - 1) diff --git a/src/primaite/game/agent/utils.py b/src/primaite/game/agent/utils.py new file mode 100644 index 00000000..42e8f30b --- /dev/null +++ b/src/primaite/game/agent/utils.py @@ -0,0 +1,32 @@ +from typing import Any, Dict, Hashable, Optional, Sequence + +NOT_PRESENT_IN_STATE = object() +""" +Need an object to return when the sim state does not contain a requested value. Cannot use None because sometimes +the thing requested in the state could equal None. This NOT_PRESENT_IN_STATE is a sentinel for this purpose. +""" + + +def access_from_nested_dict(dictionary: Dict, keys: Optional[Sequence[Hashable]]) -> Any: + """ + Access an item from a deeply dictionary with a list of keys. + + For example, if the dictionary is {1: 'a', 2: {3: {4: 'b'}}}, then the key [2, 3, 4] would return 'b', and the key + [2, 3] would return {4: 'b'}. Raises a KeyError if specified key does not exist at any level of nesting. + + :param dictionary: Deeply nested dictionary + :type dictionary: Dict + :param keys: List of dict keys used to traverse the nested dict. Each item corresponds to one level of depth. + :type keys: List[Hashable] + :return: The value in the dictionary + :rtype: Any + """ + if keys is None: + return NOT_PRESENT_IN_STATE + key_list = [*keys] # copy keys to a new list to prevent editing original list + if len(key_list) == 0: + return dictionary + k = key_list.pop(0) + if k not in dictionary: + return NOT_PRESENT_IN_STATE + return access_from_nested_dict(dictionary[k], key_list) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py new file mode 100644 index 00000000..772ab5aa --- /dev/null +++ b/src/primaite/game/game.py @@ -0,0 +1,545 @@ +"""PrimAITE game - Encapsulates the simulation and agents.""" +from ipaddress import IPv4Address +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + +from primaite import getLogger +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.interface import AbstractAgent, AgentSettings, ProxyAgent +from primaite.game.agent.observations.observation_manager import ObservationManager +from primaite.game.agent.rewards import RewardFunction, SharedReward +from primaite.game.agent.scripted_agents.data_manipulation_bot import DataManipulationAgent +from primaite.game.agent.scripted_agents.probabilistic_agent import ProbabilisticAgent +from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent +from primaite.game.agent.scripted_agents.tap001 import TAP001 +from primaite.game.science import graph_has_cycle, topological_sort +from primaite.simulator.network.hardware.base import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.host_node import NIC +from primaite.simulator.network.hardware.nodes.host.server import Printer, Server +from primaite.simulator.network.hardware.nodes.network.firewall import Firewall +from primaite.simulator.network.hardware.nodes.network.router import Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter +from primaite.simulator.network.nmne import set_nmne_config +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot +from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.web_server.web_server import WebServer + +_LOGGER = getLogger(__name__) + +APPLICATION_TYPES_MAPPING = { + "WebBrowser": WebBrowser, + "DatabaseClient": DatabaseClient, + "DataManipulationBot": DataManipulationBot, + "DoSBot": DoSBot, + "RansomwareScript": RansomwareScript, +} +"""List of available applications that can be installed on nodes in the PrimAITE Simulation.""" + +SERVICE_TYPES_MAPPING = { + "DNSClient": DNSClient, + "DNSServer": DNSServer, + "DatabaseService": DatabaseService, + "WebServer": WebServer, + "FTPClient": FTPClient, + "FTPServer": FTPServer, + "NTPClient": NTPClient, + "NTPServer": NTPServer, +} +"""List of available services that can be installed on nodes in the PrimAITE Simulation.""" + + +class PrimaiteGameOptions(BaseModel): + """ + Global options which are applicable to all of the agents in the game. + + Currently this is used to restrict which ports and protocols exist in the world of the simulation. + """ + + model_config = ConfigDict(extra="forbid") + + max_episode_length: int = 256 + """Maximum number of episodes for the PrimAITE game.""" + ports: List[str] + """A whitelist of available ports in the simulation.""" + protocols: List[str] + """A whitelist of available protocols in the simulation.""" + thresholds: Optional[Dict] = {} + """A dict containing the thresholds used for determining what is acceptable during observations.""" + + +class PrimaiteGame: + """ + Primaite game encapsulates the simulation and agents which interact with it. + + Provides main logic loop for the game. However, it does not provide policy training, or a gymnasium environment. + """ + + def __init__(self): + """Initialise a PrimaiteGame object.""" + self.simulation: Simulation = Simulation() + """Simulation object with which the agents will interact.""" + + self.agents: Dict[str, AbstractAgent] = {} + """Mapping from agent name to agent object.""" + + self.rl_agents: Dict[str, ProxyAgent] = {} + """Subset of agents which are intended for reinforcement learning.""" + + self.step_counter: int = 0 + """Current timestep within the episode.""" + + self.options: PrimaiteGameOptions + """Special options that apply for the entire game.""" + + self.save_step_metadata: bool = False + """Whether to save the RL agents' action, environment state, and other data at every single step.""" + + self._reward_calculation_order: List[str] = [name for name in self.agents] + """Agent order for reward evaluation, as some rewards can be dependent on other agents' rewards.""" + + def step(self): + """ + Perform one step of the simulation/agent loop. + + This is the main loop of the game. It corresponds to one timestep in the simulation, and one action from each + agent. The steps are as follows: + 1. The simulation state is updated. + 2. The simulation state is sent to each agent. + 3. Each agent converts the state to an observation and calculates a reward. + 4. Each agent chooses an action based on the observation. + 5. Each agent converts the action to a request. + 6. The simulation applies the requests. + + Warning: This method should only be used with scripted agents. For RL agents, the environment that the agent + interacts with should implement a step method that calls methods used by this method. For example, if using a + single-agent gym, make sure to update the ProxyAgent's action with the action before calling + ``self.apply_agent_actions()``. + """ + _LOGGER.debug(f"Stepping. Step counter: {self.step_counter}") + + self.pre_timestep() + + if self.step_counter == 0: + state = self.get_sim_state() + for agent in self.agents.values(): + agent.update_observation(state=state) + # Apply all actions to simulation as requests + self.apply_agent_actions() + + # Advance timestep + self.advance_timestep() + + # Get the current state of the simulation + sim_state = self.get_sim_state() + + # Update agents' observations and rewards based on the current state, and the response from the last action + self.update_agents(state=sim_state) + + def get_sim_state(self) -> Dict: + """Get the current state of the simulation.""" + return self.simulation.describe_state() + + def update_agents(self, state: Dict) -> None: + """Update agents' observations and rewards based on the current state.""" + for agent_name in self._reward_calculation_order: + agent = self.agents[agent_name] + if self.step_counter > 0: # can't get reward before first action + agent.update_reward(state=state) + agent.save_reward_to_history() + agent.update_observation(state=state) # order of this doesn't matter so just use reward order + agent.reward_function.total_reward += agent.reward_function.current_reward + + def apply_agent_actions(self) -> None: + """Apply all actions to simulation as requests.""" + for _, agent in self.agents.items(): + obs = agent.observation_manager.current_observation + action_choice, parameters = agent.get_action(obs, timestep=self.step_counter) + request = agent.format_request(action_choice, parameters) + response = self.simulation.apply_request(request) + agent.process_action_response( + timestep=self.step_counter, + action=action_choice, + parameters=parameters, + request=request, + response=response, + ) + + def pre_timestep(self) -> None: + """Apply any pre-timestep logic that helps make sure we have the correct observations.""" + self.simulation.pre_timestep(self.step_counter) + + def advance_timestep(self) -> None: + """Advance timestep.""" + self.step_counter += 1 + _LOGGER.debug(f"Advancing timestep to {self.step_counter} ") + self.simulation.apply_timestep(self.step_counter) + + def calculate_truncated(self) -> bool: + """Calculate whether the episode is truncated.""" + current_step = self.step_counter + max_steps = self.options.max_episode_length + if current_step >= max_steps: + return True + return False + + def close(self) -> None: + """Close the game, this will close the simulation.""" + return NotImplemented + + def setup_for_episode(self, episode: int) -> None: + """Perform any final configuration of components to make them ready for the game to start.""" + self.simulation.setup_for_episode(episode=episode) + + @classmethod + def from_config(cls, cfg: Dict) -> "PrimaiteGame": + """Create a PrimaiteGame object from a config dictionary. + + The config dictionary should have the following top-level keys: + 1. io_settings: options for logging data during training + 2. game_config: options for the game itself, such as agents. + 3. simulation: defines the network topology and the initial state of the simulation. + + The specification for each of the three major areas is described in a separate documentation page. + # TODO: create documentation page and add links to it here. + + :param cfg: The config dictionary. + :type cfg: dict + :return: A PrimaiteGame object. + :rtype: PrimaiteGame + """ + game = cls() + game.options = PrimaiteGameOptions(**cfg["game"]) + game.save_step_metadata = cfg.get("io_settings", {}).get("save_step_metadata") or False + + # 1. create simulation + sim = game.simulation + net = sim.network + + simulation_config = cfg.get("simulation", {}) + network_config = simulation_config.get("network", {}) + + nodes_cfg = network_config.get("nodes", []) + links_cfg = network_config.get("links", []) + + for node_cfg in nodes_cfg: + n_type = node_cfg["type"] + if n_type == "computer": + new_node = Computer( + hostname=node_cfg["hostname"], + ip_address=node_cfg["ip_address"], + subnet_mask=IPv4Address(node_cfg.get("subnet_mask", "255.255.255.0")), + default_gateway=node_cfg.get("default_gateway"), + dns_server=node_cfg.get("dns_server", None), + operating_state=NodeOperatingState.ON + if not (p := node_cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + elif n_type == "server": + new_node = Server( + hostname=node_cfg["hostname"], + ip_address=node_cfg["ip_address"], + subnet_mask=IPv4Address(node_cfg.get("subnet_mask", "255.255.255.0")), + default_gateway=node_cfg.get("default_gateway"), + dns_server=node_cfg.get("dns_server", None), + operating_state=NodeOperatingState.ON + if not (p := node_cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + elif n_type == "switch": + new_node = Switch( + hostname=node_cfg["hostname"], + num_ports=int(node_cfg.get("num_ports", "8")), + operating_state=NodeOperatingState.ON + if not (p := node_cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + elif n_type == "router": + new_node = Router.from_config(node_cfg) + elif n_type == "firewall": + new_node = Firewall.from_config(node_cfg) + elif n_type == "wireless_router": + new_node = WirelessRouter.from_config(node_cfg, airspace=net.airspace) + elif n_type == "printer": + new_node = Printer( + hostname=node_cfg["hostname"], + ip_address=node_cfg["ip_address"], + subnet_mask=node_cfg["subnet_mask"], + operating_state=NodeOperatingState.ON + if not (p := node_cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + else: + msg = f"invalid node type {n_type} in config" + _LOGGER.error(msg) + raise ValueError(msg) + if "services" in node_cfg: + for service_cfg in node_cfg["services"]: + new_service = None + service_type = service_cfg["type"] + if service_type in SERVICE_TYPES_MAPPING: + _LOGGER.debug(f"installing {service_type} on node {new_node.hostname}") + new_node.software_manager.install(SERVICE_TYPES_MAPPING[service_type]) + new_service = new_node.software_manager.software[service_type] + + # start the service + new_service.start() + else: + msg = f"Configuration contains an invalid service type: {service_type}" + _LOGGER.error(msg) + raise ValueError(msg) + # service-dependent options + if service_type == "DNSClient": + if "options" in service_cfg: + opt = service_cfg["options"] + if "dns_server" in opt: + new_service.dns_server = IPv4Address(opt["dns_server"]) + if service_type == "DNSServer": + if "options" in service_cfg: + opt = service_cfg["options"] + if "domain_mapping" in opt: + for domain, ip in opt["domain_mapping"].items(): + new_service.dns_register(domain, IPv4Address(ip)) + if service_type == "DatabaseService": + if "options" in service_cfg: + opt = service_cfg["options"] + new_service.password = opt.get("db_password", None) + new_service.configure_backup(backup_server=IPv4Address(opt.get("backup_server_ip"))) + if service_type == "FTPServer": + if "options" in service_cfg: + opt = service_cfg["options"] + new_service.server_password = opt.get("server_password") + if service_type == "NTPClient": + if "options" in service_cfg: + opt = service_cfg["options"] + new_service.ntp_server = IPv4Address(opt.get("ntp_server_ip")) + if "applications" in node_cfg: + for application_cfg in node_cfg["applications"]: + new_application = None + application_type = application_cfg["type"] + + if application_type in APPLICATION_TYPES_MAPPING: + new_node.software_manager.install(APPLICATION_TYPES_MAPPING[application_type]) + new_application = new_node.software_manager.software[application_type] + else: + msg = f"Configuration contains an invalid application type: {application_type}" + _LOGGER.error(msg) + raise ValueError(msg) + + # run the application + new_application.run() + + if application_type == "DataManipulationBot": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.configure( + server_ip_address=IPv4Address(opt.get("server_ip")), + server_password=opt.get("server_password"), + payload=opt.get("payload", "DELETE"), + port_scan_p_of_success=float(opt.get("port_scan_p_of_success", "0.1")), + data_manipulation_p_of_success=float(opt.get("data_manipulation_p_of_success", "0.1")), + ) + elif application_type == "RansomwareScript": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.configure( + server_ip_address=IPv4Address(opt.get("server_ip")), + server_password=opt.get("server_password"), + payload=opt.get("payload", "ENCRYPT"), + c2_beacon_p_of_success=float(opt.get("c2_beacon_p_of_success", "0.5")), + target_scan_p_of_success=float(opt.get("target_scan_p_of_success", "0.1")), + ransomware_encrypt_p_of_success=float( + opt.get("ransomware_encrypt_p_of_success", "0.1") + ), + ) + elif application_type == "DatabaseClient": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.configure( + server_ip_address=IPv4Address(opt.get("db_server_ip")), + server_password=opt.get("server_password"), + ) + elif application_type == "WebBrowser": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.target_url = opt.get("target_url") + elif application_type == "DoSBot": + if "options" in application_cfg: + opt = application_cfg["options"] + new_application.configure( + target_ip_address=IPv4Address(opt.get("target_ip_address")), + target_port=Port(opt.get("target_port", Port.POSTGRES_SERVER.value)), + payload=opt.get("payload"), + repeat=bool(opt.get("repeat")), + port_scan_p_of_success=float(opt.get("port_scan_p_of_success", "0.1")), + dos_intensity=float(opt.get("dos_intensity", "1.0")), + max_sessions=int(opt.get("max_sessions", "1000")), + ) + if "network_interfaces" in node_cfg: + for nic_num, nic_cfg in node_cfg["network_interfaces"].items(): + new_node.connect_nic(NIC(ip_address=nic_cfg["ip_address"], subnet_mask=nic_cfg["subnet_mask"])) + + # temporarily set to 0 so all nodes are initially on + new_node.start_up_duration = 0 + new_node.shut_down_duration = 0 + + net.add_node(new_node) + # run through the power on step if the node is to be turned on at the start + if new_node.operating_state == NodeOperatingState.ON: + new_node.power_on() + + # set start up and shut down duration + new_node.start_up_duration = int(node_cfg.get("start_up_duration", 3)) + new_node.shut_down_duration = int(node_cfg.get("shut_down_duration", 3)) + + # 2. create links between nodes + for link_cfg in links_cfg: + node_a = net.get_node_by_hostname(link_cfg["endpoint_a_hostname"]) + node_b = net.get_node_by_hostname(link_cfg["endpoint_b_hostname"]) + bandwidth = link_cfg.get("bandwidth", 100) # default value if not configured + + if isinstance(node_a, Switch): + endpoint_a = node_a.network_interface[link_cfg["endpoint_a_port"]] + else: + endpoint_a = node_a.network_interface[link_cfg["endpoint_a_port"]] + if isinstance(node_b, Switch): + endpoint_b = node_b.network_interface[link_cfg["endpoint_b_port"]] + else: + endpoint_b = node_b.network_interface[link_cfg["endpoint_b_port"]] + net.connect(endpoint_a=endpoint_a, endpoint_b=endpoint_b, bandwidth=bandwidth) + + # 3. create agents + agents_cfg = cfg.get("agents", []) + + for agent_cfg in agents_cfg: + agent_ref = agent_cfg["ref"] # noqa: F841 + agent_type = agent_cfg["type"] + action_space_cfg = agent_cfg["action_space"] + observation_space_cfg = agent_cfg["observation_space"] + reward_function_cfg = agent_cfg["reward_function"] + + # CREATE OBSERVATION SPACE + obs_space = ObservationManager.from_config(observation_space_cfg) + + # CREATE ACTION SPACE + action_space = ActionManager.from_config(game, action_space_cfg) + + # CREATE REWARD FUNCTION + reward_function = RewardFunction.from_config(reward_function_cfg) + + # CREATE AGENT + if agent_type == "ProbabilisticAgent": + # TODO: implement non-random agents and fix this parsing + settings = agent_cfg.get("agent_settings", {}) + new_agent = ProbabilisticAgent( + agent_name=agent_cfg["ref"], + action_space=action_space, + observation_space=obs_space, + reward_function=reward_function, + settings=settings, + ) + elif agent_type == "PeriodicAgent": + settings = PeriodicAgent.Settings(**agent_cfg.get("settings", {})) + new_agent = PeriodicAgent( + agent_name=agent_cfg["ref"], + action_space=action_space, + observation_space=obs_space, + reward_function=reward_function, + settings=settings, + ) + + elif agent_type == "ProxyAgent": + agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings")) + new_agent = ProxyAgent( + agent_name=agent_cfg["ref"], + action_space=action_space, + observation_space=obs_space, + reward_function=reward_function, + agent_settings=agent_settings, + ) + game.rl_agents[agent_cfg["ref"]] = new_agent + elif agent_type == "RedDatabaseCorruptingAgent": + agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings")) + + new_agent = DataManipulationAgent( + agent_name=agent_cfg["ref"], + action_space=action_space, + observation_space=obs_space, + reward_function=reward_function, + agent_settings=agent_settings, + ) + elif agent_type == "TAP001": + agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings")) + new_agent = TAP001( + agent_name=agent_cfg["ref"], + action_space=action_space, + observation_space=obs_space, + reward_function=reward_function, + agent_settings=agent_settings, + ) + else: + msg = f"Configuration error: {agent_type} is not a valid agent type." + _LOGGER.error(msg) + raise ValueError(msg) + game.agents[agent_cfg["ref"]] = new_agent + + # Validate that if any agents are sharing rewards, they aren't forming an infinite loop. + game.setup_reward_sharing() + + # Set the NMNE capture config + set_nmne_config(network_config.get("nmne_config", {})) + game.update_agents(game.get_sim_state()) + + return game + + def setup_reward_sharing(self): + """Do necessary setup to enable reward sharing between agents. + + This method ensures that there are no cycles in the reward sharing. A cycle would be for example if agent_1 + depends on agent_2 and agent_2 depends on agent_1. It would cause an infinite loop. + + Also, SharedReward requires us to pass it a callback method that will provide the reward of the agent who is + sharing their reward. This callback is provided by this setup method. + + Finally, this method sorts the agents in order in which rewards will be evaluated to make sure that any rewards + that rely on the value of another reward are evaluated later. + + :raises RuntimeError: If the reward sharing is specified with a cyclic dependency. + """ + # construct dependency graph in the reward sharing between agents. + graph = {} + for name, agent in self.agents.items(): + graph[name] = set() + for comp, weight in agent.reward_function.reward_components: + if isinstance(comp, SharedReward): + comp: SharedReward + graph[name].add(comp.agent_name) + + # while constructing the graph, we might as well set up the reward sharing itself. + comp.callback = lambda agent_name: self.agents[agent_name].reward_function.current_reward + + # make sure the graph is acyclic. Otherwise we will enter an infinite loop of reward sharing. + if graph_has_cycle(graph): + raise RuntimeError( + ( + "Detected cycle in agent reward sharing. Check the agent reward function ", + "configuration: reward sharing can only go one way.", + ) + ) + + # sort the agents so the rewards that depend on other rewards are always evaluated later + self._reward_calculation_order = topological_sort(graph) diff --git a/src/primaite/game/science.py b/src/primaite/game/science.py new file mode 100644 index 00000000..908b326f --- /dev/null +++ b/src/primaite/game/science.py @@ -0,0 +1,94 @@ +from random import random +from typing import Any, Iterable, Mapping + + +def simulate_trial(p_of_success: float) -> bool: + """ + Simulates the outcome of a single trial in a Bernoulli process. + + This function returns True with a probability 'p_of_success', simulating a success outcome in a single + trial of a Bernoulli process. When this function is executed multiple times, the set of outcomes follows + a binomial distribution. This is useful in scenarios where one needs to model or simulate events that + have two possible outcomes (success or failure) with a fixed probability of success. + + :param p_of_success: The probability of success in a single trial, ranging from 0 to 1. + :returns: True if the trial is successful (with probability 'p_of_success'); otherwise, False. + """ + return random() < p_of_success + + +def graph_has_cycle(graph: Mapping[Any, Iterable[Any]]) -> bool: + """Detect cycles in a directed graph. + + Provide the graph as a dictionary that describes which nodes are linked. For example: + {0: {1,2}, 1:{2,3}, 3:{0}} here there's a cycle 0 -> 1 -> 3 -> 0 + {'a': ('b','c'), c:('b')} here there is no cycle + + :param graph: a mapping from node to a set of nodes to which it is connected. + :type graph: Mapping[Any, Iterable[Any]] + :return: Whether the graph has any cycles + :rtype: bool + """ + visited = set() + currently_visiting = set() + + def depth_first_search(node: Any) -> bool: + """Perform depth-first search (DFS) traversal to detect cycles starting from a given node.""" + if node in currently_visiting: + return True # Cycle detected + if node in visited: + return False # Already visited, no need to explore further + + visited.add(node) + currently_visiting.add(node) + + for neighbour in graph.get(node, []): + if depth_first_search(neighbour): + return True # Cycle detected + + currently_visiting.remove(node) + return False + + # Start DFS traversal from each node + for node in graph: + if depth_first_search(node): + return True # Cycle detected + + return False # No cycles found + + +def topological_sort(graph: Mapping[Any, Iterable[Any]]) -> Iterable[Any]: + """ + Perform topological sorting on a directed graph. + + This guarantees that if there's a directed edge from node A to node B, then A appears before B. + + :param graph: A dictionary representing the directed graph, where keys are node identifiers + and values are lists of outgoing edges from each node. + :type graph: dict[int, list[Any]] + + :return: A topologically sorted list of node identifiers. + :rtype: list[Any] + """ + visited: set[Any] = set() + stack: list[Any] = [] + + def dfs(node: Any) -> None: + """ + Depth-first search traversal to visit nodes and their neighbors. + + :param node: The current node to visit. + :type node: Any + """ + if node in visited: + return + visited.add(node) + for neighbour in graph.get(node, []): + dfs(neighbour) + stack.append(node) + + # Perform DFS traversal from each node + for node in graph: + dfs(node) + + return stack diff --git a/src/primaite/interface/__init__.py b/src/primaite/interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/interface/request.py b/src/primaite/interface/request.py new file mode 100644 index 00000000..bc076599 --- /dev/null +++ b/src/primaite/interface/request.py @@ -0,0 +1,46 @@ +from typing import Dict, ForwardRef, List, Literal, Union + +from pydantic import BaseModel, ConfigDict, StrictBool, validate_call + +RequestFormat = List[Union[str, int, float]] + +RequestResponse = ForwardRef("RequestResponse") +"""This makes it possible to type-hint RequestResponse.from_bool return type.""" + + +class RequestResponse(BaseModel): + """Schema for generic request responses.""" + + model_config = ConfigDict(extra="forbid", strict=True) + """Cannot have extra fields in the response. Anything custom goes into the data field.""" + + status: Literal["pending", "success", "failure", "unreachable"] = "pending" + """ + What is the current status of the request: + - pending - the request has not been received yet, or it has been received but it's still being processed. + - success - the request has been received and executed successfully. + - failure - the request has been received and attempted, but execution failed. + - unreachable - the request could not reach it's intended target, either because it doesn't exist or the target + is off. + """ + + data: Dict = {} + """Catch-all place to provide any additional data that was generated as a response to the request.""" + # TODO: currently, status and data have default values, because I don't want to interrupt existing functionality too + # much. However, in the future we might consider making them mandatory. + + @classmethod + @validate_call + def from_bool(cls, status_bool: StrictBool) -> RequestResponse: + """ + Construct a basic request response from a boolean. + + True maps to a success status. False maps to a failure status. + + :param status_bool: Whether to create a successful response + :type status_bool: bool + """ + if status_bool is True: + return cls(status="success", data={}) + elif status_bool is False: + return cls(status="failure", data={}) diff --git a/src/primaite/links/__init__.py b/src/primaite/links/__init__.py deleted file mode 100644 index c91b6951..00000000 --- a/src/primaite/links/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Network connections between nodes in the simulation.""" diff --git a/src/primaite/links/link.py b/src/primaite/links/link.py deleted file mode 100644 index 3830a15b..00000000 --- a/src/primaite/links/link.py +++ /dev/null @@ -1,114 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The link class.""" -from typing import List - -from primaite.common.protocol import Protocol - - -class Link(object): - """Link class.""" - - def __init__(self, _id: str, _bandwidth: int, _source_node_name: str, _dest_node_name: str, _services: str) -> None: - """ - Initialise a Link within the simulated network. - - :param _id: The IER id - :param _bandwidth: The bandwidth of the link (bps) - :param _source_node_name: The name of the source node - :param _dest_node_name: The name of the destination node - :param _protocols: The protocols to add to the link - """ - self.id: str = _id - self.bandwidth: int = _bandwidth - self.source_node_name: str = _source_node_name - self.dest_node_name: str = _dest_node_name - self.protocol_list: List[Protocol] = [] - - # Add the default protocols - for protocol_name in _services: - self.add_protocol(protocol_name) - - def add_protocol(self, _protocol: str) -> None: - """ - Adds a new protocol to the list of protocols on this link. - - Args: - _protocol: The protocol to be added (enum) - """ - self.protocol_list.append(Protocol(_protocol)) - - def get_id(self) -> str: - """ - Gets link ID. - - Returns: - Link ID - """ - return self.id - - def get_source_node_name(self) -> str: - """ - Gets source node name. - - Returns: - Source node name - """ - return self.source_node_name - - def get_dest_node_name(self) -> str: - """ - Gets destination node name. - - Returns: - Destination node name - """ - return self.dest_node_name - - def get_bandwidth(self) -> int: - """ - Gets bandwidth of link. - - Returns: - Link bandwidth (bps) - """ - return self.bandwidth - - def get_protocol_list(self) -> List[Protocol]: - """ - Gets list of protocols on this link. - - Returns: - List of protocols on this link - """ - return self.protocol_list - - def get_current_load(self) -> int: - """ - Gets current total load on this link. - - Returns: - Total load on this link (bps) - """ - total_load = 0 - for protocol in self.protocol_list: - total_load += protocol.get_load() - return total_load - - def add_protocol_load(self, _protocol: str, _load: int) -> None: - """ - Adds a loading to a protocol on this link. - - Args: - _protocol: The protocol to load - _load: The amount to load (bps) - """ - for protocol in self.protocol_list: - if protocol.get_name() == _protocol: - protocol.add_load(_load) - else: - pass - - def clear_traffic(self) -> None: - """Clears all traffic on this link.""" - for protocol in self.protocol_list: - protocol.clear_load() diff --git a/src/primaite/main.py b/src/primaite/main.py deleted file mode 100644 index 03f4fb35..00000000 --- a/src/primaite/main.py +++ /dev/null @@ -1,49 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The main PrimAITE session runner module.""" -import argparse -from pathlib import Path -from typing import Optional, Union - -from primaite import getLogger -from primaite.primaite_session import PrimaiteSession - -_LOGGER = getLogger(__name__) - - -def run( - training_config_path: Optional[Union[str, Path]] = "", - lay_down_config_path: Optional[Union[str, Path]] = "", - session_path: Optional[Union[str, Path]] = None, -) -> None: - """ - Run the PrimAITE Session. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - :param session_path: directory path of the session to load - """ - session = PrimaiteSession(training_config_path, lay_down_config_path, session_path) - - session.setup() - session.learn() - session.evaluate() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--tc") - parser.add_argument("--ldc") - parser.add_argument("--load") - - args = parser.parse_args() - if args.load: - run(session_path=args.load) - else: - if not args.tc: - _LOGGER.error("Please provide a training config file using the --tc " "argument") - if not args.ldc: - _LOGGER.error("Please provide a lay down config file using the --ldc " "argument") - run(training_config_path=args.tc, lay_down_config_path=args.ldc) diff --git a/src/primaite/nodes/__init__.py b/src/primaite/nodes/__init__.py deleted file mode 100644 index 231b8d92..00000000 --- a/src/primaite/nodes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Nodes represent network hosts in the simulation.""" diff --git a/src/primaite/nodes/active_node.py b/src/primaite/nodes/active_node.py deleted file mode 100644 index 8f472e86..00000000 --- a/src/primaite/nodes/active_node.py +++ /dev/null @@ -1,208 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""An Active Node (i.e. not an actuator).""" -import logging -from typing import Final - -from primaite.common.enums import FileSystemState, HardwareState, NodeType, Priority, SoftwareState -from primaite.config.training_config import TrainingConfig -from primaite.nodes.node import Node - -_LOGGER: Final[logging.Logger] = logging.getLogger(__name__) - - -class ActiveNode(Node): - """Active Node class.""" - - def __init__( - self, - node_id: str, - name: str, - node_type: NodeType, - priority: Priority, - hardware_state: HardwareState, - ip_address: str, - software_state: SoftwareState, - file_system_state: FileSystemState, - config_values: TrainingConfig, - ) -> None: - """ - Initialise an active node. - - :param node_id: The node ID - :param name: The node name - :param node_type: The node type (enum) - :param priority: The node priority (enum) - :param hardware_state: The node Hardware State - :param ip_address: The node IP address - :param software_state: The node Software State - :param file_system_state: The node file system state - :param config_values: The config values - """ - super().__init__(node_id, name, node_type, priority, hardware_state, config_values) - self.ip_address: str = ip_address - # Related to Software - self._software_state: SoftwareState = software_state - self.patching_count: int = 0 - # Related to File System - self.file_system_state_actual: FileSystemState = file_system_state - self.file_system_state_observed: FileSystemState = file_system_state - self.file_system_scanning: bool = False - self.file_system_scanning_count: int = 0 - self.file_system_action_count: int = 0 - - @property - def software_state(self) -> SoftwareState: - """ - Get the software_state. - - :return: The software_state. - """ - return self._software_state - - @software_state.setter - def software_state(self, software_state: SoftwareState) -> None: - """ - Get the software_state. - - :param software_state: Software State. - """ - if self.hardware_state != HardwareState.OFF: - self._software_state = software_state - if software_state == SoftwareState.PATCHING: - self.patching_count = self.config_values.os_patching_duration - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so OS State cannot be " - f"changed. " - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.software_state:{self._software_state}" - ) - - def set_software_state_if_not_compromised(self, software_state: SoftwareState) -> None: - """ - Sets Software State if the node is not compromised. - - Args: - software_state: Software State - """ - if self.hardware_state != HardwareState.OFF: - if self._software_state != SoftwareState.COMPROMISED: - self._software_state = software_state - if software_state == SoftwareState.PATCHING: - self.patching_count = self.config_values.os_patching_duration - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so OS State cannot be changed." - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.software_state:{self._software_state}" - ) - - def update_os_patching_status(self) -> None: - """Updates operating system status based on patching cycle.""" - self.patching_count -= 1 - if self.patching_count <= 0: - self.patching_count = 0 - self._software_state = SoftwareState.GOOD - - def set_file_system_state(self, file_system_state: FileSystemState) -> None: - """ - Sets the file system state (actual and observed). - - Args: - file_system_state: File system state - """ - if self.hardware_state != HardwareState.OFF: - self.file_system_state_actual = file_system_state - - if file_system_state == FileSystemState.REPAIRING: - self.file_system_action_count = self.config_values.file_system_repairing_limit - self.file_system_state_observed = FileSystemState.REPAIRING - elif file_system_state == FileSystemState.RESTORING: - self.file_system_action_count = self.config_values.file_system_restoring_limit - self.file_system_state_observed = FileSystemState.RESTORING - elif file_system_state == FileSystemState.GOOD: - self.file_system_state_observed = FileSystemState.GOOD - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so File System State " - f"cannot be changed. " - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.file_system_state.actual:{self.file_system_state_actual}" - ) - - def set_file_system_state_if_not_compromised(self, file_system_state: FileSystemState) -> None: - """ - Sets the file system state (actual and observed) if not in a compromised state. - - Use for green PoL to prevent it overturning a compromised state - - Args: - file_system_state: File system state - """ - if self.hardware_state != HardwareState.OFF: - if ( - self.file_system_state_actual != FileSystemState.CORRUPT - and self.file_system_state_actual != FileSystemState.DESTROYED - ): - self.file_system_state_actual = file_system_state - - if file_system_state == FileSystemState.REPAIRING: - self.file_system_action_count = self.config_values.file_system_repairing_limit - self.file_system_state_observed = FileSystemState.REPAIRING - elif file_system_state == FileSystemState.RESTORING: - self.file_system_action_count = self.config_values.file_system_restoring_limit - self.file_system_state_observed = FileSystemState.RESTORING - elif file_system_state == FileSystemState.GOOD: - self.file_system_state_observed = FileSystemState.GOOD - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so File System State (if not " - f"compromised) cannot be changed. " - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.file_system_state.actual:{self.file_system_state_actual}" - ) - - def start_file_system_scan(self) -> None: - """Starts a file system scan.""" - self.file_system_scanning = True - self.file_system_scanning_count = self.config_values.file_system_scanning_limit - - def update_file_system_state(self) -> None: - """Updates file system status based on scanning/restore/repair cycle.""" - # Deprecate both the action count (for restoring or reparing) and the scanning count - self.file_system_action_count -= 1 - self.file_system_scanning_count -= 1 - - # Reparing / Restoring updates - if self.file_system_action_count <= 0: - self.file_system_action_count = 0 - if ( - self.file_system_state_actual == FileSystemState.REPAIRING - or self.file_system_state_actual == FileSystemState.RESTORING - ): - self.file_system_state_actual = FileSystemState.GOOD - self.file_system_state_observed = FileSystemState.GOOD - - # Scanning updates - if self.file_system_scanning == True and self.file_system_scanning_count < 0: - self.file_system_state_observed = self.file_system_state_actual - self.file_system_scanning = False - self.file_system_scanning_count = 0 - - def update_resetting_status(self) -> None: - """Updates the reset count & makes software and file state to GOOD.""" - super().update_resetting_status() - if self.resetting_count <= 0: - self.file_system_state_actual = FileSystemState.GOOD - self.software_state = SoftwareState.GOOD - - def update_booting_status(self) -> None: - """Updates the booting software and file state to GOOD.""" - super().update_booting_status() - if self.booting_count <= 0: - self.file_system_state_actual = FileSystemState.GOOD - self.software_state = SoftwareState.GOOD diff --git a/src/primaite/nodes/node.py b/src/primaite/nodes/node.py deleted file mode 100644 index fc4d41d3..00000000 --- a/src/primaite/nodes/node.py +++ /dev/null @@ -1,79 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The base Node class.""" -from typing import Final - -from primaite.common.enums import HardwareState, NodeType, Priority -from primaite.config.training_config import TrainingConfig - - -class Node: - """Node class.""" - - def __init__( - self, - node_id: str, - name: str, - node_type: NodeType, - priority: Priority, - hardware_state: HardwareState, - config_values: TrainingConfig, - ) -> None: - """ - Initialise a node. - - :param node_id: The node id. - :param name: The name of the node. - :param node_type: The type of the node. - :param priority: The priority of the node. - :param hardware_state: The state of the node. - :param config_values: Config values. - """ - self.node_id: Final[str] = node_id - self.name: Final[str] = name - self.node_type: Final[NodeType] = node_type - self.priority = priority - self.hardware_state: HardwareState = hardware_state - self.resetting_count: int = 0 - self.config_values: TrainingConfig = config_values - self.booting_count: int = 0 - self.shutting_down_count: int = 0 - - def __repr__(self) -> str: - """Returns the name of the node.""" - return self.name - - def turn_on(self) -> None: - """Sets the node state to ON.""" - self.hardware_state = HardwareState.BOOTING - self.booting_count = self.config_values.node_booting_duration - - def turn_off(self) -> None: - """Sets the node state to OFF.""" - self.hardware_state = HardwareState.OFF - self.shutting_down_count = self.config_values.node_shutdown_duration - - def reset(self) -> None: - """Sets the node state to Resetting and starts the reset count.""" - self.hardware_state = HardwareState.RESETTING - self.resetting_count = self.config_values.node_reset_duration - - def update_resetting_status(self) -> None: - """Updates the resetting count.""" - self.resetting_count -= 1 - if self.resetting_count <= 0: - self.resetting_count = 0 - self.hardware_state = HardwareState.ON - - def update_booting_status(self) -> None: - """Updates the booting count.""" - self.booting_count -= 1 - if self.booting_count <= 0: - self.booting_count = 0 - self.hardware_state = HardwareState.ON - - def update_shutdown_status(self) -> None: - """Updates the shutdown count.""" - self.shutting_down_count -= 1 - if self.shutting_down_count <= 0: - self.shutting_down_count = 0 - self.hardware_state = HardwareState.OFF diff --git a/src/primaite/nodes/node_state_instruction_green.py b/src/primaite/nodes/node_state_instruction_green.py deleted file mode 100644 index 6e35d0ec..00000000 --- a/src/primaite/nodes/node_state_instruction_green.py +++ /dev/null @@ -1,94 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Defines node behaviour for Green PoL.""" -from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: - from primaite.common.enums import FileSystemState, HardwareState, NodePOLType, SoftwareState - - -class NodeStateInstructionGreen(object): - """The Node State Instruction class.""" - - def __init__( - self, - _id: str, - _start_step: int, - _end_step: int, - _node_id: str, - _node_pol_type: "NodePOLType", - _service_name: str, - _state: Union["HardwareState", "SoftwareState", "FileSystemState"], - ) -> None: - """ - Initialise the Node State Instruction. - - :param _id: The node state instruction id - :param _start_step: The start step of the instruction - :param _end_step: The end step of the instruction - :param _node_id: The id of the associated node - :param _node_pol_type: The pattern of life type - :param _service_name: The service name - :param _state: The state (node or service) - """ - self.id = _id - self.start_step = _start_step - self.end_step = _end_step - self.node_id = _node_id - self.node_pol_type: "NodePOLType" = _node_pol_type - self.service_name: str = _service_name # Not used when not a service instruction - # TODO: confirm type of state - self.state: Union["HardwareState", "SoftwareState", "FileSystemState"] = _state - - def get_start_step(self) -> int: - """ - Gets the start step. - - Returns: - The start step - """ - return self.start_step - - def get_end_step(self) -> int: - """ - Gets the end step. - - Returns: - The end step - """ - return self.end_step - - def get_node_id(self) -> str: - """ - Gets the node ID. - - Returns: - The node ID - """ - return self.node_id - - def get_node_pol_type(self) -> "NodePOLType": - """ - Gets the node pattern of life type (enum). - - Returns: - The node pattern of life type (enum) - """ - return self.node_pol_type - - def get_service_name(self) -> str: - """ - Gets the service name. - - Returns: - The service name - """ - return self.service_name - - def get_state(self) -> Union["HardwareState", "SoftwareState", "FileSystemState"]: - """ - Gets the state (node or service). - - Returns: - The state (node or service) - """ - return self.state diff --git a/src/primaite/nodes/node_state_instruction_red.py b/src/primaite/nodes/node_state_instruction_red.py deleted file mode 100644 index eb87924b..00000000 --- a/src/primaite/nodes/node_state_instruction_red.py +++ /dev/null @@ -1,143 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Defines node behaviour for Green PoL.""" -from typing import TYPE_CHECKING, Union - -from primaite.common.enums import NodePOLType - -if TYPE_CHECKING: - from primaite.common.enums import FileSystemState, HardwareState, NodePOLInitiator, SoftwareState - - -class NodeStateInstructionRed: - """The Node State Instruction class.""" - - def __init__( - self, - _id: str, - _start_step: int, - _end_step: int, - _target_node_id: str, - _pol_initiator: "NodePOLInitiator", - _pol_type: NodePOLType, - pol_protocol: str, - _pol_state: Union["HardwareState", "SoftwareState", "FileSystemState"], - _pol_source_node_id: str, - _pol_source_node_service: str, - _pol_source_node_service_state: str, - ) -> None: - """ - Initialise the Node State Instruction for the red agent. - - :param _id: The node state instruction id - :param _start_step: The start step of the instruction - :param _end_step: The end step of the instruction - :param _target_node_id: The id of the associated node - :param -pol_initiator: The way the PoL is applied (DIRECT, IER or SERVICE) - :param _pol_type: The pattern of life type - :param pol_protocol: The pattern of life protocol/service affected - :param _pol_state: The state (node or service) - :param _pol_source_node_id: The source node Id (used for initiator type SERVICE) - :param _pol_source_node_service: The source node service (used for initiator type SERVICE) - :param _pol_source_node_service_state: The source node service state (used for initiator type SERVICE) - """ - self.id: str = _id - self.start_step: int = _start_step - self.end_step: int = _end_step - self.target_node_id: str = _target_node_id - self.initiator: "NodePOLInitiator" = _pol_initiator - self.pol_type: NodePOLType = _pol_type - self.service_name: str = pol_protocol # Not used when not a service instruction - self.state: Union["HardwareState", "SoftwareState", "FileSystemState"] = _pol_state - self.source_node_id: str = _pol_source_node_id - self.source_node_service: str = _pol_source_node_service - self.source_node_service_state = _pol_source_node_service_state - - def get_start_step(self) -> int: - """ - Gets the start step. - - Returns: - The start step - """ - return self.start_step - - def get_end_step(self) -> int: - """ - Gets the end step. - - Returns: - The end step - """ - return self.end_step - - def get_target_node_id(self) -> str: - """ - Gets the node ID. - - Returns: - The node ID - """ - return self.target_node_id - - def get_initiator(self) -> "NodePOLInitiator": - """ - Gets the initiator. - - Returns: - The initiator - """ - return self.initiator - - def get_pol_type(self) -> NodePOLType: - """ - Gets the node pattern of life type (enum). - - Returns: - The node pattern of life type (enum) - """ - return self.pol_type - - def get_service_name(self) -> str: - """ - Gets the service name. - - Returns: - The service name - """ - return self.service_name - - def get_state(self) -> Union["HardwareState", "SoftwareState", "FileSystemState"]: - """ - Gets the state (node or service). - - Returns: - The state (node or service) - """ - return self.state - - def get_source_node_id(self) -> str: - """ - Gets the source node id (used for initiator type SERVICE). - - Returns: - The source node id - """ - return self.source_node_id - - def get_source_node_service(self) -> str: - """ - Gets the source node service (used for initiator type SERVICE). - - Returns: - The source node service - """ - return self.source_node_service - - def get_source_node_service_state(self) -> str: - """ - Gets the source node service state (used for initiator type SERVICE). - - Returns: - The source node service state - """ - return self.source_node_service_state diff --git a/src/primaite/nodes/passive_node.py b/src/primaite/nodes/passive_node.py deleted file mode 100644 index 08dcbfa2..00000000 --- a/src/primaite/nodes/passive_node.py +++ /dev/null @@ -1,42 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The Passive Node class (i.e. an actuator).""" -from primaite.common.enums import HardwareState, NodeType, Priority -from primaite.config.training_config import TrainingConfig -from primaite.nodes.node import Node - - -class PassiveNode(Node): - """The Passive Node class.""" - - def __init__( - self, - node_id: str, - name: str, - node_type: NodeType, - priority: Priority, - hardware_state: HardwareState, - config_values: TrainingConfig, - ) -> None: - """ - Initialise a passive node. - - :param node_id: The node id. - :param name: The name of the node. - :param node_type: The type of the node. - :param priority: The priority of the node. - :param hardware_state: The state of the node. - :param config_values: Config values. - """ - # Pass through to Super for now - super().__init__(node_id, name, node_type, priority, hardware_state, config_values) - - @property - def ip_address(self) -> str: - """ - Gets the node IP address as an empty string. - - No concept of IP address for passive nodes for now. - - :return: The node IP address. - """ - return "" diff --git a/src/primaite/nodes/service_node.py b/src/primaite/nodes/service_node.py deleted file mode 100644 index b0d42785..00000000 --- a/src/primaite/nodes/service_node.py +++ /dev/null @@ -1,190 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""A Service Node (i.e. not an actuator).""" -import logging -from typing import Dict, Final - -from primaite.common.enums import FileSystemState, HardwareState, NodeType, Priority, SoftwareState -from primaite.common.service import Service -from primaite.config.training_config import TrainingConfig -from primaite.nodes.active_node import ActiveNode - -_LOGGER: Final[logging.Logger] = logging.getLogger(__name__) - - -class ServiceNode(ActiveNode): - """ServiceNode class.""" - - def __init__( - self, - node_id: str, - name: str, - node_type: NodeType, - priority: Priority, - hardware_state: HardwareState, - ip_address: str, - software_state: SoftwareState, - file_system_state: FileSystemState, - config_values: TrainingConfig, - ) -> None: - """ - Initialise a Service Node. - - :param node_id: The node ID - :param name: The node name - :param node_type: The node type (enum) - :param priority: The node priority (enum) - :param hardware_state: The node Hardware State - :param ip_address: The node IP address - :param software_state: The node Software State - :param file_system_state: The node file system state - :param config_values: The config values - """ - super().__init__( - node_id, - name, - node_type, - priority, - hardware_state, - ip_address, - software_state, - file_system_state, - config_values, - ) - self.services: Dict[str, Service] = {} - - def add_service(self, service: Service) -> None: - """ - Adds a service to the node. - - :param service: The service to add - """ - self.services[service.name] = service - - def has_service(self, protocol_name: str) -> bool: - """ - Indicates whether a service is on a node. - - :param protocol_name: The service (protocol)e. - :return: True if service (protocol) is on the node, otherwise False. - """ - for service_key, service_value in self.services.items(): - if service_key == protocol_name: - return True - return False - - def service_running(self, protocol_name: str) -> bool: - """ - Indicates whether a service is in a running state on the node. - - :param protocol_name: The service (protocol) - :return: True if service (protocol) is in a running state on the node, otherwise False. - """ - for service_key, service_value in self.services.items(): - if service_key == protocol_name: - if service_value.software_state != SoftwareState.PATCHING: - return True - else: - return False - return False - - def service_is_overwhelmed(self, protocol_name: str) -> bool: - """ - Indicates whether a service is in an overwhelmed state on the node. - - :param protocol_name: The service (protocol) - :return: True if service (protocol) is in an overwhelmed state on the node, otherwise False. - """ - for service_key, service_value in self.services.items(): - if service_key == protocol_name: - if service_value.software_state == SoftwareState.OVERWHELMED: - return True - else: - return False - return False - - def set_service_state(self, protocol_name: str, software_state: SoftwareState) -> None: - """ - Sets the software_state of a service (protocol) on the node. - - :param protocol_name: The service (protocol). - :param software_state: The software_state. - """ - if self.hardware_state != HardwareState.OFF: - service_key = protocol_name - service_value = self.services.get(service_key) - if service_value: - # Can't set to compromised if you're in a patching state - if ( - software_state == SoftwareState.COMPROMISED - and service_value.software_state != SoftwareState.PATCHING - ) or software_state != SoftwareState.COMPROMISED: - service_value.software_state = software_state - if software_state == SoftwareState.PATCHING: - service_value.patching_count = self.config_values.service_patching_duration - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so the state of a service " - f"cannot be changed. " - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.services[]:{protocol_name}, " - f"Node.services[].software_state:{software_state}" - ) - - def set_service_state_if_not_compromised(self, protocol_name: str, software_state: SoftwareState) -> None: - """ - Sets the software_state of a service (protocol) on the node. - - Done if the software_state is not "compromised". - - :param protocol_name: The service (protocol). - :param software_state: The software_state. - """ - if self.hardware_state != HardwareState.OFF: - service_key = protocol_name - service_value = self.services.get(service_key) - if service_value: - if service_value.software_state != SoftwareState.COMPROMISED: - service_value.software_state = software_state - if software_state == SoftwareState.PATCHING: - service_value.patching_count = self.config_values.service_patching_duration - else: - _LOGGER.info( - f"The Nodes hardware state is OFF so the state of a service " - f"cannot be changed. " - f"Node.node_id:{self.node_id}, " - f"Node.hardware_state:{self.hardware_state}, " - f"Node.services[]:{protocol_name}, " - f"Node.services[].software_state:{software_state}" - ) - - def get_service_state(self, protocol_name: str) -> SoftwareState: - """ - Gets the state of a service. - - :return: The software_state of the service. - """ - service_key = protocol_name - service_value = self.services.get(service_key) - if service_value: - return service_value.software_state - - def update_services_patching_status(self) -> None: - """Updates the patching counter for any service that are patching.""" - for service_key, service_value in self.services.items(): - if service_value.software_state == SoftwareState.PATCHING: - service_value.reduce_patching_count() - - def update_resetting_status(self) -> None: - """Update resetting counter and set software state if it reached 0.""" - super().update_resetting_status() - if self.resetting_count <= 0: - for service in self.services.values(): - service.software_state = SoftwareState.GOOD - - def update_booting_status(self) -> None: - """Update booting counter and set software to good if it reached 0.""" - super().update_booting_status() - if self.booting_count <= 0: - for service in self.services.values(): - service.software_state = SoftwareState.GOOD diff --git a/src/primaite/notebooks/.gitkeep b/src/primaite/notebooks/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/notebooks/Data-Manipulation-Customising-Red-Agent.ipynb b/src/primaite/notebooks/Data-Manipulation-Customising-Red-Agent.ipynb new file mode 100644 index 00000000..33d56fb0 --- /dev/null +++ b/src/primaite/notebooks/Data-Manipulation-Customising-Red-Agent.ipynb @@ -0,0 +1,466 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Customising UC2 Red Agents\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook will go over some examples of how red agent behaviour can be varied by changing its configuration parameters.\n", + "\n", + "First, let's load the standard Data Manipulation config file, and see what the red agent does.\n", + "\n", + "*(For a full explanation of the Data Manipulation scenario, check out the data manipulation scenario notebook)*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "\n", + "from primaite.config.load import data_manipulation_config_path\n", + "from primaite.game.agent.interface import AgentHistoryItem\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "import yaml\n", + "from pprint import pprint" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def make_cfg_have_flat_obs(cfg):\n", + " for agent in cfg['agents']:\n", + " if agent['type'] == \"ProxyAgent\":\n", + " agent['agent_settings']['flatten_obs'] = False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " make_cfg_have_flat_obs(cfg)\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "obs, info = env.reset()\n", + "print('env created successfully')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def friendly_output_red_action(info):\n", + " # parse the info dict form step output and write out what the red agent is doing\n", + " red_info : AgentHistoryItem = info['agent_actions']['data_manipulation_attacker']\n", + " red_action = red_info.action\n", + " if red_action == 'DONOTHING':\n", + " red_str = 'DO NOTHING'\n", + " elif red_action == 'NODE_APPLICATION_EXECUTE':\n", + " client = \"client 1\" if red_info.parameters['node_id'] == 0 else \"client 2\"\n", + " red_str = f\"ATTACK from {client}\"\n", + " return red_str" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, the red agent can start on client 1 or client 2. It starts its attack on a random step between 20 and 30, and it repeats its attack every 15-25 steps.\n", + "\n", + "It also has a 20% chance to fail to perform the port scan, and a 20% chance to fail launching the SQL attack. However it will continue where it left off after a failed step. I.e. if lucky, it can perform the port scan and SQL attack on the first try. If the port scan works, but the sql attack fails the first time it tries to attack, the next time it will not need to port scan again, it can go straight to trying to use SQL attack again." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for step in range(35):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " print(f\"step: {step_num:3}, Red action: {friendly_output_red_action(info)}, Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since the agent does nothing most of the time, let's only print the steps where it performs an attack." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset()\n", + "for step in range(100):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if red.startswith(\"ATTACK\"):\n", + " print(f\"step: {step_num:3}, Red action: {friendly_output_red_action(info)}, Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Red Configuration\n", + "\n", + "There are two important parts of the YAML config for varying red agent behaviour.\n", + "\n", + "### Red agent settings\n", + "Here is an annotated config for the red agent in the data manipulation scenario." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```yaml\n", + " - ref: data_manipulation_attacker # name of agent\n", + " team: RED # not used, just for human reference\n", + " type: RedDatabaseCorruptingAgent # type of agent - this lets primaite know which agent class to use\n", + "\n", + " # Since the agent does not need to react to what is happening in the environment, the observation space is empty.\n", + " observation_space:\n", + " type: UC2RedObservation\n", + " options:\n", + " nodes: {}\n", + "\n", + " action_space:\n", + "\n", + " # The agent has two action choices, either do nothing, or execute a pre-scripted attack by using \n", + " action_list:\n", + " - type: DONOTHING\n", + " - type: NODE_APPLICATION_EXECUTE\n", + "\n", + " # The agent has access to the DataManipulationBoth on clients 1 and 2.\n", + " options:\n", + " nodes:\n", + " - node_name: client_1 # The network should have a node called client_1\n", + " applications:\n", + " - application_name: DataManipulationBot # The node client_1 should have DataManipulationBot configured on it\n", + " - node_name: client_2 # The network should have a node called client_2\n", + " applications:\n", + " - application_name: DataManipulationBot # The node client_2 should have DataManipulationBot configured on it\n", + "\n", + " # not important\n", + " max_folders_per_node: 1\n", + " max_files_per_folder: 1\n", + " max_services_per_node: 1\n", + "\n", + " # red agent does not need a reward function\n", + " reward_function:\n", + " reward_components:\n", + " - type: DUMMY\n", + "\n", + " # These actions are passed to the RedDatabaseCorruptingAgent init method, they dictate the schedule of attacks\n", + " agent_settings:\n", + " start_settings:\n", + " start_step: 25 # first attack at step 25\n", + " frequency: 20 # attacks will happen every 20 steps (on average)\n", + " variance: 5 # the timing of attacks will vary by up to 5 steps earlier or later\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Malicious application settings\n", + "The red agent uses an application called `DataManipulationBot` which leverages a node's `DatabaseClient` to send a malicious SQL query to the database server. Here's an annotated example of how this is configured in the yaml *(with impertinent config items omitted)*:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```yaml\n", + "simulation:\n", + " network:\n", + " nodes:\n", + " - ref: client_1\n", + " hostname: client_1\n", + " type: computer\n", + " ip_address: 192.168.10.21\n", + " subnet_mask: 255.255.255.0\n", + " default_gateway: 192.168.10.1\n", + " \n", + " # \n", + " applications:\n", + " - ref: data_manipulation_bot\n", + " type: DataManipulationBot\n", + " options:\n", + " port_scan_p_of_success: 0.8 # Probability that port scan is successful\n", + " data_manipulation_p_of_success: 0.8 # Probability that SQL attack is successful\n", + " payload: \"DELETE\" # The SQL query which causes the attack (this has to be DELETE)\n", + " server_ip: 192.168.1.14 # IP address of server hosting the database\n", + " - ref: client_1_database_client\n", + " type: DatabaseClient # Database client must be installed in order for DataManipulationBot to function\n", + " options:\n", + " db_server_ip: 192.168.1.14 # IP address of server hosting the database\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Editing red agent settings\n", + "\n", + "### Removing randomness from attack timing\n", + "\n", + "We can make the attacks happen at completely predictable intervals if we edit the red agent's settings to set variance to 0." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "change = yaml.safe_load(\"\"\"\n", + "start_settings:\n", + " start_step: 25\n", + " frequency: 20\n", + " variance: 0\n", + "\"\"\")\n", + "\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " for agent in cfg['agents']:\n", + " if agent['ref'] == \"data_manipulation_attacker\":\n", + " agent['agent_settings'] = change\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "env.reset()\n", + "for step in range(100):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if red.startswith(\"ATTACK\"):\n", + " print(f\"step: {step_num:3}, Red action: {friendly_output_red_action(info)}, Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Making the start node always the same\n", + "\n", + "Normally, the agent randomly chooses between the nodes in its action space to send attacks from:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Open the config without changing anything\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "env.reset()\n", + "for ep in range(12):\n", + " env.reset()\n", + " for step in range(31):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if red.startswith(\"ATTACK\"):\n", + " print(f\"Episode: {ep:2}, step: {step_num:3}, Red action: {friendly_output_red_action(info)}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can make the agent always start on a node of our choice letting that be the only node in the agent's action space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "change = yaml.safe_load(\"\"\"\n", + "action_space:\n", + " action_list:\n", + " - type: DONOTHING\n", + " - type: NODE_APPLICATION_EXECUTE\n", + " options:\n", + " nodes:\n", + " - node_name: client_1\n", + " applications:\n", + " - application_name: DataManipulationBot\n", + " max_folders_per_node: 1\n", + " max_files_per_folder: 1\n", + " max_services_per_node: 1\n", + "\"\"\")\n", + "\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " for agent in cfg['agents']:\n", + " if agent['ref'] == \"data_manipulation_attacker\":\n", + " agent.update(change)\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "env.reset()\n", + "for ep in range(12):\n", + " env.reset()\n", + " for step in range(31):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if red.startswith(\"ATTACK\"):\n", + " print(f\"Episode: {ep:2}, step: {step_num:3}, Red action: {friendly_output_red_action(info)}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Make the attack less likely to succeed.\n", + "\n", + "We can change the success probabilities within the data manipulation bot application. When the attack succeeds, the reward goes down.\n", + "\n", + "Setting the probabilities to 1.0 means the attack always succeeds - the reward will always drop\n", + "\n", + "Setting the probabilities to 0.0 means the attack always fails - the reward will never drop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Make attack always succeed.\n", + "change = yaml.safe_load(\"\"\"\n", + " applications:\n", + " - ref: data_manipulation_bot\n", + " type: DataManipulationBot\n", + " options:\n", + " port_scan_p_of_success: 1.0\n", + " data_manipulation_p_of_success: 1.0\n", + " payload: \"DELETE\"\n", + " server_ip: 192.168.1.14\n", + " - ref: client_1_web_browser\n", + " type: WebBrowser\n", + " options:\n", + " target_url: http://arcd.com/users/\n", + " - ref: client_1_database_client\n", + " type: DatabaseClient\n", + " options:\n", + " db_server_ip: 192.168.1.14\n", + "\"\"\")\n", + "\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " cfg['simulation']['network']\n", + " for node in cfg['simulation']['network']['nodes']:\n", + " if node['hostname'] in ['client_1', 'client_2']:\n", + " node['applications'] = change['applications']\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "env.reset()\n", + "for ep in range(5):\n", + " env.reset()\n", + " for step in range(36):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if step_num == 35:\n", + " print(f\"Episode: {ep:2}, step: {step_num:3}, Reward: {reward:.2f}\" )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Make attack always fail.\n", + "change = yaml.safe_load(\"\"\"\n", + " applications:\n", + " - ref: data_manipulation_bot\n", + " type: DataManipulationBot\n", + " options:\n", + " port_scan_p_of_success: 0.0\n", + " data_manipulation_p_of_success: 0.0\n", + " payload: \"DELETE\"\n", + " server_ip: 192.168.1.14\n", + " - ref: client_1_web_browser\n", + " type: WebBrowser\n", + " options:\n", + " target_url: http://arcd.com/users/\n", + " - ref: client_1_database_client\n", + " type: DatabaseClient\n", + " options:\n", + " db_server_ip: 192.168.1.14\n", + "\"\"\")\n", + "\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " cfg['simulation']['network']\n", + " for node in cfg['simulation']['network']['nodes']:\n", + " if node['hostname'] in ['client_1', 'client_2']:\n", + " node['applications'] = change['applications']\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "env.reset()\n", + "for ep in range(5):\n", + " env.reset()\n", + " for step in range(36):\n", + " step_num = env.game.step_counter\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " red = friendly_output_red_action(info)\n", + " if step_num == 35:\n", + " print(f\"Episode: {ep:2}, step: {step_num:3}, Reward: {reward:.2f}\" )" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Data-Manipulation-E2E-Demonstration.ipynb b/src/primaite/notebooks/Data-Manipulation-E2E-Demonstration.ipynb new file mode 100644 index 00000000..b3a90cc0 --- /dev/null +++ b/src/primaite/notebooks/Data-Manipulation-E2E-Demonstration.ipynb @@ -0,0 +1,716 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Data Manipulation Scenario\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Scenario\n", + "\n", + "The network consists of an office subnet and a server subnet. Clients in the office access a website which fetches data from a database. Occasionally, admins need to access the database directly from the clients.\n", + "\n", + "![UC2 Network](./_package_data/uc2_network.png)\n", + "\n", + "_(click image to enlarge)_\n", + "\n", + "The red agent deletes the contents of the database. When this happens, the web app cannot fetch data and users navigating to the website get a 404 error.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network\n", + "\n", + "- The web server has:\n", + " - a web service that replies to user HTTP requests\n", + " - a database client that fetches data for the web service\n", + "- The database server has:\n", + " - a POSTGRES database service\n", + " - a database file which is accessed by the database service\n", + " - FTP client used for backing up the data to the backup_server\n", + "- The backup server has:\n", + " - a copy of the database file in a known good state\n", + " - FTP server that can send the backed up file back to the database server\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Green agent\n", + "\n", + "There are green agents logged onto client 1 and client 2. They use the web browser to navigate to `http://arcd.com/users`. The web server replies with a status code 200 if the data is available on the database or 404 if not available.\n", + "\n", + "Sometimes, the green agents send a request directly to the database to check that it is reachable." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Red agent\n", + "\n", + "At the start of every episode, the red agent randomly chooses either client 1 or client 2 to login to. It waits a bit then sends a DELETE query to the database from its chosen client. If the delete is successful, the database file is flagged as compromised to signal that data is not available.\n", + "\n", + "![uc2_attack](./_package_data/uc2_attack.png)\n", + "\n", + "_(click image to enlarge)_" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Blue agent\n", + "\n", + "The blue agent can view the entire network, but the health statuses of components are not updated until a scan is performed. The blue agent should restore the database file from backup after it was compromised. It can also prevent further attacks by blocking the red agent client from sending the malicious SQL query to the database server. This can be done by implementing an ACL rule on the router.\n", + "\n", + "However, these rules will also impact greens' ability to check the database connection. The blue agent should only block the infected client, it should let the other client connect freely. Once the attack has begun, automated traffic monitoring will detect it as suspicious network traffic. The blue agent's observation space will show this as an increase in the number of malicious network events (NMNE) on one of the network interfaces. To achieve optimal reward, the agent should only block the client which has the non-zero outbound NMNE." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reinforcement learning details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Scripted agents:\n", + "### Red\n", + "The red agent sits on a client and uses an application called DataManipulationBot whose sole purpose is to send a DELETE query to the database.\n", + "The red agent can choose one of two action each timestep:\n", + "1. do nothing\n", + "2. execute the data manipulation application\n", + "The schedule for selecting when to execute the application is controlled by three parameters:\n", + "- start time\n", + "- frequency\n", + "- variance\n", + "\n", + "Attacks start at a random timestep between (start_time - variance) and (start_time + variance). After each attack, another is attempted after a random delay between (frequency - variance) and (frequency + variance) timesteps.\n", + "\n", + "The data manipulation app itself has an element of randomness because the attack has a probability of success. The default is 0.8 to succeed with the port scan step and 0.8 to succeed with the attack itself.\n", + "Upon a successful attack, the database file becomes corrupted which incurs a negative reward for the RL defender.\n", + "\n", + "The red agent does not use information about the state of the network to decide its action.\n", + "\n", + "### Green\n", + "The green agents use the web browser application to send requests to the web server. The schedule of each green agent is currently random, it will do nothing 30% of the time, send a web request 60% of the time, and send a db status check 10% of the time.\n", + "\n", + "When a green agent is blocked from accessing the data through the webpage, this incurs a negative reward to the RL defender.\n", + "\n", + "Also, when the green agent is blocked from checking the database status, it causes a small negative reward." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Observation Space\n", + "\n", + "The blue agent's observation space is structured as nested dictionary with the following information:\n", + "```\n", + "\n", + "- NODES\n", + " - \n", + " - SERVICES\n", + " - \n", + " - operating_status\n", + " - health_status\n", + " - FOLDERS\n", + " - \n", + " - health_status\n", + " - FILES\n", + " - \n", + " - health_status\n", + " - NETWORK_INTERFACES\n", + " - \n", + " - nic_status\n", + " - nmne\n", + " - inbound\n", + " - outbound\n", + " - operating_status\n", + "- LINKS\n", + " - \n", + " - PROTOCOLS\n", + " - ALL\n", + " - load\n", + "- ACL\n", + " - \n", + " - position\n", + " - permission\n", + " - source_node_id\n", + " - source_port\n", + " - dest_node_id\n", + " - dest_port\n", + " - protocol\n", + "- ICS\n", + "```\n", + "\n", + "### Mappings\n", + "\n", + "The dict keys for `node_id` are in the following order:\n", + "\n", + "| node_id | node name |\n", + "|---------|------------------|\n", + "| 1 | domain_controller|\n", + "| 2 | web_server |\n", + "| 3 | database_server |\n", + "| 4 | backup_server |\n", + "| 5 | security_suite |\n", + "| 6 | client_1 |\n", + "| 7 | client_2 |\n", + "\n", + "Service 1 on node 2 (web_server) corresponds to the Web Server service. Other services are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "Folder 1 on node 3 corresponds to the database folder. File 1 in that folder corresponds to the database storage file. Other files and folders are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "The dict keys for `link_id` are in the following order:\n", + "\n", + "| link_id | endpoint_a | endpoint_b |\n", + "|---------|------------------|-------------------|\n", + "| 1 | router_1 | switch_1 |\n", + "| 2 | router_1 | switch_2 |\n", + "| 3 | switch_1 | domain_controller |\n", + "| 4 | switch_1 | web_server |\n", + "| 5 | switch_1 | database_server |\n", + "| 6 | switch_1 | backup_server |\n", + "| 7 | switch_1 | security_suite |\n", + "| 8 | switch_2 | client_1 |\n", + "| 9 | switch_2 | client_2 |\n", + "| 10 | switch_2 | security_suite |\n", + "\n", + "\n", + "The ACL rules in the observation space appear in the same order that they do in the actual ACL. Though, only the first 10 rules are shown, there are default rules lower down that cannot be changed by the agent. The extra rules just allow the network to function normally, by allowing pings, ARP traffic, etc.\n", + "\n", + "Most nodes have only 1 network_interface, so the observation for those is placed at NIC index 1 in the observation space. Only the security suite has 2 NICs, the second NIC in the observation space is the one that connects the security suite with swtich_2.\n", + "\n", + "The meaning of the services' operating_state is:\n", + "\n", + "| operating_state | label |\n", + "|-----------------|------------|\n", + "| 0 | UNUSED |\n", + "| 1 | RUNNING |\n", + "| 2 | STOPPED |\n", + "| 3 | PAUSED |\n", + "| 4 | DISABLED |\n", + "| 5 | INSTALLING |\n", + "| 6 | RESTARTING |\n", + "\n", + "The meaning of the services' health_state is:\n", + "\n", + "| health_state | label |\n", + "|--------------|-------------|\n", + "| 0 | UNUSED |\n", + "| 1 | GOOD |\n", + "| 2 | FIXING |\n", + "| 3 | COMPROMISED |\n", + "| 4 | OVERWHELMED |\n", + "\n", + "\n", + "The meaning of the files' and folders' health_state is:\n", + "\n", + "| health_state | label |\n", + "|--------------|-------------|\n", + "| 0 | UNUSED |\n", + "| 1 | GOOD |\n", + "| 2 | COMPROMISED |\n", + "| 3 | CORRUPT |\n", + "| 4 | RESTORING |\n", + "| 5 | REPAIRING |\n", + "\n", + "\n", + "The meaning of the NICs' operating_status is:\n", + "\n", + "| operating_status | label |\n", + "|------------------|----------|\n", + "| 0 | UNUSED |\n", + "| 1 | ENABLED |\n", + "| 2 | DISABLED |\n", + "\n", + "\n", + "NMNE (number of malicious network events) means, for inbound or outbound traffic, means:\n", + "\n", + "| value | NMNEs |\n", + "|-------|----------------|\n", + "| 0 | None |\n", + "| 1 | 1 - 5 |\n", + "| 2 | 6 - 10 |\n", + "| 3 | More than 10 |\n", + "\n", + "\n", + "Link load has the following meaning:\n", + "\n", + "| load | percent utilisation |\n", + "|------|---------------------|\n", + "| 0 | exactly 0% |\n", + "| 1 | 0-11% |\n", + "| 2 | 11-22% |\n", + "| 3 | 22-33% |\n", + "| 4 | 33-44% |\n", + "| 5 | 44-55% |\n", + "| 6 | 55-66% |\n", + "| 7 | 66-77% |\n", + "| 8 | 77-88% |\n", + "| 9 | 88-99% |\n", + "| 10 | exactly 100% |\n", + "\n", + "\n", + "ACL permission has the following meaning:\n", + "\n", + "| permission | label |\n", + "|------------|--------|\n", + "| 0 | UNUSED |\n", + "| 1 | ALLOW |\n", + "| 2 | DENY |\n", + "\n", + "\n", + "ACL source / destination node ids actually correspond to IP addresses (since ACLs work with IP addresses)\n", + "\n", + "| source / dest node id | ip_address | label |\n", + "|-----------------------|----------------|-------------------------|\n", + "| 0 | | UNUSED |\n", + "| 1 | | ALL addresses |\n", + "| 2 | 192.168.1.10 | domain_controller |\n", + "| 3 | 192.168.1.12 | web_server |\n", + "| 4 | 192.168.1.14 | database_server |\n", + "| 5 | 192.168.1.16 | backup_server |\n", + "| 6 | 192.168.1.110 | security_suite (eth-1) |\n", + "| 7 | 192.168.10.21 | client_1 |\n", + "| 8 | 192.168.10.22 | client_2 |\n", + "| 9 | 192.168.10.110 | security_suite (eth-2) |\n", + "\n", + "\n", + "ACL source / destination port ids have the following encoding:\n", + "\n", + "| port id | port number | port use |\n", + "|---------|-------------|-----------------|\n", + "| 0 | | UNUSED |\n", + "| 1 | | ALL |\n", + "| 2 | 219 | ARP |\n", + "| 3 | 53 | DNS |\n", + "| 4 | 80 | HTTP |\n", + "| 5 | 5432 | POSTGRES_SERVER |\n", + "\n", + "\n", + "ACL protocol ids have the following encoding:\n", + "\n", + "| protocol id | label |\n", + "|-------------|-------|\n", + "| 0 | UNUSED|\n", + "| 1 | ALL |\n", + "| 2 | ICMP |\n", + "| 3 | TCP |\n", + "| 4 | UDP |\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Action Space\n", + "\n", + "The blue agent chooses from a list of 54 pre-defined actions. The full list is defined in the `action_map` in the config. The most important ones are explained here:\n", + "\n", + "- `0`: Do nothing\n", + "- `1`: Scan the web service - this refreshes the health status in the observation space\n", + "- `9`: Scan the database file - this refreshes the health status of the database file\n", + "- `13`: Patch the database service - This triggers the database to restore data from the backup server\n", + "- `39`: Shut down client 1\n", + "- `40`: Start up client 1\n", + "- `46`: Block outgoing traffic from client 1\n", + "- `47`: Block outgoing traffic from client 2\n", + "- `50`: Block TCP traffic from client 1 to the database node\n", + "- `51`: Block TCP traffic from client 2 to the database node\n", + "- `52-61`: Remove ACL rules 1-10\n", + "- `66`: Disconnect client 1 from the network\n", + "- `67`: Reconnect client 1 to the network\n", + "- `68`: Disconnect client 2 from the network\n", + "- `69`: Reconnect client 2 to the network\n", + "\n", + "The other actions will either have no effect or will negatively impact the network, so the blue agent should avoid taking them." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reward Function\n", + "\n", + "The blue agent's reward is calculated using these measures:\n", + "1. Whether the database file is in a good state (+1 for good, -1 for corrupted, 0 for any other state)\n", + "2. Whether each green agents' most recent webpage request was successful (+1 for a `200` return code, -1 for a `404` return code and 0 otherwise).\n", + "3. Whether each green agents' most recent DB status check was successful (+1 for a successful connection, -1 for no connection).\n", + "\n", + "The file status reward and the two green-agent-related rewards are averaged to get a total step reward.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Demonstration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, load the required modules" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Imports\n", + "from primaite.config.load import data_manipulation_config_path\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.game.agent.interface import AgentHistoryItem\n", + "import yaml\n", + "from pprint import pprint\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instantiate the environment. \n", + "We will also disable the agent observation flattening.\n", + "\n", + "This cell will print the observation when the network is healthy. You should be able to verify Node file and service statuses against the description above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# create the env\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + " # set success probability to 1.0 to avoid rerunning cells.\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['data_manipulation_p_of_success'] = 1.0\n", + " cfg['simulation']['network']['nodes'][9]['applications'][0]['options']['data_manipulation_p_of_success'] = 1.0\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['port_scan_p_of_success'] = 1.0\n", + " cfg['simulation']['network']['nodes'][9]['applications'][0]['options']['port_scan_p_of_success'] = 1.0\n", + " # don't flatten observations so that we can see what is going on\n", + " cfg['agents'][3]['agent_settings']['flatten_obs'] = False\n", + "\n", + "env = PrimaiteGymEnv(env_config = cfg)\n", + "obs, info = env.reset()\n", + "print('env created successfully')\n", + "pprint(obs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The red agent will start attacking at some point between step 20 and 30. When this happens, the reward will drop immediately, then drop to -0.8 when green agents try to access the webpage." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def friendly_output_red_action(info):\n", + " # parse the info dict form step output and write out what the red agent is doing\n", + " red_info : AgentHistoryItem = info['agent_actions']['data_manipulation_attacker']\n", + " red_action = red_info.action\n", + " if red_action == 'DONOTHING':\n", + " red_str = 'DO NOTHING'\n", + " elif red_action == 'NODE_APPLICATION_EXECUTE':\n", + " client = \"client 1\" if red_info.parameters['node_id'] == 0 else \"client 2\"\n", + " red_str = f\"ATTACK from {client}\"\n", + " return red_str" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for step in range(35):\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " print(f\"step: {env.game.step_counter}, Red action: {friendly_output_red_action(info)}, Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now the reward is -0.8, let's have a look at blue agent's observation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pprint(obs['NODES'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The true statuses of the database file and webapp are not updated. The blue agent needs to perform a scan to see that they have degraded." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "obs, reward, terminated, truncated, info = env.step(9) # scan database file\n", + "obs, reward, terminated, truncated, info = env.step(1) # scan webapp service\n", + "\n", + "pprint(obs['NODES'])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now service 1 on node 2 has `health_status = 3`, indicating that the webapp is compromised.\n", + "File 1 in folder 1 on node 3 has `health_status = 2`, indicating that the database file is compromised." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Also, the NMNE outbound of either client 1 (node 6) or client 2 (node 7) has increased from 0 to 1. This tells us which client is being used by the red agent." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can now patch the database to restore the file to a good health status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "obs, reward, terminated, truncated, info = env.step(13) # patch the database\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['data_manipulation_attacker'].action}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_1_green_user'].action}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user'].action}\" )\n", + "print(f\"Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The fixing takes two steps, so the reward hasn't changed yet. Let's do nothing for another timestep, the reward should improve.\n", + "\n", + "The reward will increase slightly as soon as the file finishes restoring. Then, the reward will increase to 1 when both green agents make successful requests.\n", + "\n", + "Run the following cell until the green action is `NODE_APPLICATION_EXECUTE` for application 0, then the reward should become 1. If you run it enough times, another red attack will happen and the reward will drop again." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "obs, reward, terminated, truncated, info = env.step(0) # do nothing\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['data_manipulation_attacker'].action}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user']}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_1_green_user']}\" )\n", + "print(f\"Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can prevent attacks by implementing an ACL rule to stop client_1 or client_2 from sending POSTGRES traffic to the database. (Let's also patch the database file to get the reward back up.)\n", + "\n", + "Let's block both clients from communicating directly with the database." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(13) # Patch the database\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['data_manipulation_attacker'].action}, Blue reward:{reward:.2f}\" )\n", + "\n", + "env.step(50) # Block client 1\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['data_manipulation_attacker'].action}, Blue reward:{reward:.2f}\" )\n", + "\n", + "env.step(51) # Block client 2\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['data_manipulation_attacker'].action}, Blue reward:{reward:.2f}\" )\n", + "\n", + "while abs(reward - 0.8) > 1e-5:\n", + " obs, reward, terminated, truncated, info = env.step(0) # do nothing\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['data_manipulation_attacker'].action}, Blue reward:{reward:.2f}\" )\n", + " if env.game.step_counter > 10000:\n", + " break # make sure there's no infinite loop if something went wrong" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, even though the red agent executes an attack, the reward will stay at 0.8." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's also have a look at the ACL observation to verify our new ACL rule at positions 5 and 6." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "obs['NODES']['ROUTER0']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can slightly increase the reward by unblocking the client which isn't being used by the attacker. If node 6 has outbound NMNEs, let's unblock client 2, and if node 7 has outbound NMNEs, let's unblock client 1." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.step(58) # Remove the ACL rule that blocks client 1\n", + "env.step(57) # Remove the ACL rule that blocks client 2\n", + "\n", + "tries = 0\n", + "while True:\n", + " tries += 1\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + "\n", + " if obs['NODES']['HOST5']['NICS'][1]['NMNE']['outbound'] == 1:\n", + " # client 1 has NMNEs, let's block it\n", + " obs, reward, terminated, truncated, info = env.step(50) # block client 1\n", + " print(\"blocking client 1\")\n", + " break\n", + " elif obs['NODES']['HOST6']['NICS'][1]['NMNE']['outbound'] == 1:\n", + " # client 2 has NMNEs, so let's block it\n", + " obs, reward, terminated, truncated, info = env.step(51) # block client 2\n", + " print(\"blocking client 2\")\n", + " break\n", + " if tries>100:\n", + " print(\"Error: NMNE never increased\")\n", + " break\n", + "\n", + "env.step(13) # Patch the database\n", + "print()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, the reward will eventually increase to 0.9, even after red agent attempts subsequent attacks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for step in range(40):\n", + " obs, reward, terminated, truncated, info = env.step(0) # do nothing\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['data_manipulation_attacker'].action}, Blue reward:{reward:.2f}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Reset the environment, you can rerun the other cells to verify that the attack works the same every episode. (except the red agent will move between `client_1` and `client_2`.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Getting-Information-Out-Of-PrimAITE.ipynb b/src/primaite/notebooks/Getting-Information-Out-Of-PrimAITE.ipynb new file mode 100644 index 00000000..a832f3cc --- /dev/null +++ b/src/primaite/notebooks/Getting-Information-Out-Of-PrimAITE.ipynb @@ -0,0 +1,170 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Getting information out of PrimAITE\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import yaml\n", + "from primaite import PRIMAITE_CONFIG\n", + "\n", + "from primaite.config.load import data_manipulation_config_path\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", + "from notebook.services.config import ConfigManager\n", + "\n", + "cm = ConfigManager().update('notebook', {'limit_output': 50}) # limit output lines to 50 - for neatness\n", + "\n", + "# create the env\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "\n", + "env = PrimaiteGymEnv(env_config=cfg)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualising the Simulation Network" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The network can be visualised by running the code below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.game.simulation.network.draw()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Getting the state of a simulation object\n", + "\n", + "The state of the simulation object is used to determine the observation space used by agents.\n", + "\n", + "Any object created using the ``SimComponent`` class has a ``describe_state`` method which can show the state of the object.\n", + "\n", + "An example of such an object is ``Computer`` which inherits from ``SimComponent``. In the default network configuration, ``client_1`` is a Computer object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "client_1.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### More specific describe_state\n", + "\n", + "As you can see, the output from the ``describe_state`` method for the ``Computer`` object includes the describe state for all its components. This can cause a large describe state output.\n", + "\n", + "As stated, the ``describe_state`` can be called on any object that inherits ``SimComponent``. This can allow you retrieve the state of a specific item." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client_1.file_system.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## System Logs\n", + "\n", + "Objects that inherit from the ``Node`` class will inherit the ``sys_log`` attribute.\n", + "\n", + "This is to simulate the idea that items such as Computer, Routers, Servers, etc. have a logging system used to diagnose problems." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# store config\n", + "# this is to prevent the notebook from breaking your local settings\n", + "was_enabled = PRIMAITE_CONFIG[\"developer_mode\"][\"enabled\"]\n", + "was_syslogs_enabled = PRIMAITE_CONFIG[\"developer_mode\"][\"output_sys_logs\"]\n", + "\n", + "# enable dev mode so that the default config outputs are overridden for this demo\n", + "PRIMAITE_CONFIG[\"developer_mode\"][\"enabled\"] = True\n", + "PRIMAITE_CONFIG[\"developer_mode\"][\"output_sys_logs\"] = True\n", + "\n", + "\n", + "\n", + "\n", + "# Remake the environment\n", + "env = PrimaiteGymEnv(env_config=cfg)\n", + "\n", + "# get the example computer\n", + "client_1: Computer = env.game.simulation.network.get_node_by_hostname(\"client_1\")\n", + "\n", + "# show sys logs on terminal\n", + "client_1.sys_log.show()\n", + "\n", + "\n", + "\n", + "\n", + "# restore config\n", + "PRIMAITE_CONFIG[\"developer_mode\"][\"enabled\"] = was_enabled\n", + "PRIMAITE_CONFIG[\"developer_mode\"][\"output_sys_logs\"] = was_syslogs_enabled" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Requests-and-Responses.ipynb b/src/primaite/notebooks/Requests-and-Responses.ipynb new file mode 100644 index 00000000..ca9f02f5 --- /dev/null +++ b/src/primaite/notebooks/Requests-and-Responses.ipynb @@ -0,0 +1,223 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Requests and Responses\n", + "\n", + "Agents interact with the PrimAITE simulation via the Request system.\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sending a request" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's set up a minimal network simulation and send some requests to see how it works." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState\n", + "from primaite.simulator.network.hardware.nodes.host.host_node import HostNode\n", + "from primaite.simulator.sim_container import Simulation\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sim = Simulation()\n", + "sim.network.add_node(\n", + " HostNode(\n", + " hostname=\"client\",\n", + " ip_address='10.0.0.1',\n", + " subnet_mask='255.255.255.0',\n", + " operating_state=NodeOperatingState.ON)\n", + ")\n", + "client = sim.network.get_node_by_hostname('client')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A request is structured in a similar way to a command line interface - a list of strings with positional args. It's also possible to supply an optional `context` dictionary. We will craft a request that stops the pre-installed DNSClient service on the client node.\n", + "\n", + "First let's verify that the DNS Client is running on the client.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "client.software_manager.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Send a request to the simulator to stop the DNSClient." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "response = sim.apply_request(\n", + " request=[\"network\", \"node\", \"client\", \"service\", \"DNSClient\", \"stop\"],\n", + " context={}\n", + " )\n", + "print(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "The request returns a `RequestResponse` object which tells us that the request was successfully executed. Let's verify that the DNS client is in a stopped state now." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"DNS Client state: {client.software_manager.software.get('DNSClient').operating_state.name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Unreachable requests" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we attempt to send a request to something that doesn't exist, we will get an unreachable request status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "response = sim.apply_request(\n", + " request=[\"network\", \"node\", \"client\", \"service\", \"NonExistentApplication\", \"stop\"],\n", + " context={}\n", + " )\n", + "print(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Failed requests\n", + "\n", + "Sometimes requests cannot be executed by the simulation. For example if we turn off the client node, we cannot execute the software that is running on it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "response = sim.apply_request(\n", + " request = [\"network\", \"node\", \"client\", \"shutdown\"],\n", + " context = {}\n", + ")\n", + "print(response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We need to apply timestep a few times for the client to go from `SHUTTING_DOWN` to `OFF` state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"client is in state: {client.operating_state.name}\")\n", + "sim.apply_timestep(1)\n", + "sim.apply_timestep(2)\n", + "sim.apply_timestep(3)\n", + "sim.apply_timestep(4)\n", + "print(f\"client is in state: {client.operating_state.name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, if we try to start the DNSClient back up, we get a failure because we cannot start software on a node that is turned off." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "response = sim.apply_request(\n", + " request=[\"network\", \"node\", \"client\", \"service\", \"DNSClient\", \"start\"],\n", + " context={}\n", + " )\n", + "print(response)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb new file mode 100644 index 00000000..c185b8b5 --- /dev/null +++ b/src/primaite/notebooks/Training-an-RLLIB-MARL-System.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Train a Multi agent system using RLLIB\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook will demonstrate how to use the `PrimaiteRayMARLEnv` to train a very basic system with two PPO agents." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### First, Import packages and read our config file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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", + "# If you get an error saying this config file doesn't exist, you may need to run `primaite setup` in your command line\n", + "# to copy the files to your user data path.\n", + "with open(PRIMAITE_PATHS.user_config_path / 'example_config/data_manipulation_marl.yaml', 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "\n", + "ray.init(local_mode=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Create a Ray algorithm config which accepts our two agents" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "config = (\n", + " PPOConfig()\n", + " .multi_agent(\n", + " policies={'defender_1','defender_2'}, # These names are the same as the agents defined in the example config.\n", + " policy_mapping_fn=lambda agent_id, episode, worker, **kw: agent_id,\n", + " )\n", + " .environment(env=PrimaiteRayMARLEnv, env_config=cfg)\n", + " .env_runners(num_env_runners=0)\n", + " .training(train_batch_size=128)\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Set training parameters and start the training\n", + "This example will save outputs to a default Ray directory and use mostly default settings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb new file mode 100644 index 00000000..bdd60f36 --- /dev/null +++ b/src/primaite/notebooks/Training-an-RLLib-Agent.ipynb @@ -0,0 +1,107 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Train a Single agent system using RLLib\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook will demonstrate how to use PrimaiteRayEnv to train a basic PPO agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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", + "# If you get an error saying this config file doesn't exist, you may need to run `primaite setup` in your command line\n", + "# to copy the files to your user data path.\n", + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "\n", + "ray.init(local_mode=True)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Create a Ray algorithm and pass it our config." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for agent in cfg['agents']:\n", + " if agent[\"ref\"] == \"defender\":\n", + " agent['agent_settings']['flatten_obs'] = True\n", + "env_config = cfg\n", + "\n", + "config = (\n", + " PPOConfig()\n", + " .environment(env=PrimaiteRayEnv, env_config=env_config)\n", + " .env_runners(num_env_runners=0)\n", + " .training(train_batch_size=128)\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Set training parameters and start the training" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/Training-an-SB3-Agent.ipynb b/src/primaite/notebooks/Training-an-SB3-Agent.ipynb new file mode 100644 index 00000000..8a5b852b --- /dev/null +++ b/src/primaite/notebooks/Training-an-SB3-Agent.ipynb @@ -0,0 +1,190 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Training an SB3 Agent\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook will demonstrate how to use primaite to create and train a PPO agent, using a pre-defined configuration file." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### First, we import the inital packages and read in our configuration file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.game.game import PrimaiteGame\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "import yaml" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.config.load import data_manipulation_config_path" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "for agent in cfg['agents']:\n", + " if agent['ref'] == 'defender':\n", + " agent['agent_settings']['flatten_obs']=True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Using the given configuration, we generate the environment our agent will train in." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "gym = PrimaiteGymEnv(env_config=cfg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets define training parameters for the agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from stable_baselines3 import PPO\n", + "\n", + "EPISODE_LEN = 128\n", + "NUM_EPISODES = 5\n", + "NO_STEPS = EPISODE_LEN * NUM_EPISODES\n", + "BATCH_SIZE = 32\n", + "LEARNING_RATE = 3e-4" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = PPO('MlpPolicy', gym, learning_rate=LEARNING_RATE, n_steps=NO_STEPS, batch_size=BATCH_SIZE, verbose=0, tensorboard_log=\"./PPO_UC2/\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With the agent configured, let's train for our defined number of episodes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model.learn(total_timesteps=NO_STEPS)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, let's save the agent to a zip file that can be used in future evaluation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model.save(\"PrimAITE-PPO-example-agent\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we load the saved agent and run it in evaluation mode." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "eval_model = PPO(\"MlpPolicy\", gym)\n", + "eval_model = PPO.load(\"PrimAITE-PPO-example-agent\", gym)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, evaluate the agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from stable_baselines3.common.evaluation import evaluate_policy\n", + "\n", + "evaluate_policy(eval_model, gym, n_eval_episodes=10)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/src/primaite/notebooks/Using-Episode-Schedules.ipynb b/src/primaite/notebooks/Using-Episode-Schedules.ipynb new file mode 100644 index 00000000..0d0f1a4a --- /dev/null +++ b/src/primaite/notebooks/Using-Episode-Schedules.ipynb @@ -0,0 +1,336 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Using Episode Schedules\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "PrimAITE supports the ability to use different variations on a scenario at different episodes. This can be used to increase \n", + "domain randomisation to prevent overfitting, or to set up curriculum learning to train agents to perform more complicated tasks.\n", + "\n", + "When using a fixed scenario, a single yaml config file is used. However, to use episode schedules, PrimAITE uses a \n", + "directory with several config files that work together." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Demonstration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run `primaite setup` to copy the example config files into the correct directory. Then, import and define config location." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!primaite setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite import PRIMAITE_PATHS\n", + "from prettytable import PrettyTable\n", + "scenario_path = PRIMAITE_PATHS.user_config_path / \"example_config/scenario_with_placeholders\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Base Scenario File\n", + "Let's view the contents of the base scenario file:\n", + "\n", + "It contains all the base settings that stay fixed throughout all episodes, including the `io_settings`, `game` settings, the network layout and the blue agent definition. There are two placeholders: `*greens` and `*reds`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"scenario.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Schedule File\n", + "Let's view the contents of the schedule file:\n", + "\n", + "This file references the base scenario file and defines which variations should be loaded in at each episode. In this instance, there are four episodes, during the first episode `greens_0` and `reds_0` is used, during the second episode `greens_0` and `reds_1` is used, and so on." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"schedule.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Green Agent Variation Files\n", + "\n", + "There are three different variants of the green agent setup. In `greens_0`, there are no green agents, in `greens_1` there is a green agent that executes the database client application 80% of the time, and in `greens_2` there is a green agent that executes the database client application 5% of the time.\n", + "\n", + "(the difference between `greens_1` and `greens_2` is in the agent name and action probabilities)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"greens_0.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"greens_1.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"greens_2.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Red Agent Variation Files\n", + "\n", + "There are three different variants of the red agent setup. In `reds_0`, there are no red agents, in `reds_1` there is a red agent that executes every 20 steps, but in `reds_2` there is a red agent that executes every 2 steps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"reds_0.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"reds_1.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(scenario_path/\"reds_2.yaml\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Running the simulation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create the environment using the variable config." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env = PrimaiteGymEnv(env_config=scenario_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Episode 0\n", + "Let' run the episodes to verify that the agents are changing as expected. In episode 0, there should be no green or red agents, just the defender blue agent." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"Current episode number: {env.episode_counter}\")\n", + "print(f\"Agents present: {list(env.game.agents.keys())}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Episode 1\n", + "When we reset the environment, it moves onto episode 1, where it will bring in reds_1 for red agent definition.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset()\n", + "print(f\"Current episode number: {env.episode_counter}\")\n", + "print(f\"Agents present: {list(env.game.agents.keys())}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Episode 2\n", + "When we reset the environment again, it moves onto episode 2, where it will bring in greens_1 and reds_1 for green and red agent definitions. Let's verify the agent names and that they take actions at the defined frequency.\n", + "\n", + "Most green actions will be `NODE_APPLICATION_EXECUTE` while red will `DONOTHING` except at steps 10 and 20." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset()\n", + "print(f\"Current episode number: {env.episode_counter}\")\n", + "print(f\"Agents present: {list(env.game.agents.keys())}\")\n", + "for i in range(21):\n", + " env.step(0)\n", + "\n", + "table = PrettyTable()\n", + "table.field_names = [\"step\", \"Green Action\", \"Red Action\"]\n", + "for i in range(21):\n", + " green_action = env.game.agents['green_A'].history[i].action\n", + " red_action = env.game.agents['red_A'].history[i].action\n", + " table.add_row([i, green_action, red_action])\n", + "print(table)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Episode 3\n", + "When we reset the environment again, it moves onto episode 3, where it will bring in greens_2 and reds_2 for green and red agent definitions. Let's verify the agent names and that they take actions at the defined frequency.\n", + "\n", + "Now, green will perform `NODE_APPLICATION_EXECUTE` only 5% of the time, while red will perform `NODE_APPLICATION_EXECUTE` more frequently than before." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset()\n", + "print(f\"Current episode number: {env.episode_counter}\")\n", + "print(f\"Agents present: {list(env.game.agents.keys())}\")\n", + "for i in range(21):\n", + " env.step(0)\n", + "\n", + "table = PrettyTable()\n", + "table.field_names = [\"step\", \"Green Action\", \"Red Action\"]\n", + "for i in range(21):\n", + " green_action = env.game.agents['green_B'].history[i].action\n", + " red_action = env.game.agents['red_B'].history[i].action\n", + " table.add_row([i, green_action, red_action])\n", + "print(table)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Further Episodes\n", + "\n", + "Since the schedule definition only goes up to episode 3, if we reset the environment again, we run out of episodes. The environment will simply loop back to the beginning, but it produces a warning message to make users aware that the episodes are being repeated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.reset(); # semicolon suppresses jupyter outputting the observation space.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/notebooks/__init__.py b/src/primaite/notebooks/__init__.py deleted file mode 100644 index bc1dcfcd..00000000 --- a/src/primaite/notebooks/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Contains default jupyter notebooks which demonstrate PrimAITE functionality.""" - -import importlib.util -import os -import subprocess -import sys -from logging import Logger - -from primaite import getLogger, PRIMAITE_PATHS - -_LOGGER: Logger = getLogger(__name__) - - -def start_jupyter_session() -> None: - """ - Starts a new Jupyter notebook session in the app notebooks directory. - - Currently only works on Windows OS. - - .. todo:: Figure out how to get this working for Linux and MacOS too. - """ - if importlib.util.find_spec("jupyter") is not None: - jupyter_cmd = "python3 -m jupyter lab" - if sys.platform == "win32": - jupyter_cmd = "jupyter lab" - - working_dir = os.getcwd() - os.chdir(PRIMAITE_PATHS.user_notebooks_path) - subprocess.Popen(jupyter_cmd) - os.chdir(working_dir) - else: - # Jupyter is not installed - _LOGGER.error("Cannot start jupyter lab as it is not installed") diff --git a/src/primaite/notebooks/_package_data/uc2_attack.png b/src/primaite/notebooks/_package_data/uc2_attack.png new file mode 100644 index 00000000..8b8df5ce Binary files /dev/null and b/src/primaite/notebooks/_package_data/uc2_attack.png differ diff --git a/src/primaite/notebooks/_package_data/uc2_network.png b/src/primaite/notebooks/_package_data/uc2_network.png new file mode 100644 index 00000000..20fa43c9 Binary files /dev/null and b/src/primaite/notebooks/_package_data/uc2_network.png differ diff --git a/src/primaite/notebooks/multi-processing.ipynb b/src/primaite/notebooks/multi-processing.ipynb new file mode 100644 index 00000000..86b549a7 --- /dev/null +++ b/src/primaite/notebooks/multi-processing.ipynb @@ -0,0 +1,151 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Simple multi-processing demonstration\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This notebook uses SubprocVecEnv from SB3." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import packages and read config file." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import yaml\n", + "from stable_baselines3 import PPO\n", + "from stable_baselines3.common.utils import set_random_seed\n", + "from stable_baselines3.common.vec_env import SubprocVecEnv\n", + "\n", + "from primaite.session.environment import PrimaiteGymEnv\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.config.load import data_manipulation_config_path" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(data_manipulation_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set up training data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "EPISODE_LEN = 128\n", + "NUM_EPISODES = 10\n", + "NO_STEPS = EPISODE_LEN * NUM_EPISODES\n", + "BATCH_SIZE = 32\n", + "LEARNING_RATE = 3e-4\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define an environment function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "\n", + "def make_env(rank: int, seed: int = 0) -> callable:\n", + " \"\"\"Wrapper script for _init function.\"\"\"\n", + "\n", + " def _init() -> PrimaiteGymEnv:\n", + " env = PrimaiteGymEnv(env_config=cfg)\n", + " env.reset(seed=seed + rank)\n", + " model = PPO(\n", + " \"MlpPolicy\",\n", + " env,\n", + " learning_rate=LEARNING_RATE,\n", + " n_steps=NO_STEPS,\n", + " batch_size=BATCH_SIZE,\n", + " verbose=0,\n", + " tensorboard_log=\"./PPO_UC2/\",\n", + " )\n", + " model.learn(total_timesteps=NO_STEPS)\n", + " return env\n", + "\n", + " set_random_seed(seed)\n", + " return _init\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run experiment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "n_procs = 2\n", + "train_env = SubprocVecEnv([make_env(i + n_procs) for i in range(n_procs)])\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/pol/__init__.py b/src/primaite/pol/__init__.py deleted file mode 100644 index d0d9f616..00000000 --- a/src/primaite/pol/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Pattern of Life- Represents the actions of users on the network.""" diff --git a/src/primaite/pol/green_pol.py b/src/primaite/pol/green_pol.py deleted file mode 100644 index 814aa314..00000000 --- a/src/primaite/pol/green_pol.py +++ /dev/null @@ -1,264 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Implements Pattern of Life on the network (nodes and links).""" -from typing import Dict - -from networkx import MultiGraph, shortest_path - -from primaite.acl.access_control_list import AccessControlList -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import HardwareState, NodePOLType, NodeType, SoftwareState -from primaite.links.link import Link -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.node_state_instruction_green import NodeStateInstructionGreen -from primaite.nodes.service_node import ServiceNode -from primaite.pol.ier import IER - -_VERBOSE: bool = False - - -def apply_iers( - network: MultiGraph, - nodes: Dict[str, NodeUnion], - links: Dict[str, Link], - iers: Dict[str, IER], - acl: AccessControlList, - step: int, -) -> None: - """ - Applies IERs to the links (link pattern of life). - - Args: - network: The network modelled in the environment - nodes: The nodes within the environment - links: The links within the environment - iers: The IERs to apply to the links - acl: The Access Control List - step: The step number. - """ - if _VERBOSE: - print("Applying IERs") - - # Go through each IER and check the conditions for it being applied - # If everything is in place, apply the IER protocol load to the relevant links - for ier_key, ier_value in iers.items(): - start_step = ier_value.get_start_step() - stop_step = ier_value.get_end_step() - protocol = ier_value.get_protocol() - port = ier_value.get_port() - load = ier_value.get_load() - source_node_id = ier_value.get_source_node_id() - dest_node_id = ier_value.get_dest_node_id() - - # Need to set the running status to false first for all IERs - ier_value.set_is_running(False) - - source_valid = True - dest_valid = True - acl_block = False - - if step >= start_step and step <= stop_step: - # continue -------------------------- - - # Get the source and destination node for this link - source_node = nodes[source_node_id] - dest_node = nodes[dest_node_id] - - # 1. Check the source node situation - # TODO: should be using isinstance rather than checking node type attribute. IE. just because it's a switch - # doesn't mean it has a software state? It could be a PassiveNode or ActiveNode - if source_node.node_type == NodeType.SWITCH: - # It's a switch - if ( - source_node.hardware_state == HardwareState.ON - and source_node.software_state != SoftwareState.PATCHING - ): - source_valid = True - else: - # IER no longer valid - source_valid = False - elif source_node.node_type == NodeType.ACTUATOR: - # It's an actuator - # TO DO - pass - else: - # It's not a switch or an actuator (so active node) - if ( - source_node.hardware_state == HardwareState.ON - and source_node.software_state != SoftwareState.PATCHING - ): - if source_node.has_service(protocol): - if source_node.service_running(protocol) and not source_node.service_is_overwhelmed(protocol): - source_valid = True - else: - source_valid = False - else: - # Do nothing - IER is not valid on this node - # (This shouldn't happen if the IER has been written correctly) - source_valid = False - else: - # Do nothing - IER no longer valid - source_valid = False - - # 2. Check the dest node situation - if dest_node.node_type == NodeType.SWITCH: - # It's a switch - if dest_node.hardware_state == HardwareState.ON and dest_node.software_state != SoftwareState.PATCHING: - dest_valid = True - else: - # IER no longer valid - dest_valid = False - elif dest_node.node_type == NodeType.ACTUATOR: - # It's an actuator - pass - else: - # It's not a switch or an actuator (so active node) - if dest_node.hardware_state == HardwareState.ON and dest_node.software_state != SoftwareState.PATCHING: - if dest_node.has_service(protocol): - if dest_node.service_running(protocol) and not dest_node.service_is_overwhelmed(protocol): - dest_valid = True - else: - dest_valid = False - else: - # Do nothing - IER is not valid on this node - # (This shouldn't happen if the IER has been written correctly) - dest_valid = False - else: - # Do nothing - IER no longer valid - dest_valid = False - - # 3. Check that the ACL doesn't block it - acl_block = acl.is_blocked(source_node.ip_address, dest_node.ip_address, protocol, port) - if acl_block: - if _VERBOSE: - print( - "ACL block on source: " - + source_node.ip_address - + ", dest: " - + dest_node.ip_address - + ", protocol: " - + protocol - + ", port: " - + port - ) - else: - if _VERBOSE: - print("No ACL block") - - # Check whether both the source and destination are valid, and there's no ACL block - if source_valid and dest_valid and not acl_block: - # Load up the link(s) with the traffic - - if _VERBOSE: - print("Source, Dest and ACL valid") - - # Get the shortest path (i.e. nodes) between source and destination - path_node_list = shortest_path(network, source_node, dest_node) - path_node_list_length = len(path_node_list) - path_valid = True - - # We might have a switch in the path, so check all nodes are operational - for node in path_node_list: - if node.hardware_state != HardwareState.ON or node.software_state == SoftwareState.PATCHING: - path_valid = False - - if path_valid: - if _VERBOSE: - print("Applying IER to link(s)") - count = 0 - link_capacity_exceeded = False - - # Check that the link capacity is not exceeded by the new load - while count < path_node_list_length - 1: - # Get the link between the next two nodes - edge_dict = network.get_edge_data(path_node_list[count], path_node_list[count + 1]) - link_id = edge_dict[0].get("id") - link = links[link_id] - # Check whether the new load exceeds the bandwidth - if (link.get_current_load() + load) > link.get_bandwidth(): - link_capacity_exceeded = True - if _VERBOSE: - print("Link capacity exceeded") - pass - count += 1 - - # Check whether the link capacity for any links on this path have been exceeded - if link_capacity_exceeded == False: - # Now apply the new loads to the links - count = 0 - while count < path_node_list_length - 1: - # Get the link between the next two nodes - edge_dict = network.get_edge_data( - path_node_list[count], - path_node_list[count + 1], - ) - link_id = edge_dict[0].get("id") - link = links[link_id] - # Add the load from this IER - link.add_protocol_load(protocol, load) - count += 1 - # This IER is now valid, so set it to running - ier_value.set_is_running(True) - else: - # One of the nodes is not operational - if _VERBOSE: - print("Path not valid - one or more nodes not operational") - pass - - else: - if _VERBOSE: - print("Source, Dest or ACL were not valid") - pass - # ------------------------------------ - else: - # Do nothing - IER no longer valid - pass - - -def apply_node_pol( - nodes: Dict[str, NodeUnion], - node_pol: Dict[str, NodeStateInstructionGreen], - step: int, -) -> None: - """ - Applies node pattern of life. - - Args: - nodes: The nodes within the environment - node_pol: The node pattern of life to apply - step: The step number. - """ - if _VERBOSE: - print("Applying Node PoL") - - for key, node_instruction in node_pol.items(): - start_step = node_instruction.get_start_step() - stop_step = node_instruction.get_end_step() - node_id = node_instruction.get_node_id() - node_pol_type = node_instruction.get_node_pol_type() - service_name = node_instruction.get_service_name() - state = node_instruction.get_state() - - if step >= start_step and step <= stop_step: - # continue -------------------------- - node = nodes[node_id] - - if node_pol_type == NodePOLType.OPERATING: - # Change hardware state - node.hardware_state = state - elif node_pol_type == NodePOLType.OS: - # Change OS state - # Don't allow PoL to fix something that is compromised. Only the Blue agent can do this - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - node.set_software_state_if_not_compromised(state) - elif node_pol_type == NodePOLType.SERVICE: - # Change a service state - # Don't allow PoL to fix something that is compromised. Only the Blue agent can do this - if isinstance(node, ServiceNode): - node.set_service_state_if_not_compromised(service_name, state) - else: - # Change the file system status - if isinstance(node, ActiveNode) or isinstance(node, ServiceNode): - node.set_file_system_state_if_not_compromised(state) - else: - # PoL is not valid in this time step - pass diff --git a/src/primaite/pol/ier.py b/src/primaite/pol/ier.py deleted file mode 100644 index b8da28c0..00000000 --- a/src/primaite/pol/ier.py +++ /dev/null @@ -1,147 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -""" -Information Exchange Requirements for APE. - -Used to represent an information flow from source to destination. -""" - - -class IER(object): - """Information Exchange Requirement class.""" - - def __init__( - self, - _id: str, - _start_step: int, - _end_step: int, - _load: int, - _protocol: str, - _port: str, - _source_node_id: str, - _dest_node_id: str, - _mission_criticality: int, - _running: bool = False, - ) -> None: - """ - Initialise an Information Exchange Request. - - :param _id: The IER id - :param _start_step: The step when this IER should start - :param _end_step: The step when this IER should end - :param _load: The load this IER should put on a link (bps) - :param _protocol: The protocol of this IER - :param _port: The port this IER runs on - :param _source_node_id: The source node ID - :param _dest_node_id: The destination node ID - :param _mission_criticality: Criticality of this IER to the mission (0 none, 5 mission critical) - :param _running: Indicates whether the IER is currently running - """ - self.id: str = _id - self.start_step: int = _start_step - self.end_step: int = _end_step - self.source_node_id: str = _source_node_id - self.dest_node_id: str = _dest_node_id - self.load: int = _load - self.protocol: str = _protocol - self.port: str = _port - self.mission_criticality: int = _mission_criticality - self.running: bool = _running - - def get_id(self) -> str: - """ - Gets IER ID. - - Returns: - IER ID - """ - return self.id - - def get_start_step(self) -> int: - """ - Gets IER start step. - - Returns: - IER start step - """ - return self.start_step - - def get_end_step(self) -> int: - """ - Gets IER end step. - - Returns: - IER end step - """ - return self.end_step - - def get_load(self) -> int: - """ - Gets IER load. - - Returns: - IER load - """ - return self.load - - def get_protocol(self) -> str: - """ - Gets IER protocol. - - Returns: - IER protocol - """ - return self.protocol - - def get_port(self) -> str: - """ - Gets IER port. - - Returns: - IER port - """ - return self.port - - def get_source_node_id(self) -> str: - """ - Gets IER source node ID. - - Returns: - IER source node ID - """ - return self.source_node_id - - def get_dest_node_id(self) -> str: - """ - Gets IER destination node ID. - - Returns: - IER destination node ID - """ - return self.dest_node_id - - def get_is_running(self) -> bool: - """ - Informs whether the IER is currently running. - - Returns: - True if running - """ - return self.running - - def set_is_running(self, _value: bool) -> None: - """ - Sets the running state of the IER. - - Args: - _value: running status - """ - self.running = _value - - def get_mission_criticality(self) -> int: - """ - Gets the IER mission criticality (used in the reward function). - - Returns: - Mission criticality value (0 lowest to 5 highest) - """ - return self.mission_criticality diff --git a/src/primaite/pol/red_agent_pol.py b/src/primaite/pol/red_agent_pol.py deleted file mode 100644 index ca1a58da..00000000 --- a/src/primaite/pol/red_agent_pol.py +++ /dev/null @@ -1,353 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Implements POL on the network (nodes and links) resulting from the red agent attack.""" -from typing import Dict - -from networkx import MultiGraph, shortest_path - -from primaite import getLogger -from primaite.acl.access_control_list import AccessControlList -from primaite.common.custom_typing import NodeUnion -from primaite.common.enums import HardwareState, NodePOLInitiator, NodePOLType, NodeType, SoftwareState -from primaite.links.link import Link -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.node_state_instruction_red import NodeStateInstructionRed -from primaite.nodes.service_node import ServiceNode -from primaite.pol.ier import IER - -_LOGGER = getLogger(__name__) - -_VERBOSE: bool = False - - -def apply_red_agent_iers( - network: MultiGraph, - nodes: Dict[str, NodeUnion], - links: Dict[str, Link], - iers: Dict[str, IER], - acl: AccessControlList, - step: int, -) -> None: - """ - Applies IERs to the links (link POL) resulting from red agent attack. - - Args: - network: The network modelled in the environment - nodes: The nodes within the environment - links: The links within the environment - iers: The red agent IERs to apply to the links - acl: The Access Control List - step: The step number. - """ - # Go through each IER and check the conditions for it being applied - # If everything is in place, apply the IER protocol load to the relevant links - for ier_key, ier_value in iers.items(): - start_step = ier_value.get_start_step() - stop_step = ier_value.get_end_step() - protocol = ier_value.get_protocol() - port = ier_value.get_port() - load = ier_value.get_load() - source_node_id = ier_value.get_source_node_id() - dest_node_id = ier_value.get_dest_node_id() - - # Need to set the running status to false first for all IERs - ier_value.set_is_running(False) - - source_valid = True - dest_valid = True - acl_block = False - - if step >= start_step and step <= stop_step: - # continue -------------------------- - - # Get the source and destination node for this link - source_node = nodes[source_node_id] - dest_node = nodes[dest_node_id] - - # 1. Check the source node situation - if source_node.node_type == NodeType.SWITCH: - # It's a switch - if source_node.hardware_state == HardwareState.ON: - source_valid = True - else: - # IER no longer valid - source_valid = False - elif source_node.node_type == NodeType.ACTUATOR: - # It's an actuator - # TO DO - pass - else: - # It's not a switch or an actuator (so active node) - # TODO: this occurs after ruling out the possibility that the node is a switch or an actuator, but it - # could still be a passive/active node, therefore it won't have a hardware_state. The logic here needs - # to change according to duck typing. - if source_node.hardware_state == HardwareState.ON: - if source_node.has_service(protocol): - # Red agents IERs can only be valid if the source service is in a compromised state - if source_node.get_service_state(protocol) == SoftwareState.COMPROMISED: - source_valid = True - else: - source_valid = False - else: - # Do nothing - IER is not valid on this node - # (This shouldn't happen if the IER has been written correctly) - source_valid = False - else: - # Do nothing - IER no longer valid - source_valid = False - - # 2. Check the dest node situation - if dest_node.node_type == NodeType.SWITCH: - # It's a switch - if dest_node.hardware_state == HardwareState.ON: - dest_valid = True - else: - # IER no longer valid - dest_valid = False - elif dest_node.node_type == NodeType.ACTUATOR: - # It's an actuator - pass - else: - # It's not a switch or an actuator (so active node) - if dest_node.hardware_state == HardwareState.ON: - if dest_node.has_service(protocol): - # We don't care what state the destination service is in for an IER - dest_valid = True - else: - # Do nothing - IER is not valid on this node - # (This shouldn't happen if the IER has been written correctly) - dest_valid = False - else: - # Do nothing - IER no longer valid - dest_valid = False - - # 3. Check that the ACL doesn't block it - acl_block = acl.is_blocked(source_node.ip_address, dest_node.ip_address, protocol, port) - if acl_block: - if _VERBOSE: - print( - "ACL block on source: " - + source_node.ip_address - + ", dest: " - + dest_node.ip_address - + ", protocol: " - + protocol - + ", port: " - + port - ) - else: - if _VERBOSE: - print("No ACL block") - - # Check whether both the source and destination are valid, and there's no ACL block - if source_valid and dest_valid and not acl_block: - # Load up the link(s) with the traffic - - if _VERBOSE: - print("Source, Dest and ACL valid") - - # Get the shortest path (i.e. nodes) between source and destination - path_node_list = shortest_path(network, source_node, dest_node) - path_node_list_length = len(path_node_list) - path_valid = True - - # We might have a switch in the path, so check all nodes are operational - # We're assuming here that red agents can get past switches that are patching - for node in path_node_list: - if node.hardware_state != HardwareState.ON: - path_valid = False - - if path_valid: - if _VERBOSE: - print("Applying IER to link(s)") - count = 0 - link_capacity_exceeded = False - - # Check that the link capacity is not exceeded by the new load - while count < path_node_list_length - 1: - # Get the link between the next two nodes - edge_dict = network.get_edge_data(path_node_list[count], path_node_list[count + 1]) - link_id = edge_dict[0].get("id") - link = links[link_id] - # Check whether the new load exceeds the bandwidth - if (link.get_current_load() + load) > link.get_bandwidth(): - link_capacity_exceeded = True - if _VERBOSE: - print("Link capacity exceeded") - pass - count += 1 - - # Check whether the link capacity for any links on this path have been exceeded - if link_capacity_exceeded == False: - # Now apply the new loads to the links - count = 0 - while count < path_node_list_length - 1: - # Get the link between the next two nodes - edge_dict = network.get_edge_data( - path_node_list[count], - path_node_list[count + 1], - ) - link_id = edge_dict[0].get("id") - link = links[link_id] - # Add the load from this IER - link.add_protocol_load(protocol, load) - count += 1 - # This IER is now valid, so set it to running - ier_value.set_is_running(True) - if _VERBOSE: - print("Red IER was allowed to run in step " + str(step)) - else: - # One of the nodes is not operational - if _VERBOSE: - print("Path not valid - one or more nodes not operational") - pass - - else: - if _VERBOSE: - print("Red IER was NOT allowed to run in step " + str(step)) - print("Source, Dest or ACL were not valid") - pass - # ------------------------------------ - else: - # Do nothing - IER no longer valid - pass - - pass - - -def apply_red_agent_node_pol( - nodes: Dict[str, NodeUnion], - iers: Dict[str, IER], - node_pol: Dict[str, NodeStateInstructionRed], - step: int, -) -> None: - """ - Applies node pattern of life. - - Args: - nodes: The nodes within the environment - iers: The red agent IERs - node_pol: The red agent node pattern of life to apply - step: The step number. - """ - if _VERBOSE: - print("Applying Node Red Agent PoL") - - for key, node_instruction in node_pol.items(): - start_step = node_instruction.get_start_step() - stop_step = node_instruction.get_end_step() - target_node_id = node_instruction.get_target_node_id() - initiator = node_instruction.get_initiator() - pol_type = node_instruction.get_pol_type() - service_name = node_instruction.get_service_name() - state = node_instruction.get_state() - source_node_id = node_instruction.get_source_node_id() - source_node_service_name = node_instruction.get_source_node_service() - source_node_service_state_value = node_instruction.get_source_node_service_state() - - passed_checks = False - - if step >= start_step and step <= stop_step: - # continue -------------------------- - target_node: NodeUnion = nodes[target_node_id] - - # check if the initiator type is a str, and if so, cast it as - # NodePOLInitiator - if isinstance(initiator, str): - initiator = NodePOLInitiator[initiator] - - # Based the action taken on the initiator type - if initiator == NodePOLInitiator.DIRECT: - # No conditions required, just apply the change - passed_checks = True - elif initiator == NodePOLInitiator.IER: - # Need to check there is a red IER incoming - passed_checks = is_red_ier_incoming(target_node, iers, pol_type) - elif initiator == NodePOLInitiator.SERVICE: - # Need to check the condition of a service on another node - source_node = nodes[source_node_id] - if source_node.has_service(source_node_service_name): - if ( - source_node.get_service_state(source_node_service_name) - == SoftwareState[source_node_service_state_value] - ): - passed_checks = True - else: - # Do nothing, no matching state value - pass - else: - # Do nothing, service not on this node - pass - else: - _LOGGER.warning("Node Red Agent PoL not allowed - misconfiguration") - - # Only apply the PoL if the checks have passed (based on the initiator type) - if passed_checks: - # Apply the change - if pol_type == NodePOLType.OPERATING: - # Change hardware state - target_node.hardware_state = state - elif pol_type == NodePOLType.OS: - # Change OS state - if isinstance(target_node, ActiveNode) or isinstance(target_node, ServiceNode): - target_node.software_state = state - elif pol_type == NodePOLType.SERVICE: - # Change a service state - if isinstance(target_node, ServiceNode): - target_node.set_service_state(service_name, state) - else: - # Change the file system status - if isinstance(target_node, ActiveNode) or isinstance(target_node, ServiceNode): - target_node.set_file_system_state(state) - else: - _LOGGER.debug("Node Red Agent PoL not allowed - did not pass checks") - else: - # PoL is not valid in this time step - pass - - -def is_red_ier_incoming(node: NodeUnion, iers: Dict[str, IER], node_pol_type: NodePOLType) -> bool: - """Checks if the RED IER is incoming. - - :param node: Destination node of the IER - :type node: NodeUnion - :param iers: Directory of IERs - :type iers: Dict[str,IER] - :param node_pol_type: Type of Pattern-Of-Life - :type node_pol_type: NodePOLType - :return: Whether the RED IER is incoming. - :rtype: bool - """ - node_id = node.node_id - - for ier_key, ier_value in iers.items(): - if ier_value.get_is_running() and ier_value.get_dest_node_id() == node_id: - if ( - node_pol_type == NodePOLType.OPERATING - or node_pol_type == NodePOLType.OS - or node_pol_type == NodePOLType.FILE - ): - # It's looking to change hardware state, file system or SoftwareState, so valid - return True - elif node_pol_type == NodePOLType.SERVICE: - # Check if the service is present on the node and running - ier_protocol = ier_value.get_protocol() - if isinstance(node, ServiceNode): - if node.has_service(ier_protocol): - if node.service_running(ier_protocol): - # Matching service is present and running, so valid - return True - else: - # Service is present, but not running - return False - else: - # Service is not present - return False - else: - # Not a service node - return False - else: - # Shouldn't get here - instruction type is undefined - return False - else: - # The IER destination is not this node, or the IER is not running - return False diff --git a/src/primaite/primaite_session.py b/src/primaite/primaite_session.py deleted file mode 100644 index c64b51fb..00000000 --- a/src/primaite/primaite_session.py +++ /dev/null @@ -1,209 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Main entry point to PrimAITE. Configure training/evaluation experiments and input/output.""" -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Dict, Final, Optional, Tuple, Union - -from primaite import getLogger -from primaite.agents.agent_abc import AgentSessionABC -from primaite.agents.hardcoded_acl import HardCodedACLAgent -from primaite.agents.hardcoded_node import HardCodedNodeAgent -from primaite.agents.rllib import RLlibAgent -from primaite.agents.sb3 import SB3Agent -from primaite.agents.simple import DoNothingACLAgent, DoNothingNodeAgent, DummyAgent, RandomAgent -from primaite.common.enums import ActionType, AgentFramework, AgentIdentifier, SessionType -from primaite.config import lay_down_config, training_config -from primaite.config.training_config import TrainingConfig -from primaite.utils.session_metadata_parser import parse_session_metadata -from primaite.utils.session_output_reader import all_transactions_dict, av_rewards_dict - -_LOGGER = getLogger(__name__) - - -class PrimaiteSession: - """ - The PrimaiteSession class. - - Provides a single learning and evaluation entry point for all training and lay down configurations. - """ - - def __init__( - self, - training_config_path: Optional[Union[str, Path]] = "", - lay_down_config_path: Optional[Union[str, Path]] = "", - session_path: Optional[Union[str, Path]] = None, - ) -> None: - """ - The PrimaiteSession constructor. - - :param training_config_path: YAML file containing configurable items defined in - `primaite.config.training_config.TrainingConfig` - :type training_config_path: Union[path, str] - :param lay_down_config_path: YAML file containing configurable items for generating network laydown. - :type lay_down_config_path: Union[path, str] - :param session_path: directory path of the session to load - """ - self._agent_session: AgentSessionABC = None # noqa - self.session_path: Path = session_path # noqa - self.timestamp_str: str = None # noqa - self.learning_path: Path = None # noqa - self.evaluation_path: Path = None # noqa - - # check if session path is provided - if session_path is not None: - # set load_session to true - self.is_load_session = True - if not isinstance(session_path, Path): - session_path = Path(session_path) - - # if a session path is provided, load it - if not session_path.exists(): - raise Exception(f"Session could not be loaded. Path does not exist: {session_path}") - - md_dict, training_config_path, lay_down_config_path = parse_session_metadata(session_path) - - if not isinstance(training_config_path, Path): - training_config_path = Path(training_config_path) - self._training_config_path: Final[Union[Path, str]] = training_config_path - self._training_config: Final[TrainingConfig] = training_config.load(self._training_config_path) - - if not isinstance(lay_down_config_path, Path): - lay_down_config_path = Path(lay_down_config_path) - self._lay_down_config_path: Final[Union[Path, str]] = lay_down_config_path - self._lay_down_config: Dict = lay_down_config.load(self._lay_down_config_path) # noqa - - def setup(self) -> None: - """Performs the session setup.""" - if self._training_config.agent_framework == AgentFramework.CUSTOM: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Framework = {AgentFramework.CUSTOM}") - if self._training_config.agent_identifier == AgentIdentifier.HARDCODED: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Identifier =" f" {AgentIdentifier.HARDCODED}") - if self._training_config.action_type == ActionType.NODE: - # Deterministic Hardcoded Agent with Node Action Space - self._agent_session = HardCodedNodeAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - - elif self._training_config.action_type == ActionType.ACL: - # Deterministic Hardcoded Agent with ACL Action Space - self._agent_session = HardCodedACLAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - - elif self._training_config.action_type == ActionType.ANY: - # Deterministic Hardcoded Agent with ANY Action Space - raise NotImplementedError - - else: - # Invalid AgentIdentifier ActionType combo - raise ValueError - - elif self._training_config.agent_identifier == AgentIdentifier.DO_NOTHING: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Identifier =" f" {AgentIdentifier.DO_NOTHING}") - if self._training_config.action_type == ActionType.NODE: - self._agent_session = DoNothingNodeAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - - elif self._training_config.action_type == ActionType.ACL: - # Deterministic Hardcoded Agent with ACL Action Space - self._agent_session = DoNothingACLAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - - elif self._training_config.action_type == ActionType.ANY: - # Deterministic Hardcoded Agent with ANY Action Space - raise NotImplementedError - - else: - # Invalid AgentIdentifier ActionType combo - raise ValueError - - elif self._training_config.agent_identifier == AgentIdentifier.RANDOM: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Identifier =" f" {AgentIdentifier.RANDOM}") - self._agent_session = RandomAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - elif self._training_config.agent_identifier == AgentIdentifier.DUMMY: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Identifier =" f" {AgentIdentifier.DUMMY}") - self._agent_session = DummyAgent( - self._training_config_path, self._lay_down_config_path, self.session_path - ) - - else: - # Invalid AgentFramework AgentIdentifier combo - raise ValueError - - elif self._training_config.agent_framework == AgentFramework.SB3: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Framework = {AgentFramework.SB3}") - # Stable Baselines3 Agent - self._agent_session = SB3Agent(self._training_config_path, self._lay_down_config_path, self.session_path) - - elif self._training_config.agent_framework == AgentFramework.RLLIB: - _LOGGER.debug(f"PrimaiteSession Setup: Agent Framework = {AgentFramework.RLLIB}") - # Ray RLlib Agent - self._agent_session = RLlibAgent(self._training_config_path, self._lay_down_config_path, self.session_path) - - else: - # Invalid AgentFramework - raise ValueError - - self.session_path: Path = self._agent_session.session_path - self.timestamp_str: str = self._agent_session.timestamp_str - self.learning_path: Path = self._agent_session.learning_path - self.evaluation_path: Path = self._agent_session.evaluation_path - - def learn( - self, - **kwargs: Any, - ) -> None: - """ - Train the agent. - - :param kwargs: Any agent-framework specific key word args. - """ - if not self._training_config.session_type == SessionType.EVAL: - self._agent_session.learn(**kwargs) - - def evaluate( - self, - **kwargs: Any, - ) -> None: - """ - Evaluate the agent. - - :param kwargs: Any agent-framework specific key word args. - """ - if not self._training_config.session_type == SessionType.TRAIN: - self._agent_session.evaluate(**kwargs) - - def close(self) -> None: - """Closes the agent.""" - self._agent_session.close() - - def learn_av_reward_per_episode_dict(self) -> Dict[int, float]: - """Get the learn av reward per episode from file.""" - csv_file = f"average_reward_per_episode_{self.timestamp_str}.csv" - return av_rewards_dict(self.learning_path / csv_file) - - def eval_av_reward_per_episode_dict(self) -> Dict[int, float]: - """Get the eval av reward per episode from file.""" - csv_file = f"average_reward_per_episode_{self.timestamp_str}.csv" - return av_rewards_dict(self.evaluation_path / csv_file) - - def learn_all_transactions_dict(self) -> Dict[Tuple[int, int], Dict[str, Any]]: - """Get the learn all transactions from file.""" - csv_file = f"all_transactions_{self.timestamp_str}.csv" - return all_transactions_dict(self.learning_path / csv_file) - - def eval_all_transactions_dict(self) -> Dict[Tuple[int, int], Dict[str, Any]]: - """Get the eval all transactions from file.""" - csv_file = f"all_transactions_{self.timestamp_str}.csv" - return all_transactions_dict(self.evaluation_path / csv_file) - - def metadata_file_as_dict(self) -> Dict[str, Any]: - """Read the session_metadata.json file and return as a dict.""" - with open(self.session_path / "session_metadata.json", "r") as file: - return json.load(file) diff --git a/src/primaite/session/__init__.py b/src/primaite/session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py new file mode 100644 index 00000000..8fd39f40 --- /dev/null +++ b/src/primaite/session/environment.py @@ -0,0 +1,135 @@ +import json +from os import PathLike +from typing import Any, Dict, Optional, SupportsFloat, Tuple, Union + +import gymnasium +from gymnasium.core import ActType, ObsType + +from primaite import getLogger +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.session.episode_schedule import build_scheduler, EpisodeScheduler +from primaite.session.io import PrimaiteIO +from primaite.simulator import SIM_OUTPUT +from primaite.simulator.system.core.packet_capture import PacketCapture + +_LOGGER = getLogger(__name__) + + +class PrimaiteGymEnv(gymnasium.Env): + """ + Thin wrapper env to provide agents with a gymnasium API. + + This is always a single agent environment since gymnasium is a single agent API. Therefore, we can make some + assumptions about the agent list always having a list of length 1. + """ + + def __init__(self, env_config: Union[Dict, str, PathLike]): + """Initialise the environment.""" + super().__init__() + self.episode_scheduler: EpisodeScheduler = build_scheduler(env_config) + """Object that returns a config corresponding to the current episode.""" + self.io = PrimaiteIO.from_config(self.episode_scheduler(0).get("io_settings", {})) + """Handles IO for the environment. This produces sys logs, agent logs, etc.""" + self.game: PrimaiteGame = PrimaiteGame.from_config(self.episode_scheduler(0)) + """Current game.""" + self._agent_name = next(iter(self.game.rl_agents)) + """Name of the RL agent. Since there should only be one RL agent we can just pull the first and only key.""" + self.episode_counter: int = 0 + """Current episode number.""" + self.total_reward_per_episode: Dict[int, float] = {} + """Average rewards of agents per episode.""" + + @property + def agent(self) -> ProxyAgent: + """Grab a fresh reference to the agent object because it will be reinstantiated each episode.""" + return self.game.rl_agents[self._agent_name] + + def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: + """Perform a step in the environment.""" + # make ProxyAgent store the action chosen by the RL policy + step = self.game.step_counter + self.agent.store_action(action) + self.game.pre_timestep() + # apply_agent_actions accesses the action we just stored + self.game.apply_agent_actions() + self.game.advance_timestep() + state = self.game.get_sim_state() + self.game.update_agents(state) + + next_obs = self._get_obs() # this doesn't update observation, just gets the current observation + reward = self.agent.reward_function.current_reward + _LOGGER.info(f"step: {self.game.step_counter}, Blue reward: {reward}") + terminated = False + truncated = self.game.calculate_truncated() + info = { + "agent_actions": {name: agent.history[-1] for name, agent in self.game.agents.items()} + } # tell us what all the agents did for convenience. + if self.game.save_step_metadata: + self._write_step_metadata_json(step, action, state, reward) + return next_obs, reward, terminated, truncated, info + + def _write_step_metadata_json(self, step: int, action: int, state: Dict, reward: int): + output_dir = SIM_OUTPUT.path / f"episode_{self.episode_counter}" / "step_metadata" + + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"step_{step}.json" + + data = { + "episode": self.episode_counter, + "step": step, + "action": int(action), + "reward": int(reward), + "state": state, + } + with open(path, "w") as file: + json.dump(data, file) + + def reset(self, seed: Optional[int] = None, options: Optional[Dict] = None) -> Tuple[ObsType, Dict[str, Any]]: + """Reset the environment.""" + _LOGGER.info( + f"Resetting environment, episode {self.episode_counter}, " + f"avg. reward: {self.agent.reward_function.total_reward}" + ) + self.total_reward_per_episode[self.episode_counter] = self.agent.reward_function.total_reward + + if self.io.settings.save_agent_actions: + all_agent_actions = {name: agent.history for name, agent in self.game.agents.items()} + self.io.write_agent_log(agent_actions=all_agent_actions, episode=self.episode_counter) + self.episode_counter += 1 + PacketCapture.clear() + self.game: PrimaiteGame = PrimaiteGame.from_config(cfg=self.episode_scheduler(self.episode_counter)) + self.game.setup_for_episode(episode=self.episode_counter) + state = self.game.get_sim_state() + self.game.update_agents(state=state) + next_obs = self._get_obs() + info = {} + return next_obs, info + + @property + def action_space(self) -> gymnasium.Space: + """Return the action space of the environment.""" + return self.agent.action_manager.space + + @property + def observation_space(self) -> gymnasium.Space: + """Return the observation space of the environment.""" + if self.agent.flatten_obs: + return gymnasium.spaces.flatten_space(self.agent.observation_manager.space) + else: + return self.agent.observation_manager.space + + def _get_obs(self) -> ObsType: + """Return the current observation.""" + if self.agent.flatten_obs: + unflat_space = self.agent.observation_manager.space + unflat_obs = self.agent.observation_manager.current_observation + return gymnasium.spaces.flatten(unflat_space, unflat_obs) + else: + return self.agent.observation_manager.current_observation + + def close(self): + """Close the simulation.""" + if self.io.settings.save_agent_actions: + all_agent_actions = {name: agent.history for name, agent in self.game.agents.items()} + self.io.write_agent_log(agent_actions=all_agent_actions, episode=self.episode_counter) diff --git a/src/primaite/session/episode_schedule.py b/src/primaite/session/episode_schedule.py new file mode 100644 index 00000000..c009fa09 --- /dev/null +++ b/src/primaite/session/episode_schedule.py @@ -0,0 +1,123 @@ +import copy +from abc import ABC, abstractmethod +from itertools import chain +from pathlib import Path +from typing import Dict, List, Mapping, Sequence, Union + +import pydantic +import yaml + +from primaite import getLogger + +_LOGGER = getLogger(__name__) + + +class EpisodeScheduler(pydantic.BaseModel, ABC): + """ + Episode schedulers provide functionality to select different scenarios and game setups for each episode. + + This is useful when implementing advanced RL concepts like curriculum learning and domain randomisation. + """ + + @abstractmethod + def __call__(self, episode_num: int) -> Dict: + """Return the config that should be used during this episode.""" + ... + + +class ConstantEpisodeScheduler(EpisodeScheduler): + """The constant episode schedule simply provides the same game setup every time.""" + + config: Dict + + def __call__(self, episode_num: int) -> Dict: + """Return the same config every time.""" + return copy.deepcopy(self.config) + + +class EpisodeListScheduler(EpisodeScheduler): + """Cycle through a list of different game setups for each episode.""" + + schedule: Mapping[int, List[str]] + """Mapping from episode number to list of filenames""" + episode_data: Mapping[str, str] + """Mapping from filename to yaml string.""" + base_scenario: str + """yaml string containing the base scenario.""" + + _exceeded_episode_list: bool = False + """ + Flag that's set to true when attempting to keep generating episodes after schedule runs out. + + When this happens, we loop back to the beginning, but a warning is raised. + """ + + def __call__(self, episode_num: int) -> Dict: + """Return the config for the given episode number.""" + if episode_num >= len(self.schedule): + if not self._exceeded_episode_list: + self._exceeded_episode_list = True + _LOGGER.warning( + f"Running episode {episode_num} but the schedule only defines " + f"{len(self.schedule)} episodes. Looping back to the beginning" + ) + # not sure if we should be using a traditional warning, or a _LOGGER.warning + episode_num = episode_num % len(self.schedule) + + filenames_to_join = self.schedule[episode_num] + yaml_data_to_join = [self.episode_data[fn] for fn in filenames_to_join] + [self.base_scenario] + joined_yaml = "\n".join(yaml_data_to_join) + parsed_cfg = yaml.safe_load(joined_yaml) + + # Unfortunately, using placeholders like this is slightly hacky, so we have to flatten the list of agents + flat_agents_list = [] + for a in parsed_cfg["agents"]: + if isinstance(a, Sequence): + flat_agents_list.extend(a) + else: + flat_agents_list.append(a) + parsed_cfg["agents"] = flat_agents_list + + return parsed_cfg + + +def build_scheduler(config: Union[str, Path, Dict]) -> EpisodeScheduler: + """ + Convenience method to build an EpisodeScheduler with a dict, file path, or folder path. + + If a path to a folder is provided, it will be treated as a list of game scenarios. + Otherwise, if a dict or a single file is provided, it will be treated as a constant game scenario. + """ + # If we get a dict, return a constant episode schedule that repeats that one config forever + if isinstance(config, Dict): + return ConstantEpisodeScheduler(config=config) + + # Cast string to Path + if isinstance(config, str): + config = Path(config) + + if not config.exists(): + raise FileNotFoundError(f"Provided config path {config} could not be found.") + + if config.is_file(): + with open(config, "r") as f: + cfg_data = yaml.safe_load(f) + return ConstantEpisodeScheduler(config=cfg_data) + + if not config.is_dir(): + raise RuntimeError("Something went wrong while building Primaite config.") + + root = config + schedule_path = root / "schedule.yaml" + + with open(schedule_path, "r") as f: + schedule = yaml.safe_load(f) + + base_scenario_path = root / schedule["base_scenario"] + files_to_load = set(chain.from_iterable(schedule["schedule"].values())) + + episode_data = {fp: (root / fp).read_text() for fp in files_to_load} + + return EpisodeListScheduler( + schedule=schedule["schedule"], episode_data=episode_data, base_scenario=base_scenario_path.read_text() + ) diff --git a/src/primaite/session/io.py b/src/primaite/session/io.py new file mode 100644 index 00000000..2901457f --- /dev/null +++ b/src/primaite/session/io.py @@ -0,0 +1,119 @@ +import json +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + +from primaite import _PRIMAITE_ROOT, getLogger, PRIMAITE_CONFIG, PRIMAITE_PATHS +from primaite.simulator import LogLevel, SIM_OUTPUT +from primaite.utils.cli.primaite_config_utils import is_dev_mode + +_LOGGER = getLogger(__name__) + + +class PrimaiteIO: + """ + Class for managing session IO. + + Currently it's handling path generation, but could expand to handle loading, transaction, and so on. + """ + + class Settings(BaseModel): + """Config schema for PrimaiteIO object.""" + + model_config = ConfigDict(extra="forbid") + + save_logs: bool = True + """Whether to save logs""" + save_agent_actions: bool = True + """Whether to save a log of all agents' actions every step.""" + save_step_metadata: bool = False + """Whether to save the RL agents' action, environment state, and other data at every single step.""" + save_pcap_logs: bool = True + """Whether to save PCAP logs.""" + save_sys_logs: bool = True + """Whether to save system logs.""" + write_sys_log_to_terminal: bool = False + """Whether to write the sys log to the terminal.""" + sys_log_level: LogLevel = LogLevel.INFO + """The level of log that should be included in the logfiles/logged into terminal.""" + + def __init__(self, settings: Optional[Settings] = None) -> None: + """ + Init the PrimaiteIO object. + + Note: Instantiating this object creates a new directory for outputs, and sets the global SIM_OUTPUT variable. + It is intended that this object is instantiated when a new environment is created. + """ + self.settings = settings or PrimaiteIO.Settings() + self.session_path: Path = self.generate_session_path() + # set global SIM_OUTPUT path + SIM_OUTPUT.path = self.session_path / "simulation_output" + SIM_OUTPUT.save_pcap_logs = self.settings.save_pcap_logs + SIM_OUTPUT.save_sys_logs = self.settings.save_sys_logs + SIM_OUTPUT.write_sys_log_to_terminal = self.settings.write_sys_log_to_terminal + SIM_OUTPUT.sys_log_level = self.settings.sys_log_level + + def generate_session_path(self, timestamp: Optional[datetime] = None) -> Path: + """Create a folder for the session and return the path to it.""" + if timestamp is None: + timestamp = datetime.now() + date_str = timestamp.strftime("%Y-%m-%d") + time_str = timestamp.strftime("%H-%M-%S") + + session_path = PRIMAITE_PATHS.user_sessions_path / date_str / time_str + + # check if running in dev mode + if is_dev_mode(): + session_path = _PRIMAITE_ROOT.parent.parent / "sessions" / date_str / time_str + + # check if there is an output directory set in config + if PRIMAITE_CONFIG["developer_mode"]["output_dir"]: + session_path = Path(PRIMAITE_CONFIG["developer_mode"]["output_dir"]) / "sessions" / date_str / time_str + + session_path.mkdir(exist_ok=True, parents=True) + return session_path + + def generate_model_save_path(self, agent_name: str) -> Path: + """Return the path where the final model will be saved (excluding filename extension).""" + return self.session_path / "checkpoints" / f"{agent_name}_final" + + def generate_checkpoint_save_path(self, agent_name: str, episode: int) -> Path: + """Return the path where the checkpoint model will be saved (excluding filename extension).""" + return self.session_path / "checkpoints" / f"{agent_name}_checkpoint_{episode}.pt" + + def generate_agent_actions_save_path(self, episode: int) -> Path: + """Return the path where agent actions will be saved.""" + return self.session_path / "agent_actions" / f"episode_{episode}.json" + + def write_agent_log(self, agent_actions: Dict[str, List], episode: int) -> None: + """Take the contents of the agent action log and write it to a file. + + :param episode: Episode number + :type episode: int + """ + data = {} + longest_history = max([len(hist) for hist in agent_actions.values()]) + for i in range(longest_history): + data[i] = {"timestep": i, "episode": episode} + data[i].update({name: acts[i] for name, acts in agent_actions.items() if len(acts) > i}) + + path = self.generate_agent_actions_save_path(episode=episode) + path.parent.mkdir(exist_ok=True, parents=True) + path.touch() + _LOGGER.info(f"Saving agent action log to {path}") + with open(path, "w") as file: + json.dump(data, fp=file, indent=1, default=lambda x: x.model_dump()) + + @classmethod + def from_config(cls, config: Dict) -> "PrimaiteIO": + """Create an instance of PrimaiteIO based on a configuration dict.""" + config = config or {} + + if config.get("sys_log_level"): + config["sys_log_level"] = LogLevel[config["sys_log_level"].upper()] # convert to enum + + new = cls(settings=cls.Settings(**config)) + + return new diff --git a/src/primaite/session/ray_envs.py b/src/primaite/session/ray_envs.py new file mode 100644 index 00000000..f9ab3405 --- /dev/null +++ b/src/primaite/session/ray_envs.py @@ -0,0 +1,177 @@ +import json +from typing import Dict, SupportsFloat, Tuple + +import gymnasium +from gymnasium.core import ActType, ObsType +from ray.rllib.env.multi_agent_env import MultiAgentEnv + +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.session.environment import _LOGGER, PrimaiteGymEnv +from primaite.session.episode_schedule import build_scheduler, EpisodeScheduler +from primaite.session.io import PrimaiteIO +from primaite.simulator import SIM_OUTPUT +from primaite.simulator.system.core.packet_capture import PacketCapture + + +class PrimaiteRayMARLEnv(MultiAgentEnv): + """Ray Environment that inherits from MultiAgentEnv to allow training MARL systems.""" + + def __init__(self, env_config: Dict) -> None: + """Initialise the environment. + + :param env_config: A dictionary containing the environment configuration. It must contain a single key, `game` + which is the PrimaiteGame instance. + :type env_config: Dict + """ + self.episode_counter: int = 0 + """Current episode number.""" + self.episode_scheduler: EpisodeScheduler = build_scheduler(env_config) + """Object that returns a config corresponding to the current episode.""" + self.io = PrimaiteIO.from_config(self.episode_scheduler(0).get("io_settings", {})) + """Handles IO for the environment. This produces sys logs, agent logs, etc.""" + self.game: PrimaiteGame = PrimaiteGame.from_config(self.episode_scheduler(self.episode_counter)) + """Reference to the primaite game""" + self._agent_ids = list(self.game.rl_agents.keys()) + """Agent ids. This is a list of strings of agent names.""" + + self.terminateds = set() + self.truncateds = set() + self.observation_space = gymnasium.spaces.Dict( + { + name: gymnasium.spaces.flatten_space(agent.observation_manager.space) + for name, agent in self.agents.items() + } + ) + self.action_space = gymnasium.spaces.Dict( + {name: agent.action_manager.space for name, agent in self.agents.items()} + ) + self._obs_space_in_preferred_format = True + self._action_space_in_preferred_format = True + super().__init__() + + @property + def agents(self) -> Dict[str, ProxyAgent]: + """Grab a fresh reference to the agents from this episode's game object.""" + return {name: self.game.rl_agents[name] for name in self._agent_ids} + + def reset(self, *, seed: int = None, options: dict = None) -> Tuple[ObsType, Dict]: + """Reset the environment.""" + rewards = {name: agent.reward_function.total_reward for name, agent in self.agents.items()} + _LOGGER.info(f"Resetting environment, episode {self.episode_counter}, " f"avg. reward: {rewards}") + + if self.io.settings.save_agent_actions: + all_agent_actions = {name: agent.history for name, agent in self.game.agents.items()} + self.io.write_agent_log(agent_actions=all_agent_actions, episode=self.episode_counter) + + self.episode_counter += 1 + PacketCapture.clear() + self.game: PrimaiteGame = PrimaiteGame.from_config(self.episode_scheduler(self.episode_counter)) + self.game.setup_for_episode(episode=self.episode_counter) + state = self.game.get_sim_state() + self.game.update_agents(state) + next_obs = self._get_obs() + info = {} + return next_obs, info + + def step( + self, actions: Dict[str, ActType] + ) -> Tuple[Dict[str, ObsType], Dict[str, SupportsFloat], Dict[str, bool], Dict[str, bool], Dict]: + """Perform a step in the environment. Adherent to Ray MultiAgentEnv step API. + + :param actions: Dict of actions. The key is agent identifier and the value is a gymnasium action instance. + :type actions: Dict[str, ActType] + :return: Observations, rewards, terminateds, truncateds, and info. Each one is a dictionary keyed by agent + identifier. + :rtype: Tuple[Dict[str,ObsType], Dict[str, SupportsFloat], Dict[str,bool], Dict[str,bool], Dict] + """ + step = self.game.step_counter + # 1. Perform actions + for agent_name, action in actions.items(): + self.agents[agent_name].store_action(action) + self.game.pre_timestep() + self.game.apply_agent_actions() + + # 2. Advance timestep + self.game.advance_timestep() + + # 3. Get next observations + state = self.game.get_sim_state() + self.game.update_agents(state) + next_obs = self._get_obs() + + # 4. Get rewards + rewards = {name: agent.reward_function.current_reward for name, agent in self.agents.items()} + _LOGGER.info(f"step: {self.game.step_counter}, Rewards: {rewards}") + terminateds = {name: False for name, _ in self.agents.items()} + truncateds = {name: self.game.calculate_truncated() for name, _ in self.agents.items()} + infos = {name: {} for name, _ in self.agents.items()} + terminateds["__all__"] = len(self.terminateds) == len(self.agents) + truncateds["__all__"] = self.game.calculate_truncated() + if self.game.save_step_metadata: + self._write_step_metadata_json(step, actions, state, rewards) + return next_obs, rewards, terminateds, truncateds, infos + + def _write_step_metadata_json(self, step: int, actions: Dict, state: Dict, rewards: Dict): + output_dir = SIM_OUTPUT.path / f"episode_{self.episode_counter}" / "step_metadata" + + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"step_{step}.json" + + data = { + "episode": self.episode_counter, + "step": step, + "actions": {agent_name: int(action) for agent_name, action in actions.items()}, + "reward": rewards, + "state": state, + } + with open(path, "w") as file: + json.dump(data, file) + + def _get_obs(self) -> Dict[str, ObsType]: + """Return the current observation.""" + obs = {} + for agent_name in self._agent_ids: + agent = self.game.rl_agents[agent_name] + unflat_space = agent.observation_manager.space + unflat_obs = agent.observation_manager.current_observation + obs[agent_name] = gymnasium.spaces.flatten(unflat_space, unflat_obs) + return obs + + def close(self): + """Close the simulation.""" + if self.io.settings.save_agent_actions: + all_agent_actions = {name: agent.history for name, agent in self.game.agents.items()} + self.io.write_agent_log(agent_actions=all_agent_actions, episode=self.episode_counter) + + +class PrimaiteRayEnv(gymnasium.Env): + """Ray wrapper that accepts a single `env_config` parameter in init function for compatibility with Ray.""" + + def __init__(self, env_config: Dict) -> None: + """Initialise the environment. + + :param env_config: A dictionary containing the environment configuration. + :type env_config: Dict + """ + self.env = PrimaiteGymEnv(env_config=env_config) + # self.env.episode_counter -= 1 + self.action_space = self.env.action_space + self.observation_space = self.env.observation_space + + def reset(self, *, seed: int = None, options: dict = None) -> Tuple[ObsType, Dict]: + """Reset the environment.""" + return self.env.reset(seed=seed) + + def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict]: + """Perform a step in the environment.""" + return self.env.step(action) + + def close(self): + """Close the simulation.""" + self.env.close() + + @property + def game(self) -> PrimaiteGame: + """Pass through game from env.""" + return self.env.game diff --git a/src/primaite/setup/_package_data/primaite_config.yaml b/src/primaite/setup/_package_data/primaite_config.yaml index b9e0d73c..c1caf1f4 100644 --- a/src/primaite/setup/_package_data/primaite_config.yaml +++ b/src/primaite/setup/_package_data/primaite_config.yaml @@ -1,5 +1,13 @@ # The main PrimAITE application config file +developer_mode: + enabled: False # not enabled by default + sys_log_level: DEBUG # level of output for system logs, DEBUG by default + output_sys_logs: False # system logs not output by default + output_pcap_logs: False # pcap logs not output by default + output_to_terminal: False # do not output to terminal by default + output_dir: null # none by default - none will print to repository root + # Logging logging: log_level: INFO @@ -9,14 +17,3 @@ logging: WARNING: '%(asctime)s::%(levelname)s::%(name)s::%(lineno)s::%(message)s' ERROR: '%(asctime)s::%(levelname)s::%(name)s::%(lineno)s::%(message)s' CRITICAL: '%(asctime)s::%(levelname)s::%(name)s::%(lineno)s::%(message)s' - -# Session -session: - outputs: - plots: - size: - auto_size: false - width: 1500 - height: 900 - template: plotly_white - range_slider: false diff --git a/src/primaite/setup/old_installation_clean_up.py b/src/primaite/setup/old_installation_clean_up.py deleted file mode 100644 index 412aed60..00000000 --- a/src/primaite/setup/old_installation_clean_up.py +++ /dev/null @@ -1,14 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -from primaite import getLogger - -_LOGGER = getLogger(__name__) - - -def run() -> None: - """Perform the full clean-up.""" - pass - - -if __name__ == "__main__": - run() diff --git a/src/primaite/setup/reset_demo_notebooks.py b/src/primaite/setup/reset_demo_notebooks.py index 1f96c90f..bcf89b6a 100644 --- a/src/primaite/setup/reset_demo_notebooks.py +++ b/src/primaite/setup/reset_demo_notebooks.py @@ -1,35 +1,55 @@ # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK import filecmp -import os import shutil from logging import Logger from pathlib import Path -import pkg_resources - from primaite import getLogger, PRIMAITE_PATHS _LOGGER: Logger = getLogger(__name__) +def should_copy_file(src: Path, dest: Path, overwrite_existing: bool) -> bool: + """ + Determine if the file should be copied. + + :param src: The source file Path. + :param dest: The destination file Path. + :param overwrite_existing: A bool to toggle replacing existing edited files on or off. + :return: True if file should be copied, otherwise False. + """ + if not dest.is_file(): + return True + + if overwrite_existing and not filecmp.cmp(src, dest): + return True + + return False + + def run(overwrite_existing: bool = True) -> None: """ - Resets the demo jupyter notebooks in the users app notebooks directory. + Resets the demo Jupyter notebooks in the user's app notebooks directory. :param overwrite_existing: A bool to toggle replacing existing edited notebooks on or off. """ - notebooks_package_data_root = pkg_resources.resource_filename("primaite", "notebooks/_package_data") - for subdir, dirs, files in os.walk(notebooks_package_data_root): - for file in files: - fp = os.path.join(subdir, file) - path_split = os.path.relpath(fp, notebooks_package_data_root).split(os.sep) - target_fp = PRIMAITE_PATHS.user_notebooks_path / Path(*path_split) - target_fp.parent.mkdir(exist_ok=True, parents=True) - copy_file = not target_fp.is_file() + primaite_root = Path(__file__).parent.parent + example_notebooks_user_dir = PRIMAITE_PATHS.user_notebooks_path / "example_notebooks" + example_notebooks_user_dir.mkdir(exist_ok=True, parents=True) - if overwrite_existing and not copy_file: - copy_file = (not filecmp.cmp(fp, target_fp)) and (".ipynb_checkpoints" not in str(target_fp)) + for src_fp in primaite_root.glob("**/*.ipynb"): + dst_fp = example_notebooks_user_dir / src_fp.name - if copy_file: - shutil.copy2(fp, target_fp) - _LOGGER.info(f"Reset example notebook: {target_fp}") + if should_copy_file(src_fp, dst_fp, overwrite_existing): + print(dst_fp) + shutil.copy2(src_fp, dst_fp) + _LOGGER.info(f"Reset example notebook: {dst_fp}") + + for src_fp in primaite_root.glob("notebooks/_package_data/*"): + dst_fp = example_notebooks_user_dir / "_package_data" / src_fp.name + if should_copy_file(src_fp, dst_fp, overwrite_existing): + if not Path.exists(example_notebooks_user_dir / "_package_data/"): + Path.mkdir(example_notebooks_user_dir / "_package_data/") + print(dst_fp) + shutil.copy2(src_fp, dst_fp) + _LOGGER.info(f"Copied notebook resource to: {dst_fp}") diff --git a/src/primaite/simulator/__init__.py b/src/primaite/simulator/__init__.py new file mode 100644 index 00000000..d2993b34 --- /dev/null +++ b/src/primaite/simulator/__init__.py @@ -0,0 +1,104 @@ +"""Warning: SIM_OUTPUT is a mutable global variable for the simulation output directory.""" +from datetime import datetime +from enum import IntEnum +from pathlib import Path + +from primaite import _PRIMAITE_ROOT, PRIMAITE_CONFIG, PRIMAITE_PATHS + +__all__ = ["SIM_OUTPUT"] + +from primaite.utils.cli.primaite_config_utils import is_dev_mode + + +class LogLevel(IntEnum): + """Enum containing all the available log levels for PrimAITE simulation output.""" + + DEBUG = 10 + """Debug items will be output to terminal or log file.""" + INFO = 20 + """Info items will be output to terminal or log file.""" + WARNING = 30 + """Warnings will be output to terminal or log file.""" + ERROR = 40 + """Errors will be output to terminal or log file.""" + CRITICAL = 50 + """Critical errors will be output to terminal or log file.""" + + +class _SimOutput: + def __init__(self): + self.date_str = datetime.now().strftime("%Y-%m-%d") + self.time_str = datetime.now().strftime("%H-%M-%S") + + path = PRIMAITE_PATHS.user_sessions_path / self.date_str / self.time_str + + self._path = path + self._save_pcap_logs: bool = False + self._save_sys_logs: bool = False + self._write_sys_log_to_terminal: bool = False + self._sys_log_level: LogLevel = LogLevel.WARNING # default log level is at WARNING + + @property + def path(self) -> Path: + if is_dev_mode(): + # if dev mode is enabled, if output dir is not set, print to primaite repo root + path: Path = _PRIMAITE_ROOT.parent.parent / "sessions" / self.date_str / self.time_str / "simulation_output" + # otherwise print to output dir + if PRIMAITE_CONFIG["developer_mode"]["output_dir"]: + path: Path = ( + Path(PRIMAITE_CONFIG["developer_mode"]["output_dir"]) + / "sessions" + / self.date_str + / self.time_str + / "simulation_output" + ) + self._path = path + return self._path + + @path.setter + def path(self, new_path: Path) -> None: + self._path = new_path + self._path.mkdir(exist_ok=True, parents=True) + + @property + def save_pcap_logs(self) -> bool: + if is_dev_mode(): + return PRIMAITE_CONFIG.get("developer_mode").get("output_pcap_logs") + return self._save_pcap_logs + + @save_pcap_logs.setter + def save_pcap_logs(self, save_pcap_logs: bool) -> None: + self._save_pcap_logs = save_pcap_logs + + @property + def save_sys_logs(self) -> bool: + if is_dev_mode(): + return PRIMAITE_CONFIG.get("developer_mode").get("output_sys_logs") + return self._save_sys_logs + + @save_sys_logs.setter + def save_sys_logs(self, save_sys_logs: bool) -> None: + self._save_sys_logs = save_sys_logs + + @property + def write_sys_log_to_terminal(self) -> bool: + if is_dev_mode(): + return PRIMAITE_CONFIG.get("developer_mode").get("output_to_terminal") + return self._write_sys_log_to_terminal + + @write_sys_log_to_terminal.setter + def write_sys_log_to_terminal(self, write_sys_log_to_terminal: bool) -> None: + self._write_sys_log_to_terminal = write_sys_log_to_terminal + + @property + def sys_log_level(self) -> LogLevel: + if is_dev_mode(): + return LogLevel[PRIMAITE_CONFIG.get("developer_mode").get("sys_log_level")] + return self._sys_log_level + + @sys_log_level.setter + def sys_log_level(self, sys_log_level: LogLevel) -> None: + self._sys_log_level = sys_log_level + + +SIM_OUTPUT = _SimOutput() diff --git a/src/primaite/simulator/_package_data/create-simulation_demo.ipynb b/src/primaite/simulator/_package_data/create-simulation_demo.ipynb new file mode 100644 index 00000000..9f4abbf3 --- /dev/null +++ b/src/primaite/simulator/_package_data/create-simulation_demo.ipynb @@ -0,0 +1,271 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Build a simulation using the Python API\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "Currently, this notebook manipulates the simulation by directly placing objects inside of the attributes of the network and domain. It should be refactored when proper methods exist for adding these objects." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import the Simulation class" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.sim_container import Simulation\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create an empty simulation. By default this has a network with no nodes or links, and a domain controller with no accounts.\n", + "\n", + "Let's use the simulation's `describe_state()` method to verify that it is empty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_sim = Simulation()\n", + "net = my_sim.network\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", + "from primaite.simulator.network.hardware.nodes.host.server import Server" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc = Computer(hostname=\"Computer\", ip_address=\"192.168.1.10\", subnet_mask=\"255.255.255.0\")\n", + "net.add_node(my_pc)\n", + "my_server = Server(hostname=\"Server\", ip_address=\"192.168.1.11\", subnet_mask=\"255.255.255.0\")\n", + "net.add_node(my_server)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect the nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.nodes.host.host_node import NIC\n", + "from primaite.simulator.network.hardware.nodes.network.switch import Switch\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_switch = Switch(hostname=\"switch1\", num_ports=12)\n", + "net.add_node(my_switch)\n", + "\n", + "pc_nic = NIC(ip_address=\"130.1.1.1\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_pc.connect_nic(pc_nic)\n", + "\n", + "server_nic = NIC(ip_address=\"130.1.1.2\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_server.connect_nic(server_nic)\n", + "\n", + "net.connect(pc_nic, my_switch.network_interface[1])\n", + "net.connect(server_nic, my_switch.network_interface[2])\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add files and folders to nodes\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.file_system.file_type import FileType\n", + "from primaite.simulator.file_system.file_system import File\n", + "from primaite.simulator.system.core.sys_log import SysLog" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc_downloads_folder = my_pc.file_system.create_folder(\"downloads\")\n", + "my_pc_downloads_folder.add_file(File(name=\"firefox_installer.zip\",folder_id=\"Test\", folder_name=\"downloads\" ,file_type=FileType.ZIP, sys_log=SysLog(hostname=\"Test\")))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_server_folder = my_server.file_system.create_folder(\"static\")\n", + "my_server.file_system.create_file(\"favicon.ico\", file_type=FileType.PNG)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add applications to nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "from primaite.simulator.system.applications.application import Application, ApplicationOperatingState\n", + "from primaite.simulator.system.software import SoftwareHealthState, SoftwareCriticality\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "from primaite.simulator.network.transmission.network_layer import IPProtocol\n", + "from primaite.simulator.file_system.file_system import FileSystem\n", + "\n", + "# no applications exist yet so we will create our own.\n", + "class MSPaint(Application):\n", + " def describe_state(self):\n", + " return super().describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mspaint = MSPaint(name = \"mspaint\", health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, port=Port.HTTP, protocol = IPProtocol.NONE,operating_state=ApplicationOperatingState.RUNNING,execution_control_status='manual', file_system=FileSystem(sys_log=SysLog(hostname=\"Test\"), sim_root=Path(__name__).parent),)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc.applications[mspaint.uuid] = mspaint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create a domain account" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.domain.account import Account, AccountType\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "acct = Account(username=\"admin\", password=\"admin12\", account_type=AccountType.USER)\n", + "my_sim.domain.accounts[acct.uuid] = acct" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the state dictionary contains no non-serialisable objects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_sim.describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "json.dumps(my_sim.describe_state())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/simulator/_package_data/network_simulator_demo.ipynb b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb new file mode 100644 index 00000000..17a0f796 --- /dev/null +++ b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb @@ -0,0 +1,672 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# PrimAITE Router Simulation Demo\n", + "\n", + "© Crown-owned copyright 2024, Defence Science and Technology Laboratory UK\n", + "\n", + "This demo uses a modified version of the ARCD Use Case 2 Network (seen below) to demonstrate the capabilities of the Network simulator in PrimAITE." + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "``` text\n", + " +------------+\n", + " | domain_ |\n", + " +------------+ controller |\n", + " | | |\n", + " | +------------+\n", + " |\n", + " |\n", + "+------------+ | +------------+\n", + "| | | | |\n", + "| client_1 +---------+ | +---------+ web_server |\n", + "| | | | | | |\n", + "+------------+ | | | +------------+\n", + " +--+---------+ +------------+ +------+--+--+\n", + " | | | | | |\n", + " | switch_2 +------+ router_1 +------+ switch_1 |\n", + " | | | | | |\n", + " +--+------+--+ +------------+ +--+---+--+--+\n", + "+------------+ | | | | | +------------+\n", + "| | | | | | | | database |\n", + "| client_2 +---------+ | | | +---------+ _server |\n", + "| | | | | | |\n", + "+------------+ | | | +------------+\n", + " | +------------+ | |\n", + " | | security | | |\n", + " +---------+ _suite +---------+ | +------------+\n", + " | | | | backup_ |\n", + " +------------+ +------------+ server |\n", + " | |\n", + " +------------+\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## The Network\n", + "First let's create our network. The network comes 'pre-packaged' with PrimAITE in the `primaite.simulator.network.networks` module.\n", + "\n", + "> ℹ️ You'll see a bunch of logs associated with parts of the Network that aren't an 'electronic' device on the Network and thus don't have a stream to log to. Soon these logs are going to be pushed to a Network Logger so we're not clogging up the PrimAITE application logs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from primaite.simulator.network.networks import network_simulator_demo_example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network = network_simulator_demo_example()" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "Most of the Network components have a `.show()` function that prints a table of information about that object. We can view the Nodes and Links on the Network by calling `network.show()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.show()" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": { + "tags": [] + }, + "source": [ + "## Nodes\n", + "\n", + "Now let's inspect some of the nodes. We can directly access a node on the Network by calling .`get_node_by_hostname`. Like Network, a Node, along with some core services like ARP, have a `.show()` method." + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Router Nodes\n", + "\n", + "First we'll inspect the Router node and some of it's core services." + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "Calling `router.show()` displays the Ethernet interfaces on the Router. If you need a table in markdown format, pass `markdown=True`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "Calling `router.arp.show()` displays the Router ARP Cache." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").arp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "Calling `router.acl.show()` displays the Access Control List." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").acl.show()" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "Calling `router.router_table.show()` displays the static routes the Router provides. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").route_table.show()" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "Calling `router.sys_log.show()` displays the Router system log. By default, only the last 10 log entries are displayed, this can be changed by passing `last_n=`.\n", + "\n", + "NB: For `sys_log.show()` to work correctly log files need to be created with a sys_log level of INFO or below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "### Switch Nodes\n", + "\n", + "Next we'll inspect the Switch node and some of its core services." + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "Calling `switch.show()` displays the Switch ports on the Switch." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"switch_1\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "Calling `switch.sys_log.show()` displays the Switch system log. By default, only the last 10 log entries are displayed, this can be changed by passing `last_n=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"switch_1\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "### Computer/Server Nodes\n", + "\n", + "Finally, we'll inspect a Computer or Server Node and some of its core services." + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": { + "tags": [] + }, + "source": [ + "Calling `computer.show()` displays the NICs on the Computer/Server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "Calling `computer.arp.show()` displays the Computer/Server ARP Cache." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").arp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "Calling `computer.sys_log.show()` displays the Computer/Server system log. By default, only the last 10 log entries are displayed; this can be changed by passing `last_n=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").sys_log.show(last_n=25)" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "## Basic Network Comms Check\n", + "\n", + "We can perform a good old ping to check that Nodes are able to communicate with each other." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.show(nodes=False, links=False)" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "We'll first ping client_1's default gateway." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.10.1\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").sys_log.show(last_n=15)" + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "Next, we'll ping the interface of the 192.168.1.0/24 Network on the Router (port 1)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.1\")" + ] + }, + { + "cell_type": "markdown", + "id": "38", + "metadata": {}, + "source": [ + "And finally, we'll ping the web server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "40", + "metadata": {}, + "source": [ + "To confirm that the ping was received and processed by the web_server, we can view the sys log" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"web_server\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "42", + "metadata": {}, + "source": [ + "## Advanced Network Usage\n", + "\n", + "We can now use the Network to perform some more advanced things." + ] + }, + { + "cell_type": "markdown", + "id": "43", + "metadata": {}, + "source": [ + "Let's attempt to prevent client_2 from being able to ping the web server. First, we'll confirm that it can ping the server..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "45", + "metadata": {}, + "source": [ + "If we look at the client_2 sys log we can see that the four ICMP echo requests were sent and four ICMP each replies were received." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "47", + "metadata": {}, + "source": [ + "Now we'll add an ACL to block ICMP from 192.168.10.22." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from primaite.simulator.network.transmission.network_layer import IPProtocol\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "from primaite.simulator.network.hardware.nodes.network.router import ACLAction\n", + "network.get_node_by_hostname(\"router_1\").acl.add_rule(\n", + " action=ACLAction.DENY,\n", + " protocol=IPProtocol.ICMP,\n", + " src_ip_address=\"192.168.10.22\",\n", + " position=1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").acl.show()" + ] + }, + { + "cell_type": "markdown", + "id": "50", + "metadata": {}, + "source": [ + "Now we attempt (and fail) to ping the web server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "52", + "metadata": {}, + "source": [ + "We can check that the ping was actually sent by client_2 by viewing the sys log." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "54", + "metadata": {}, + "source": [ + "We can check the router sys log to see why the traffic was blocked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "56", + "metadata": {}, + "source": [ + "Now a final check to ensure that client_1 can still ping the web_server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").sys_log.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py new file mode 100644 index 00000000..835f24fe --- /dev/null +++ b/src/primaite/simulator/core.py @@ -0,0 +1,279 @@ +# flake8: noqa +"""Core of the PrimAITE Simulator.""" +import warnings +from abc import abstractmethod +from typing import Callable, Dict, List, Literal, Optional, Union +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, validate_call + +from primaite import getLogger +from primaite.interface.request import RequestFormat, RequestResponse + +_LOGGER = getLogger(__name__) + + +class RequestPermissionValidator(BaseModel): + """ + Base class for request validators. + + The permissions manager is designed to be generic. So, although in the first instance the permissions + are evaluated purely on membership to AccountGroup, this class can support validating permissions based on any + arbitrary criteria. + """ + + @abstractmethod + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Use the request and context parameters to decide whether the request should be permitted.""" + pass + + @property + @abstractmethod + def fail_message(self) -> str: + """Message that is reported when a request is rejected by this validator.""" + return "request rejected" + + +class AllowAllValidator(RequestPermissionValidator): + """Always allows the request.""" + + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Always allow the request.""" + return True + + @property + def fail_message(self) -> str: + """ + Message that is reported when a request is rejected by this validator. + + This method should really never be called because this validator never rejects requests. + """ + warnings.warn("Something went wrong - AllowAllValidator rejected a request.") + return super().fail_message + + +class RequestType(BaseModel): + """ + This object stores data related to a single request type. + + This includes the callable that can execute the request, and the validator that will decide whether + the request can be performed or not. + """ + + func: Callable[[RequestFormat, Dict], RequestResponse] + """ + ``func`` is a function that accepts a request and a context dict. Typically this would be a lambda function + that invokes a class method of your SimComponent. For example if the component is a node and the request type is for + turning it off, then the SimComponent should have a turn_off(self) method that does not need to accept any args. + Then, this request will be given something like ``func = lambda request, context: self.turn_off()``. + + ``func`` can also be another request manager, since RequestManager is a callable with a signature that matches what is + expected by ``func``. + """ + validator: RequestPermissionValidator = AllowAllValidator() + """ + ``validator`` is an instance of ``RequestPermissionValidator``. This is essentially a callable that + accepts `request` and `context` and returns a boolean to represent whether the permission is granted to perform + the request. The default validator will allow + """ + + +class RequestManager(BaseModel): + """ + RequestManager is used by `SimComponent` instances to keep track of requests. + + Its main purpose is to be a lookup from request name to request function and corresponding validation function. This + class is responsible for providing a consistent API for processing requests as well as helpful error messages. + """ + + request_types: Dict[str, RequestType] = {} + """maps request name to an RequestType object.""" + + def __call__(self, request: RequestFormat, context: Dict) -> RequestResponse: + """ + Process an request request. + + :param request: A list of strings describing the request. The first string must be one of the allowed + request names, i.e. it must be a key of self.request_types. The subsequent strings in the list are passed as + parameters to the request function. + :type request: List[str] + :param context: Dictionary of additional information necessary to process or validate the request. + :type context: Dict + :raises RuntimeError: If the request parameter does not have a valid request name as the first item. + """ + request_key = request[0] + request_options = request[1:] + + if request_key not in self.request_types: + msg = ( + f"Request {request} could not be processed because {request_key} is not a valid request name", + "within this RequestManager", + ) + _LOGGER.debug(msg) + return RequestResponse(status="unreachable", data={"reason": msg}) + + request_type = self.request_types[request_key] + + if not request_type.validator(request_options, context): + _LOGGER.debug(f"Request {request} was denied due to insufficient permissions") + return RequestResponse(status="failure", data={"reason": request_type.validator.fail_message}) + + return request_type.func(request_options, context) + + def add_request(self, name: str, request_type: RequestType) -> None: + """ + Add a request type to this request manager. + + :param name: The string associated to this request. + :type name: str + :param request_type: Request type object which contains information about how to resolve request. + :type request_type: RequestType + """ + if name in self.request_types: + msg = f"Overwriting request type {name}." + _LOGGER.debug(msg) + + self.request_types[name] = request_type + + def remove_request(self, name: str) -> None: + """ + Remove a request from this manager. + + :param name: name identifier of the request + :type name: str + """ + if name not in self.request_types: + msg = f"Attempted to remove request {name} from request manager, but it was not registered." + _LOGGER.error(msg) + raise RuntimeError(msg) + + self.request_types.pop(name) + + def get_request_types_recursively(self) -> List[List[str]]: + """Recursively generate request tree for this component.""" + requests = [] + for req_name, req in self.request_types.items(): + if isinstance(req.func, RequestManager): + sub_requests = req.func.get_request_types_recursively() + sub_requests = [[req_name] + a for a in sub_requests] + requests.extend(sub_requests) + else: + requests.append([req_name]) + return requests + + +class SimComponent(BaseModel): + """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") + """Configure pydantic to allow arbitrary types and to let the instance have attributes not present in model.""" + + uuid: str = Field(default_factory=lambda: str(uuid4())) + """The component UUID.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._request_manager: RequestManager = self._init_request_manager() + self._parent: Optional["SimComponent"] = None + + def setup_for_episode(self, episode: int): + """ + Perform any additional setup on this component that can't happen during __init__. + + For instance, some components may require for the entire network to exist before some configuration can be set. + """ + pass + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager for this component. + + When using a hierarchy of components, the child classes should call the parent class's _init_request_manager and + add additional requests on top of the existing generic ones. + + Example usage for inherited classes: + + ..code::python + + class WebBrowser(Application): + def _init_request_manager(self) -> RequestManager: + rm = super()._init_request_manager() # all requests generic to any Application get initialised + rm.add_request(...) # initialise any requests specific to the web browser + return rm + + :return: Request manager object belonging to this sim component. + :rtype: RequestManager + """ + return RequestManager() + + @abstractmethod + def describe_state(self) -> Dict: + """ + Return a dictionary describing the state of this object and any objects managed by it. + + This is similar to pydantic ``model_dump()``, but it only outputs information about the objects owned by this + object. If there are objects referenced by this object that are owned by something else, it is not included in + this output. + """ + state = { + "uuid": self.uuid, + } + return state + + @validate_call + def apply_request(self, request: RequestFormat, context: Dict = {}) -> RequestResponse: + """ + Apply a request to a simulation component. Request data is passed in as a 'namespaced' list of strings. + + If the list only has one element, the request is intended to be applied directly to this object. If the list has + multiple entries, the request is passed to the child of this object specified by the first one or two entries. + This is essentially a namespace. + + For example, ["turn_on",] is meant to apply a request of 'turn on' to this component. + + However, ["services", "email_client", "turn_on"] is meant to 'turn on' this component's email client service. + + :param request: List describing the request to apply to this object. + :type request: List[str] + + :param: context: Dict containing context for requests + :type context: Dict + """ + if self._request_manager is None: + return + return self._request_manager(request, context) + + def pre_timestep(self, timestep: int) -> None: + """ + Apply any logic that needs to happen at the beginning of the timestep to ensure correct observations/rewards. + + :param timestep: what's the current time + :type timestep: int + """ + pass + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep evolution to this component. + + Override this method with anything that happens automatically in the component such as scheduled restarts or + sending data. + """ + pass + + @property + def parent(self) -> "SimComponent": + """Reference to the parent object which manages this object. + + :return: Parent object. + :rtype: SimComponent + """ + return self._parent + + @parent.setter + def parent(self, new_parent: Union["SimComponent", None]) -> None: + if self._parent and new_parent: + msg = f"Overwriting parent of {self.uuid}. Old parent: {self._parent.uuid}, New parent: {new_parent.uuid}" + _LOGGER.warning(msg) + raise RuntimeWarning(msg) + self._parent = new_parent diff --git a/src/primaite/simulator/domain/__init__.py b/src/primaite/simulator/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py new file mode 100644 index 00000000..186caf5b --- /dev/null +++ b/src/primaite/simulator/domain/account.py @@ -0,0 +1,82 @@ +"""User account simulation.""" +from enum import Enum +from typing import Dict + +from primaite import getLogger +from primaite.simulator.core import SimComponent + +_LOGGER = getLogger(__name__) + + +class AccountType(Enum): + """Whether the account is intended for a user to log in or for a service to use.""" + + SERVICE = 1 + "Service accounts are used to grant permissions to software on nodes to perform actions" + USER = 2 + "User accounts are used to allow agents to log in and perform actions" + + +class PasswordPolicyLevel(Enum): + """Complexity requirements for account passwords.""" + + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class Account(SimComponent): + """User accounts.""" + + num_logons: int = 0 + "The number of times this account was logged into since last reset." + num_logoffs: int = 0 + "The number of times this account was logged out of since last reset." + num_group_changes: int = 0 + "The number of times this account was moved in or out of an AccountGroup." + username: str + "Account username." + password: str + "Account password." + account_type: AccountType + "Account Type, currently this can be service account (used by apps) or user account." + enabled: bool = True + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "num_logons": self.num_logons, + "num_logoffs": self.num_logoffs, + "num_group_changes": self.num_group_changes, + "username": self.username, + "password": self.password, + "account_type": self.account_type.value, + "enabled": self.enabled, + } + ) + return state + + def enable(self): + """Set the status to enabled.""" + self.enabled = True + + def disable(self): + """Set the status to disabled.""" + self.enabled = False + + def log_on(self) -> None: + """TODO. Once the accounts are integrated with nodes, populate this accordingly.""" + self.num_logons += 1 + + def log_off(self) -> None: + """TODO. Once the accounts are integrated with nodes, populate this accordingly.""" + self.num_logoffs += 1 diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py new file mode 100644 index 00000000..82312dd3 --- /dev/null +++ b/src/primaite/simulator/domain/controller.py @@ -0,0 +1,161 @@ +from enum import Enum +from typing import Dict, Final, List, Literal, Tuple + +from primaite.simulator.core import RequestManager, RequestPermissionValidator, RequestType, SimComponent +from primaite.simulator.domain.account import Account, AccountType + + +# placeholder while these objects don't yet exist +class temp_node: + """Placeholder for node class for type hinting purposes.""" + + pass + + +class temp_application: + """Placeholder for application class for type hinting purposes.""" + + pass + + +class temp_folder: + """Placeholder for folder class for type hinting purposes.""" + + pass + + +class temp_file: + """Placeholder for file class for type hinting purposes.""" + + pass + + +class AccountGroup(Enum): + """Permissions are set at group-level and accounts can belong to these groups.""" + + LOCAL_USER = 1 + "For performing basic actions on a node" + DOMAIN_USER = 2 + "For performing basic actions to the domain" + LOCAL_ADMIN = 3 + "For full access to actions on a node" + DOMAIN_ADMIN = 4 + "For full access" + + +class GroupMembershipValidator(RequestPermissionValidator): + """Permit actions based on group membership.""" + + allowed_groups: List[AccountGroup] + + def __call__(self, request: List[str], context: Dict) -> bool: + """Permit the action if the request comes from an account which belongs to the right group.""" + # if context request source is part of any groups mentioned in self.allow_groups, return true, otherwise false + requestor_groups: List[str] = context["request_source"]["groups"] + for allowed_group in self.allowed_groups: + if allowed_group.name in requestor_groups: + return True + return False + + @property + def fail_message(self) -> str: + """Message that is reported when a request is rejected by this validator.""" + return "User does not belong to group" + + +class DomainController(SimComponent): + """Main object for controlling the domain.""" + + # owned objects + accounts: Dict[str, Account] = {} + groups: Final[List[AccountGroup]] = list(AccountGroup) + + domain_group_membership: Dict[Literal[AccountGroup.DOMAIN_ADMIN, AccountGroup.DOMAIN_USER], List[Account]] = {} + local_group_membership: Dict[ + Tuple[temp_node, Literal[AccountGroup.LOCAL_ADMIN, AccountGroup.LOCAL_USER]], List[Account] + ] = {} + + # references to non-owned objects. Not sure if all are needed here. + nodes: Dict[str, temp_node] = {} + applications: Dict[str, temp_application] = {} + folders: List[temp_folder] = {} + files: List[temp_file] = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + 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() + # Action 'account' matches requests like: + # ['account', '', *account_action] + rm.add_request( + "account", + RequestType( + func=lambda request, context: self.accounts[request.pop(0)].apply_request(request, context), + # TODO: not sure what should get returned here, revisit + validator=GroupMembershipValidator(allowed_groups=[AccountGroup.DOMAIN_ADMIN]), + ), + ) + return rm + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update({"accounts": {acct.username: acct.describe_state() for acct in self.accounts.values()}}) + return state + + def _register_account(self, account: Account) -> None: + """TODO.""" + ... + + def _deregister_account(self, account: Account) -> None: + """TODO.""" + ... + + def create_account(self, username: str, password: str, account_type: AccountType) -> Account: + """TODO.""" + ... + + def delete_account(self, account: Account) -> None: + """TODO.""" + ... + + def rotate_all_credentials(self) -> None: + """TODO.""" + ... + + def rotate_account_credentials(self, account: Account) -> None: + """TODO.""" + ... + + def add_account_to_group(self, account: Account, group: AccountGroup) -> None: + """TODO.""" + ... + + def remove_account_from_group(self, account: Account, group: AccountGroup) -> None: + """TODO.""" + ... + + def check_account_permissions(self, account: Account, node: temp_node) -> List[AccountGroup]: + """Return a list of permission groups that this account has on this node.""" + ... + + def register_node(self, node: temp_node) -> None: + """TODO.""" + ... + + def deregister_node(self, node: temp_node) -> None: + """TODO.""" + ... diff --git a/src/primaite/simulator/file_system/__init__.py b/src/primaite/simulator/file_system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/file_system/file.py b/src/primaite/simulator/file_system/file.py new file mode 100644 index 00000000..328b052b --- /dev/null +++ b/src/primaite/simulator/file_system/file.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import hashlib +import json +import warnings +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC, FileSystemItemHealthStatus +from primaite.simulator.file_system.file_type import FileType, get_file_type_from_extension + +_LOGGER = getLogger(__name__) + + +class File(FileSystemItemABC): + """ + Class representing a file in the simulation. + + :ivar Folder folder: The folder in which the file resides. + :ivar FileType file_type: The type of the file. + :ivar Optional[int] sim_size: The simulated file size. + """ + + folder_id: str + "The id of the Folder the File is in." + folder_name: str + "The name of the Folder the file is in." + file_type: FileType + "The type of File." + sim_size: Optional[int] = None + "The simulated file size." + num_access: int = 0 + "Number of times the file was accessed in the current step." + + def __init__(self, **kwargs): + """ + Initialise File class. + + :param name: The name of the file. + :param file_type: The FileType of the file + :param size: The size of the FileSystemItemABC + """ + has_extension = "." in kwargs["name"] + + # Attempt to use the file type extension to set/override the FileType + if has_extension: + extension = kwargs["name"].split(".")[-1] + kwargs["file_type"] = get_file_type_from_extension(extension) + else: + # If the file name does not have a extension, override file type to FileType.UNKNOWN + if not kwargs["file_type"]: + kwargs["file_type"] = FileType.UNKNOWN + if kwargs["file_type"] != FileType.UNKNOWN: + kwargs["name"] = f"{kwargs['name']}.{kwargs['file_type'].name.lower()}" + + # set random file size if none provided + if not kwargs.get("sim_size"): + kwargs["sim_size"] = kwargs["file_type"].default_size + super().__init__(**kwargs) + self.sys_log.info(f"Created file /{self.path} (id: {self.uuid})") + + @property + def path(self) -> str: + """ + Get the path of the file in the file system. + + :return: The full path of the file. + """ + return f"{self.folder_name}/{self.name}" + + @property + def size(self) -> int: + """ + Get the size of the file in bytes. + + :return: The size of the file in bytes. + """ + return self.sim_size + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the file. + + :param timestep: The current timestep of the simulation. + """ + super().apply_timestep(timestep=timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + + # reset the number of accesses to 0 + self.num_access = 0 + + def describe_state(self) -> Dict: + """Produce a dictionary describing the current state of this object.""" + state = super().describe_state() + state["size"] = self.size + state["file_type"] = self.file_type.name + state["num_access"] = self.num_access + return state + + def scan(self) -> bool: + """Updates the visible statuses of the file.""" + if self.deleted: + self.sys_log.error(f"Unable to scan deleted file {self.folder_name}/{self.name}") + return False + + self.num_access += 1 # file was accessed + path = self.folder.name + "/" + self.name + self.sys_log.info(f"Scanning file {path}") + self.visible_health_status = self.health_status + return True + + def reveal_to_red(self) -> None: + """Reveals the folder/file to the red agent.""" + if self.deleted: + self.sys_log.error(f"Unable to reveal deleted file {self.folder_name}/{self.name}") + return + self.revealed_to_red = True + + def check_hash(self) -> bool: + """ + Check if the file has been changed. + + If changed, the file is considered corrupted. + + Return False if corruption is detected, otherwise True + """ + warnings.warn("NODE_FILE_CHECKHASH is currently not implemented.") + self.sys_log.warning("NODE_FILE_CHECKHASH is currently not implemented.") + return False + + if self.deleted: + self.sys_log.error(f"Unable to check hash of deleted file {self.folder_name}/{self.name}") + return False + current_hash = None + + # otherwise get describe_state dict and hash that + current_hash = hashlib.blake2b(json.dumps(self.describe_state(), sort_keys=True).encode()).hexdigest() + + # if the previous hash is None, set the current hash to previous + if self.previous_hash is None: + self.previous_hash = current_hash + + # if the previous hash and current hash do not match, mark file as corrupted + if self.previous_hash is not current_hash: + self.corrupt() + return True + + def repair(self) -> bool: + """Repair a corrupted File by setting the status to FileSystemItemStatus.GOOD.""" + if self.deleted: + self.sys_log.error(f"Unable to repair deleted file {self.folder_name}/{self.name}") + return False + + # set file status to good if corrupt + if self.health_status == FileSystemItemHealthStatus.CORRUPT: + self.health_status = FileSystemItemHealthStatus.GOOD + + self.num_access += 1 # file was accessed + path = self.folder.name + "/" + self.name + self.sys_log.info(f"Repaired file {path}") + return True + + def corrupt(self) -> bool: + """Corrupt a File by setting the status to FileSystemItemStatus.CORRUPT.""" + if self.deleted: + self.sys_log.error(f"Unable to corrupt deleted file {self.folder_name}/{self.name}") + return False + + # set file status to good if corrupt + if self.health_status == FileSystemItemHealthStatus.GOOD: + self.health_status = FileSystemItemHealthStatus.CORRUPT + + self.num_access += 1 # file was accessed + path = self.folder.name + "/" + self.name + self.sys_log.info(f"Corrupted file {path}") + return True + + def restore(self) -> bool: + """Determines if the file needs to be repaired or unmarked as deleted.""" + if self.deleted: + self.deleted = False + return True + + if self.health_status == FileSystemItemHealthStatus.CORRUPT: + self.health_status = FileSystemItemHealthStatus.GOOD + + self.num_access += 1 # file was accessed + path = self.folder.name + "/" + self.name + self.sys_log.info(f"Restored file {path}") + return True + + def delete(self) -> bool: + """Marks the file as deleted.""" + if self.deleted: + self.sys_log.error(f"Unable to delete an already deleted file {self.folder_name}/{self.name}") + return False + + self.num_access += 1 # file was accessed + self.deleted = True + self.sys_log.info(f"File deleted {self.folder_name}/{self.name}") + return True diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py new file mode 100644 index 00000000..40a74e68 --- /dev/null +++ b/src/primaite/simulator/file_system/file_system.py @@ -0,0 +1,580 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from prettytable import MARKDOWN, PrettyTable + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType, SimComponent +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_type import FileType +from primaite.simulator.file_system.folder import Folder +from primaite.simulator.system.core.sys_log import SysLog + + +class FileSystem(SimComponent): + """Class that contains all the simulation File System.""" + + folders: Dict[str, Folder] = {} + "List containing all the folders in the file system." + deleted_folders: Dict[str, Folder] = {} + "List containing all the folders that have been deleted." + sys_log: SysLog + "Instance of SysLog used to create system logs." + sim_root: Path + "Root path of the simulation." + num_file_creations: int = 0 + "Number of file creations in the current step." + num_file_deletions: int = 0 + "Number of file deletions in the current step." + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Ensure a default root folder + if not self.folders: + self.create_folder("root") + + 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() + + self._delete_manager = RequestManager() + self._delete_manager.add_request( + name="file", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.delete_file(folder_name=request[0], file_name=request[1]) + ) + ), + ) + self._delete_manager.add_request( + name="folder", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self.delete_folder(folder_name=request[0])) + ), + ) + rm.add_request( + name="delete", + request_type=RequestType(func=self._delete_manager), + ) + + self._create_manager = RequestManager() + + def _create_file_action(request: List[Any], context: Any) -> RequestResponse: + file = self.create_file(folder_name=request[0], file_name=request[1]) + if not file: + return RequestResponse.from_bool(False) + return RequestResponse( + status="success", + data={ + "file_name": file.name, + "folder_name": file.folder_name, + "file_type": file.file_type, + "file_size": file.size, + }, + ) + + self._create_manager.add_request( + name="file", + request_type=RequestType(func=_create_file_action), + ) + + def _create_folder_action(request: List[Any], context: Any) -> RequestResponse: + folder = self.create_folder(folder_name=request[0]) + if not folder: + return RequestResponse.from_bool(False) + return RequestResponse(status="success", data={"folder_name": folder.name}) + + self._create_manager.add_request( + name="folder", + request_type=RequestType(func=_create_folder_action), + ) + rm.add_request( + name="create", + request_type=RequestType(func=self._create_manager), + ) + + def _access_file_action(request: List[Any], context: Any) -> RequestResponse: + file = self.get_file(folder_name=request[0], file_name=request[1]) + if not file: + return RequestResponse.from_bool(False) + + if self.access_file(folder_name=request[0], file_name=request[1]): + return RequestResponse( + status="success", + data={ + "file_name": file.name, + "folder_name": file.folder_name, + "file_type": file.file_type, + "file_size": file.size, + "file_status": file.health_status, + }, + ) + return RequestResponse.from_bool(False) + + rm.add_request( + name="access", + request_type=RequestType(func=_access_file_action), + ) + + self._restore_manager = RequestManager() + self._restore_manager.add_request( + name="file", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.restore_file(folder_name=request[0], file_name=request[1]) + ) + ), + ) + self._restore_manager.add_request( + name="folder", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self.restore_folder(folder_name=request[0])) + ), + ) + rm.add_request( + name="restore", + request_type=RequestType(func=self._restore_manager), + ) + + self._folder_request_manager = RequestManager() + rm.add_request("folder", RequestType(func=self._folder_request_manager)) + + self._file_request_manager = RequestManager() + rm.add_request("file", RequestType(func=self._file_request_manager)) + + return rm + + @property + def size(self) -> int: + """ + Calculate and return the total size of all folders in the file system. + + :return: The sum of the sizes of all folders in the file system. + """ + return sum(folder.size for folder in self.folders.values()) + + def show(self, markdown: bool = False, full: bool = False): + """ + Prints a table of the FileSystem, displaying either just folders or full files. + + :param markdown: Flag indicating if output should be in markdown format. + :param full: Flag indicating if to show full files. + """ + headers = ["Folder", "Size", "Deleted"] + if full: + headers[0] = "File Path" + table = PrettyTable(headers) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} File System" + folders = {**self.folders, **self.deleted_folders} + for folder in folders.values(): + if not full: + table.add_row([folder.name, folder.size_str, folder.deleted]) + else: + files = {**folder.files, **folder.deleted_files} + if not files: + table.add_row([folder.name, folder.size_str, folder.deleted]) + else: + for file in files.values(): + table.add_row([file.path, file.size_str, file.deleted]) + if full: + print(table.get_string(sortby="File Path")) + else: + print(table.get_string(sortby="Folder")) + + ############################################################### + # Folder methods + ############################################################### + def create_folder(self, folder_name: str) -> Folder: + """ + Creates a Folder and adds it to the list of folders. + + :param folder_name: The name of the folder. + """ + # check if folder with name already exists + if self.get_folder(folder_name): + raise Exception(f"Cannot create folder as it already exists: {folder_name}") + + folder = Folder(name=folder_name, sys_log=self.sys_log) + + self.folders[folder.uuid] = folder + self._folder_request_manager.add_request( + name=folder.name, request_type=RequestType(func=folder._request_manager) + ) + return folder + + def delete_folder(self, folder_name: str) -> bool: + """ + Deletes a folder, removes it from the folders list and removes any child folders and files. + + :param folder_name: The name of the folder. + """ + if folder_name == "root": + self.sys_log.error("Cannot delete the root folder.") + return False + folder = self.get_folder(folder_name) + if not folder: + self.sys_log.error(f"Cannot delete folder as it does not exist: {folder_name}") + return False + + # set folder to deleted state + folder.delete() + + # remove from folder list + self.folders.pop(folder.uuid) + + # add to deleted list + folder.remove_all_files() + + self.deleted_folders[folder.uuid] = folder + self.sys_log.warning(f"Deleted folder /{folder.name} and its contents") + return True + + def delete_folder_by_id(self, folder_uuid: str) -> None: + """ + Deletes a folder via its uuid. + + :param: folder_uuid: UUID of the folder to delete + """ + folder = self.get_folder_by_id(folder_uuid=folder_uuid) + self.delete_folder(folder_name=folder.name) + + def get_folder(self, folder_name: str, include_deleted: bool = False) -> Optional[Folder]: + """ + Get a folder by its name if it exists. + + :param folder_name: The folder name. + :return: The matching Folder. + """ + for folder in self.folders.values(): + if folder.name == folder_name: + return folder + if include_deleted: + for folder in self.deleted_folders.values(): + if folder.name == folder_name: + return folder + return None + + def get_folder_by_id(self, folder_uuid: str, include_deleted: Optional[bool] = False) -> Optional[Folder]: + """ + Get a folder by its uuid if it exists. + + :param: folder_uuid: The folder uuid. + :param: include_deleted: If true, the deleted folders will also be checked + :return: The matching Folder. + """ + if include_deleted: + folder = self.deleted_folders.get(folder_uuid) + if folder: + return folder + + return self.folders.get(folder_uuid) + + ############################################################### + # File methods + ############################################################### + + def create_file( + self, + file_name: str, + size: Optional[int] = None, + file_type: Optional[FileType] = None, + folder_name: Optional[str] = None, + ) -> File: + """ + Creates a File and adds it to the list of files. + + :param file_name: The file name. + :param size: The size the file takes on disk in bytes. + :param file_type: The type of the file. + :param folder_name: The folder to add the file to. + """ + if folder_name: + # check if file with name already exists + folder = self.get_folder(folder_name) + # If not then create it + if not folder: + folder = self.create_folder(folder_name) + else: + # Use root folder if folder_name not supplied + folder = self.get_folder("root") + + # Create the file and add it to the folder + file = File( + name=file_name, + sim_size=size, + file_type=file_type, + folder_id=folder.uuid, + folder_name=folder.name, + sim_root=self.sim_root, + sys_log=self.sys_log, + ) + folder.add_file(file) + self._file_request_manager.add_request(name=file.name, request_type=RequestType(func=file._request_manager)) + # increment file creation + self.num_file_creations += 1 + return file + + def get_file(self, folder_name: str, file_name: str, include_deleted: Optional[bool] = False) -> Optional[File]: + """ + Retrieve a file by its name from a specific folder. + + :param folder_name: The name of the folder where the file resides. + :param file_name: The name of the file to be retrieved, including its extension. + :return: An instance of File if it exists, otherwise `None`. + """ + folder = self.get_folder(folder_name, include_deleted=include_deleted) + if folder: + return folder.get_file(file_name, include_deleted=include_deleted) + self.sys_log.warning(f"File not found /{folder_name}/{file_name}") + + def get_file_by_id( + self, file_uuid: str, folder_uuid: Optional[str] = None, include_deleted: Optional[bool] = False + ) -> Optional[File]: + """ + Retrieve a file by its uuid from a specific folder. + + :param: file_uuid: The uuid of the folder where the file resides. + :param: folder_uuid: The uuid of the file to be retrieved, including its extension. + :param: include_deleted: If true, the deleted files will also be checked + :return: An instance of File if it exists, otherwise `None`. + """ + folder = self.get_folder_by_id(folder_uuid=folder_uuid, include_deleted=include_deleted) + + if folder: + return folder.get_file_by_id(file_uuid=file_uuid, include_deleted=include_deleted) + + # iterate through every folder looking for file + file = None + + for folder_id in self.folders: + folder = self.folders.get(folder_id) + res = folder.get_file_by_id(file_uuid=file_uuid, include_deleted=True) + if res: + file = res + + if include_deleted: + for folder_id in self.deleted_folders: + folder = self.deleted_folders.get(folder_id) + res = folder.get_file_by_id(file_uuid=file_uuid, include_deleted=True) + if res: + file = res + + return file + + def delete_file(self, folder_name: str, file_name: str) -> bool: + """ + Delete a file by its name from a specific folder. + + :param folder_name: The name of the folder containing the file. + :param file_name: The name of the file to be deleted, including its extension. + """ + folder = self.get_folder(folder_name) + if folder: + file = folder.get_file(file_name) + if file: + # increment file creation + self.num_file_deletions += 1 + folder.remove_file(file) + return True + return False + + def delete_file_by_id(self, folder_uuid: str, file_uuid: str) -> None: + """ + Deletes a file via its uuid. + + :param: folder_uuid: UUID of the folder the file belongs to + :param: file_uuid: UUID of the file to delete + """ + folder = self.get_folder_by_id(folder_uuid=folder_uuid) + + if folder: + file = folder.get_file_by_id(file_uuid=file_uuid) + + if file: + self.delete_file(folder_name=folder.name, file_name=file.name) + else: + self.sys_log.error(f"Unable to delete file that does not exist. (id: {file_uuid})") + + def move_file(self, src_folder_name: str, src_file_name: str, dst_folder_name: str) -> None: + """ + Move a file from one folder to another. + + :param src_folder_name: The name of the source folder containing the file. + :param src_file_name: The name of the file to be moved. + :param dst_folder_name: The name of the destination folder. + """ + file = self.get_file(folder_name=src_folder_name, file_name=src_file_name) + if file: + # remove file from src + self.delete_file(folder_name=file.folder_name, file_name=file.name) + dst_folder = self.get_folder(folder_name=dst_folder_name) + if not dst_folder: + dst_folder = self.create_folder(dst_folder_name) + # add file to dst + dst_folder.add_file(file) + self.num_file_creations += 1 + + def copy_file(self, src_folder_name: str, src_file_name: str, dst_folder_name: str): + """ + Copy a file from one folder to another. + + :param src_folder_name: The name of the source folder containing the file. + :param src_file_name: The name of the file to be copied. + :param dst_folder_name: The name of the destination folder. + """ + file = self.get_file(folder_name=src_folder_name, file_name=src_file_name) + if file: + # check that dest folder exists + dst_folder = self.get_folder(folder_name=dst_folder_name) + if not dst_folder: + # create dest folder + dst_folder = self.create_folder(dst_folder_name) + + file_copy = File( + folder_id=dst_folder.uuid, + folder_name=dst_folder.name, + **file.model_dump(exclude={"uuid", "folder_id", "folder_name", "sim_path"}), + ) + self.num_file_creations += 1 + # increment access counter + file.num_access += 1 + + dst_folder.add_file(file_copy, force=True) + + else: + self.sys_log.error(f"Unable to copy file. {src_file_name} does not exist.") + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state["folders"] = {folder.name: folder.describe_state() for folder in self.folders.values()} + state["deleted_folders"] = {folder.name: folder.describe_state() for folder in self.deleted_folders.values()} + state["num_file_creations"] = self.num_file_creations + state["num_file_deletions"] = self.num_file_deletions + return state + + def apply_timestep(self, timestep: int) -> None: + """Apply time step to FileSystem and its child folders and files.""" + super().apply_timestep(timestep=timestep) + + # apply timestep to folders + for folder_id in self.folders: + self.folders[folder_id].apply_timestep(timestep=timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + # reset number of file creations + self.num_file_creations = 0 + + # reset number of file deletions + self.num_file_deletions = 0 + + for folder in self.folders.values(): + folder.pre_timestep(timestep) + + ############################################################### + # Agent actions + ############################################################### + + def scan(self, instant_scan: bool = False) -> None: + """ + Scan all the folders (and child files) in the file system. + + :param: instant_scan: If True, the scan is completed instantly and ignores scan duration. Default False. + """ + for folder_id in self.folders: + self.folders[folder_id].scan(instant_scan=instant_scan) + + def reveal_to_red(self, instant_scan: bool = False) -> None: + """ + Reveals all the folders (and child files) in the file system to the red agent. + + :param: instant_scan: If True, the scan is completed instantly and ignores scan duration. Default False. + """ + for folder_id in self.folders: + self.folders[folder_id].reveal_to_red(instant_scan=instant_scan) + + def restore_folder(self, folder_name: str) -> bool: + """ + Restore a folder. + + Checks the current folder's status and applies the correct fix for the folder. + + :param: folder_name: name of the folder to restore + :type: folder_uuid: str + """ + folder = self.get_folder(folder_name=folder_name, include_deleted=True) + + if folder is None: + self.sys_log.error(f"Unable to restore folder {folder_name}. Folder is not in deleted folder list.") + return False + + self.deleted_folders.pop(folder.uuid, None) + folder.restore() + self.folders[folder.uuid] = folder + return True + + def restore_file(self, folder_name: str, file_name: str) -> bool: + """ + Restore a file. + + Checks the current file's status and applies the correct fix for the file. + + :param: folder_name: name of the folder where the file is stored + :type: folder_name: str + + :param: file_name: name of the file to restore + :type: file_name: str + """ + folder = self.get_folder(folder_name=folder_name) + if not folder: + self.sys_log.error(f"Cannot restore file {file_name} in folder {folder_name} as the folder does not exist.") + return False + + file = folder.get_file(file_name=file_name, include_deleted=True) + + if not file: + msg = f"Unable to restore file {file_name}. File was not found." + self.sys_log.error(msg) + return False + + return folder.restore_file(file_name=file_name) + + def access_file(self, folder_name: str, file_name: str) -> bool: + """ + Access a file. + + Used by agents to simulate a file being accessed. + + :param: folder_name: name of the folder where the file is stored + :type: folder_name: str + + :param: file_name: name of the file to access + :type: file_name: str + """ + folder = self.get_folder(folder_name=folder_name) + + if folder: + file = folder.get_file(file_name=file_name) + + if file: + file.num_access += 1 + return True + else: + self.sys_log.error(f"Unable to access file that does not exist. (file name: {file_name})") + + return False diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py new file mode 100644 index 00000000..c89152b4 --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import math +from abc import abstractmethod +from enum import Enum +from typing import Dict, Optional + +from primaite import getLogger +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType, SimComponent +from primaite.simulator.system.core.sys_log import SysLog + +_LOGGER = getLogger(__name__) + + +def convert_size(size_bytes: int) -> str: + """ + Convert a file size from bytes to a string with a more human-readable format. + + This function takes the size of a file in bytes and converts it to a string representation with appropriate size + units (B, KB, MB, GB, etc.). + + :param size_bytes: The size of the file in bytes. + :return: The human-readable string representation of the file size. + """ + if size_bytes == 0: + return "0 B" + + # Tuple of size units starting from Bytes up to Yottabytes + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + + # Calculate the index (i) that will be used to select the appropriate size unit from size_name + i = int(math.floor(math.log(size_bytes, 1024))) + + # Calculate the adjusted size value (s) in terms of the new size unit + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + + return f"{s} {size_name[i]}" + + +class FileSystemItemHealthStatus(Enum): + """Status of the FileSystemItem.""" + + GOOD = 1 + """File/Folder is OK.""" + + COMPROMISED = 2 + """File/Folder is quarantined.""" + + CORRUPT = 3 + """File/Folder is corrupted.""" + + RESTORING = 4 + """File/Folder is in the process of being restored.""" + + REPAIRING = 5 + """File/Folder is in the process of being repaired.""" + + +class FileSystemItemABC(SimComponent): + """ + Abstract base class for file system items used in the file system simulation. + + :ivar name: The name of the FileSystemItemABC. + """ + + name: str + "The name of the FileSystemItemABC." + + health_status: FileSystemItemHealthStatus = FileSystemItemHealthStatus.GOOD + "Actual status of the current FileSystemItem" + + visible_health_status: FileSystemItemHealthStatus = FileSystemItemHealthStatus.GOOD + "Visible status of the current FileSystemItem" + + previous_hash: Optional[str] = None + "Hash of the file contents or the description state" + + revealed_to_red: bool = False + "If true, the folder/file has been revealed to the red agent." + + sys_log: SysLog + "Used for creating system logs." + + deleted: bool = False + "If true, the FileSystemItem was deleted." + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state["name"] = self.name + state["health_status"] = self.health_status.value + state["visible_status"] = self.visible_health_status.value + state["previous_hash"] = self.previous_hash + state["revealed_to_red"] = self.revealed_to_red + return state + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + rm.add_request( + name="scan", request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.scan())) + ) + rm.add_request( + name="checkhash", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.check_hash())), + ) + rm.add_request( + name="repair", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.repair())), + ) + rm.add_request( + name="restore", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.restore())), + ) + + rm.add_request( + name="corrupt", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.corrupt())), + ) + + return rm + + @property + def size_str(self) -> str: + """ + Get the file size in a human-readable string format. + + This property makes use of the :func:`convert_size` function to convert the `self.size` attribute to a string + that is easier to read and understand. + + :return: The human-readable string representation of the file size. + """ + return convert_size(self.size) + + @abstractmethod + def scan(self) -> bool: + """Scan the folder/file - updates the visible_health_status.""" + return False + + @abstractmethod + def reveal_to_red(self) -> None: + """Reveal the folder/file to the red agent.""" + pass + + @abstractmethod + def check_hash(self) -> bool: + """ + Checks the hash of the file to detect any changes. + + For current implementation, any change in file hash means it is compromised. + + Return False if corruption is detected, otherwise True + """ + return False + + @abstractmethod + def repair(self) -> bool: + """ + Repair the FileSystemItem. + + True if successfully repaired. False otherwise. + """ + return False + + @abstractmethod + def corrupt(self) -> bool: + """ + Corrupt the FileSystemItem. + + True if successfully corrupted. False otherwise. + """ + return False + + @abstractmethod + def restore(self) -> bool: + """Restore the file/folder to the state before it got ruined.""" + return False + + @abstractmethod + def delete(self) -> None: + """Mark the file/folder as deleted.""" + self.deleted = True diff --git a/src/primaite/simulator/file_system/file_type.py b/src/primaite/simulator/file_system/file_type.py new file mode 100644 index 00000000..f87cd86f --- /dev/null +++ b/src/primaite/simulator/file_system/file_type.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from enum import Enum +from random import choice +from typing import Any + + +class FileType(Enum): + """An enumeration of common file types.""" + + UNKNOWN = 0 + "Unknown file type." + + # Text formats + TXT = 1 + "Plain text file." + DOC = 2 + "Microsoft Word document (.doc)" + DOCX = 3 + "Microsoft Word document (.docx)" + PDF = 4 + "Portable Document Format." + HTML = 5 + "HyperText Markup Language file." + XML = 6 + "Extensible Markup Language file." + CSV = 7 + "Comma-Separated Values file." + + # Spreadsheet formats + XLS = 8 + "Microsoft Excel file (.xls)" + XLSX = 9 + "Microsoft Excel file (.xlsx)" + + # Image formats + JPEG = 10 + "JPEG image file." + PNG = 11 + "PNG image file." + GIF = 12 + "GIF image file." + BMP = 13 + "Bitmap image file." + + # Audio formats + MP3 = 14 + "MP3 audio file." + WAV = 15 + "WAV audio file." + + # Video formats + MP4 = 16 + "MP4 video file." + AVI = 17 + "AVI video file." + MKV = 18 + "MKV video file." + FLV = 19 + "FLV video file." + + # Presentation formats + PPT = 20 + "Microsoft PowerPoint file (.ppt)" + PPTX = 21 + "Microsoft PowerPoint file (.pptx)" + + # Web formats + JS = 22 + "JavaScript file." + CSS = 23 + "Cascading Style Sheets file." + + # Programming languages + PY = 24 + "Python script file." + C = 25 + "C source code file." + CPP = 26 + "C++ source code file." + JAVA = 27 + "Java source code file." + + # Compressed file types + RAR = 28 + "RAR archive file." + ZIP = 29 + "ZIP archive file." + TAR = 30 + "TAR archive file." + GZ = 31 + "Gzip compressed file." + + # Database file types + DB = 32 + "Generic DB file. Used by sqlite3." + + @classmethod + def _missing_(cls, value: Any) -> FileType: + return cls.UNKNOWN + + @classmethod + def random(cls) -> FileType: + """ + Returns a random FileType. + + :return: A random FileType. + """ + return choice(list(FileType)) + + @property + def default_size(self) -> int: + """ + Get the default size of the FileType in bytes. + + Returns 0 if a default size does not exist. + """ + size = file_type_sizes_bytes[self] + return size if size else 0 + + +def get_file_type_from_extension(file_type_extension: str) -> FileType: + """ + Get a FileType from a file type extension. + + If a matching extension does not exist, FileType.UNKNOWN is returned. + + :param file_type_extension: A file type extension. + :return: A file type extension. + """ + try: + return FileType[file_type_extension.upper()] + except KeyError: + return FileType.UNKNOWN + + +file_type_sizes_bytes = { + FileType.UNKNOWN: 0, + FileType.TXT: 4096, + FileType.DOC: 51200, + FileType.DOCX: 30720, + FileType.PDF: 102400, + FileType.HTML: 15360, + FileType.XML: 10240, + FileType.CSV: 15360, + FileType.XLS: 102400, + FileType.XLSX: 25600, + FileType.JPEG: 102400, + FileType.PNG: 40960, + FileType.GIF: 30720, + FileType.BMP: 307200, + FileType.MP3: 5120000, + FileType.WAV: 25600000, + FileType.MP4: 25600000, + FileType.AVI: 51200000, + FileType.MKV: 51200000, + FileType.FLV: 15360000, + FileType.PPT: 204800, + FileType.PPTX: 102400, + FileType.JS: 10240, + FileType.CSS: 5120, + FileType.PY: 5120, + FileType.C: 5120, + FileType.CPP: 10240, + FileType.JAVA: 10240, + FileType.RAR: 1024000, + FileType.ZIP: 1024000, + FileType.TAR: 1024000, + FileType.GZ: 819200, + FileType.DB: 15360000, +} diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py new file mode 100644 index 00000000..90ad4425 --- /dev/null +++ b/src/primaite/simulator/file_system/folder.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import warnings +from typing import Dict, Optional + +from prettytable import MARKDOWN, PrettyTable + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC, FileSystemItemHealthStatus + + +class Folder(FileSystemItemABC): + """Simulation Folder.""" + + files: Dict[str, File] = {} + "Files stored in the folder." + deleted_files: Dict[str, File] = {} + "Files that have been deleted." + + scan_duration: int = 3 + "How many timesteps to complete a scan. Default 3 timesteps" + + scan_countdown: int = 0 + "Time steps needed until scan completion." + + red_scan_duration: int = 3 + "How many timesteps to complete reveal to red scan. Default 3 timesteps" + + red_scan_countdown: int = 0 + "Time steps needed until red scan completion." + + restore_duration: int = 3 + "How many timesteps to complete a restore. Default 3 timesteps" + + restore_countdown: int = 0 + "Time steps needed until restore completion." + + def __init__(self, **kwargs): + """ + Initialise Folder class. + + :param name: The name of the folder. + :param sys_log: The SysLog instance to us to create system logs. + """ + super().__init__(**kwargs) + + self.sys_log.info(f"Created file /{self.name} (id: {self.uuid})") + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + name="delete", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self.remove_file_by_name(file_name=request[0])) + ), + ) + self._file_request_manager = RequestManager() + rm.add_request( + name="file", + request_type=RequestType(func=self._file_request_manager), + ) + return rm + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state["files"] = {file.name: file.describe_state() for uuid, file in self.files.items()} + state["deleted_files"] = {file.name: file.describe_state() for uuid, file in self.deleted_files.items()} + return state + + def show(self, markdown: bool = False): + """ + Display the contents of the Folder in tabular format. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["File", "Size", "Deleted"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} File System Folder ({self.name})" + for file in self.files.values(): + table.add_row([file.name, file.size_str, file.deleted]) + print(table.get_string(sortby="File")) + + @property + def size(self) -> int: + """ + Calculate and return the total size of all files in the folder. + + :return: The total size of all files in the folder. If no files exist or all have `None` + size, returns 0. + """ + return sum(file.size for file in self.files.values() if file.size is not None) + + def apply_timestep(self, timestep: int): + """ + Apply a single timestep of simulation dynamics to this folder and its files. + + In this instance, if any multi-timestep processes are currently occurring (such as scanning), + then they are brought one step closer to being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + super().apply_timestep(timestep=timestep) + + self._scan_timestep() + + self._reveal_to_red_timestep() + + self._restoring_timestep() + + # apply timestep to files in folder + for file_id in self.files: + self.files[file_id].apply_timestep(timestep=timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + + for file in self.files.values(): + file.pre_timestep(timestep) + + def _scan_timestep(self) -> None: + """Apply the scan action timestep.""" + if self.scan_countdown >= 0: + self.scan_countdown -= 1 + + if self.scan_countdown == 0: + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.scan() + if file.visible_health_status == FileSystemItemHealthStatus.CORRUPT: + self.health_status = FileSystemItemHealthStatus.CORRUPT + self.visible_health_status = self.health_status + + def _reveal_to_red_timestep(self) -> None: + """Apply reveal to red timestep.""" + if self.red_scan_countdown >= 0: + self.red_scan_countdown -= 1 + + if self.red_scan_countdown == 0: + self.revealed_to_red = True + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.reveal_to_red() + + def _restoring_timestep(self) -> None: + """Apply restoring timestep.""" + if self.restore_countdown >= 0: + self.restore_countdown -= 1 + + if self.restore_countdown == 0: + # repair all files + for file_id, file in self.files.items(): + self.restore_file(file_name=file.name) + + deleted_files = self.deleted_files.copy() + for file_id, file in deleted_files.items(): + self.restore_file(file_name=file.name) + + if self.deleted: + self.deleted = False + elif self.health_status in [FileSystemItemHealthStatus.CORRUPT, FileSystemItemHealthStatus.RESTORING]: + self.health_status = FileSystemItemHealthStatus.GOOD + + def get_file(self, file_name: str, include_deleted: Optional[bool] = False) -> Optional[File]: + """ + Get a file by its name. + + File name must be the filename and prefix, like 'memo.docx'. + + :param file_name: The file name. + :return: The matching File. + """ + # TODO: Increment read count? + for file in self.files.values(): + if file.name == file_name: + return file + if include_deleted: + for file in self.deleted_files.values(): + if file.name == file_name: + return file + return None + + def get_file_by_id(self, file_uuid: str, include_deleted: Optional[bool] = False) -> File: + """ + Get a file by its uuid. + + :param: file_uuid: The file uuid. + :param: include_deleted: If true, the deleted files will also be checked + :return: The matching File. + """ + if include_deleted: + deleted_file = self.deleted_files.get(file_uuid) + + if deleted_file: + return deleted_file + + return self.files.get(file_uuid) + + def add_file(self, file: File, force: Optional[bool] = False): + """ + Adds a file to the folder. + + :param: file: The File object to be added to the folder. + :param: force: Overwrite file - do not check if uuid or name already exists in folder. Default False. + :raises Exception: If the provided `file` parameter is None or not an instance of the + `File` class. + """ + if file is None or not isinstance(file, File): + raise Exception(f"Invalid file: {file}") + + # check if file with id or name already exists in folder + if self.get_file(file.name) is not None and not force: + raise Exception(f"File with name {file.name} already exists in folder") + + if (file.uuid in self.files) and not force: + raise Exception(f"File with uuid {file.uuid} already exists in folder") + + # add to list + self.files[file.uuid] = file + self._file_request_manager.add_request(file.name, RequestType(func=file._request_manager)) + file.folder = self + + def remove_file(self, file: Optional[File]): + """ + Removes a file from the folder list. + + The method can take a File object or a file id. + + :param file: The file to remove + """ + if file is None or not isinstance(file, File): + raise Exception(f"Invalid file: {file}") + + if self.files.get(file.uuid): + self.files.pop(file.uuid) + self.deleted_files[file.uuid] = file + file.delete() + self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") + else: + self.sys_log.error(f"File with UUID {file.uuid} was not found.") + + def remove_file_by_id(self, file_uuid: str): + """ + Remove a file using id. + + :param: file_uuid: The UUID of the file to remove. + """ + file = self.get_file_by_id(file_uuid=file_uuid) + self.remove_file(file=file) + + def remove_file_by_name(self, file_name: str) -> bool: + """ + Remove a file using its name. + + :param file_name: filename + :type file_name: str + :return: Whether it was successfully removed. + :rtype: bool + """ + for f in self.files.values(): + if f.name == file_name: + self.remove_file(f) + return True + return False + + def remove_all_files(self): + """Removes all the files in the folder.""" + for file_id in self.files: + file = self.files.get(file_id) + file.delete() + self.deleted_files[file_id] = file + + self.files = {} + + def restore_file(self, file_name: str) -> bool: + """ + Restores a file. + + :param file_name: The name of the file to restore + """ + # if the file was not deleted, run a repair + file = self.get_file(file_name=file_name, include_deleted=True) + if not file: + self.sys_log.error(f"Unable to restore file {file_name}. File does not exist.") + return False + + file.restore() + self.files[file.uuid] = file + + if file.deleted: + self.deleted_files.pop(file.uuid) + return True + + def quarantine(self): + """Quarantines the File System Folder.""" + pass + + def unquarantine(self): + """Unquarantine of the File System Folder.""" + pass + + def quarantine_status(self) -> bool: + """Returns true if the folder is being quarantined.""" + pass + + def scan(self, instant_scan: bool = False) -> bool: + """ + Update Folder visible status. + + :param: instant_scan: If True, the scan is completed instantly and ignores scan duration. Default False. + """ + if self.deleted: + self.sys_log.error(f"Unable to scan deleted folder {self.name}") + return False + + if instant_scan: + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.scan() + if file.visible_health_status == FileSystemItemHealthStatus.CORRUPT: + self.visible_health_status = FileSystemItemHealthStatus.CORRUPT + return True + + if self.scan_countdown <= 0: + # scan one file per timestep + self.scan_countdown = self.scan_duration + self.sys_log.info(f"Scanning folder {self.name} (id: {self.uuid})") + else: + # scan already in progress + self.sys_log.info(f"Scan is already in progress {self.name} (id: {self.uuid})") + return True + + def reveal_to_red(self, instant_scan: bool = False): + """ + Reveals the folders and files to the red agent. + + :param: instant_scan: If True, the scan is completed instantly and ignores scan duration. Default False. + """ + if self.deleted: + self.sys_log.error(f"Unable to reveal deleted folder {self.name}") + return + + if instant_scan: + self.revealed_to_red = True + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.reveal_to_red() + return + + if self.red_scan_countdown <= 0: + # scan one file per timestep + self.red_scan_countdown = self.red_scan_duration + self.sys_log.info(f"Folder revealed to red agent: {self.name} (id: {self.uuid})") + else: + # scan already in progress + self.sys_log.info(f"Red Agent Scan is already in progress {self.name} (id: {self.uuid})") + + def check_hash(self) -> bool: + """ + Runs a :func:`check_hash` on all files in the folder. + + If a file under the folder is corrupted, the whole folder is considered corrupted. + + TODO: For now this will just iterate through the files and run :func:`check_hash` and ignores + any other changes to the folder + + Return False if corruption is detected, otherwise True + """ + warnings.warn("NODE_FOLDER_CHECKHASH is currently not implemented.") + self.sys_log.error("NODE_FOLDER_CHECKHASH is currently not implemented.") + return False + + if self.deleted: + self.sys_log.error(f"Unable to check hash of deleted folder {self.name}") + return False + + # iterate through the files and run a check hash + no_corrupted_files = True + + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.check_hash() + if file.health_status == FileSystemItemHealthStatus.CORRUPT: + no_corrupted_files = False + + # if one file in the folder is corrupted, set the folder status to corrupted + if not no_corrupted_files: + self.corrupt() + + self.sys_log.info(f"Checking hash of folder {self.name} (id: {self.uuid})") + return True + + def repair(self) -> bool: + """Repair a corrupted Folder by setting the folder and containing files status to FileSystemItemStatus.GOOD.""" + if self.deleted: + self.sys_log.error(f"Unable to repair deleted folder {self.name}") + return False + + # iterate through the files in the folder + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.repair() + + # set file status to good if corrupt + if self.health_status == FileSystemItemHealthStatus.CORRUPT: + self.health_status = FileSystemItemHealthStatus.GOOD + + self.health_status = FileSystemItemHealthStatus.GOOD + + self.sys_log.info(f"Repaired folder {self.name} (id: {self.uuid})") + return True + + def restore(self) -> bool: + """ + If a Folder is corrupted, run a repair on the folder and its child files. + + If the folder is deleted, restore the folder by setting deleted status to False. + """ + if self.deleted: + self.deleted = False + + if self.restore_countdown <= 0: + self.restore_countdown = self.restore_duration + self.health_status = FileSystemItemHealthStatus.RESTORING + self.sys_log.info(f"Restoring folder: {self.name} (id: {self.uuid})") + else: + # scan already in progress + self.sys_log.info(f"Folder restoration already in progress {self.name} (id: {self.uuid})") + return True + + def corrupt(self) -> bool: + """Corrupt a File by setting the folder and containing files status to FileSystemItemStatus.CORRUPT.""" + if self.deleted: + self.sys_log.error(f"Unable to corrupt deleted folder {self.name}") + return False + + # iterate through the files in the folder + for file_id in self.files: + file = self.get_file_by_id(file_uuid=file_id) + file.corrupt() + + # set file status to corrupt + self.health_status = FileSystemItemHealthStatus.CORRUPT + + self.sys_log.info(f"Corrupted folder {self.name} (id: {self.uuid})") + return True + + def delete(self) -> bool: + """Marks the file as deleted. Prevents agent actions from occuring.""" + if self.deleted: + self.sys_log.error(f"Unable to delete an already deleted folder {self.name}") + return False + + self.deleted = True + return True diff --git a/src/primaite/simulator/network/__init__.py b/src/primaite/simulator/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/airspace.py b/src/primaite/simulator/network/airspace.py new file mode 100644 index 00000000..907ab233 --- /dev/null +++ b/src/primaite/simulator/network/airspace.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, Dict, List, Optional + +from prettytable import PrettyTable + +from primaite import getLogger +from primaite.simulator.network.hardware.base import Layer3Interface, NetworkInterface, WiredNetworkInterface +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.system.core.packet_capture import PacketCapture + +_LOGGER = getLogger(__name__) + +__all__ = ["AirSpaceFrequency", "WirelessNetworkInterface", "IPWirelessNetworkInterface"] + + +class AirSpace: + """Represents a wireless airspace, managing wireless network interfaces and handling wireless transmission.""" + + def __init__(self): + self._wireless_interfaces: Dict[str, WirelessNetworkInterface] = {} + self._wireless_interfaces_by_frequency: Dict[AirSpaceFrequency, List[WirelessNetworkInterface]] = {} + + def show(self, frequency: Optional[AirSpaceFrequency] = None): + """ + Displays a summary of wireless interfaces in the airspace, optionally filtered by a specific frequency. + + :param frequency: The frequency band to filter devices by. If None, devices for all frequencies are shown. + """ + table = PrettyTable() + table.field_names = ["Connected Node", "MAC Address", "IP Address", "Subnet Mask", "Frequency", "Status"] + + # If a specific frequency is provided, filter by it; otherwise, use all frequencies. + frequencies_to_show = [frequency] if frequency else self._wireless_interfaces_by_frequency.keys() + + for freq in frequencies_to_show: + interfaces = self._wireless_interfaces_by_frequency.get(freq, []) + for interface in interfaces: + status = "Enabled" if interface.enabled else "Disabled" + table.add_row( + [ + interface._connected_node.hostname, # noqa + interface.mac_address, + interface.ip_address if hasattr(interface, "ip_address") else None, + interface.subnet_mask if hasattr(interface, "subnet_mask") else None, + str(freq), + status, + ] + ) + + print(table) + + def add_wireless_interface(self, wireless_interface: WirelessNetworkInterface): + """ + Adds a wireless network interface to the airspace if it's not already present. + + :param wireless_interface: The wireless network interface to be added. + """ + if wireless_interface.mac_address not in self._wireless_interfaces: + self._wireless_interfaces[wireless_interface.mac_address] = wireless_interface + if wireless_interface.frequency not in self._wireless_interfaces_by_frequency: + self._wireless_interfaces_by_frequency[wireless_interface.frequency] = [] + self._wireless_interfaces_by_frequency[wireless_interface.frequency].append(wireless_interface) + + def remove_wireless_interface(self, wireless_interface: WirelessNetworkInterface): + """ + Removes a wireless network interface from the airspace if it's present. + + :param wireless_interface: The wireless network interface to be removed. + """ + if wireless_interface.mac_address in self._wireless_interfaces: + self._wireless_interfaces.pop(wireless_interface.mac_address) + self._wireless_interfaces_by_frequency[wireless_interface.frequency].remove(wireless_interface) + + def clear(self): + """ + Clears all wireless network interfaces and their frequency associations from the airspace. + + After calling this method, the airspace will contain no wireless network interfaces, and transmissions cannot + occur until new interfaces are added again. + """ + self._wireless_interfaces.clear() + self._wireless_interfaces_by_frequency.clear() + + def transmit(self, frame: Frame, sender_network_interface: WirelessNetworkInterface): + """ + Transmits a frame to all enabled wireless network interfaces on a specific frequency within the airspace. + + This ensures that a wireless interface does not receive its own transmission. + + :param frame: The frame to be transmitted. + :param sender_network_interface: The wireless network interface sending the frame. This interface will be + excluded from the list of receivers to prevent it from receiving its own transmission. + """ + for wireless_interface in self._wireless_interfaces_by_frequency.get(sender_network_interface.frequency, []): + if wireless_interface != sender_network_interface and wireless_interface.enabled: + wireless_interface.receive_frame(frame) + + +class AirSpaceFrequency(Enum): + """Enumeration representing the operating frequencies for wireless communications.""" + + WIFI_2_4 = 2.4e9 + """WiFi 2.4 GHz. Known for its extensive range and ability to penetrate solid objects effectively.""" + WIFI_5 = 5e9 + """WiFi 5 GHz. Known for its higher data transmission speeds and reduced interference from other devices.""" + + def __str__(self) -> str: + if self == AirSpaceFrequency.WIFI_2_4: + return "WiFi 2.4 GHz" + elif self == AirSpaceFrequency.WIFI_5: + return "WiFi 5 GHz" + else: + return "Unknown Frequency" + + +class WirelessNetworkInterface(NetworkInterface, ABC): + """ + Represents a wireless network interface in a network device. + + This abstract base class models wireless network interfaces, encapsulating properties and behaviors specific to + wireless connectivity. It provides a framework for managing wireless connections, including signal strength, + security protocols, and other wireless-specific attributes and methods. + + Wireless network interfaces differ from wired ones in their medium of communication, relying on radio frequencies + for data transmission and reception. This class serves as a base for more specific types of wireless network + interfaces, such as Wi-Fi adapters or radio network interfaces, ensuring that essential wireless functionality is + defined and standardised. + + Inherits from: + - NetworkInterface: Provides basic network interface properties and methods. + + As an abstract base class, it requires subclasses to implement specific methods related to wireless communication + and may define additional properties and methods specific to wireless technology. + """ + + airspace: AirSpace + frequency: AirSpaceFrequency = AirSpaceFrequency.WIFI_2_4 + + def enable(self): + """Attempt to enable the network interface.""" + if self.enabled: + return + + if not self._connected_node: + _LOGGER.warning(f"Interface {self} cannot be enabled as it is not connected to a Node") + return + + if self._connected_node.operating_state != NodeOperatingState.ON: + self._connected_node.sys_log.error( + f"Interface {self} cannot be enabled as the connected Node is not powered on" + ) + return + + self.enabled = True + self._connected_node.sys_log.info(f"Network Interface {self} enabled") + self.pcap = PacketCapture( + hostname=self._connected_node.hostname, port_num=self.port_num, port_name=self.port_name + ) + self.airspace.add_wireless_interface(self) + + def disable(self): + """Disable the network interface.""" + if not self.enabled: + return + self.enabled = False + if self._connected_node: + self._connected_node.sys_log.info(f"Network Interface {self} disabled") + else: + _LOGGER.debug(f"Interface {self} disabled") + self.airspace.remove_wireless_interface(self) + + def send_frame(self, frame: Frame) -> bool: + """ + Attempts to send a network frame over the airspace. + + This method sends a frame if the network interface is enabled and connected to a wireless airspace. It captures + the frame using PCAP (if available) and transmits it through the airspace. Returns True if the frame is + successfully sent, False otherwise (e.g., if the network interface is disabled). + + :param frame: The network frame to be sent. + :return: True if the frame is sent successfully, False if the network interface is disabled. + """ + if self.enabled: + frame.set_sent_timestamp() + self.pcap.capture_outbound(frame) + self.airspace.transmit(frame, self) + return True + # Cannot send Frame as the network interface is not enabled + return False + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the network interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + if self.enabled: + frame.set_sent_timestamp() + self.pcap.capture_inbound(frame) + self._connected_node.receive_frame(frame, self) + return True + # Cannot receive Frame as the network interface is not enabled + return False + + +class IPWirelessNetworkInterface(WirelessNetworkInterface, Layer3Interface, ABC): + """ + Represents an IP wireless network interface. + + This interface operates at both the data link layer (Layer 2) and the network layer (Layer 3) of the OSI model, + specifically tailored for IP-based communication over wireless connections. This abstract class provides a + template for creating specific wireless network interfaces that support Internet Protocol (IP) functionalities. + + As this class is a combination of its parent classes without additional attributes or methods, please refer to + the documentation of `WirelessNetworkInterface` and `Layer3Interface` for more details on the supported operations + and functionalities. + + The class inherits from: + - `WirelessNetworkInterface`: Providing the functionalities and characteristics of a wireless connection, such as + managing wireless signal transmission, reception, and associated wireless protocols. + - `Layer3Interface`: Enabling network layer capabilities, including IP address assignment, routing, and + potentially, Layer 3 protocols like IPsec. + + As an abstract class, `IPWirelessNetworkInterface` does not implement specific methods but ensures that any derived + class provides implementations for the functionalities of both `WirelessNetworkInterface` and `Layer3Interface`. + This setup is ideal for representing network interfaces in devices that require wireless connections and are capable + of IP routing and addressing, such as wireless routers, access points, and wireless end-host devices like + smartphones and laptops. + + This class should be extended by concrete classes that define specific behaviors and properties of an IP-capable + wireless network interface. + """ + + def model_post_init(self, __context: Any) -> None: + """ + Performs post-initialisation checks to ensure the model's IP configuration is valid. + + This method is invoked after the initialisation of a network model object to validate its network settings, + particularly to ensure that the assigned IP address is not a network address. This validation is crucial for + maintaining the integrity of network simulations and avoiding configuration errors that could lead to + unrealistic or incorrect behavior. + + :param __context: Contextual information or parameters passed to the method, used for further initializing or + validating the model post-creation. + :raises ValueError: If the IP address is the same as the network address, indicating an incorrect configuration. + """ + if self.ip_network.network_address == self.ip_address: + raise ValueError(f"{self.ip_address}/{self.subnet_mask} must not be a network address") + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + # Get the state from the WiredNetworkInterface + state = WiredNetworkInterface.describe_state(self) + + # Update the state with information from Layer3Interface + state.update(Layer3Interface.describe_state(self)) + + state["frequency"] = self.frequency.value + + return state + + def enable(self): + """ + Enables this wired network interface and attempts to send a "hello" message to the default gateway. + + This method activates the network interface, making it operational for network communications. After enabling, + it tries to initiate a default gateway "hello" process, typically to establish initial connectivity and resolve + the default gateway's MAC address. This step is crucial for ensuring the interface can successfully send data + to and receive data from the network. + + The method safely handles cases where the connected node might not have a default gateway set or the + `default_gateway_hello` method is not defined, ignoring such errors to proceed without interruption. + """ + super().enable() + try: + self._connected_node.default_gateway_hello() + except AttributeError: + pass + + @abstractmethod + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + pass diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py new file mode 100644 index 00000000..91ea3c71 --- /dev/null +++ b/src/primaite/simulator/network/container.py @@ -0,0 +1,366 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, List, Optional + +import matplotlib.pyplot as plt +import networkx as nx +from networkx import MultiGraph +from prettytable import MARKDOWN, PrettyTable +from pydantic import Field + +from primaite import getLogger +from primaite.simulator.core import RequestManager, RequestType, SimComponent +from primaite.simulator.network.airspace import AirSpace +from primaite.simulator.network.hardware.base import Link, Node, WiredNetworkInterface +from primaite.simulator.network.hardware.nodes.host.server import Printer +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.services.service import Service + +_LOGGER = getLogger(__name__) + + +class Network(SimComponent): + """ + Top level container object representing the physical network. + + This class manages nodes, links, and other network components. It also + offers methods for rendering the network topology and gathering states. + + :ivar Dict[str, Node] nodes: Dictionary mapping node UUIDs to Node instances. + :ivar Dict[str, Link] links: Dictionary mapping link UUIDs to Link instances. + """ + + nodes: Dict[str, Node] = {} + + links: Dict[str, Link] = {} + airspace: AirSpace = Field(default_factory=lambda: AirSpace()) + _node_id_map: Dict[int, Node] = {} + _link_id_map: Dict[int, Node] = {} + + def __init__(self, **kwargs): + """ + Initialise the network. + + Constructs the network and sets up its initial state including + the request manager and an empty MultiGraph for topology representation. + """ + super().__init__(**kwargs) + + self._nx_graph = MultiGraph() + + def setup_for_episode(self, episode: int): + """Reset the original state of the SimComponent.""" + for node in self.nodes.values(): + node.setup_for_episode(episode=episode) + for link in self.links.values(): + link.setup_for_episode(episode=episode) + + for node in self.nodes.values(): + node.power_on() + + for network_interface in node.network_interfaces.values(): + network_interface.enable() + # Reset software + for software in node.software_manager.software.values(): + if isinstance(software, Service): + software.start() + elif isinstance(software, Application): + software.run() + + 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() + self._node_request_manager = RequestManager() + rm.add_request( + "node", + RequestType(func=self._node_request_manager), + ) + return rm + + def apply_timestep(self, timestep: int) -> None: + """Apply a timestep evolution to this the network and its nodes and links.""" + super().apply_timestep(timestep=timestep) + # apply timestep to nodes + for node_id in self.nodes: + self.nodes[node_id].apply_timestep(timestep=timestep) + + # apply timestep to links + for link_id in self.links: + self.links[link_id].apply_timestep(timestep=timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + + for node in self.nodes.values(): + node.pre_timestep(timestep) + + for link in self.links.values(): + link.pre_timestep(timestep) + + @property + def router_nodes(self) -> List[Node]: + """The Routers in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "Router"] + + @property + def switch_nodes(self) -> List[Node]: + """The Switches in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "Switch"] + + @property + def computer_nodes(self) -> List[Node]: + """The Computers in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "Computer"] + + @property + def server_nodes(self) -> List[Node]: + """The Servers in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "Server"] + + @property + def firewall_nodes(self) -> List[Node]: + """The Firewalls in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "Firewall"] + + @property + def printer_nodes(self) -> List[Node]: + """The printers on the network.""" + return [node for node in self.nodes.values() if isinstance(node, Printer)] + + @property + def wireless_router_nodes(self) -> List[Node]: + """The Routers in the Network.""" + return [node for node in self.nodes.values() if node.__class__.__name__ == "WirelessRouter"] + + def show(self, nodes: bool = True, ip_addresses: bool = True, links: bool = True, markdown: bool = False): + """ + Print tables describing the Network. + + Generate and print PrettyTable instances that show details about nodes, + IP addresses, and links in the network. Output can be in Markdown format. + + :param nodes: Include node details in the output. Defaults to True. + :param ip_addresses: Include IP address details in the output. Defaults to True. + :param links: Include link details in the output. Defaults to True. + :param markdown: Use Markdown style in table output. Defaults to False. + """ + nodes_type_map = { + "Router": self.router_nodes, + "Firewall": self.firewall_nodes, + "Switch": self.switch_nodes, + "Server": self.server_nodes, + "Computer": self.computer_nodes, + "Printer": self.printer_nodes, + "Wireless Router": self.wireless_router_nodes, + } + if nodes: + table = PrettyTable(["Node", "Type", "Operating State"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = "Nodes" + for node_type, nodes in nodes_type_map.items(): + for node in nodes: + table.add_row([node.hostname, node_type, node.operating_state.name]) + print(table) + + if ip_addresses: + table = PrettyTable(["Node", "Port", "IP Address", "Subnet Mask", "Default Gateway"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = "IP Addresses" + for nodes in nodes_type_map.values(): + for node in nodes: + for i, port in node.network_interface.items(): + if hasattr(port, "ip_address"): + if port.ip_address != IPv4Address("127.0.0.1"): + port_str = port.port_name if port.port_name else port.port_num + table.add_row( + [node.hostname, port_str, port.ip_address, port.subnet_mask, node.default_gateway] + ) + print(table) + + if links: + table = PrettyTable( + ["Endpoint A", "A Port", "Endpoint B", "B Port", "is Up", "Bandwidth (MBits)", "Current Load"] + ) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = "Links" + links = list(self.links.values()) + for nodes in nodes_type_map.values(): + for node in nodes: + for link in links[::-1]: + if node in [link.endpoint_a.parent, link.endpoint_b.parent]: + table.add_row( + [ + link.endpoint_a.parent.hostname, + str(link.endpoint_a), + link.endpoint_b.parent.hostname, + str(link.endpoint_b), + link.is_up, + link.bandwidth, + link.current_load_percent, + ] + ) + links.remove(link) + print(table) + + def clear_links(self): + """Clear all the links in the network by resetting their component state for the episode.""" + for link in self.links.values(): + link.setup_for_episode(episode=0) # TODO: shouldn't be using this method here. + + def draw(self, seed: int = 123): + """ + Draw the Network using NetworkX and matplotlib.pyplot. + + :param seed: An integer seed for reproducible layouts. Default is 123. + """ + pos = nx.spring_layout(self._nx_graph, seed=seed) + nx.draw(self._nx_graph, pos, with_labels=True) + plt.show() + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of the Network. + + :return: A dictionary capturing the current state of the Network and its child objects. + """ + state = super().describe_state() + state.update( + { + "nodes": {node.hostname: node.describe_state() for node in self.nodes.values()}, + "links": {}, + } + ) + # Update the links one-by-one. The key is a 4-tuple of `hostname_a, port_a, hostname_b, port_b` + for _, link in self.links.items(): + node_a = link.endpoint_a._connected_node + node_b = link.endpoint_b._connected_node + hostname_a = node_a.hostname if node_a else None + hostname_b = node_b.hostname if node_b else None + port_a = link.endpoint_a.port_num + port_b = link.endpoint_b.port_num + link_key = f"{hostname_a}:eth-{port_a}<->{hostname_b}:eth-{port_b}" + state["links"][link_key] = link.describe_state() + state["links"][link_key]["hostname_a"] = hostname_a + state["links"][link_key]["hostname_b"] = hostname_b + state["links"][link_key]["port_a"] = port_a + state["links"][link_key]["port_b"] = port_b + + return state + + def add_node(self, node: Node) -> None: + """ + Add an existing node to the network. + + .. note:: If the node is already present in the network, a warning is logged. + + :param node: Node instance that should be kept track of by the network. + """ + if node in self: + _LOGGER.warning(f"Can't add node {node.uuid}. It is already in the network.") + return + self.nodes[node.uuid] = node + self._node_id_map[len(self.nodes)] = node + node.parent = self + self._nx_graph.add_node(node.hostname) + _LOGGER.debug(f"Added node {node.uuid} to Network {self.uuid}") + self._node_request_manager.add_request(name=node.hostname, request_type=RequestType(func=node._request_manager)) + + def get_node_by_hostname(self, hostname: str) -> Optional[Node]: + """ + Get a Node from the Network by its hostname. + + .. note:: Assumes hostnames on the network are unique. + + :param hostname: The Node hostname. + :return: The Node if it exists in the network. + """ + for node in self.nodes.values(): + if node.hostname == hostname: + return node + + def remove_node(self, node: Node) -> None: + """ + Remove a node from the network. + + .. note:: If the node is not found in the network, a warning is logged. + + :param node: Node instance that is currently part of the network that should be removed. + :type node: Node + """ + if node not in self: + _LOGGER.warning(f"Can't remove node {node.hostname}. It's not in the network.") + return + self.nodes.pop(node.uuid) + for i, _node in self._node_id_map.items(): + if node == _node: + self._node_id_map.pop(i) + break + node.parent = None + self._node_request_manager.remove_request(name=node.hostname) + _LOGGER.info(f"Removed node {node.hostname} from network {self.uuid}") + + def connect( + self, endpoint_a: WiredNetworkInterface, endpoint_b: WiredNetworkInterface, bandwidth: int = 100, **kwargs + ) -> Optional[Link]: + """ + Connect two endpoints on the network by creating a link between their NICs/SwitchPorts. + + .. note:: If the nodes owning the endpoints are not already in the network, they are automatically added. + + :param endpoint_a: The first endpoint to connect. + :type endpoint_a: WiredNetworkInterface + :param endpoint_b: The second endpoint to connect. + :type endpoint_b: WiredNetworkInterface + :param bandwidth: bandwidth of new link, default of 100mbps + :type bandwidth: int + :raises RuntimeError: If any validation or runtime checks fail. + """ + node_a: Node = endpoint_a.parent + node_b: Node = endpoint_b.parent + if node_a not in self: + self.add_node(node_a) + if node_b not in self: + self.add_node(node_b) + if node_a is node_b: + _LOGGER.warning(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") + return + link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, bandwidth=bandwidth, **kwargs) + self.links[link.uuid] = link + self._link_id_map[len(self.links)] = link + self._nx_graph.add_edge(endpoint_a.parent.hostname, endpoint_b.parent.hostname) + link.parent = self + _LOGGER.debug(f"Added link {link.uuid} to connect {endpoint_a} and {endpoint_b}") + return link + + def remove_link(self, link: Link) -> None: + """Disconnect a link from the network. + + :param link: The link to be removed + :type link: Link + """ + link.endpoint_a.disconnect_link() + link.endpoint_b.disconnect_link() + self.links.pop(link.uuid) + for i, _link in self._link_id_map.items(): + if link == _link: + self._link_id_map.pop(i) + break + link.parent = None + _LOGGER.info(f"Removed link {link.uuid} from network {self.uuid}.") + + def __contains__(self, item: Any) -> bool: + if isinstance(item, Node): + return item.uuid in self.nodes + elif isinstance(item, Link): + return item.uuid in self.links + return False diff --git a/src/primaite/simulator/network/creation.py b/src/primaite/simulator/network/creation.py new file mode 100644 index 00000000..f4475bec --- /dev/null +++ b/src/primaite/simulator/network/creation.py @@ -0,0 +1,153 @@ +from ipaddress import IPv4Address +from typing import Optional + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def num_of_switches_required(num_nodes: int, max_network_interface: int = 24) -> int: + """ + Calculate the minimum number of network switches required to connect a given number of nodes. + + Each switch is assumed to have one port reserved for connecting to a router, reducing the effective + number of ports available for PCs. The function calculates the total number of switches needed + to accommodate all nodes under this constraint. + + :param num_nodes: The total number of nodes that need to be connected in the network. + :param max_network_interface: The maximum number of ports available on each switch. Defaults to 24. + + :return: The minimum number of switches required to connect all PCs. + + Example: + >>> num_of_switches_required(5) + 1 + >>> num_of_switches_required(24,24) + 2 + >>> num_of_switches_required(48,24) + 3 + >>> num_of_switches_required(25,10) + 3 + """ + # Reduce the effective number of switch ports by 1 to leave space for the router + effective_network_interface = max_network_interface - 1 + + # Calculate the number of fully utilised switches and any additional switch for remaining PCs + full_switches = num_nodes // effective_network_interface + extra_pcs = num_nodes % effective_network_interface + + # Return the total number of switches required + return full_switches + (1 if extra_pcs > 0 else 0) + + +def create_office_lan( + lan_name: str, + subnet_base: int, + pcs_ip_block_start: int, + num_pcs: int, + network: Optional[Network] = None, + include_router: bool = True, + bandwidth: int = 100, +) -> Network: + """ + Creates a 2-Tier or 3-Tier office local area network (LAN). + + The LAN is configured with a specified number of personal computers (PCs), optionally including a router, + and multiple edge switches to connect them. A core switch is added only if more than one edge switch is required. + The network topology involves edge switches connected either directly to the router in a 2-Tier setup or + to a core switch in a 3-Tier setup. If a router is included, it is connected to the core switch (if present) + and configured with basic access control list (ACL) rules. PCs are distributed across the edge switches. + + + :param str lan_name: The name to be assigned to the LAN. + :param int subnet_base: The subnet base number to be used in the IP addresses. + :param int pcs_ip_block_start: The starting block for assigning IP addresses to PCs. + :param int num_pcs: The number of PCs to be added to the LAN. + :param Optional[Network] network: The network to which the LAN components will be added. If None, a new network is + created. + :param bool include_router: Flag to determine if a router should be included in the LAN. Defaults to True. + :return: The network object with the LAN components added. + :raises ValueError: If pcs_ip_block_start is less than or equal to the number of required switches. + """ + # Initialise the network if not provided + if not network: + network = Network() + + # Calculate the required number of switches + num_of_switches = num_of_switches_required(num_nodes=num_pcs) + effective_network_interface = 23 # One port less for router connection + if pcs_ip_block_start <= num_of_switches: + raise ValueError(f"pcs_ip_block_start must be greater than the number of required switches {num_of_switches}") + + # Create a core switch if more than one edge switch is needed + if num_of_switches > 1: + core_switch = Switch(hostname=f"switch_core_{lan_name}", start_up_duration=0) + core_switch.power_on() + network.add_node(core_switch) + core_switch_port = 1 + + # Initialise the default gateway to None + default_gateway = None + + # Optionally include a router in the LAN + if include_router: + default_gateway = IPv4Address(f"192.168.{subnet_base}.1") + router = Router(hostname=f"router_{lan_name}", start_up_duration=0) + router.power_on() + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + network.add_node(router) + router.configure_port(port=1, ip_address=default_gateway, subnet_mask="255.255.255.0") + router.enable_port(1) + + # Initialise the first edge switch and connect to the router or core switch + switch_port = 0 + switch_n = 1 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + if num_of_switches > 1: + network.connect( + core_switch.network_interface[core_switch_port], switch.network_interface[24], bandwidth=bandwidth + ) + else: + network.connect(router.network_interface[1], switch.network_interface[24], bandwidth=bandwidth) + + # Add PCs to the LAN and connect them to switches + for i in range(1, num_pcs + 1): + # Add a new edge switch if the current one is full + if switch_port == effective_network_interface: + switch_n += 1 + switch_port = 0 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + # Connect the new switch to the router or core switch + if num_of_switches > 1: + core_switch_port += 1 + network.connect( + core_switch.network_interface[core_switch_port], switch.network_interface[24], bandwidth=bandwidth + ) + else: + network.connect(router.network_interface[1], switch.network_interface[24], bandwidth=bandwidth) + + # Create and add a PC to the network + pc = Computer( + hostname=f"pc_{i}_{lan_name}", + ip_address=f"192.168.{subnet_base}.{i+pcs_ip_block_start-1}", + subnet_mask="255.255.255.0", + default_gateway=default_gateway, + start_up_duration=0, + ) + pc.power_on() + network.add_node(pc) + + # Connect the PC to the switch + switch_port += 1 + network.connect(switch.network_interface[switch_port], pc.network_interface[1], bandwidth=bandwidth) + switch.network_interface[switch_port].enable() + + return network diff --git a/src/primaite/simulator/network/hardware/__init__.py b/src/primaite/simulator/network/hardware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py new file mode 100644 index 00000000..a515ce58 --- /dev/null +++ b/src/primaite/simulator/network/hardware/base.py @@ -0,0 +1,1447 @@ +from __future__ import annotations + +import re +import secrets +from abc import ABC, abstractmethod +from ipaddress import IPv4Address, IPv4Network +from pathlib import Path +from typing import Any, Dict, Optional, Type, TypeVar, Union + +from prettytable import MARKDOWN, PrettyTable +from pydantic import BaseModel, Field + +from primaite import getLogger +from primaite.exceptions import NetworkError +from primaite.interface.request import RequestResponse +from primaite.simulator import SIM_OUTPUT +from primaite.simulator.core import RequestFormat, RequestManager, RequestPermissionValidator, RequestType, SimComponent +from primaite.simulator.domain.account import Account +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.nmne import ( + CAPTURE_BY_DIRECTION, + CAPTURE_BY_IP_ADDRESS, + CAPTURE_BY_KEYWORD, + CAPTURE_BY_PORT, + CAPTURE_BY_PROTOCOL, + CAPTURE_NMNE, + NMNE_CAPTURE_KEYWORDS, +) +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.core.packet_capture import PacketCapture +from primaite.simulator.system.core.session_manager import SessionManager +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.processes.process import Process +from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import IOSoftware +from primaite.utils.validators import IPV4Address + +IOSoftwareClass = TypeVar("IOSoftwareClass", bound=IOSoftware) + +_LOGGER = getLogger(__name__) + + +def generate_mac_address(oui: Optional[str] = None) -> str: + """ + Generate a random MAC Address. + + :param oui: The Organizationally Unique Identifier (OUI) portion of the MAC address. It should be a string with + the first 3 bytes (24 bits) in the format "XX:XX:XX". + :raises ValueError: If the 'oui' is not in the correct format (hexadecimal and 6 characters). + """ + random_bytes = [secrets.randbits(8) for _ in range(6)] + + if oui: + oui_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){2}[0-9A-Fa-f]{2}$") + if not oui_pattern.match(oui): + msg = f"Invalid oui. The oui should be in the format xx:xx:xx, where x is a hexadecimal digit, got '{oui}'" + _LOGGER.error(msg) + raise ValueError(msg) + oui_bytes = [int(chunk, 16) for chunk in oui.split(":")] + mac = oui_bytes + random_bytes[len(oui_bytes) :] + else: + mac = random_bytes + + return ":".join(f"{b:02x}" for b in mac) + + +class NetworkInterface(SimComponent, ABC): + """ + A generic Network Interface in a Node on a Network. + + This is a base class for specific types of network interfaces, providing common attributes and methods required + for network communication. It defines the fundamental properties that all network interfaces share, such as + MAC address, speed, MTU (maximum transmission unit), and the ability to enable or disable the interface. + + :ivar str mac_address: The MAC address of the network interface. Default to a randomly generated MAC address. + :ivar int speed: The speed of the interface in Mbps. Default is 100 Mbps. + :ivar int mtu: The Maximum Transmission Unit (MTU) of the interface in Bytes. Default is 1500 B. + """ + + mac_address: str = Field(default_factory=generate_mac_address) + "The MAC address of the interface." + + speed: int = 100 + "The speed of the interface in Mbps. Default is 100 Mbps." + + mtu: int = 1500 + "The Maximum Transmission Unit (MTU) of the interface in Bytes. Default is 1500 B" + + enabled: bool = False + "Indicates whether the interface is enabled." + + _connected_node: Optional[Node] = None + "The Node to which the interface is connected." + + port_num: Optional[int] = None + "The port number assigned to this interface on the connected node." + + port_name: Optional[str] = None + "The port name assigned to this interface on the connected node." + + pcap: Optional[PacketCapture] = None + "A PacketCapture instance for capturing and analysing packets passing through this interface." + + nmne: Dict = Field(default_factory=lambda: {}) + "A dict containing details of the number of malicious network events captured." + + def setup_for_episode(self, episode: int): + """Reset the original state of the SimComponent.""" + super().setup_for_episode(episode=episode) + self.nmne = {} + if episode and self.pcap and SIM_OUTPUT.save_pcap_logs: + self.pcap.current_episode = episode + self.pcap.setup_logger() + self.enable() + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + rm.add_request("enable", RequestType(func=lambda request, context: RequestResponse.from_bool(self.enable()))) + rm.add_request("disable", RequestType(func=lambda request, context: RequestResponse.from_bool(self.disable()))) + + return rm + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state.update( + { + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "enabled": self.enabled, + } + ) + if CAPTURE_NMNE: + state.update({"nmne": {k: v for k, v in self.nmne.items()}}) + return state + + @abstractmethod + def enable(self) -> bool: + """Enable the interface.""" + pass + return False + + @abstractmethod + def disable(self) -> bool: + """Disable the interface.""" + pass + return False + + def _capture_nmne(self, frame: Frame, inbound: bool = True) -> None: + """ + Processes and captures network frame data based on predefined global NMNE settings. + + This method updates the NMNE structure with counts of malicious network events based on the frame content and + direction. The structure is dynamically adjusted according to the enabled capture settings. + + .. note:: + While there is a lot of logic in this code that defines a multi-level hierarchical NMNE structure, + most of it is unused for now as a result of all `CAPTURE_BY_<>` variables in + ``primaite.simulator.network.nmne`` being hardcoded and set as final. Once they're 'released' and made + configurable, this function will be updated to properly explain the dynamic data structure. + + :param frame: The network frame to process, containing IP, TCP/UDP, and payload information. + :param inbound: Boolean indicating if the frame direction is inbound. Defaults to True. + """ + # Exit function if NMNE capturing is disabled + if not CAPTURE_NMNE: + return + + # Initialise basic frame data variables + direction = "inbound" if inbound else "outbound" # Direction of the traffic + ip_address = str(frame.ip.src_ip_address if inbound else frame.ip.dst_ip_address) # Source or destination IP + protocol = frame.ip.protocol.name # Network protocol used in the frame + + # Initialise port variable; will be determined based on protocol type + port = None + + # Determine the source or destination port based on the protocol (TCP/UDP) + if frame.tcp: + port = frame.tcp.src_port.value if inbound else frame.tcp.dst_port.value + elif frame.udp: + port = frame.udp.src_port.value if inbound else frame.udp.dst_port.value + + # Convert frame payload to string for keyword checking + frame_str = str(frame.payload) + + # Proceed only if any NMNE keyword is present in the frame payload + if any(keyword in frame_str for keyword in NMNE_CAPTURE_KEYWORDS): + # Start with the root of the NMNE capture structure + current_level = self.nmne + + # Update NMNE structure based on enabled settings + if CAPTURE_BY_DIRECTION: + # Set or get the dictionary for the current direction + current_level = current_level.setdefault("direction", {}) + current_level = current_level.setdefault(direction, {}) + + if CAPTURE_BY_IP_ADDRESS: + # Set or get the dictionary for the current IP address + current_level = current_level.setdefault("ip_address", {}) + current_level = current_level.setdefault(ip_address, {}) + + if CAPTURE_BY_PROTOCOL: + # Set or get the dictionary for the current protocol + current_level = current_level.setdefault("protocol", {}) + current_level = current_level.setdefault(protocol, {}) + + if CAPTURE_BY_PORT: + # Set or get the dictionary for the current port + current_level = current_level.setdefault("port", {}) + current_level = current_level.setdefault(port, {}) + + # Ensure 'KEYWORD' level is present in the structure + keyword_level = current_level.setdefault("keywords", {}) + + # Increment the count for detected keywords in the payload + if CAPTURE_BY_KEYWORD: + for keyword in NMNE_CAPTURE_KEYWORDS: + if keyword in frame_str: + # Update the count for each keyword found + keyword_level[keyword] = keyword_level.get(keyword, 0) + 1 + else: + # Increment a generic counter if keyword capturing is not enabled + keyword_level["*"] = keyword_level.get("*", 0) + 1 + + @abstractmethod + def send_frame(self, frame: Frame) -> bool: + """ + Attempts to send a network frame through the interface. + + :param frame: The network frame to be sent. + :return: A boolean indicating whether the frame was successfully sent. + """ + self._capture_nmne(frame, inbound=False) + + @abstractmethod + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + self._capture_nmne(frame, inbound=True) + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number and the mac address + """ + return f"Port {self.port_name if self.port_name else self.port_num}: {self.mac_address}" + + def __hash__(self) -> int: + return hash(self.uuid) + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep evolution to this component. + + This just clears the nmne count back to 0. + """ + super().apply_timestep(timestep=timestep) + + +class WiredNetworkInterface(NetworkInterface, ABC): + """ + Represents a wired network interface in a network device. + + This abstract base class serves as a foundational blueprint for wired network interfaces, offering core + functionalities and enforcing the implementation of key operational methods such as enabling and disabling the + interface. It encapsulates common attributes and behaviors intrinsic to wired interfaces, including the + management of physical or logical connections to network links and the provision of methods for connecting to and + disconnecting from these links. + + Inherits from: + - NetworkInterface: Provides basic network interface properties and methods. + + + Subclasses of this class are expected to provide concrete implementations for the abstract methods defined here, + tailoring the functionality to the specific requirements of the wired interface types they represent. + """ + + _connected_link: Optional[Link] = None + "The network link to which the network interface is connected." + + def enable(self) -> bool: + """Attempt to enable the network interface.""" + if self.enabled: + return True + + if not self._connected_node: + _LOGGER.warning(f"Interface {self} cannot be enabled as it is not connected to a Node") + return False + + if self._connected_node.operating_state != NodeOperatingState.ON: + self._connected_node.sys_log.warning( + f"Interface {self} cannot be enabled as the connected Node is not powered on" + ) + return False + + if not self._connected_link: + self._connected_node.sys_log.warning(f"Interface {self} cannot be enabled as there is no Link connected.") + return False + + self.enabled = True + self._connected_node.sys_log.info(f"Network Interface {self} enabled") + self.pcap = PacketCapture( + hostname=self._connected_node.hostname, port_num=self.port_num, port_name=self.port_name + ) + if self._connected_link: + self._connected_link.endpoint_up() + return True + + def disable(self) -> bool: + """Disable the network interface.""" + if not self.enabled: + return True + self.enabled = False + if self._connected_node: + self._connected_node.sys_log.info(f"Network Interface {self} disabled") + else: + _LOGGER.debug(f"Interface {self} disabled") + if self._connected_link: + self._connected_link.endpoint_down() + return True + + def connect_link(self, link: Link): + """ + Connect this network interface to a specified link. + + This method establishes a connection between the network interface and a network link if the network interface + is not already connected. If the network interface is already connected to a link, it logs an error and does + not change the existing connection. + + :param link: The Link instance to connect to this network interface. + """ + if self._connected_link: + _LOGGER.warning(f"Cannot connect Link to network interface {self} as it already has a connection") + return + + if self._connected_link == link: + _LOGGER.warning(f"Cannot connect Link to network interface {self} as it is already connected") + return + + self._connected_link = link + self.enable() + + def disconnect_link(self): + """ + Disconnect the network interface from its connected Link, if any. + + This method removes the association between the network interface and its connected Link. It updates the + connected Link's endpoints to reflect the disconnection. + """ + if self._connected_link.endpoint_a == self: + self._connected_link.endpoint_a = None + if self._connected_link.endpoint_b == self: + self._connected_link.endpoint_b = None + self._connected_link = None + + def send_frame(self, frame: Frame) -> bool: + """ + Attempt to send a network frame through the connected Link. + + This method sends a frame if the NIC is enabled and connected to a link. It captures the frame using PCAP + (if available) and transmits it through the connected link. Returns True if the frame is successfully sent, + False otherwise (e.g., if the Network Interface is disabled). + + :param frame: The network frame to be sent. + :return: True if the frame is sent, False if the Network Interface is disabled or not connected to a link. + """ + super().send_frame(frame) + if self.enabled: + frame.set_sent_timestamp() + self.pcap.capture_outbound(frame) + self._connected_link.transmit_frame(sender_nic=self, frame=frame) + return True + # Cannot send Frame as the NIC is not enabled + return False + + @abstractmethod + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the network interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + return super().receive_frame(frame) + + +class Layer3Interface(BaseModel, ABC): + """ + Represents a Layer 3 (Network Layer) interface in a network device. + + This class serves as a base for network interfaces that operate at Layer 3 of the OSI model, providing IP + connectivity and subnetting capabilities. It's not meant to be instantiated directly but to be subclassed by + specific types of network interfaces that require IP addressing capabilities. + + :ivar IPV4Address ip_address: The IP address assigned to the interface. This address enables the interface to + participate in IP-based networking, allowing it to send and receive IP packets. + :ivar IPv4Address subnet_mask: The subnet mask assigned to the interface. This mask helps in determining the + network segment that the interface belongs to and is used in IP routing decisions. + """ + + ip_address: IPV4Address + "The IP address assigned to the interface for communication on an IP-based network." + + subnet_mask: IPV4Address + "The subnet mask assigned to the interface, defining the network portion and the host portion of the IP address." + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = { + "ip_address": str(self.ip_address), + "subnet_mask": str(self.subnet_mask), + } + + return state + + @property + def ip_network(self) -> IPv4Network: + """ + Calculate and return the IPv4Network derived from the NIC's IP address and subnet mask. + + This property constructs an IPv4Network object which represents the whole network that the NIC's IP address + belongs to, based on its subnet mask. It's useful for determining the network range and broadcast address. + + :return: An IPv4Network instance representing the network of this NIC. + """ + return IPv4Network(f"{self.ip_address}/{self.subnet_mask}", strict=False) + + +class IPWiredNetworkInterface(WiredNetworkInterface, Layer3Interface, ABC): + """ + Represents an IP wired network interface. + + This interface operates at both the data link layer (Layer 2) and the network layer (Layer 3) of the OSI model, + specifically tailored for IP-based communication. This abstract class serves as a template for creating specific + wired network interfaces that support Internet Protocol (IP) functionalities. + + As this class is an amalgamation of its parent classes without additional attributes or methods, it is recommended + to refer to the documentation of `WiredNetworkInterface` and `Layer3Interface` for detailed information on the + supported operations and functionalities. + + The class inherits from: + - `WiredNetworkInterface`: Provides the functionalities and characteristics of a wired connection, such as + physical link establishment and data transmission over a cable. + - `Layer3Interface`: Enables network layer capabilities, including IP address assignment, routing, and + potentially, Layer 3 protocols like IPsec. + + As an abstract class, `IPWiredNetworkInterface` does not implement specific methods but mandates that any derived + class provides implementations for the functionalities of both `WiredNetworkInterface` and `Layer3Interface`. + This structure is ideal for representing network interfaces in devices that require wired connections and are + capable of IP routing and addressing, such as routers, switches, as well as end-host devices like computers and + servers. + + Derived classes should define specific behaviors and properties of an IP-capable wired network interface, + customizing it for their specific use cases. + """ + + _connected_link: Optional[Link] = None + "The network link to which the network interface is connected." + + def model_post_init(self, __context: Any) -> None: + """ + Performs post-initialisation checks to ensure the model's IP configuration is valid. + + This method is invoked after the initialisation of a network model object to validate its network settings, + particularly to ensure that the assigned IP address is not a network address. This validation is crucial for + maintaining the integrity of network simulations and avoiding configuration errors that could lead to + unrealistic or incorrect behavior. + + :param __context: Contextual information or parameters passed to the method, used for further initializing or + validating the model post-creation. + :raises ValueError: If the IP address is the same as the network address, indicating an incorrect configuration. + """ + if self.ip_network.network_address == self.ip_address: + raise ValueError(f"{self.ip_address}/{self.subnet_mask} must not be a network address") + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + # Get the state from the WiredNetworkInterface + state = WiredNetworkInterface.describe_state(self) + + # Update the state with information from Layer3Interface + state.update(Layer3Interface.describe_state(self)) + + return state + + def enable(self) -> bool: + """ + Enables this wired network interface and attempts to send a "hello" message to the default gateway. + + This method activates the network interface, making it operational for network communications. After enabling, + it tries to initiate a default gateway "hello" process, typically to establish initial connectivity and resolve + the default gateway's MAC address. This step is crucial for ensuring the interface can successfully send data + to and receive data from the network. + + The method safely handles cases where the connected node might not have a default gateway set or the + `default_gateway_hello` method is not defined, ignoring such errors to proceed without interruption. + """ + super().enable() + try: + self._connected_node.default_gateway_hello() + except AttributeError: + pass + return True + + @abstractmethod + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the network interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + return super().receive_frame(frame) + + +class Link(SimComponent): + """ + Represents a network link between NIC<-->NIC, NIC<-->SwitchPort, or SwitchPort<-->SwitchPort. + + :param endpoint_a: The first NIC or SwitchPort connected to the Link. + :param endpoint_b: The second NIC or SwitchPort connected to the Link. + :param bandwidth: The bandwidth of the Link in Mbps. + """ + + endpoint_a: WiredNetworkInterface + "The first WiredNetworkInterface connected to the Link." + endpoint_b: WiredNetworkInterface + "The second WiredNetworkInterface connected to the Link." + bandwidth: float + "The bandwidth of the Link in Mbps." + current_load: float = 0.0 + "The current load on the link in Mbps." + + def __init__(self, **kwargs): + """ + Ensure that endpoint_a and endpoint_b are not the same NIC. + + Connect the link to the NICs after creation. + + :raises ValueError: If endpoint_a and endpoint_b are the same NIC. + """ + if kwargs["endpoint_a"] == kwargs["endpoint_b"]: + msg = "endpoint_a and endpoint_b cannot be the same NIC or SwitchPort" + _LOGGER.error(msg) + raise ValueError(msg) + super().__init__(**kwargs) + self.endpoint_a.connect_link(self) + self.endpoint_b.connect_link(self) + self.endpoint_up() + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "endpoint_a": self.endpoint_a.uuid, # TODO: consider if using UUID is the best way to do this + "endpoint_b": self.endpoint_b.uuid, # TODO: consider if using UUID is the best way to do this + "bandwidth": self.bandwidth, + "current_load": self.current_load, + } + ) + return state + + @property + def current_load_percent(self) -> str: + """Get the current load formatted as a percentage string.""" + return f"{self.current_load / self.bandwidth:.5f}%" + + def endpoint_up(self): + """Let the Link know and endpoint has been brought up.""" + if self.is_up: + _LOGGER.debug(f"Link {self} up") + + def endpoint_down(self): + """Let the Link know and endpoint has been brought down.""" + if not self.is_up: + self.current_load = 0.0 + _LOGGER.debug(f"Link {self} down") + + @property + def is_up(self) -> bool: + """ + Informs whether the link is up. + + This is based upon both NIC endpoints being enabled. + """ + return self.endpoint_a.enabled and self.endpoint_b.enabled + + def _can_transmit(self, frame: Frame) -> bool: + if self.is_up: + frame_size_Mbits = frame.size_Mbits # noqa - Leaving it as Mbits as this is how they're expressed + # return self.current_load + frame_size_Mbits <= self.bandwidth + # TODO: re add this check once packet size limiting and MTU checks are implemented + return True + return False + + def transmit_frame(self, sender_nic: WiredNetworkInterface, frame: Frame) -> bool: + """ + Send a network frame from one NIC or SwitchPort to another connected NIC or SwitchPort. + + :param sender_nic: The NIC or SwitchPort sending the frame. + :param frame: The network frame to be sent. + :return: True if the Frame can be sent, otherwise False. + """ + can_transmit = self._can_transmit(frame) + if not can_transmit: + _LOGGER.debug(f"Cannot transmit frame as {self} is at capacity") + return False + + receiver = self.endpoint_a + if receiver == sender_nic: + receiver = self.endpoint_b + frame_size = frame.size_Mbits + + if receiver.receive_frame(frame): + # Frame transmitted successfully + # Load the frame size on the link + self.current_load += frame_size + _LOGGER.debug( + f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits " + f"({self.current_load_percent})" + ) + return True + return False + + def __str__(self) -> str: + return f"{self.endpoint_a}<-->{self.endpoint_b}" + + def apply_timestep(self, timestep: int) -> None: + """Apply a timestep to the simulation.""" + super().apply_timestep(timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + self.current_load = 0.0 + + +class Node(SimComponent): + """ + A basic Node class that represents a node on the network. + + This class manages the state of the node, including the NICs (Network Interface Cards), accounts, applications, + services, processes, file system, and various managers like ARP, ICMP, SessionManager, and SoftwareManager. + + :param hostname: The node hostname on the network. + :param operating_state: The node operating state, either ON or OFF. + """ + + hostname: str + "The node hostname on the network." + default_gateway: Optional[IPV4Address] = None + "The default gateway IP address for forwarding network traffic to other networks." + operating_state: NodeOperatingState = NodeOperatingState.OFF + "The hardware state of the node." + network_interfaces: Dict[str, NetworkInterface] = {} + "The Network Interfaces on the node." + network_interface: Dict[int, NetworkInterface] = {} + "The Network Interfaces on the node by port id." + dns_server: Optional[IPv4Address] = None + "List of IP addresses of DNS servers used for name resolution." + + accounts: Dict[str, Account] = {} + "All accounts on the node." + applications: Dict[str, Application] = {} + "All applications on the node." + services: Dict[str, Service] = {} + "All services on the node." + processes: Dict[str, Process] = {} + "All processes on the node." + file_system: FileSystem + "The nodes file system." + root: Path + "Root directory for simulation output." + sys_log: SysLog + session_manager: SessionManager + software_manager: SoftwareManager + + revealed_to_red: bool = False + "Informs whether the node has been revealed to a red agent." + + start_up_duration: int = 3 + "Time steps needed for the node to start up." + + start_up_countdown: int = 0 + "Time steps needed until node is booted up." + + shut_down_duration: int = 3 + "Time steps needed for the node to shut down." + + shut_down_countdown: int = 0 + "Time steps needed until node is shut down." + + is_resetting: bool = False + "If true, the node will try turning itself off then back on again." + + node_scan_duration: int = 10 + "How many timesteps until the whole node is scanned. Default 10 time steps." + + node_scan_countdown: int = 0 + "Time steps until scan is complete" + + red_scan_countdown: int = 0 + "Time steps until reveal to red scan is complete." + + 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 + provided. + """ + if not kwargs.get("sys_log"): + kwargs["sys_log"] = SysLog(kwargs["hostname"]) + if not kwargs.get("session_manager"): + kwargs["session_manager"] = SessionManager(sys_log=kwargs.get("sys_log")) + if not kwargs.get("root"): + kwargs["root"] = SIM_OUTPUT.path / kwargs["hostname"] + if not kwargs.get("file_system"): + kwargs["file_system"] = FileSystem(sys_log=kwargs["sys_log"], sim_root=kwargs["root"] / "fs") + if not kwargs.get("software_manager"): + kwargs["software_manager"] = SoftwareManager( + parent_node=self, + sys_log=kwargs.get("sys_log"), + session_manager=kwargs.get("session_manager"), + file_system=kwargs.get("file_system"), + dns_server=kwargs.get("dns_server"), + ) + super().__init__(**kwargs) + self.session_manager.node = self + self.session_manager.software_manager = self.software_manager + self._install_system_software() + + def setup_for_episode(self, episode: int): + """Reset the original state of the SimComponent.""" + super().setup_for_episode(episode=episode) + + # Reset File System + self.file_system.setup_for_episode(episode=episode) + + # Reset all Nics + for network_interface in self.network_interfaces.values(): + network_interface.setup_for_episode(episode=episode) + + for software in self.software_manager.software.values(): + software.setup_for_episode(episode=episode) + + if episode and self.sys_log: + self.sys_log.current_episode = episode + self.sys_log.setup_logger() + + class _NodeIsOnValidator(RequestPermissionValidator): + """ + When requests come in, this validator will only let them through if the node is on. + + This is useful because no actions should be being resolved if the node is off. + """ + + node: Node + """Save a reference to the node instance.""" + + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Return whether the node is on or off.""" + return self.node.operating_state == NodeOperatingState.ON + + @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." + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + _node_is_on = Node._NodeIsOnValidator(node=self) + + rm = super()._init_request_manager() + # since there are potentially many services, create an request manager that can map service name + self._service_request_manager = RequestManager() + rm.add_request("service", RequestType(func=self._service_request_manager, validator=_node_is_on)) + self._nic_request_manager = RequestManager() + rm.add_request("network_interface", RequestType(func=self._nic_request_manager, validator=_node_is_on)) + + rm.add_request("file_system", RequestType(func=self.file_system._request_manager, validator=_node_is_on)) + + # currently we don't have any applications nor processes, so these will be empty + self._process_request_manager = RequestManager() + rm.add_request("process", RequestType(func=self._process_request_manager, validator=_node_is_on)) + self._application_request_manager = RequestManager() + rm.add_request("application", RequestType(func=self._application_request_manager, validator=_node_is_on)) + + rm.add_request( + "scan", + RequestType( + func=lambda request, context: RequestResponse.from_bool(self.reveal_to_red()), validator=_node_is_on + ), + ) + + rm.add_request( + "shutdown", + RequestType( + func=lambda request, context: RequestResponse.from_bool(self.power_off()), validator=_node_is_on + ), + ) + rm.add_request("startup", RequestType(func=lambda request, context: RequestResponse.from_bool(self.power_on()))) + rm.add_request( + "reset", + RequestType(func=lambda request, context: RequestResponse.from_bool(self.reset()), validator=_node_is_on), + ) # TODO implement node reset + rm.add_request( + "logon", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on) + ) # TODO implement logon request + rm.add_request( + "logoff", RequestType(func=lambda request, context: RequestResponse.from_bool(False), validator=_node_is_on) + ) # TODO implement logoff request + + self._os_request_manager = RequestManager() + self._os_request_manager.add_request( + "scan", + RequestType(func=lambda request, context: RequestResponse.from_bool(self.scan()), validator=_node_is_on), + ) + rm.add_request("os", RequestType(func=self._os_request_manager, validator=_node_is_on)) + + self._software_request_manager = RequestManager() + rm.add_request("software_manager", RequestType(func=self._software_request_manager, validator=_node_is_on)) + self._application_manager = RequestManager() + self._software_request_manager.add_request( + name="application", request_type=RequestType(func=self._application_manager) + ) + + self._application_manager.add_request( + name="install", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.application_install_action( + application=self._read_application_type(request[0]), ip_address=request[1] + ) + ) + ), + ) + + self._application_manager.add_request( + name="uninstall", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.application_uninstall_action(application=self._read_application_type(request[0])) + ) + ), + ) + + return rm + + def _install_system_software(self): + """Install System Software - software that is usually provided with the OS.""" + pass + + def _read_application_type(self, application_class_str: str) -> Type[IOSoftwareClass]: + """Wrapper that converts the string from the request manager into the appropriate class for the application.""" + if application_class_str == "DoSBot": + from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot + + return DoSBot + elif application_class_str == "DataManipulationBot": + from primaite.simulator.system.applications.red_applications.data_manipulation_bot import ( + DataManipulationBot, + ) + + return DataManipulationBot + elif application_class_str == "WebBrowser": + from primaite.simulator.system.applications.web_browser import WebBrowser + + return WebBrowser + elif application_class_str == "RansomwareScript": + from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript + + return RansomwareScript + else: + return 0 + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "hostname": self.hostname, + "operating_state": self.operating_state.value, + "NICs": { + eth_num: network_interface.describe_state() + for eth_num, network_interface in self.network_interface.items() + }, + "file_system": self.file_system.describe_state(), + "applications": {app.name: app.describe_state() for app in self.applications.values()}, + "services": {svc.name: svc.describe_state() for svc in self.services.values()}, + "process": {proc.name: proc.describe_state() for proc in self.processes.values()}, + "revealed_to_red": self.revealed_to_red, + } + ) + return state + + def show(self, markdown: bool = False): + """Show function that calls both show NIC and show open ports.""" + self.show_nic(markdown) + self.show_open_ports(markdown) + + def show_open_ports(self, markdown: bool = False): + """Prints a table of the open ports on the Node.""" + table = PrettyTable(["Port", "Name"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Open Ports" + for port in self.software_manager.get_open_ports(): + if port.value > 0: + table.add_row([port.value, port.name]) + print(table.get_string(sortby="Port")) + + @property + def has_enabled_network_interface(self) -> bool: + """ + Checks if the node has at least one enabled network interface. + + Iterates through all network interfaces associated with the node to determine if at least one is enabled. This + property is essential for determining the node's ability to communicate within the network. + + :return: True if there is at least one enabled network interface; otherwise, False. + """ + for network_interface in self.network_interfaces.values(): + if network_interface.enabled: + return True + return False + + def show_nic(self, markdown: bool = False): + """Prints a table of the NICs on the Node.""" + table = PrettyTable(["Port", "Type", "MAC Address", "Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Network Interface Cards" + for port, network_interface in self.network_interface.items(): + ip_address = "" + if hasattr(network_interface, "ip_address"): + ip_address = f"{network_interface.ip_address}/{network_interface.ip_network.prefixlen}" + table.add_row( + [ + port, + network_interface.__class__.__name__, + network_interface.mac_address, + ip_address, + network_interface.speed, + "Enabled" if network_interface.enabled else "Disabled", + ] + ) + print(table) + + def apply_timestep(self, timestep: int): + """ + Apply a single timestep of simulation dynamics to this node. + + In this instance, if any multi-timestep processes are currently occurring + (such as starting up or shutting down), then they are brought one step closer to + being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + super().apply_timestep(timestep=timestep) + + for network_interface in self.network_interfaces.values(): + network_interface.apply_timestep(timestep=timestep) + + # count down to boot up + if self.start_up_countdown > 0: + self.start_up_countdown -= 1 + else: + if self.operating_state == NodeOperatingState.BOOTING: + self.operating_state = NodeOperatingState.ON + self.sys_log.info(f"{self.hostname}: Turned on") + for network_interface in self.network_interfaces.values(): + network_interface.enable() + + self._start_up_actions() + + # count down to shut down + if self.shut_down_countdown > 0: + self.shut_down_countdown -= 1 + else: + if self.operating_state == NodeOperatingState.SHUTTING_DOWN: + self.operating_state = NodeOperatingState.OFF + self.sys_log.info(f"{self.hostname}: Turned off") + self._shut_down_actions() + + # if resetting turn back on + if self.is_resetting: + self.is_resetting = False + self.power_on() + + # time steps which require the node to be on + if self.operating_state == NodeOperatingState.ON: + # node scanning + if self.node_scan_countdown > 0: + self.node_scan_countdown -= 1 + + if self.node_scan_countdown == 0: + # scan everything! + for process_id in self.processes: + self.processes[process_id].scan() + + # scan services + for service_id in self.services: + self.services[service_id].scan() + + # scan applications + for application_id in self.applications: + self.applications[application_id].scan() + + # scan file system + self.file_system.scan(instant_scan=True) + + if self.red_scan_countdown > 0: + self.red_scan_countdown -= 1 + + if self.red_scan_countdown == 0: + # scan processes + for process_id in self.processes: + self.processes[process_id].reveal_to_red() + + # scan services + for service_id in self.services: + self.services[service_id].reveal_to_red() + + # scan applications + for application_id in self.applications: + self.applications[application_id].reveal_to_red() + + # scan file system + self.file_system.reveal_to_red(instant_scan=True) + + for process_id in self.processes: + self.processes[process_id].apply_timestep(timestep=timestep) + + for service_id in self.services: + self.services[service_id].apply_timestep(timestep=timestep) + + for application_id in self.applications: + self.applications[application_id].apply_timestep(timestep=timestep) + + self.file_system.apply_timestep(timestep=timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + for network_interface in self.network_interfaces.values(): + network_interface.pre_timestep(timestep=timestep) + + for process_id in self.processes: + self.processes[process_id].pre_timestep(timestep=timestep) + + for service_id in self.services: + self.services[service_id].pre_timestep(timestep=timestep) + + for application_id in self.applications: + self.applications[application_id].pre_timestep(timestep=timestep) + + self.file_system.pre_timestep(timestep=timestep) + + def scan(self) -> bool: + """ + Scan the node and all the items within it. + + Scans the: + - Processes + - Services + - Applications + - Folders + - Files + + to the red agent. + """ + self.node_scan_countdown = self.node_scan_duration + return True + + def reveal_to_red(self) -> bool: + """ + Reveals the node and all the items within it to the red agent. + + Set all the: + - Processes + - Services + - Applications + - Folders + - Files + + `revealed_to_red` to `True`. + """ + self.red_scan_countdown = self.node_scan_duration + return True + + def power_on(self) -> bool: + """Power on the Node, enabling its NICs if it is in the OFF state.""" + if self.start_up_duration <= 0: + self.operating_state = NodeOperatingState.ON + self._start_up_actions() + self.sys_log.info("Power on") + for network_interface in self.network_interfaces.values(): + network_interface.enable() + return True + if self.operating_state == NodeOperatingState.OFF: + self.operating_state = NodeOperatingState.BOOTING + self.start_up_countdown = self.start_up_duration + return True + + return False + + def power_off(self) -> bool: + """Power off the Node, disabling its NICs if it is in the ON state.""" + if self.shut_down_duration <= 0: + self._shut_down_actions() + self.operating_state = NodeOperatingState.OFF + self.sys_log.info("Power off") + return True + if self.operating_state == NodeOperatingState.ON: + for network_interface in self.network_interfaces.values(): + network_interface.disable() + self.operating_state = NodeOperatingState.SHUTTING_DOWN + self.shut_down_countdown = self.shut_down_duration + return True + return False + + def reset(self) -> bool: + """ + Resets the node. + + Powers off the node and sets is_resetting to True. + Applying more timesteps will eventually turn the node back on. + """ + if self.operating_state.ON: + self.is_resetting = True + self.sys_log.info("Resetting") + self.power_off() + return True + return False + + def connect_nic(self, network_interface: NetworkInterface, port_name: Optional[str] = None): + """ + Connect a Network Interface to the node. + + :param network_interface: The NIC to connect. + :raise NetworkError: If the NIC is already connected. + """ + if network_interface.uuid not in self.network_interface: + self.network_interfaces[network_interface.uuid] = network_interface + new_nic_num = len(self.network_interfaces) + self.network_interface[new_nic_num] = network_interface + network_interface._connected_node = self + network_interface.port_num = new_nic_num + if port_name: + network_interface.port_name = port_name + network_interface.parent = self + self.sys_log.info(f"Connected Network Interface {network_interface}") + if self.operating_state == NodeOperatingState.ON: + network_interface.enable() + self._nic_request_manager.add_request(new_nic_num, RequestType(func=network_interface._request_manager)) + else: + msg = f"Cannot connect NIC {network_interface} as it is already connected" + self.sys_log.logger.warning(msg) + raise NetworkError(msg) + + def disconnect_nic(self, network_interface: Union[NetworkInterface, str]): + """ + Disconnect a NIC (Network Interface Card) from the node. + + :param network_interface: The NIC to Disconnect, or its UUID. + :raise NetworkError: If the NIC is not connected. + """ + if isinstance(network_interface, str): + network_interface = self.network_interfaces.get(network_interface) + if network_interface or network_interface.uuid in self.network_interfaces: + network_interface_num = -1 + for port, _network_interface in self.network_interface.items(): + if network_interface == _network_interface: + self.network_interface.pop(port) + network_interface_num = port + break + self.network_interfaces.pop(network_interface.uuid) + network_interface.parent = None + network_interface.disable() + self.sys_log.info(f"Disconnected Network Interface {network_interface}") + if network_interface_num != -1: + self._nic_request_manager.remove_request(network_interface_num) + else: + msg = f"Cannot disconnect Network Interface {network_interface} as it is not connected" + self.sys_log.logger.warning(msg) + raise NetworkError(msg) + + def ping(self, target_ip_address: Union[IPv4Address, str], pings: int = 4) -> bool: + """ + Ping an IP address, performing a standard ICMP echo request/response. + + :param target_ip_address: The target IP address to ping. + :param pings: The number of pings to attempt, default is 4. + :return: True if the ping is successful, otherwise False. + """ + if not isinstance(target_ip_address, IPv4Address): + target_ip_address = IPv4Address(target_ip_address) + if self.software_manager.icmp: + return self.software_manager.icmp.ping(target_ip_address, pings) + return False + + @abstractmethod + def receive_frame(self, frame: Frame, from_network_interface: NetworkInterface): + """ + Receive a Frame from the connected NIC and process it. + + This is an abstract implementation of receive_frame with some very basic functionality (ARP population). All + Node subclasses should have their own implementation of receive_frame that first calls super().receive_frame( + ) before implementing its own internal receive_frame logic. + + :param frame: The Frame being received. + :param from_network_interface: The Network Interface that received the frame. + """ + if self.operating_state == NodeOperatingState.ON: + if frame.ip: + if self.software_manager.arp: + self.software_manager.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip_address, + mac_address=frame.ethernet.src_mac_addr, + network_interface=from_network_interface, + ) + else: + return + + def install_service(self, service: Service) -> None: + """ + Install a service on this node. + + :param service: Service instance that has not been installed on any node yet. + :type service: Service + """ + if service in self: + _LOGGER.warning(f"Can't add service {service.name} to node {self.hostname}. It's already installed.") + return + self.services[service.uuid] = service + service.parent = self + service.install() # Perform any additional setup, such as creating files for this service on the node. + self.sys_log.info(f"Installed service {service.name}") + _LOGGER.debug(f"Added service {service.name} to node {self.hostname}") + self._service_request_manager.add_request(service.name, RequestType(func=service._request_manager)) + + def uninstall_service(self, service: Service) -> None: + """ + Uninstall and completely remove service from this node. + + :param service: Service object that is currently associated with this node. + :type service: Service + """ + if service not in self: + _LOGGER.warning(f"Can't remove service {service.name} from node {self.hostname}. It's not installed.") + return + service.uninstall() # Perform additional teardown, such as removing files or restarting the machine. + self.services.pop(service.uuid) + service.parent = None + self.sys_log.info(f"Uninstalled service {service.name}") + self._service_request_manager.remove_request(service.name) + + def install_application(self, application: Application) -> None: + """ + Install an application on this node. + + :param application: Application instance that has not been installed on any node yet. + :type application: Application + """ + if application in self: + _LOGGER.warning( + f"Can't add application {application.name} to node {self.hostname}. It's already installed." + ) + return + self.applications[application.uuid] = application + application.parent = self + self.sys_log.info(f"Installed application {application.name}") + _LOGGER.debug(f"Added application {application.name} to node {self.hostname}") + self._application_request_manager.add_request(application.name, RequestType(func=application._request_manager)) + + def uninstall_application(self, application: Application) -> None: + """ + Uninstall and completely remove application from this node. + + :param application: Application object that is currently associated with this node. + :type application: Application + """ + if application not in self: + _LOGGER.warning( + f"Can't remove application {application.name} from node {self.hostname}. It's not installed." + ) + return + self.applications.pop(application.uuid) + application.parent = None + self.sys_log.info(f"Uninstalled application {application.name}") + self._application_request_manager.remove_request(application.name) + + def application_install_action(self, application: Application, ip_address: Optional[str] = None) -> bool: + """ + Install an application on this node and configure it. + + This method is useful for allowing agents to take this action. + + :param application: Application object that has not been installed on any node yet. + :type application: Application + :param ip_address: IP address used to configure the application + (target IP for the DoSBot or server IP for the DataManipulationBot) + :type ip_address: str + :return: True if the application is installed successfully, otherwise False. + """ + if application in self: + _LOGGER.warning( + f"Can't add application {application.__name__}" + f"to node {self.hostname}. It's already installed." + ) + return True + + self.software_manager.install(application) + application_instance = self.software_manager.software.get(str(application.__name__)) + self.applications[application_instance.uuid] = application_instance + _LOGGER.debug(f"Added application {application_instance.name} to node {self.hostname}") + self._application_request_manager.add_request( + application_instance.name, RequestType(func=application_instance._request_manager) + ) + + # Configure application if additional parameters are given + if ip_address: + if application_instance.name == "DoSBot": + application_instance.configure(target_ip_address=IPv4Address(ip_address)) + elif application_instance.name == "DataManipulationBot": + application_instance.configure(server_ip_address=IPv4Address(ip_address)) + elif application_instance.name == "RansomwareScript": + application_instance.configure(server_ip_address=IPv4Address(ip_address)) + else: + pass + application_instance.install() + if application_instance.name in self.software_manager.software: + return True + else: + return False + + def application_uninstall_action(self, application: Application) -> bool: + """ + Uninstall and completely remove application from this node. + + This method is useful for allowing agents to take this action. + + :param application: Application object that is currently associated with this node. + :type application: Application + :return: True if the application is uninstalled successfully, otherwise False. + """ + if application.__name__ not in self.software_manager.software: + _LOGGER.warning( + f"Can't remove application {application.__name__}" + f"from node {self.hostname}. It's not installed." + ) + return True + + application_instance = self.software_manager.software.get( + str(application.__name__) + ) # This works because we can't have two applications with the same name on the same node + # self.uninstall_application(application_instance) + self.software_manager.uninstall(application_instance.name) + + if application_instance.name not in self.software_manager.software: + return True + else: + return False + + def _shut_down_actions(self): + """Actions to perform when the node is shut down.""" + # Turn off all the services in the node + for service_id in self.services: + self.services[service_id].stop() + + # Turn off all the applications in the node + for app_id in self.applications: + self.applications[app_id].close() + + # Turn off all processes in the node + # for process_id in self.processes: + # self.processes[process_id] + + def _start_up_actions(self): + """Actions to perform when the node is starting up.""" + # Turn on all the services in the node + for service_id in self.services: + self.services[service_id].start() + + # Turn on all the applications in the node + for app_id in self.applications: + self.applications[app_id].run() + + # Turn off all processes in the node + # for process_id in self.processes: + # self.processes[process_id] + + def __contains__(self, item: Any) -> bool: + if isinstance(item, Service): + return item.uuid in self.services + elif isinstance(item, Application): + return item.uuid in self.applications + return None diff --git a/src/primaite/simulator/network/hardware/network_interface/__init__.py b/src/primaite/simulator/network/hardware/network_interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/network_interface/wireless/__init__.py b/src/primaite/simulator/network/hardware/network_interface/wireless/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_access_point.py b/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_access_point.py new file mode 100644 index 00000000..4b73b6a8 --- /dev/null +++ b/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_access_point.py @@ -0,0 +1,88 @@ +from typing import Dict + +from primaite.simulator.network.hardware.base import ( + IPWirelessNetworkInterface, + Layer3Interface, + WirelessNetworkInterface, +) +from primaite.simulator.network.transmission.data_link_layer import Frame + + +class WirelessAccessPoint(IPWirelessNetworkInterface): + """ + Represents a Wireless Access Point (AP) in a network. + + This class models a Wireless Access Point, a device that allows wireless devices to connect to a wired network + using Wi-Fi or other wireless standards. The Wireless Access Point bridges the wireless and wired segments of + the network, allowing wireless devices to communicate with other devices on the network. + + As an integral component of wireless networking, a Wireless Access Point provides functionalities for network + management, signal broadcasting, security enforcement, and connection handling. It also possesses Layer 3 + capabilities such as IP addressing and subnetting, allowing for network segmentation and routing. + + Inherits from: + - WirelessNetworkInterface: Provides basic properties and methods specific to wireless interfaces. + - Layer3Interface: Provides Layer 3 properties like ip_address and subnet_mask, enabling the device to manage + network traffic and routing. + + This class can be further specialised or extended to support specific features or standards related to wireless + networking, such as different Wi-Fi versions, frequency bands, or advanced security protocols. + """ + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + # Get the state from the WirelessNetworkInterface + state = WirelessNetworkInterface.describe_state(self) + + # Update the state with information from Layer3Interface + state.update(Layer3Interface.describe_state(self)) + + # Update the state with NIC-specific information + state.update( + { + "wake_on_lan": self.wake_on_lan, + } + ) + + return state + + def enable(self) -> bool: + """Enable the interface.""" + pass + return True + + def disable(self) -> bool: + """Disable the interface.""" + pass + return True + + def send_frame(self, frame: Frame) -> bool: + """ + Attempts to send a network frame through the interface. + + :param frame: The network frame to be sent. + :return: A boolean indicating whether the frame was successfully sent. + """ + pass + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + pass + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number, MAC address and IP address of the NIC. + """ + return f"Port {self.port_name if self.port_name else self.port_num}: {self.mac_address}/{self.ip_address}" diff --git a/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_nic.py b/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_nic.py new file mode 100644 index 00000000..2e0a1823 --- /dev/null +++ b/src/primaite/simulator/network/hardware/network_interface/wireless/wireless_nic.py @@ -0,0 +1,85 @@ +from typing import Dict + +from primaite.simulator.network.hardware.base import ( + IPWirelessNetworkInterface, + Layer3Interface, + WirelessNetworkInterface, +) +from primaite.simulator.network.transmission.data_link_layer import Frame + + +class WirelessNIC(IPWirelessNetworkInterface): + """ + Represents a Wireless Network Interface Card (Wireless NIC) in a network device. + + This class encapsulates the functionalities and attributes of a wireless NIC, combining the characteristics of a + wireless network interface with Layer 3 features. It is capable of connecting to wireless networks, managing + wireless-specific properties such as signal strength and security protocols, and also handling IP-related + functionalities like IP addressing and subnetting. + + Inherits from: + - WirelessNetworkInterface: Provides basic properties and methods specific to wireless interfaces. + - Layer3Interface: Provides Layer 3 properties like ip_address and subnet_mask, enabling the device to participate + in IP-based networking. + + This class can be extended to include more advanced features or to tailor its behavior for specific types of + wireless networks or protocols. + """ + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + # Get the state from the WirelessNetworkInterface + state = WirelessNetworkInterface.describe_state(self) + + # Update the state with information from Layer3Interface + state.update(Layer3Interface.describe_state(self)) + + # Update the state with NIC-specific information + state.update( + { + "wake_on_lan": self.wake_on_lan, + } + ) + + return state + + def enable(self) -> bool: + """Enable the interface.""" + pass + return True + + def disable(self) -> bool: + """Disable the interface.""" + pass + return True + + def send_frame(self, frame: Frame) -> bool: + """ + Attempts to send a network frame through the interface. + + :param frame: The network frame to be sent. + :return: A boolean indicating whether the frame was successfully sent. + """ + pass + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + pass + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number, MAC address and IP address of the NIC. + """ + return f"Port {self.port_name if self.port_name else self.port_num}: {self.mac_address}/{self.ip_address}" diff --git a/src/primaite/simulator/network/hardware/node_operating_state.py b/src/primaite/simulator/network/hardware/node_operating_state.py new file mode 100644 index 00000000..1fd1225f --- /dev/null +++ b/src/primaite/simulator/network/hardware/node_operating_state.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class NodeOperatingState(Enum): + """Enumeration of Node Operating States.""" + + ON = 1 + "The node is powered on." + OFF = 2 + "The node is powered off." + BOOTING = 3 + "The node is in the process of booting up." + SHUTTING_DOWN = 4 + "The node is in the process of shutting down." diff --git a/src/primaite/simulator/network/hardware/nodes/__init__.py b/src/primaite/simulator/network/hardware/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/nodes/host/__init__.py b/src/primaite/simulator/network/hardware/nodes/host/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/nodes/host/computer.py b/src/primaite/simulator/network/hardware/nodes/host/computer.py new file mode 100644 index 00000000..7ce64867 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/host/computer.py @@ -0,0 +1,37 @@ +from typing import ClassVar, Dict + +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode +from primaite.simulator.system.services.ftp.ftp_client import FTPClient + + +class Computer(HostNode): + """ + A basic Computer class. + + Example: + >>> pc_a = Computer( + hostname="pc_a", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + >>> pc_a.power_on() + + Instances of computer come 'pre-packaged' with the following: + + * Core Functionality: + * Packet Capture + * Sys Log + * Services: + * ARP Service + * ICMP Service + * DNS Client + * FTP Client + * NTP Client + * Applications: + * Web Browser + """ + + SYSTEM_SOFTWARE: ClassVar[Dict] = {**HostNode.SYSTEM_SOFTWARE, "FTPClient": FTPClient} + + pass diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py new file mode 100644 index 00000000..caea2dd7 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +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.node_operating_state import NodeOperatingState +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.arp.arp import ARP, ARPPacket +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.icmp.icmp import ICMP +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.utils.validators import IPV4Address + +_LOGGER = getLogger(__name__) + + +class HostARP(ARP): + """ + The Host ARP Service. + + Extends the ARP service for host-specific functionalities within a network, focusing on resolving and caching + MAC addresses and network interfaces (NICs) based on IP addresses, especially concerning the default gateway. + + This specialized ARP service for hosts facilitates efficient network communication by managing ARP entries + and handling ARP requests and replies with additional logic for default gateway processing. + """ + + def get_default_gateway_mac_address(self) -> Optional[str]: + """ + Retrieves the MAC address of the default gateway as known from the ARP cache. + + :return: The MAC address of the default gateway if present in the ARP cache; otherwise, None. + """ + if self.software_manager.node.default_gateway: + return self.get_arp_cache_mac_address(self.software_manager.node.default_gateway) + + def get_default_gateway_network_interface(self) -> Optional[NIC]: + """ + Obtains the network interface card (NIC) associated with the default gateway from the ARP cache. + + :return: The NIC associated with the default gateway if it exists in the ARP cache; otherwise, None. + """ + if self.software_manager.node.default_gateway and self.software_manager.node.has_enabled_network_interface: + return self.get_arp_cache_network_interface(self.software_manager.node.default_gateway) + + def _get_arp_cache_mac_address( + self, ip_address: IPV4Address, is_reattempt: bool = False, is_default_gateway_attempt: bool = False + ) -> Optional[str]: + """ + Internal method to retrieve the MAC address associated with an IP address from the ARP cache. + + :param ip_address: The IP address whose MAC address is to be retrieved. + :param is_reattempt: Indicates if this call is a reattempt after a failed initial attempt. + :param is_default_gateway_attempt: Indicates if this call is an attempt to get the default gateway's MAC + address. + :return: The MAC address associated with the IP address if found, otherwise None. + """ + arp_entry = self.arp.get(ip_address) + + if arp_entry: + return arp_entry.mac_address + + if ip_address == self.software_manager.node.default_gateway: + is_reattempt = True + if not is_reattempt: + self.send_arp_request(ip_address) + return self._get_arp_cache_mac_address( + ip_address=ip_address, is_reattempt=True, is_default_gateway_attempt=is_default_gateway_attempt + ) + else: + if self.software_manager.node.default_gateway: + if not is_default_gateway_attempt: + self.send_arp_request(self.software_manager.node.default_gateway) + return self._get_arp_cache_mac_address( + ip_address=self.software_manager.node.default_gateway, + is_reattempt=True, + is_default_gateway_attempt=True, + ) + return None + + def get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: + """ + Retrieves the MAC address associated with a given IP address from the ARP cache. + + :param ip_address: The IP address for which the MAC address is sought. + :return: The MAC address if available in the ARP cache; otherwise, None. + """ + return self._get_arp_cache_mac_address(ip_address) + + def _get_arp_cache_network_interface( + self, ip_address: IPV4Address, is_reattempt: bool = False, is_default_gateway_attempt: bool = False + ) -> Optional[NIC]: + """ + Internal method to retrieve the NIC associated with an IP address from the ARP cache. + + :param ip_address: The IP address whose NIC is to be retrieved. + :param is_reattempt: Indicates if this call is a reattempt after a failed initial attempt. + :param is_default_gateway_attempt: Indicates if this call is an attempt to get the NIC of the default gateway. + :return: The NIC associated with the IP address if found, otherwise None. + """ + arp_entry = self.arp.get(ip_address) + + if arp_entry: + return self.software_manager.node.network_interfaces[arp_entry.network_interface_uuid] + else: + if ip_address == self.software_manager.node.default_gateway: + is_reattempt = True + if not is_reattempt: + self.send_arp_request(ip_address) + return self._get_arp_cache_network_interface( + ip_address=ip_address, is_reattempt=True, is_default_gateway_attempt=is_default_gateway_attempt + ) + else: + if self.software_manager.node.default_gateway: + if not is_default_gateway_attempt: + self.send_arp_request(self.software_manager.node.default_gateway) + return self._get_arp_cache_network_interface( + ip_address=self.software_manager.node.default_gateway, + is_reattempt=True, + is_default_gateway_attempt=True, + ) + return None + + def get_arp_cache_network_interface(self, ip_address: IPv4Address) -> Optional[NIC]: + """ + Retrieves the network interface card (NIC) associated with a given IP address from the ARP cache. + + :param ip_address: The IP address for which the associated NIC is sought. + :return: The NIC if available in the ARP cache; otherwise, None. + """ + return self._get_arp_cache_network_interface(ip_address) + + def _process_arp_request(self, arp_packet: ARPPacket, from_network_interface: NIC): + """ + Processes an ARP request. + + Adds a new entry to the ARP cache if the target IP address matches the NIC's IP address and sends an ARP + reply back. + + :param arp_packet: The ARP packet containing the request. + :param from_network_interface: The NIC that received the ARP request. + """ + super()._process_arp_request(arp_packet, from_network_interface) + # Unmatched ARP Request + if arp_packet.target_ip_address != from_network_interface.ip_address: + self.sys_log.warning( + f"Ignoring ARP request for {arp_packet.target_ip_address}. Current IP address is " + f"{from_network_interface.ip_address}" + ) + return + + arp_packet = arp_packet.generate_reply(from_network_interface.mac_address) + self.send_arp_reply(arp_packet) + + +class NIC(IPWiredNetworkInterface): + """ + Represents a Network Interface Card (NIC) in a Host Node. + + A NIC is a hardware component that provides a computer or other network device with the ability to connect to a + network. It operates at both Layer 2 (Data Link Layer) and Layer 3 (Network Layer) of the OSI model, meaning it + can interpret both MAC addresses and IP addresses. This class combines the functionalities of + WiredNetworkInterface and Layer3Interface, allowing the NIC to manage physical connections and network layer + addressing. + + Inherits from: + - WiredNetworkInterface: Provides properties and methods specific to wired connections, including methods to connect + and disconnect from network links and to manage the enabled/disabled state of the interface. + - Layer3Interface: Provides properties for Layer 3 network configuration, such as IP address and subnet mask. + """ + + _connected_link: Optional[Link] = None + "The network link to which the network interface is connected." + wake_on_lan: bool = False + "Indicates if the NIC supports Wake-on-LAN functionality." + + def model_post_init(self, __context: Any) -> None: + """ + Performs post-initialisation checks to ensure the model's IP configuration is valid. + + This method is invoked after the initialisation of a network model object to validate its network settings, + particularly to ensure that the assigned IP address is not a network address. This validation is crucial for + maintaining the integrity of network simulations and avoiding configuration errors that could lead to + unrealistic or incorrect behavior. + + :param __context: Contextual information or parameters passed to the method, used for further initializing or + validating the model post-creation. + :raises ValueError: If the IP address is the same as the network address, indicating an incorrect configuration. + """ + if self.ip_network.network_address == self.ip_address: + raise ValueError(f"{self.ip_address}/{self.subnet_mask} must not be a network address") + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + # Get the state from the IPWiredNetworkInterface + state = super().describe_state() + + # Update the state with NIC-specific information + state.update({"wake_on_lan": self.wake_on_lan}) + + return state + + def receive_frame(self, frame: Frame) -> bool: + """ + Attempt to receive and process a network frame from the connected Link. + + This method processes a frame if the NIC is enabled. It checks the frame's destination and TTL, captures the + frame using PCAP, and forwards it to the connected Node if valid. Returns True if the frame is processed, + False otherwise (e.g., if the NIC is disabled, or TTL expired). + + :param frame: The network frame being received. + :return: True if the frame is processed and passed to the node, False otherwise. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info(f"Frame discarded at {self} as TTL limit reached") + return False + frame.set_received_timestamp() + self.pcap.capture_inbound(frame) + # If this destination or is broadcast + accept_frame = False + + # Check if it's a broadcast: + if frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + if frame.ip.dst_ip_address in {self.ip_address, self.ip_network.broadcast_address}: + accept_frame = True + else: + if frame.ethernet.dst_mac_addr == self.mac_address: + accept_frame = True + + if accept_frame: + super().receive_frame(frame) + self._connected_node.receive_frame(frame=frame, from_network_interface=self) + return True + return False + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number, MAC address and IP address of the NIC. + """ + return f"Port {self.port_name if self.port_name else self.port_num}: {self.mac_address}/{self.ip_address}" + + +class HostNode(Node): + """ + Represents a host node in the network. + + An end-user device within the network, such as a computer or server, equipped with the capability to initiate and + respond to network communications. + + A `HostNode` extends the base `Node` class by incorporating host-specific services and applications, thereby + simulating the functionalities typically expected from a networked end-user device. + + **Example**:: + + >>> pc_a = HostNode( + ... hostname="pc_a", + ... ip_address="192.168.1.10", + ... subnet_mask="255.255.255.0", + ... default_gateway="192.168.1.1" + ... ) + >>> pc_a.power_on() + + The host node comes pre-equipped with a range of core functionalities, services, and applications necessary + for engaging in various network operations and tasks. + + Core Functionality: + ------------------- + + * Packet Capture: Monitors and logs network traffic. + * Sys Log: Logs system events and errors. + + Services: + --------- + + * ARP (Address Resolution Protocol) Service: Resolves IP addresses to MAC addresses. + * ICMP (Internet Control Message Protocol) Service: Handles ICMP operations, such as ping requests. + * DNS (Domain Name System) Client: Resolves domain names to IP addresses. + * FTP (File Transfer Protocol) Client: Enables file transfers between the host and FTP servers. + * NTP (Network Time Protocol) Client: Synchronizes the system clock with NTP servers. + + Applications: + ------------ + + * Web Browser: Provides web browsing capabilities. + """ + + SYSTEM_SOFTWARE: ClassVar[Dict] = { + "HostARP": HostARP, + "ICMP": ICMP, + "DNSClient": DNSClient, + "NTPClient": NTPClient, + "WebBrowser": WebBrowser, + } + """List of system software that is automatically installed on nodes.""" + + network_interfaces: Dict[str, NIC] = {} + "The Network Interfaces on the node." + network_interface: Dict[int, NIC] = {} + "The NICs on the node by port id." + + def __init__(self, ip_address: IPV4Address, subnet_mask: IPV4Address, **kwargs): + super().__init__(**kwargs) + self.connect_nic(NIC(ip_address=ip_address, subnet_mask=subnet_mask)) + + @property + def arp(self) -> Optional[ARP]: + """ + Return the ARP Cache of the HostNode. + + :return: ARP Cache for given HostNode + :rtype: Optional[ARP] + """ + 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. + + This method is invoked to ensure the host node can communicate with its default gateway, primarily to confirm + network connectivity and populate the ARP cache with the gateway's MAC address. + """ + if self.operating_state == NodeOperatingState.ON and self.default_gateway: + self.software_manager.arp.get_default_gateway_mac_address() + + def receive_frame(self, frame: Frame, from_network_interface: NIC): + """ + Receive a Frame from the connected NIC and process it. + + Depending on the protocol, the frame is passed to the appropriate handler such as ARP or ICMP, or up to the + SessionManager if no code manager exists. + + :param frame: The Frame being received. + :param from_network_interface: The NIC that received the frame. + """ + super().receive_frame(frame, from_network_interface) + + # Check if the destination port is open on the Node + dst_port = None + if frame.tcp: + dst_port = frame.tcp.dst_port + elif frame.udp: + dst_port = frame.udp.dst_port + + accept_frame = False + if frame.icmp or dst_port in self.software_manager.get_open_ports(): + # accept the frame as the port is open or if it's an ICMP frame + accept_frame = True + + # TODO: add internal node firewall check here? + + if accept_frame: + self.session_manager.receive_frame(frame, from_network_interface) + else: + self.sys_log.info(f"Ignoring frame from {frame.ip.src_ip_address}") + # TODO: do we need to do anything more here? + pass diff --git a/src/primaite/simulator/network/hardware/nodes/host/server.py b/src/primaite/simulator/network/hardware/nodes/host/server.py new file mode 100644 index 00000000..593cd0dd --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/host/server.py @@ -0,0 +1,36 @@ +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode + + +class Server(HostNode): + """ + A basic Server class. + + Example: + >>> server_a = Server( + hostname="server_a", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + >>> server_a.power_on() + + Instances of Server come 'pre-packaged' with the following: + + * Core Functionality: + * Packet Capture + * Sys Log + * Services: + * ARP Service + * ICMP Service + * DNS Client + * FTP Client + * NTP Client + * Applications: + * Web Browser + """ + + +class Printer(HostNode): + """Printer? I don't even know her!.""" + + # TODO: Implement printer-specific behaviour diff --git a/src/primaite/simulator/network/hardware/nodes/network/__init__.py b/src/primaite/simulator/network/hardware/nodes/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/nodes/network/firewall.py b/src/primaite/simulator/network/hardware/nodes/network/firewall.py new file mode 100644 index 00000000..abdaf323 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/network/firewall.py @@ -0,0 +1,696 @@ +from ipaddress import IPv4Address +from typing import Dict, Final, Union + +from prettytable import MARKDOWN, PrettyTable +from pydantic import Field, validate_call + +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.router import ( + AccessControlList, + ACLAction, + Router, + RouterInterface, +) +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.core.sys_log import SysLog +from primaite.utils.validators import IPV4Address + +EXTERNAL_PORT_ID: Final[int] = 1 +"""The Firewall port ID of the external port.""" +INTERNAL_PORT_ID: Final[int] = 2 +"""The Firewall port ID of the internal port.""" +DMZ_PORT_ID: Final[int] = 3 +"""The Firewall port ID of the DMZ port.""" + + +class Firewall(Router): + """ + A Firewall class that extends the functionality of a Router. + + The Firewall class acts as a network security system that monitors and controls incoming and outgoing + network traffic based on predetermined security rules. It is an intermediary between internal and external + networks (including DMZ - De-Militarized Zone), ensuring that all inbound and outbound traffic complies with + the security policies. + + The Firewall employs Access Control Lists (ACLs) to filter traffic. Both the internal and DMZ ports have both + inbound and outbound ACLs that determine what traffic is allowed to pass. + + In addition to the security functions, the Firewall can also perform some routing functions similar to a Router, + forwarding packets between its interfaces based on the destination IP address. + + Usage: + To utilise the Firewall class, instantiate it with a hostname and optionally specify sys_log for logging. + Configure the internal, external, and DMZ ports with IP addresses and subnet masks. Define ACL rules to + permit or deny traffic based on your security policies. The Firewall will process frames based on these + rules, determining whether to allow or block traffic at each network interface. + + Example: + >>> from primaite.simulator.network.transmission.network_layer import IPProtocol + >>> from primaite.simulator.network.transmission.transport_layer import Port + >>> firewall = Firewall(hostname="Firewall1") + >>> firewall.configure_internal_port(ip_address="192.168.1.1", subnet_mask="255.255.255.0") + >>> firewall.configure_external_port(ip_address="10.0.0.1", subnet_mask="255.255.255.0") + >>> firewall.configure_dmz_port(ip_address="172.16.0.1", subnet_mask="255.255.255.0") + >>> # Permit HTTP traffic to the DMZ + >>> firewall.dmz_inbound_acl.add_rule( + ... action=ACLAction.PERMIT, + ... protocol=IPProtocol.TCP, + ... dst_port=Port.HTTP, + ... src_ip_address="0.0.0.0", + ... src_wildcard_mask="0.0.0.0", + ... dst_ip_address="172.16.0.0", + ... dst_wildcard_mask="0.0.0.255" + ... ) + + :ivar str hostname: The Firewall hostname. + """ + + internal_inbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="Internal Inbound", implicit_action=ACLAction.DENY) + ) + """Access Control List for managing entering the internal network.""" + + internal_outbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="Internal Outbound", implicit_action=ACLAction.DENY) + ) + """Access Control List for managing traffic leaving the internal network.""" + + dmz_inbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="DMZ Inbound", implicit_action=ACLAction.DENY) + ) + """Access Control List for managing traffic entering the DMZ.""" + + dmz_outbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="DMZ Outbound", implicit_action=ACLAction.DENY) + ) + """Access Control List for managing traffic leaving the DMZ.""" + + external_inbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="External Inbound", implicit_action=ACLAction.PERMIT) + ) + """Access Control List for managing traffic entering from an external network.""" + + external_outbound_acl: AccessControlList = Field( + default_factory=lambda: AccessControlList(name="External Outbound", implicit_action=ACLAction.PERMIT) + ) + """Access Control List for managing traffic leaving towards an external network.""" + + def __init__(self, hostname: str, **kwargs): + if not kwargs.get("sys_log"): + kwargs["sys_log"] = SysLog(hostname) + + super().__init__(hostname=hostname, num_ports=0, **kwargs) + + self.connect_nic( + RouterInterface(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0", port_name="external") + ) + self.connect_nic( + RouterInterface(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0", port_name="internal") + ) + self.connect_nic( + RouterInterface(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0", port_name="dmz") + ) + # Update ACL objects with firewall's hostname and syslog to allow accurate logging + self.internal_inbound_acl.sys_log = kwargs["sys_log"] + self.internal_inbound_acl.name = f"{hostname} - Internal Inbound" + + self.internal_outbound_acl.sys_log = kwargs["sys_log"] + self.internal_outbound_acl.name = f"{hostname} - Internal Outbound" + + self.dmz_inbound_acl.sys_log = kwargs["sys_log"] + self.dmz_inbound_acl.name = f"{hostname} - DMZ Inbound" + + self.dmz_outbound_acl.sys_log = kwargs["sys_log"] + self.dmz_outbound_acl.name = f"{hostname} - DMZ Outbound" + + self.external_inbound_acl.sys_log = kwargs["sys_log"] + self.external_inbound_acl.name = f"{hostname} - External Inbound" + + self.external_outbound_acl.sys_log = kwargs["sys_log"] + self.external_outbound_acl.name = f"{hostname} - External Outbound" + + 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() + self._internal_acl_request_manager = RequestManager() + rm.add_request("internal", RequestType(func=self._internal_acl_request_manager)) + + self._dmz_acl_request_manager = RequestManager() + rm.add_request("dmz", RequestType(func=self._dmz_acl_request_manager)) + + self._external_acl_request_manager = RequestManager() + rm.add_request("external", RequestType(func=self._external_acl_request_manager)) + + self._internal_inbound_acl_request_manager = RequestManager() + self._internal_outbound_acl_request_manager = RequestManager() + self._internal_acl_request_manager.add_request( + "inbound", RequestType(func=self._internal_inbound_acl_request_manager) + ) + self._internal_acl_request_manager.add_request( + "outbound", RequestType(func=self._internal_outbound_acl_request_manager) + ) + + self.dmz_inbound_acl_request_manager = RequestManager() + self.dmz_outbound_acl_request_manager = RequestManager() + self._dmz_acl_request_manager.add_request("inbound", RequestType(func=self.dmz_inbound_acl_request_manager)) + self._dmz_acl_request_manager.add_request("outbound", RequestType(func=self.dmz_outbound_acl_request_manager)) + + self.external_inbound_acl_request_manager = RequestManager() + self.external_outbound_acl_request_manager = RequestManager() + self._external_acl_request_manager.add_request( + "inbound", RequestType(func=self.external_inbound_acl_request_manager) + ) + self._external_acl_request_manager.add_request( + "outbound", RequestType(func=self.external_outbound_acl_request_manager) + ) + + self._internal_inbound_acl_request_manager.add_request( + "acl", RequestType(func=self.internal_inbound_acl._request_manager) + ) + self._internal_outbound_acl_request_manager.add_request( + "acl", RequestType(func=self.internal_outbound_acl._request_manager) + ) + + self.dmz_inbound_acl_request_manager.add_request("acl", RequestType(func=self.dmz_inbound_acl._request_manager)) + self.dmz_outbound_acl_request_manager.add_request( + "acl", RequestType(func=self.dmz_outbound_acl._request_manager) + ) + + self.external_inbound_acl_request_manager.add_request( + "acl", RequestType(func=self.external_inbound_acl._request_manager) + ) + self.external_outbound_acl_request_manager.add_request( + "acl", RequestType(func=self.external_outbound_acl._request_manager) + ) + + return rm + + def describe_state(self) -> Dict: + """ + Describes the current state of the Firewall. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + + state.update( + { + "internal_port": self.internal_port.describe_state(), + "external_port": self.external_port.describe_state(), + "dmz_port": self.dmz_port.describe_state(), + "internal_inbound_acl": self.internal_inbound_acl.describe_state(), + "internal_outbound_acl": self.internal_outbound_acl.describe_state(), + "dmz_inbound_acl": self.dmz_inbound_acl.describe_state(), + "dmz_outbound_acl": self.dmz_outbound_acl.describe_state(), + "external_inbound_acl": self.external_inbound_acl.describe_state(), + "external_outbound_acl": self.external_outbound_acl.describe_state(), + } + ) + + return state + + def show(self, markdown: bool = False): + """ + Displays the current configuration of the firewall's network interfaces in a table format. + + The table includes information about each port (External, Internal, DMZ), their MAC addresses, IP + configurations, link speeds, and operational status. The output can be formatted as Markdown if specified. + + :param markdown: If True, formats the output table in Markdown style. Useful for documentation or reporting + purposes within Markdown-compatible platforms. + """ + table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Network Interfaces" + ports = {"External": self.external_port, "Internal": self.internal_port, "DMZ": self.dmz_port} + for port, network_interface in ports.items(): + table.add_row( + [ + port, + network_interface.mac_address, + f"{network_interface.ip_address}/{network_interface.ip_network.prefixlen}", + network_interface.speed, + "Enabled" if network_interface.enabled else "Disabled", + ] + ) + print(table) + + def show_rules(self, external: bool = True, internal: bool = True, dmz: bool = True, markdown: bool = False): + """ + Prints the configured ACL rules for each specified network zone of the firewall. + + This method allows selective viewing of ACL rules applied to external, internal, and DMZ interfaces, providing + a clear overview of the firewall's current traffic filtering policies. Each section can be independently + toggled. + + :param external: If True, shows ACL rules for external interfaces. + :param internal: If True, shows ACL rules for internal interfaces. + :param dmz: If True, shows ACL rules for DMZ interfaces. + :param markdown: If True, formats the output in Markdown, enhancing readability in Markdown-compatible viewers. + """ + print(f"{self.hostname} Firewall Rules") + print() + if external: + self.external_inbound_acl.show(markdown) + print() + self.external_outbound_acl.show(markdown) + print() + if internal: + self.internal_inbound_acl.show(markdown) + print() + self.internal_outbound_acl.show(markdown) + print() + if dmz: + self.dmz_inbound_acl.show(markdown) + print() + self.dmz_outbound_acl.show(markdown) + print() + + def receive_frame(self, frame: Frame, from_network_interface: RouterInterface): + """ + Receive a frame and process it. + + Acts as the primary entry point for all network frames arriving at the Firewall, determining the flow of + traffic based on the source network interface controller (NIC) and applying the appropriate Access Control + List (ACL) rules. + + This method categorizes the incoming traffic into three main pathways based on the source NIC: external inbound, + internal outbound, and DMZ (De-Militarized Zone) outbound. It plays a crucial role in enforcing the firewall's + security policies by directing each frame to the corresponding processing method that evaluates it against + specific ACL rules. + + Based on the originating NIC: + - Frames from the external port are processed as external inbound traffic, potentially destined for either the + DMZ or the internal network. + - Frames from the internal port are treated as internal outbound traffic, aimed at reaching the external + network or a service within the DMZ. + - Frames from the DMZ port are handled as DMZ outbound traffic, with potential destinations including the + internal network or the external network. + + :param frame: The network frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. Used to + determine the direction of the traffic (inbound or outbound) and the zone (external, internal, + DMZ) it belongs to. + """ + # If the frame comes from the external port, it's considered as external inbound traffic + if from_network_interface == self.external_port: + self._process_external_inbound_frame(frame, from_network_interface) + return + # If the frame comes from the internal port, it's considered as internal outbound traffic + elif from_network_interface == self.internal_port: + self._process_internal_outbound_frame(frame, from_network_interface) + return + # If the frame comes from the DMZ port, it's considered as DMZ outbound traffic + elif from_network_interface == self.dmz_port: + self._process_dmz_outbound_frame(frame, from_network_interface) + return + + def _process_external_inbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames arriving from the external network. + + Determines the path for frames based on their destination IP addresses and ACL rules for the external inbound + interface. Frames destined for the DMZ or internal network are forwarded accordingly, if allowed by the ACL. + + If a frame is permitted by the ACL, it is either passed to the session manager (if applicable) or forwarded to + the appropriate network zone (DMZ/internal). Denied frames are logged and dropped. + + :param frame: The frame to be processed, containing network layer and transport layer information. + :param from_network_interface: The interface on the firewall through which the frame was received. + """ + # check if External Inbound ACL Rules permit frame + permitted, rule = self.external_inbound_acl.is_permitted(frame) + if not permitted: + self.sys_log.info(f"Frame blocked at external inbound by rule {rule}") + return + self.software_manager.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip_address, + mac_address=frame.ethernet.src_mac_addr, + network_interface=from_network_interface, + ) + + if self.check_send_frame_to_session_manager(frame): + # Port is open on this Router so pass Frame up to session manager first + self.session_manager.receive_frame(frame, from_network_interface) + else: + # If the destination IP is within the DMZ network, process the frame as DMZ inbound + if frame.ip.dst_ip_address in self.dmz_port.ip_network: + self._process_dmz_inbound_frame(frame, from_network_interface) + else: + # Otherwise, process the frame as internal inbound + self._process_internal_inbound_frame(frame, from_network_interface) + + def _process_external_outbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames that are outbound towards the external network. + + :param frame: The frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. + :param re_attempt: Indicates if the processing is a re-attempt, defaults to False. + """ + # check if External Outbound ACL Rules permit frame + permitted, rule = self.external_outbound_acl.is_permitted(frame=frame) + if not permitted: + self.sys_log.info(f"Frame blocked at external outbound by rule {rule}") + return + + self.process_frame(frame=frame, from_network_interface=from_network_interface) + + def _process_internal_inbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames that are inbound towards the internal LAN. + + This method is responsible for handling frames coming from either the external network or the DMZ towards + the internal LAN. It checks the frames against the internal inbound ACL to decide whether to allow or deny + the traffic, and take appropriate actions. + + :param frame: The frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. + :param re_attempt: Indicates if the processing is a re-attempt, defaults to False. + """ + # check if Internal Inbound ACL Rules permit frame + permitted, rule = self.internal_inbound_acl.is_permitted(frame=frame) + if not permitted: + self.sys_log.info(f"Frame blocked at internal inbound by rule {rule}") + return + + self.process_frame(frame=frame, from_network_interface=from_network_interface) + + def _process_internal_outbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames that are outbound from the internal network. + + This method handles frames that are leaving the internal network. Depending on the destination IP address, + the frame may be forwarded to the DMZ or to the external network. + + :param frame: The frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. + :param re_attempt: Indicates if the processing is a re-attempt, defaults to False. + """ + permitted, rule = self.internal_outbound_acl.is_permitted(frame) + if not permitted: + self.sys_log.info(f"Frame blocked at internal outbound by rule {rule}") + return + self.software_manager.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip_address, + mac_address=frame.ethernet.src_mac_addr, + network_interface=from_network_interface, + ) + + if self.check_send_frame_to_session_manager(frame): + # Port is open on this Router so pass Frame up to session manager first + self.session_manager.receive_frame(frame, from_network_interface) + else: + # If the destination IP is within the DMZ network, process the frame as DMZ inbound + if frame.ip.dst_ip_address in self.dmz_port.ip_network: + self._process_dmz_inbound_frame(frame, from_network_interface) + else: + # If the destination IP is not within the DMZ network, process the frame as external outbound + self._process_external_outbound_frame(frame, from_network_interface) + + def _process_dmz_inbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames that are inbound from the DMZ. + + This method is responsible for handling frames coming from either the external network or the internal LAN + towards the DMZ. It checks the frames against the DMZ inbound ACL to decide whether to allow or deny the + traffic, and take appropriate actions. + + :param frame: The frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. + :param re_attempt: Indicates if the processing is a re-attempt, defaults to False. + """ + # check if DMZ Inbound ACL Rules permit frame + permitted, rule = self.dmz_inbound_acl.is_permitted(frame=frame) + if not permitted: + self.sys_log.info(f"Frame blocked at DMZ inbound by rule {rule}") + return + + self.process_frame(frame=frame, from_network_interface=from_network_interface) + + def _process_dmz_outbound_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Process frames that are outbound from the DMZ. + + This method handles frames originating from the DMZ and determines their appropriate path based on the + destination IP address. It involves checking the DMZ outbound ACL, consulting the ARP cache and the routing + table to find the correct outbound NIC, and then forwarding the frame to either the internal network or the + external network. + + :param frame: The frame to be processed. + :param from_network_interface: The network interface controller from which the frame is coming. + :param re_attempt: Indicates if the processing is a re-attempt, defaults to False. + """ + permitted, rule = self.dmz_outbound_acl.is_permitted(frame) + if not permitted: + self.sys_log.info(f"Frame blocked at DMZ outbound by rule {rule}") + return + self.software_manager.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip_address, + mac_address=frame.ethernet.src_mac_addr, + network_interface=from_network_interface, + ) + + if self.check_send_frame_to_session_manager(frame): + # Port is open on this Router so pass Frame up to session manager first + self.session_manager.receive_frame(frame, from_network_interface) + else: + # Attempt to get the outbound NIC from the ARP cache using the destination IP address + outbound_nic = self.software_manager.arp.get_arp_cache_network_interface(frame.ip.dst_ip_address) + + # If outbound NIC is not found in the ARP cache, consult the routing table to find the best route + if not outbound_nic: + route = self.route_table.find_best_route(frame.ip.dst_ip_address) + if route: + # If a route is found, get the corresponding outbound NIC from the ARP cache using the next-hop IP + # address + outbound_nic = self.software_manager.arp.get_arp_cache_network_interface(route.next_hop_ip_address) + + # If an outbound NIC is determined + if outbound_nic: + if outbound_nic == self.external_port: + # If the outbound NIC is the external port, check the frame against the DMZ outbound ACL and + # process it as an external outbound frame + self._process_external_outbound_frame(frame, from_network_interface) + return + elif outbound_nic == self.internal_port: + # If the outbound NIC is the internal port, check the frame against the DMZ outbound ACL and + # process it as an internal inbound frame + self._process_internal_inbound_frame(frame, from_network_interface) + return + # TODO: What to do here? Destination unreachable? Send ICMP back? + return + + @property + def external_port(self) -> RouterInterface: + """ + The external port of the firewall. + + :return: The external port connecting the firewall to the external network. + """ + return self.network_interface[EXTERNAL_PORT_ID] + + @validate_call() + def configure_external_port(self, ip_address: Union[IPV4Address, str], subnet_mask: Union[IPV4Address, str]): + """ + Configure the external port with an IP address and a subnet mask. + + Enables the port once configured. + + :param ip_address: The IP address to assign to the external port. + :param subnet_mask: The subnet mask to assign to the external port. + """ + # Configure the external port with the specified IP address and subnet mask + self.configure_port(EXTERNAL_PORT_ID, ip_address, subnet_mask) + self.external_port.enable() + + @property + def internal_port(self) -> RouterInterface: + """ + The internal port of the firewall. + + :return: The external port connecting the firewall to the internal LAN. + """ + return self.network_interface[INTERNAL_PORT_ID] + + @validate_call() + def configure_internal_port(self, ip_address: Union[IPV4Address, str], subnet_mask: Union[IPV4Address, str]): + """ + Configure the internal port with an IP address and a subnet mask. + + Enables the port once configured. + + :param ip_address: The IP address to assign to the internal port. + :param subnet_mask: The subnet mask to assign to the internal port. + """ + self.configure_port(INTERNAL_PORT_ID, ip_address, subnet_mask) + self.internal_port.enable() + + @property + def dmz_port(self) -> RouterInterface: + """ + The DMZ port of the firewall. + + :return: The external port connecting the firewall to the DMZ. + """ + return self.network_interface[DMZ_PORT_ID] + + @validate_call() + def configure_dmz_port(self, ip_address: Union[IPV4Address, str], subnet_mask: Union[IPV4Address, str]): + """ + Configure the DMZ port with an IP address and a subnet mask. + + Enables the port once configured. + + :param ip_address: The IP address to assign to the DMZ port. + :param subnet_mask: The subnet mask to assign to the DMZ port. + """ + self.configure_port(DMZ_PORT_ID, ip_address, subnet_mask) + self.dmz_port.enable() + + @classmethod + def from_config(cls, cfg: dict) -> "Firewall": + """Create a firewall based on a config dict.""" + firewall = Firewall( + hostname=cfg["hostname"], + operating_state=NodeOperatingState.ON + if not (p := cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + if "ports" in cfg: + internal_port = cfg["ports"]["internal_port"] + external_port = cfg["ports"]["external_port"] + dmz_port = cfg["ports"].get("dmz_port") + + # configure internal port + firewall.configure_internal_port( + ip_address=IPV4Address(internal_port.get("ip_address")), + subnet_mask=IPV4Address(internal_port.get("subnet_mask", "255.255.255.0")), + ) + + # configure external port + firewall.configure_external_port( + ip_address=IPV4Address(external_port.get("ip_address")), + subnet_mask=IPV4Address(external_port.get("subnet_mask", "255.255.255.0")), + ) + + # configure dmz port if not none + if dmz_port is not None: + firewall.configure_dmz_port( + ip_address=IPV4Address(dmz_port.get("ip_address")), + subnet_mask=IPV4Address(dmz_port.get("subnet_mask", "255.255.255.0")), + ) + if "acl" in cfg: + # acl rules for internal_inbound_acl + if cfg["acl"]["internal_inbound_acl"]: + for r_num, r_cfg in cfg["acl"]["internal_inbound_acl"].items(): + firewall.internal_inbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + # acl rules for internal_outbound_acl + if cfg["acl"]["internal_outbound_acl"]: + for r_num, r_cfg in cfg["acl"]["internal_outbound_acl"].items(): + firewall.internal_outbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + # acl rules for dmz_inbound_acl + if cfg["acl"]["dmz_inbound_acl"]: + for r_num, r_cfg in cfg["acl"]["dmz_inbound_acl"].items(): + firewall.dmz_inbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + # acl rules for dmz_outbound_acl + if cfg["acl"]["dmz_outbound_acl"]: + for r_num, r_cfg in cfg["acl"]["dmz_outbound_acl"].items(): + firewall.dmz_outbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + # acl rules for external_inbound_acl + if cfg["acl"].get("external_inbound_acl"): + for r_num, r_cfg in cfg["acl"]["external_inbound_acl"].items(): + firewall.external_inbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + # acl rules for external_outbound_acl + if cfg["acl"].get("external_outbound_acl"): + for r_num, r_cfg in cfg["acl"]["external_outbound_acl"].items(): + firewall.external_outbound_acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + + if "routes" in cfg: + for route in cfg.get("routes"): + firewall.route_table.add_route( + address=IPv4Address(route.get("address")), + subnet_mask=IPv4Address(route.get("subnet_mask", "255.255.255.0")), + next_hop_ip_address=IPv4Address(route.get("next_hop_ip_address")), + metric=float(route.get("metric", 0)), + ) + if "default_route" in cfg: + next_hop_ip_address = cfg["default_route"].get("next_hop_ip_address", None) + if next_hop_ip_address: + firewall.route_table.set_default_route_next_hop_ip_address(next_hop_ip_address) + + return firewall diff --git a/src/primaite/simulator/network/hardware/nodes/network/network_node.py b/src/primaite/simulator/network/hardware/nodes/network/network_node.py new file mode 100644 index 00000000..0474ca08 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/network/network_node.py @@ -0,0 +1,42 @@ +from abc import abstractmethod +from typing import Optional + +from primaite.simulator.network.hardware.base import NetworkInterface, Node +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.system.services.arp.arp import ARP + + +class NetworkNode(Node): + """ + Represents an abstract base class for a network node that can receive and process network frames. + + This class provides a common interface for network nodes such as routers and switches, defining the essential + behavior that allows these devices to handle incoming network traffic. Implementations of this class must + provide functionality for receiving and processing frames received on their network interfaces. + """ + + @abstractmethod + def receive_frame(self, frame: Frame, from_network_interface: NetworkInterface): + """ + Abstract method that must be implemented by subclasses to define how to receive and process frames. + + This method is called when a frame is received by a network interface belonging to this node. Subclasses + should implement the logic to process the frame, including examining its contents, making forwarding decisions, + or performing any necessary actions based on the frame's protocol and destination. + + :param frame: The network frame that has been received. + :type frame: Frame + :param from_network_interface: The network interface on which the frame was received. + :type from_network_interface: NetworkInterface + """ + pass + + @property + def arp(self) -> Optional[ARP]: + """ + Return the ARP Cache of the NetworkNode. + + :return: ARP Cache for given NetworkNode + :rtype: Optional[ARP] + """ + return self.software_manager.software.get("ARP") diff --git a/src/primaite/simulator/network/hardware/nodes/network/router.py b/src/primaite/simulator/network/hardware/nodes/network/router.py new file mode 100644 index 00000000..53bb4827 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/network/router.py @@ -0,0 +1,1645 @@ +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 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.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.network_node import NetworkNode +from primaite.simulator.network.protocols.arp import ARPPacket +from primaite.simulator.network.protocols.icmp import ICMPPacket, ICMPType +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.core.session_manager import SessionManager +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.services.arp.arp import ARP +from primaite.simulator.system.services.icmp.icmp import ICMP +from primaite.utils.validators import IPV4Address + + +@validate_call() +def ip_matches_masked_range(ip_to_check: IPV4Address, base_ip: IPV4Address, wildcard_mask: IPV4Address) -> bool: + """ + Determine if a given IP address matches a range defined by a base IP address and a wildcard mask. + + The wildcard mask specifies which bits in the IP address should be ignored (1) and which bits must match (0). + + The function applies the wildcard mask to both the base IP and the IP address to check by first negating the + wildcard mask and then performing a bitwise AND operation. This process effectively masks out the bits indicated + by the wildcard mask. If the resulting masked IP addresses are equal, it means the IP address to check falls within + the range defined by the base IP and wildcard mask. + + :param IPV4Address ip_to_check: The IP address to be checked. + :param IPV4Address base_ip: The base IP address defining the start of the range. + :param IPV4Address wildcard_mask: The wildcard mask specifying which bits to ignore. + :return: A boolean value indicating whether the IP address matches the masked range. + :rtype: bool + + Example usage: + >>> ip_matches_masked_range(ip_to_check="192.168.10.10", base_ip="192.168.1.1", wildcard_mask="0.0.255.255") + False + """ + # Convert the IP addresses from IPv4Address objects to integer representations for bitwise operations + base_ip_int = int(base_ip) + ip_to_check_int = int(ip_to_check) + wildcard_int = int(wildcard_mask) + + # Negate the wildcard mask and apply it to both the base IP and the IP to check using bitwise AND + # This step masks out the bits to be ignored according to the wildcard mask + masked_base_ip = base_ip_int & ~wildcard_int + masked_ip_to_check = ip_to_check_int & ~wildcard_int + + # Compare the masked IP addresses to determine if they match within the masked range + return masked_base_ip == masked_ip_to_check + + +class ACLAction(Enum): + """Enum for defining the ACL action types.""" + + PERMIT = 1 + DENY = 2 + + +class ACLRule(SimComponent): + """ + Represents an Access Control List (ACL) rule within a network device. + + Enables fine-grained control over network traffic based on specified criteria such as IP addresses, protocols, + and ports. ACL rules can be configured to permit or deny traffic, providing a powerful mechanism for enforcing + security policies and traffic flow. + + ACL rules support specifying exact match conditions, ranges of IP addresses using wildcard masks, and + protocol types. This flexibility allows for complex traffic filtering scenarios, from blocking or allowing + specific types of traffic to entire subnets. + + **Usage:** + + - **Dedicated IP Addresses**: To match traffic from or to a specific IP address, set the `src_ip_address` + and/or `dst_ip_address` without a wildcard mask. This is useful for rules that apply to individual hosts. + + - **IP Ranges with Wildcard Masks**: For rules that apply to a range of IP addresses, use the `src_wildcard_mask` + and/or `dst_wildcard_mask` in conjunction with the base IP address. Wildcard masks are a way to specify which + bits of the IP address should be matched exactly and which bits can vary. For example, a wildcard mask of + `0.0.0.255` applied to a base address of `192.168.1.0` allows for any address from `192.168.1.0` to + `192.168.1.255`. + + - **Allowing All IP Traffic**: To mimic the Cisco ACL rule that permits all IP traffic from a specific range, + you may use wildcard masks with the rule action set to `PERMIT`. If your implementation includes an `ALL` + option in the `IPProtocol` enum, use it to allow all protocols; otherwise, consider the rule without a + specified protocol to apply to all IP traffic. + + + The combination of these attributes allows for the creation of granular rules to control traffic flow + effectively, enhancing network security and management. + + + :ivar ACLAction action: Specifies whether to `PERMIT` or `DENY` the traffic that matches the rule conditions. + The default action is `DENY`. + :ivar Optional[IPProtocol] protocol: The network protocol (e.g., TCP, UDP, ICMP) to match. If `None`, the rule + applies to all protocols. + :ivar Optional[IPV4Address] src_ip_address: The source IP address to match. If combined with `src_wildcard_mask`, + it specifies the start of an IP range. + :ivar Optional[IPV4Address] src_wildcard_mask: The wildcard mask for the source IP address, defining the range + of addresses to match. + :ivar Optional[IPV4Address] dst_ip_address: The destination IP address to match. If combined with + `dst_wildcard_mask`, it specifies the start of an IP range. + :ivar Optional[IPv4Address] dst_wildcard_mask: The wildcard mask for the destination IP address, defining the + range of addresses to match. + :ivar Optional[Port] src_port: The source port number to match. Relevant for TCP/UDP protocols. + :ivar Optional[Port] dst_port: The destination port number to match. Relevant for TCP/UDP protocols. + """ + + action: ACLAction = ACLAction.DENY + protocol: Optional[IPProtocol] = None + src_ip_address: Optional[IPV4Address] = None + src_wildcard_mask: Optional[IPV4Address] = None + dst_ip_address: Optional[IPV4Address] = None + dst_wildcard_mask: Optional[IPV4Address] = None + src_port: Optional[Port] = None + dst_port: Optional[Port] = None + match_count: int = 0 + + def __str__(self) -> str: + rule_strings = [] + for key, value in self.model_dump(exclude={"uuid", "request_manager"}).items(): + if value is None: + value = "ANY" + if isinstance(value, Enum): + rule_strings.append(f"{key}={value.name}") + else: + rule_strings.append(f"{key}={value}") + return ", ".join(rule_strings) + + def describe_state(self) -> Dict: + """ + Describes the current state of the ACLRule. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + state["action"] = self.action.value + state["protocol"] = self.protocol.name if self.protocol else None + state["src_ip_address"] = str(self.src_ip_address) if self.src_ip_address else None + state["src_wildcard_mask"] = str(self.src_wildcard_mask) if self.src_wildcard_mask else None + state["src_port"] = self.src_port.name if self.src_port else None + state["dst_ip_address"] = str(self.dst_ip_address) if self.dst_ip_address else None + state["dst_wildcard_mask"] = str(self.dst_wildcard_mask) if self.dst_wildcard_mask else None + state["dst_port"] = self.dst_port.name if self.dst_port else None + state["match_count"] = self.match_count + return state + + def permit_frame_check(self, frame: Frame) -> Tuple[bool, bool]: + """ + Evaluates whether a given network frame should be permitted or denied based on this ACL rule. + + This method checks the frame against the ACL rule's criteria, including protocol, source and destination IP + addresses (with support for wildcard masking), and source and destination ports. The method assumes that an + unspecified (None) criterion implies a match for any value in that category. For IP addresses, wildcard masking + can be used to specify ranges of addresses that match the rule. + + The method follows these steps to determine if a frame is permitted: + + 1. Check if the frame's protocol matches the ACL rule's protocol. + 2. For source and destination IP addresses: + 1. If a wildcard mask is defined, check if the frame's IP address is within the range specified by the base + IP address and the wildcard mask. + 2. If no wildcard mask is defined, directly compare the frame's IP address to the one specified in the rule. + 3. Check if the frame's source and destination ports match those specified in the rule. + 4. The frame is permitted if it matches all specified criteria and the rule's action is PERMIT. Conversely, it + is not permitted if any criterion does not match or if the rule's action is DENY. + + :param frame: The network frame to be evaluated. + :return: A tuple containing two boolean values: The first indicates if the frame is permitted by this rule ( + True if permitted, otherwise False). The second indicates if the frame matches the rule's criteria (True + if it matches, otherwise False). + """ + permitted = False + frame_matches_rule = False + protocol_matches = self.protocol == frame.ip.protocol if self.protocol else True + + src_ip_matches = self.src_ip_address is None # Assume match if no specific src IP is defined + if self.src_ip_address: + if self.src_wildcard_mask: + # If a src wildcard mask is provided, use it to check the range + src_ip_matches = ip_matches_masked_range( + ip_to_check=frame.ip.src_ip_address, + base_ip=self.src_ip_address, + wildcard_mask=self.src_wildcard_mask, + ) + else: + # Direct comparison if no wildcard mask is defined + src_ip_matches = frame.ip.src_ip_address == self.src_ip_address + + dst_ip_matches = self.dst_ip_address is None # Assume match if no specific dst IP is defined + if self.dst_ip_address: + if self.dst_wildcard_mask: + # If a dst wildcard mask is provided, use it to check the range + dst_ip_matches = ip_matches_masked_range( + ip_to_check=frame.ip.dst_ip_address, + base_ip=self.dst_ip_address, + wildcard_mask=self.dst_wildcard_mask, + ) + else: + # Direct comparison if no wildcard mask is defined + dst_ip_matches = frame.ip.dst_ip_address == self.dst_ip_address + + src_port = None + dst_port = None + if frame.tcp: + src_port = frame.tcp.src_port + dst_port = frame.tcp.dst_port + elif frame.udp: + src_port = frame.udp.src_port + dst_port = frame.udp.dst_port + + src_port_matches = self.src_port == src_port if self.src_port else True + dst_port_matches = self.dst_port == dst_port if self.dst_port else True + + # The frame is permitted if all conditions are met + if protocol_matches and src_ip_matches and dst_ip_matches and src_port_matches and dst_port_matches: + frame_matches_rule = True + permitted = self.action == ACLAction.PERMIT + + return permitted, frame_matches_rule + + +class AccessControlList(SimComponent): + """ + Manages a list of ACLRules to filter network traffic. + + Manages a list of ACLRule instances to filter network traffic based on predefined criteria. This class + provides functionalities to add, remove, and evaluate ACL rules, thereby controlling the flow of traffic + through a network device. + + ACL rules can specify conditions based on source and destination IP addresses, IP protocols (TCP, UDP, ICMP), + and port numbers. Rules can be configured to permit or deny traffic that matches these conditions, offering + granular control over network security policies. + + Usage: + - **Dedicated IP Addresses**: Directly specify the source and/or destination IP addresses in an ACL rule to + match traffic to or from specific hosts. + - **IP Ranges with Wildcard Masks**: Use wildcard masks along with base IP addresses to define ranges of IP + addresses that an ACL rule applies to. This is useful for specifying subnets or ranges of IP addresses. + - **Allowing All IP Traffic**: To mimic a Cisco-style ACL rule that allows all IP traffic from a specified + range, use the wildcard mask in conjunction with a permit action. If your system supports an `ALL` option + for the IP protocol, this can be used to allow all types of IP traffic; otherwise, the absence of a + specified protocol can be interpreted to mean all protocols. + + Methods include functionalities to add and remove rules, reset to default configurations, and evaluate + whether specific frames are permitted or denied based on the current set of rules. The class also provides + utility functions to describe the current state and display the rules in a human-readable format. + + Example: + >>> # To add a rule that permits all TCP traffic from the subnet 192.168.1.0/24 to 192.168.2.0/24: + >>> acl = AccessControlList() + >>> acl.add_rule( + ... action=ACLAction.PERMIT, + ... protocol=IPProtocol.TCP, + ... src_ip_address="192.168.1.0", + ... src_wildcard_mask="0.0.0.255", + ... dst_ip_address="192.168.2.0", + ... dst_wildcard_mask="0.0.0.255" + ...) + + This example demonstrates adding a rule with specific source and destination IP ranges, using wildcard masks + to allow a broad range of traffic while maintaining control over the flow of data for security and + management purposes. + + :ivar ACLAction implicit_action: The default action (permit or deny) applied when no other rule matches. + Typically set to deny to follow the principle of least privilege. + :ivar int max_acl_rules: The maximum number of ACL rules that can be added to the list. Defaults to 25. + """ + + sys_log: Optional[SysLog] = None + implicit_action: ACLAction + implicit_rule: ACLRule + max_acl_rules: int = 25 + name: str + _acl: List[Optional[ACLRule]] = [None] * 24 + _default_config: Dict[int, dict] = {} + """Config dict describing how the ACL list should look at episode start""" + + def __init__(self, **kwargs) -> None: + if not kwargs.get("implicit_action"): + kwargs["implicit_action"] = ACLAction.DENY + + kwargs["implicit_rule"] = ACLRule(action=kwargs["implicit_action"]) + + super().__init__(**kwargs) + self._acl = [None] * (self.max_acl_rules - 1) + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + # TODO: Add src and dst wildcard masks as positional args in this request. + rm = super()._init_request_manager() + + # When the request reaches this action, it should now contain solely positional args for the 'add_rule' action. + # POSITIONAL ARGUMENTS: + # 0: action (str name of an ACLAction) + # 1: protocol (str name of an IPProtocol) + # 2: source ip address (str castable to IPV4Address (e.g. '10.10.1.2')) + # 3: source port (str name of a Port (e.g. "HTTP")) # should we be using value, such as 80 or 443? + # 4: destination ip address (str castable to IPV4Address (e.g. '10.10.1.2')) + # 5: destination port (str name of a Port (e.g. "HTTP")) + # 6: position (int) + rm.add_request( + "add_rule", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.add_rule( + action=ACLAction[request[0]], + protocol=None if request[1] == "ALL" else IPProtocol[request[1]], + src_ip_address=None if request[2] == "ALL" else IPv4Address(request[2]), + src_wildcard_mask=None if request[3] == "NONE" else IPv4Address(request[3]), + src_port=None if request[4] == "ALL" else Port[request[4]], + dst_ip_address=None if request[5] == "ALL" else IPv4Address(request[5]), + dst_wildcard_mask=None if request[6] == "NONE" else IPv4Address(request[6]), + dst_port=None if request[7] == "ALL" else Port[request[7]], + position=int(request[8]), + ) + ) + ), + ) + + rm.add_request( + "remove_rule", + RequestType(func=lambda request, context: RequestResponse.from_bool(self.remove_rule(int(request[0])))), + ) + return rm + + def describe_state(self) -> Dict: + """ + Describes the current state of the AccessControlList. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + state["implicit_action"] = self.implicit_action.value + state["implicit_rule"] = self.implicit_rule.describe_state() + state["max_acl_rules"] = self.max_acl_rules + state["acl"] = {i: r.describe_state() if isinstance(r, ACLRule) else None for i, r in enumerate(self._acl)} + return state + + @property + def acl(self) -> List[Optional[ACLRule]]: + """ + Get the list of ACL rules. + + :return: The list of ACL rules. + """ + return self._acl + + @property + def num_rules(self) -> int: + """ + Get the number of rules in the ACL. + + :return: The number of rules in the ACL. + """ + return len([rule for rule in self._acl if rule is not None]) + + @validate_call() + def add_rule( + self, + action: ACLAction = ACLAction.DENY, + protocol: Optional[IPProtocol] = None, + src_ip_address: Optional[IPV4Address] = None, + src_wildcard_mask: Optional[IPV4Address] = None, + dst_ip_address: Optional[IPV4Address] = None, + dst_wildcard_mask: Optional[IPV4Address] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + position: int = 0, + ) -> bool: + """ + Adds a new ACL rule to control network traffic based on specified criteria. + + This method allows defining rules that specify whether to permit or deny traffic with particular + characteristics, including source and destination IP addresses, ports, and protocols. Wildcard masks can be + used to specify a range of IP addresses, allowing for broader rule application. If specifying a dedicated IP + address without needing a range, the wildcard mask can be omitted. + + Example: + >>> # To block all traffic except SSH from a specific IP range to a server: + >>> router = Router("router") + >>> router.add_rule( + ... action=ACLAction.DENY, + ... protocol=IPProtocol.TCP, + ... src_ip_address="192.168.1.0", + ... src_wildcard_mask="0.0.0.255", + ... dst_ip_address="10.10.10.5", + ... dst_port=Port.SSH, + ... position=5 + ... ) + >>> # This permits SSH traffic from the 192.168.1.0/24 subnet to the 10.10.10.5 server. + >>> + >>> # Then if we want to allow a specific IP address from this subnet to SSH into the server + >>> router.add_rule( + ... action=ACLAction.PERMIT, + ... protocol=IPProtocol.TCP, + ... src_ip_address="192.168.1.25", + ... dst_ip_address="10.10.10.5", + ... dst_port=Port.SSH, + ... position=4 + ... ) + + :param action: The action to take (Permit/Deny) when the rule matches traffic. + :param protocol: The network protocol (TCP/UDP/ICMP) to match. If None, matches any protocol. + :param src_ip_address: The source IP address to match. If None, matches any source IP. + :param src_wildcard_mask: Specifies a wildcard mask for the source IP. Use for IP ranges. + :param dst_ip_address: The destination IP address to match. If None, matches any destination IP. + :param dst_wildcard_mask: Specifies a wildcard mask for the destination IP. Use for IP ranges. + :param src_port: The source port to match. If None, matches any source port. + :param dst_port: The destination port to match. If None, matches any destination port. + :param int position: The position in the ACL list to insert this rule. Defaults is position 0 right at the top. + :raises ValueError: If the position is out of bounds. + """ + if 0 <= position < self.max_acl_rules: + if self._acl[position]: + self.sys_log.info(f"Overwriting ACL rule at position {position}") + self._acl[position] = ACLRule( + action=action, + src_ip_address=src_ip_address, + src_wildcard_mask=src_wildcard_mask, + dst_ip_address=dst_ip_address, + dst_wildcard_mask=dst_wildcard_mask, + protocol=protocol, + src_port=src_port, + dst_port=dst_port, + ) + return True + else: + raise ValueError(f"Cannot add ACL rule, position {position} is out of bounds.") + return False + + def remove_rule(self, position: int) -> bool: + """ + Remove an ACL rule from a specific position. + + :param int position: The position of the rule to be removed. + :raises ValueError: When the position is out of bounds. + """ + if 0 <= position < self.max_acl_rules - 1: + rule = self._acl[position] # noqa + self._acl[position] = None + del rule + return True + else: + raise ValueError(f"Cannot remove ACL rule, position {position} is out of bounds.") + return False + + def is_permitted(self, frame: Frame) -> Tuple[bool, ACLRule]: + """Check if a packet with the given properties is permitted through the ACL.""" + permitted = False + rule: ACLRule = None + for _rule in self._acl: + if not _rule: + continue + + permitted, rule_match = _rule.permit_frame_check(frame) + if rule_match: + rule = _rule + break + if not rule: + permitted = self.implicit_action == ACLAction.PERMIT + rule = self.implicit_rule + + rule.match_count += 1 + + return permitted, rule + + def get_relevant_rules( + self, + protocol: IPProtocol, + src_ip_address: Union[str, IPv4Address], + src_port: Port, + dst_ip_address: Union[str, IPv4Address], + dst_port: Port, + ) -> List[ACLRule]: + """ + Get the list of relevant rules for a packet with given properties. + + :param protocol: The protocol of the packet. + :param src_ip_address: Source IP address of the packet. Accepts string and IPv4Address. + :param src_port: Source port of the packet. + :param dst_ip_address: Destination IP address of the packet. Accepts string and IPv4Address. + :param dst_port: Destination port of the packet. + :return: A list of relevant ACLRules. + """ + if not isinstance(src_ip_address, IPv4Address): + src_ip_address = IPv4Address(src_ip_address) + if not isinstance(dst_ip_address, IPv4Address): + dst_ip_address = IPv4Address(dst_ip_address) + relevant_rules = [] + for rule in self._acl: + if rule is None: + continue + + if ( + (rule.src_ip_address == src_ip_address or rule.src_ip_address is None) + or (rule.dst_ip_address == dst_ip_address or rule.dst_ip_address is None) + or (rule.protocol == protocol or rule.protocol is None) + or (rule.src_port == src_port or rule.src_port is None) + or (rule.dst_port == dst_port or rule.dst_port is None) + ): + relevant_rules.append(rule) + + return relevant_rules + + def show(self, markdown: bool = False): + """ + Display the current ACL rules as a table. + + :param markdown: Whether to display the table in Markdown format. Defaults to False. + """ + table = PrettyTable( + [ + "Index", + "Action", + "Protocol", + "Src IP", + "Src Wildcard", + "Src Port", + "Dst IP", + "Dst Wildcard", + "Dst Port", + "Matched", + ] + ) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + + table.title = f"{self.name} Access Control List" + for index, rule in enumerate(self.acl + [self.implicit_rule]): + if rule: + table.add_row( + [ + index, + rule.action.name if rule.action else "ANY", + rule.protocol.name if rule.protocol else "ANY", + rule.src_ip_address if rule.src_ip_address else "ANY", + rule.src_wildcard_mask if rule.src_wildcard_mask else "ANY", + f"{rule.src_port.value} ({rule.src_port.name})" if rule.src_port else "ANY", + rule.dst_ip_address if rule.dst_ip_address else "ANY", + rule.dst_wildcard_mask if rule.dst_wildcard_mask else "ANY", + f"{rule.dst_port.value} ({rule.dst_port.name})" if rule.dst_port else "ANY", + rule.match_count, + ] + ) + print(table) + + +class RouteEntry(SimComponent): + """ + Represents a single entry in a routing table. + + :ivar address: The destination IP address or network address. + :ivar subnet_mask: The subnet mask for the network. + :ivar next_hop_ip_address: The next hop IP address to which packets should be forwarded. + :ivar metric: The cost metric for this route. Default is 0.0. + + Example: + >>> entry = RouteEntry( + ... IPv4Address("192.168.1.0"), + ... IPv4Address("255.255.255.0"), + ... IPv4Address("192.168.2.1"), + ... metric=5 + ... ) + """ + + address: IPv4Address + "The destination IP address or network address." + subnet_mask: IPv4Address + "The subnet mask for the network." + next_hop_ip_address: IPv4Address + "The next hop IP address to which packets should be forwarded." + metric: float = 0.0 + "The cost metric for this route. Default is 0.0." + + def describe_state(self) -> Dict: + """ + Describes the current state of the RouteEntry. + + :return: A dictionary representing the current state. + """ + pass + + +class RouteTable(SimComponent): + """ + Represents a routing table holding multiple route entries. + + :ivar List[RouteEntry] routes: A list of RouteEntry objects. + + Example: + >>> rt = RouteTable() + >>> rt.add_route( + ... RouteEntry( + ... IPv4Address("192.168.1.0"), + ... IPv4Address("255.255.255.0"), + ... IPv4Address("192.168.2.1"), + ... metric=5 + ... ) + ... ) + >>> best_route = rt.find_best_route(IPv4Address("192.168.1.34")) + """ + + routes: List[RouteEntry] = [] + default_route: Optional[RouteEntry] = None + sys_log: SysLog + + def describe_state(self) -> Dict: + """ + Describes the current state of the RouteTable. + + :return: A dictionary representing the current state. + """ + pass + + @validate_call() + def add_route( + self, + address: Union[IPV4Address, str], + subnet_mask: Union[IPV4Address, str], + next_hop_ip_address: Union[IPV4Address, str], + metric: float = 0.0, + ): + """ + Add a route to the routing table. + + :param address: The destination address of the route. + :param subnet_mask: The subnet mask of the route. + :param next_hop_ip_address: The next hop IP for the route. + :param metric: The metric of the route, default is 0.0. + """ + for key in {address, subnet_mask, next_hop_ip_address}: + if not isinstance(key, IPv4Address): + key = IPv4Address(key) + route = RouteEntry( + address=address, subnet_mask=subnet_mask, next_hop_ip_address=next_hop_ip_address, metric=metric + ) + self.routes.append(route) + + @validate_call() + def set_default_route_next_hop_ip_address(self, ip_address: IPV4Address): + """ + Sets the next-hop IP address for the default route in a routing table. + + This method checks if a default route (0.0.0.0/0) exists in the routing table. If it does not exist, + the method creates a new default route with the specified next-hop IP address. If a default route already + exists, it updates the next-hop IP address of the existing default route. After setting the next-hop + IP address, the method logs this action. + + :param ip_address: The next-hop IP address to be set for the default route. + """ + if not self.default_route: + self.default_route = RouteEntry( + address=IPv4Address("0.0.0.0"), + subnet_mask=IPv4Address("0.0.0.0"), + next_hop_ip_address=ip_address, + ) + else: + self.default_route.next_hop_ip_address = ip_address + self.sys_log.info(f"Default configured to use {ip_address} as the next-hop") + + def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: + """ + Find the best route for a given destination IP. + + This method uses the Longest Prefix Match algorithm and considers metrics to find the best route. + + If no dedicated route exists but a default route does, then the default route is returned as a last resort. + + :param destination_ip: The destination IP to find the route for. + :return: The best matching RouteEntry, or None if no route matches. + """ + if not isinstance(destination_ip, IPv4Address): + destination_ip = IPv4Address(destination_ip) + best_route = None + longest_prefix = -1 + lowest_metric = float("inf") # Initialise at infinity as any other number we compare to it will be smaller + + for route in self.routes: + route_network = IPv4Network(f"{route.address}/{route.subnet_mask}", strict=False) + prefix_len = route_network.prefixlen + + if destination_ip in route_network: + if prefix_len > longest_prefix or (prefix_len == longest_prefix and route.metric < lowest_metric): + best_route = route + longest_prefix = prefix_len + lowest_metric = route.metric + + if not best_route and self.default_route: + best_route = self.default_route + + return best_route + + def show(self, markdown: bool = False): + """ + Display the current routing table as a table. + + :param markdown: Whether to display the table in Markdown format. Defaults to False. + """ + table = PrettyTable(["Index", "Address", "Next Hop", "Metric"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} Route Table" + for index, route in enumerate(self.routes): + network = IPv4Network(f"{route.address}/{route.subnet_mask}") + table.add_row([index, f"{route.address}/{network.prefixlen}", route.next_hop_ip_address, route.metric]) + print(table) + + +class RouterARP(ARP): + """ + Extends ARP functionality with router-specific ARP packet processing capabilities. + + This class is designed to manage ARP requests and replies within a router, handling both the resolution of MAC + addresses for IP addresses within the router's networks and the forwarding of ARP requests to other networks + based on routing information. + """ + + router: Optional[Router] = None + + def _get_arp_cache_mac_address( + self, ip_address: IPv4Address, is_reattempt: bool = False, is_default_route_attempt: bool = False + ) -> Optional[str]: + """ + Attempts to retrieve the MAC address associated with the given IP address from the ARP cache. + + If the address is not in the cache, an ARP request may be sent, and the method may reattempt the lookup. + + :param ip_address: The IP address for which to find the corresponding MAC address. + :type ip_address: IPv4Address + :param is_reattempt: Indicates whether this call is a reattempt after a failed initial attempt to find the MAC + address. + :type is_reattempt: bool + :param is_default_route_attempt: Indicates whether the attempt is being made to resolve the MAC address for the + default route. + :type is_default_route_attempt: bool + :return: The MAC address associated with the given IP address, if found; otherwise, None. + :rtype: Optional[str] + """ + arp_entry = self.arp.get(ip_address) + + if arp_entry: + return arp_entry.mac_address + + if not is_reattempt: + if self.router.ip_is_in_router_interface_subnet(ip_address): + self.send_arp_request(ip_address) + return self._get_arp_cache_mac_address( + ip_address=ip_address, is_reattempt=True, is_default_route_attempt=is_default_route_attempt + ) + + route = self.router.route_table.find_best_route(ip_address) + if route and route != self.router.route_table.default_route: + self.send_arp_request(route.next_hop_ip_address) + return self._get_arp_cache_mac_address( + ip_address=route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=is_default_route_attempt, + ) + elif route and route == self.router.route_table.default_route: + self.send_arp_request(self.router.route_table.default_route.next_hop_ip_address) + return self._get_arp_cache_mac_address( + ip_address=self.router.route_table.default_route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=True, + ) + else: + if self.router.route_table.default_route: + if not is_default_route_attempt: + self.send_arp_request(self.router.route_table.default_route.next_hop_ip_address) + return self._get_arp_cache_mac_address( + ip_address=self.router.route_table.default_route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=True, + ) + return None + + def get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: + """ + Public interface to retrieve the MAC address associated with the given IP address from the ARP cache. + + :param ip_address: The IP address for which to find the corresponding MAC address. + :type ip_address: IPv4Address + :return: The MAC address associated with the given IP address, if found; otherwise, None. + :rtype: Optional[str] + """ + return self._get_arp_cache_mac_address(ip_address) + + def _get_arp_cache_network_interface( + self, ip_address: IPv4Address, is_reattempt: bool = False, is_default_route_attempt: bool = False + ) -> Optional[RouterInterface]: + """ + Attempts to retrieve the router interface associated with the given IP address. + + If the address is not directly associated with a router interface, it may send an ARP request based on + routing information. + + :param ip_address: The IP address for which to find the corresponding router interface. + :type ip_address: IPv4Address + :param is_reattempt: Indicates whether this call is a reattempt after a failed initial attempt. + :type is_reattempt: bool + :param is_default_route_attempt: Indicates whether the attempt is being made for the default route's next-hop + IP address. + :type is_default_route_attempt: bool + :return: The router interface associated with the given IP address, if applicable; otherwise, None. + :rtype: Optional[RouterInterface] + """ + arp_entry = self.arp.get(ip_address) + if arp_entry: + return self.software_manager.node.network_interfaces[arp_entry.network_interface_uuid] + + for network_interface in self.router.network_interfaces.values(): + if ip_address in network_interface.ip_network: + return network_interface + + if not is_reattempt: + if self.router.ip_is_in_router_interface_subnet(ip_address): + self.send_arp_request(ip_address) + return self._get_arp_cache_network_interface( + ip_address=ip_address, is_reattempt=True, is_default_route_attempt=is_default_route_attempt + ) + + route = self.router.route_table.find_best_route(ip_address) + if route and route != self.router.route_table.default_route: + self.send_arp_request(route.next_hop_ip_address) + return self._get_arp_cache_network_interface( + ip_address=route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=is_default_route_attempt, + ) + elif route and route == self.router.route_table.default_route: + self.send_arp_request(self.router.route_table.default_route.next_hop_ip_address) + return self._get_arp_cache_network_interface( + ip_address=self.router.route_table.default_route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=True, + ) + else: + if self.router.route_table.default_route: + if not is_default_route_attempt: + self.send_arp_request(self.router.route_table.default_route.next_hop_ip_address) + return self._get_arp_cache_network_interface( + ip_address=self.router.route_table.default_route.next_hop_ip_address, + is_reattempt=True, + is_default_route_attempt=True, + ) + return None + + def get_arp_cache_network_interface(self, ip_address: IPv4Address) -> Optional[RouterInterface]: + """ + Public interface to retrieve the router interface associated with the given IP address. + + :param ip_address: The IP address for which to find the corresponding router interface. + :type ip_address: IPv4Address + :return: The router interface associated with the given IP address, if found; otherwise, None. + :rtype: Optional[RouterInterface] + """ + return self._get_arp_cache_network_interface(ip_address) + + def _process_arp_request(self, arp_packet: ARPPacket, from_network_interface: RouterInterface): + """ + Processes an ARP request packet received on a router interface. + + If the target IP address matches the interface's IP address, generates and sends an ARP reply. + + :param arp_packet: The received ARP request packet. + :type arp_packet: ARPPacket + :param from_network_interface: The router interface on which the ARP request was received. + :type from_network_interface: RouterInterface + """ + super()._process_arp_request(arp_packet, from_network_interface) + + # If the target IP matches one of the router's NICs + if from_network_interface.enabled and from_network_interface.ip_address == arp_packet.target_ip_address: + arp_reply = arp_packet.generate_reply(from_network_interface.mac_address) + self.send_arp_reply(arp_reply) + return + + def _process_arp_reply(self, arp_packet: ARPPacket, from_network_interface: RouterInterface): + """ + Processes an ARP reply packet received on a router interface. Updates the ARP cache with the new information. + + :param arp_packet: The received ARP reply packet. + :type arp_packet: ARPPacket + :param from_network_interface: The router interface on which the ARP reply was received. + :type from_network_interface: RouterInterface + """ + if arp_packet.target_ip_address == from_network_interface.ip_address: + super()._process_arp_reply(arp_packet, from_network_interface) + + +class RouterICMP(ICMP): + """ + The Router Internet Control Message Protocol (ICMP) service. + + Extends the ICMP service to provide router-specific functionalities for processing ICMP packets. This class is + responsible for handling ICMP operations such as echo requests and replies in the context of a router. + + Inherits from: + ICMP: Inherits core functionalities for handling ICMP operations, including the processing of echo requests + and replies. + """ + + router: Optional[Router] = None + + def _process_icmp_echo_request(self, frame: Frame, from_network_interface: RouterInterface): + """ + Processes an ICMP echo request received by the service. + + :param frame: The network frame containing the ICMP echo request. + """ + self.sys_log.info(f"Received echo request from {frame.ip.src_ip_address}") + + network_interface = self.software_manager.session_manager.resolve_outbound_network_interface( + frame.ip.src_ip_address + ) + + if not network_interface: + self.sys_log.warning( + "Cannot send ICMP echo reply as there is no outbound Network Interface to use. Try configuring the " + "default gateway." + ) + return + + icmp_packet = ICMPPacket( + icmp_type=ICMPType.ECHO_REPLY, + icmp_code=0, + identifier=frame.icmp.identifier, + sequence=frame.icmp.sequence + 1, + ) + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") + + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=frame.ip.src_ip_address, + dst_port=self.port, + ip_protocol=self.protocol, + icmp_packet=icmp_packet, + ) + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Processes received data, specifically handling ICMP echo requests and replies. + + This method determines the appropriate action based on the packet type and the destination IP address's + association with the router interfaces. + + Initially, it checks if the destination IP address of the ICMP packet corresponds to any router interface. If + the packet is not destined for an enabled interface but still matches a router interface, it is redirected + back to the router for further processing. This ensures proper handling of packets intended for the router + itself or needing to be routed to other destinations. + + :param payload: The payload received, expected to be an ICMP packet. + :param session_id: The session ID associated with the received data. + :param kwargs: Additional keyword arguments, including 'frame' (the received network frame) and + 'from_network_interface' (the router interface that received the frame). + :return: True if the ICMP packet was processed successfully, False otherwise. False indicates either the packet + was not ICMP, the destination IP does not correspond to an enabled router interface (and no further action + was required), or the ICMP packet type is not handled by this method. + """ + frame: Frame = kwargs["frame"] + from_network_interface = kwargs["from_network_interface"] + + # Check for the presence of an ICMP payload in the frame. + if not frame.icmp: + return False + + # If the frame's destination IP address corresponds to any router interface, not just enabled ones. + if not self.router.ip_is_router_interface(frame.ip.dst_ip_address): + # If the frame is not for this router, pass it back down to the Router for potential further routing. + self.router.process_frame(frame=frame, from_network_interface=from_network_interface) + return True + + # Ensure the destination IP address corresponds to an enabled router interface. + if not self.router.ip_is_router_interface(frame.ip.dst_ip_address, enabled_only=True): + return False + + # Process ICMP echo requests and replies. + if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: + self._process_icmp_echo_request(frame, from_network_interface) + elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: + self._process_icmp_echo_reply(frame) + + return True + + +class RouterInterface(IPWiredNetworkInterface): + """ + Represents a Router Interface. + + Router interfaces are used to connect routers to networks. They can route packets across different networks, + hence have IP addressing information. + + Inherits from: + - WiredNetworkInterface: Provides properties and methods specific to wired connections. + - Layer3Interface: Provides Layer 3 properties like ip_address and subnet_mask. + """ + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False + frame.set_received_timestamp() + self.pcap.capture_inbound(frame) + # If this destination or is broadcast + if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + self._connected_node.receive_frame(frame=frame, from_network_interface=self) + return True + return False + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number, MAC address and IP address of the NIC. + """ + return f"Port {self.port_name if self.port_name else self.port_num}: {self.mac_address}/{self.ip_address}" + + +class RouterSessionManager(SessionManager): + """ + Manages network sessions, including session creation, lookup, and communication with other components. + + The RouterSessionManager is a Router/Firewall specific implementation of SessionManager. It overrides the + resolve_outbound_network_interface and resolve_outbound_transmission_details functions, allowing them to leverage + the route table instead of the default gateway. + + :param sys_log: A reference to the system log component. + """ + + def resolve_outbound_network_interface(self, dst_ip_address: IPv4Address) -> Optional[RouterInterface]: + """ + Resolves the appropriate outbound network interface for a given destination IP address. + + This method determines the most suitable network interface for sending a packet to the specified + destination IP address. It considers only enabled network interfaces and checks if the destination + IP address falls within the subnet of each interface. If no suitable local network interface is found, + the method defaults to performing a route table look-up to determine if there is a dedicated route or a default + route it can use. + + The search process prioritises local network interfaces based on the IP network to which they belong. + If the destination IP address does not match any local subnet, the method assumes that the destination + is outside the local network and hence, routes the packet according to route table look-up. + + :param dst_ip_address: The destination IP address for which the outbound interface is to be resolved. + :type dst_ip_address: IPv4Address + :return: The network interface through which the packet should be sent to reach the destination IP address, + or the default gateway's network interface if the destination is not within any local subnet. + :rtype: Optional[RouterInterface] + """ + network_interface = super().resolve_outbound_network_interface(dst_ip_address) + if not network_interface: + route = self.node.route_table.find_best_route(dst_ip_address) + if not route: + return None + network_interface = super().resolve_outbound_network_interface(route.next_hop_ip_address) + return network_interface + + def resolve_outbound_transmission_details( + self, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + protocol: Optional[IPProtocol] = None, + session_id: Optional[str] = None, + ) -> Tuple[ + Optional[RouterInterface], + Optional[str], + IPv4Address, + Optional[Port], + Optional[Port], + Optional[IPProtocol], + bool, + ]: + """ + Resolves the necessary details for outbound transmission based on the provided parameters. + + This method determines whether the payload should be broadcast or unicast based on the destination IP address + and resolves the outbound network interface and destination MAC address accordingly. + + The method first checks if `session_id` is provided and uses the session details if available. For broadcast + transmissions, it finds a suitable network interface and uses a broadcast MAC address. For unicast + transmissions, it attempts to resolve the destination MAC address using ARP and finds the appropriate + outbound network interface. If the destination IP address is outside the local network and no specific MAC + address is resolved, it defaults to performing a route table look-up to determine if there is a dedicated route + or a default route it can use. + + :param dst_ip_address: The destination IP address or network. If an IPv4Network is provided, the method + treats the transmission as a broadcast to that network. Optional. + :type dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] + :param src_port: The source port number for the transmission. Optional. + :type src_port: Optional[Port] + :param dst_port: The destination port number for the transmission. Optional. + :type dst_port: Optional[Port] + :param protocol: The IP protocol to be used for the transmission. Optional. + :type protocol: Optional[IPProtocol] + :param session_id: The session ID associated with the transmission. If provided, the session details override + other parameters. Optional. + :type session_id: Optional[str] + :return: A tuple containing the resolved outbound network interface, destination MAC address, destination IP + address, source port, destination port, protocol, and a boolean indicating whether the transmission is a + broadcast. + :rtype: Tuple[Optional[RouterInterface], Optional[str], IPv4Address, Optional[Port], Optional[Port], + Optional[IPProtocol], bool] + """ + if dst_ip_address and not isinstance(dst_ip_address, (IPv4Address, IPv4Network)): + dst_ip_address = IPv4Address(dst_ip_address) + is_broadcast = False + outbound_network_interface = None + dst_mac_address = None + + # Use session details if session_id is provided + if session_id: + session = self.sessions_by_uuid[session_id] + + dst_ip_address = session.with_ip_address + protocol = session.protocol + src_port = session.src_port + dst_port = session.dst_port + + # Determine if the payload is for broadcast or unicast + + # Handle broadcast transmission + if isinstance(dst_ip_address, IPv4Network): + is_broadcast = True + dst_ip_address = dst_ip_address.broadcast_address + if dst_ip_address: + # Find a suitable NIC for the broadcast + for network_interface in self.node.network_interfaces.values(): + if dst_ip_address in network_interface.ip_network and network_interface.enabled: + dst_mac_address = "ff:ff:ff:ff:ff:ff" + outbound_network_interface = network_interface + break + else: + # Resolve MAC address for unicast transmission + use_route_table = True + for network_interface in self.node.network_interfaces.values(): + if dst_ip_address in network_interface.ip_network and network_interface.enabled: + dst_mac_address = self.software_manager.arp.get_arp_cache_mac_address(dst_ip_address) + break + + if dst_mac_address: + use_route_table = False + outbound_network_interface = self.software_manager.arp.get_arp_cache_network_interface(dst_ip_address) + + if use_route_table: + route = self.node.route_table.find_best_route(dst_ip_address) + if not route: + raise Exception("cannot use route to resolve outbound details") + + dst_mac_address = self.software_manager.arp.get_arp_cache_mac_address(route.next_hop_ip_address) + outbound_network_interface = self.software_manager.arp.get_arp_cache_network_interface( + route.next_hop_ip_address + ) + return outbound_network_interface, dst_mac_address, dst_ip_address, src_port, dst_port, protocol, is_broadcast + + +class Router(NetworkNode): + """ + Represents a network router, managing routing and forwarding of IP packets across network interfaces. + + A router operates at the network layer and is responsible for receiving, processing, and forwarding data packets + between computer networks. It examines the destination IP address of incoming packets and determines the best way + to route them towards their destination. + + The router integrates various network services and protocols to facilitate IP routing, including ARP (Address + Resolution Protocol) and ICMP (Internet Control Message Protocol) for handling network diagnostics and errors. + + :ivar str hostname: The name of the router, used for identification and logging. + :ivar int num_ports: The number of physical or logical ports on the router. + :ivar dict kwargs: Optional keyword arguments for initializing components like SysLog, ACL (Access Control List), + RouteTable, RouterARP, and RouterICMP services. + """ + + num_ports: int + network_interfaces: Dict[str, RouterInterface] = {} + "The Router Interfaces on the node." + network_interface: Dict[int, RouterInterface] = {} + "The Router Interfaces on the node by port id." + acl: AccessControlList + route_table: RouteTable + + def __init__(self, hostname: str, num_ports: int = 5, **kwargs): + if not kwargs.get("sys_log"): + kwargs["sys_log"] = SysLog(hostname) + if not kwargs.get("acl"): + kwargs["acl"] = AccessControlList(sys_log=kwargs["sys_log"], implicit_action=ACLAction.DENY, name=hostname) + if not kwargs.get("route_table"): + kwargs["route_table"] = RouteTable(sys_log=kwargs["sys_log"]) + super().__init__(hostname=hostname, num_ports=num_ports, **kwargs) + self.session_manager = RouterSessionManager(sys_log=self.sys_log) + self.session_manager.node = self + self.software_manager.session_manager = self.session_manager + self.session_manager.software_manager = self.software_manager + for i in range(1, self.num_ports + 1): + network_interface = RouterInterface(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") + self.connect_nic(network_interface) + self.network_interface[i] = network_interface + + self._set_default_acl() + + def _install_system_software(self): + """ + Installs essential system software and network services on the router. + + This includes initializing and setting up RouterICMP for handling ICMP packets and RouterARP for address + resolution within the network. These services are crucial for the router's operation, enabling it to manage + network traffic efficiently. + """ + self.software_manager.install(RouterICMP) + icmp: RouterICMP = self.software_manager.icmp # noqa + icmp.router = self + self.software_manager.install(RouterARP) + self.arp.router = self + + def _set_default_acl(self): + """ + Sets default access control rules for the router. + + Initializes the router's ACL (Access Control List) with default rules, permitting essential protocols like ARP + and ICMP, which are necessary for basic network operations and diagnostics. + """ + self.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + self.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + def setup_for_episode(self, episode: int): + """ + Resets the router's components for a new network simulation episode. + + Clears ARP cache, resets ACL and route table to their original states, and re-enables all network interfaces. + This ensures that the router starts from a clean state for each simulation episode. + + :param episode: The episode number for which the router is being reset. + """ + self.software_manager.arp.clear() + for i, _ in self.network_interface.items(): + self.enable_port(i) + + super().setup_for_episode(episode=episode) + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request("acl", RequestType(func=self.acl._request_manager)) + return rm + + def ip_is_router_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: + """ + Checks if a given IP address belongs to any of the router's interfaces. + + :param ip_address: The IP address to check. + :param enabled_only: If True, only considers enabled network interfaces. + :return: True if the IP address is assigned to one of the router's interfaces; False otherwise. + """ + for router_interface in self.network_interface.values(): + if router_interface.ip_address == ip_address: + if enabled_only: + return router_interface.enabled + else: + return True + return False + + def ip_is_in_router_interface_subnet(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool: + """ + Determines if a given IP address falls within the subnet of any router interface. + + :param ip_address: The IP address to check. + :param enabled_only: If True, only considers enabled network interfaces. + :return: True if the IP address is within the subnet of any router's interface; False otherwise. + """ + for router_interface in self.network_interface.values(): + if ip_address in router_interface.ip_network: + if enabled_only: + return router_interface.enabled + else: + return True + return False + + def _get_port_of_nic(self, target_nic: RouterInterface) -> Optional[int]: + """ + Retrieves the port number associated with a given network interface controller (NIC). + + :param target_nic: The NIC whose port number is being queried. + :return: The port number if the NIC is found; otherwise, None. + """ + for port, network_interface in self.network_interface.items(): + if network_interface == target_nic: + return port + + def describe_state(self) -> Dict: + """ + Describes the current state of the Router. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + state["num_ports"] = self.num_ports + state["acl"] = self.acl.describe_state() + return state + + def check_send_frame_to_session_manager(self, frame: Frame) -> bool: + """ + Determines whether a given network frame should be forwarded to the session manager. + + This function evaluates whether the destination IP address of the frame corresponds to one of the router's + interface IP addresses. If so, it then checks if the frame is an ICMP packet or if the destination port matches + any of the ports that the router's software manager identifies as open. If either condition is met, the frame + is considered for further processing by the session manager, implying potential application-level handling or + response generation. + + :param frame: The network frame to be evaluated. + + :return: A boolean value indicating whether the frame should be sent to the session manager. ``True`` if the + frame's destination IP matches the router's interface and is directed to an open port or is an ICMP packet, + otherwise, ``False``. + """ + dst_ip_address = frame.ip.dst_ip_address + dst_port = None + if frame.ip.protocol == IPProtocol.TCP: + dst_port = frame.tcp.dst_port + elif frame.ip.protocol == IPProtocol.UDP: + dst_port = frame.udp.dst_port + + if self.ip_is_router_interface(dst_ip_address) and ( + frame.icmp or dst_port in self.software_manager.get_open_ports() + ): + return True + + return False + + def receive_frame(self, frame: Frame, from_network_interface: RouterInterface): + """ + Processes an incoming frame received on one of the router's interfaces. + + Examines the frame's destination and protocol, applies ACL rules, and either forwards or drops the frame based + on routing decisions and ACL permissions. + + :param frame: The incoming frame to be processed. + :param from_network_interface: The router interface on which the frame was received. + """ + if self.operating_state != NodeOperatingState.ON: + return + + # Check if it's permitted + permitted, rule = self.acl.is_permitted(frame) + + if not permitted: + at_port = self._get_port_of_nic(from_network_interface) + self.sys_log.info(f"Frame blocked at port {at_port} by rule {rule}") + return + + if frame.ip and self.software_manager.arp: + self.software_manager.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip_address, + mac_address=frame.ethernet.src_mac_addr, + network_interface=from_network_interface, + ) + + if self.check_send_frame_to_session_manager(frame): + # Port is open on this Router so pass Frame up to session manager first + self.session_manager.receive_frame(frame, from_network_interface) + else: + self.process_frame(frame, from_network_interface) + + def process_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Routes or forwards a frame based on the router's routing table and interface configurations. + + This method is called if a frame is not directly addressed to the router or does not match any open service + ports. It determines the next hop for the frame and forwards it accordingly. + + :param frame: The frame to be routed or forwarded. + :param from_network_interface: The network interface from which the frame originated. + """ + # check if frame is addressed to this Router but has failed to be received by a service of application at the + # receive_frame stage + if frame.ip: + for network_interface in self.network_interfaces.values(): + if network_interface.ip_address == frame.ip.dst_ip_address: + self.sys_log.info("Dropping frame destined for this router on a port that isn't open.") + return + + network_interface: RouterInterface = self.software_manager.arp.get_arp_cache_network_interface( + frame.ip.dst_ip_address + ) + target_mac = self.software_manager.arp.get_arp_cache_mac_address(frame.ip.dst_ip_address) + + if not target_mac: + self.sys_log.info(f"Frame dropped as ARP cannot be resolved for {frame.ip.dst_ip_address}") + # TODO: Send something back to src, is it some sort of ICMP? + return + + if not network_interface: + self.sys_log.info(f"Destination {frame.ip.dst_ip_address} is unreachable") + # TODO: Send something back to src, is it some sort of ICMP? + return + + if not network_interface.enabled: + self.sys_log.info(f"Frame dropped as NIC {network_interface} is not enabled") + # TODO: Send something back to src, is it some sort of ICMP? + return + + if frame.ip.dst_ip_address in network_interface.ip_network: + from_port = self._get_port_of_nic(from_network_interface) + to_port = self._get_port_of_nic(network_interface) + self.sys_log.info(f"Forwarding frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + # TODO: Send something back to src, is it some sort of ICMP? + return + frame.ethernet.src_mac_addr = network_interface.mac_address + frame.ethernet.dst_mac_addr = target_mac + network_interface.send_frame(frame) + return + else: + self.route_frame(frame, from_network_interface) + + def route_frame(self, frame: Frame, from_network_interface: RouterInterface) -> None: + """ + Determines the best route for a frame and forwards it towards its destination. + + Uses the router's routing table to find the best route for the frame's destination IP address and forwards the + frame through the appropriate interface. + + :param frame: The frame to be routed. + :param from_network_interface: The source network interface. + """ + route = self.route_table.find_best_route(frame.ip.dst_ip_address) + if route: + network_interface = self.software_manager.arp.get_arp_cache_network_interface(route.next_hop_ip_address) + target_mac = self.software_manager.arp.get_arp_cache_mac_address(route.next_hop_ip_address) + if not network_interface: + self.sys_log.info(f"Destination {frame.ip.dst_ip_address} is unreachable") + # TODO: Send something back to src, is it some sort of ICMP? + return + + if not network_interface.enabled: + self.sys_log.info(f"Frame dropped as NIC {network_interface} is not enabled") + # TODO: Send something back to src, is it some sort of ICMP? + return + + from_port = self._get_port_of_nic(from_network_interface) + to_port = self._get_port_of_nic(network_interface) + self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + # TODO: Send something back to src, is it some sort of ICMP? + return + frame.ethernet.src_mac_addr = network_interface.mac_address + frame.ethernet.dst_mac_addr = target_mac + network_interface.send_frame(frame) + else: + self.sys_log.warning(f"Frame dropped as there is no route to {frame.ip.dst_ip_address}") + + def configure_port(self, port: int, ip_address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str]): + """ + Configures the IP settings for a specified router port. + + :param port: The port number to configure. + :param ip_address: The IP address to assign to the port. + :param subnet_mask: The subnet mask for the port. + """ + if not isinstance(ip_address, IPv4Address): + ip_address = IPv4Address(ip_address) + if not isinstance(subnet_mask, IPv4Address): + subnet_mask = IPv4Address(subnet_mask) + network_interface = self.network_interface[port] + network_interface.ip_address = ip_address + network_interface.subnet_mask = subnet_mask + self.sys_log.info(f"Configured Network Interface {network_interface}") + + def enable_port(self, port: int): + """ + Enables a specified port on the router. + + :param port: The port number to enable. + """ + network_interface = self.network_interface.get(port) + if network_interface: + network_interface.enable() + + def disable_port(self, port: int): + """ + Disables a specified port on the router. + + :param port: The port number to disable. + """ + network_interface = self.network_interface.get(port) + if network_interface: + network_interface.disable() + + def show(self, markdown: bool = False): + """ + Prints the state of the network interfaces on the Router. + + :param markdown: Flag to indicate if the output should be in markdown format. + """ + """Prints a table of the NICs on the Node.""" + table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Network Interfaces" + for port, network_interface in self.network_interface.items(): + table.add_row( + [ + port, + network_interface.mac_address, + f"{network_interface.ip_address}/{network_interface.ip_network.prefixlen}", + network_interface.speed, + "Enabled" if network_interface.enabled else "Disabled", + ] + ) + print(table) + + @classmethod + def from_config(cls, cfg: dict, **kwargs) -> "Router": + """Create a router based on a config dict. + + Schema: + - hostname (str): unique name for this router. + - num_ports (int, optional): Number of network ports on the router. 8 by default + - ports (dict): Dict with integers from 1 - num_ports as keys. The values should be another dict specifying + ip_address and subnet_mask assigned to that ports (as strings) + - acl (dict): Dict with integers from 1 - max_acl_rules as keys. The key defines the position within the ACL + where the rule will be added (lower number is resolved first). The values should describe valid ACL + Rules as: + - action (str): either PERMIT or DENY + - src_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - dst_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - protocol (str, optional): the named IP protocol such as ICMP, TCP, or UDP + - src_ip_address (str, optional): IP address octet written in base 10 + - dst_ip_address (str, optional): IP address octet written in base 10 + - routes (list[dict]): List of route dicts with values: + - address (str): The destination address of the route. + - subnet_mask (str): The subnet mask of the route. + - next_hop_ip_address (str): The next hop IP for the route. + - metric (int): The metric of the route. Optional. + - default_route: + - next_hop_ip_address (str): The next hop IP for the route. + + Example config: + ``` + { + 'hostname': 'router_1', + 'num_ports': 5, + 'ports': { + 1: { + 'ip_address' : '192.168.1.1', + 'subnet_mask' : '255.255.255.0', + }, + 2: { + 'ip_address' : '192.168.0.1', + 'subnet_mask' : '255.255.255.252', + } + }, + 'acl' : { + 21: {'action': 'PERMIT', 'src_port': 'HTTP', dst_port: 'HTTP'}, + 22: {'action': 'PERMIT', 'src_port': 'ARP', 'dst_port': 'ARP'}, + 23: {'action': 'PERMIT', 'protocol': 'ICMP'}, + }, + 'routes' : [ + {'address': '192.168.0.0', 'subnet_mask': '255.255.255.0', 'next_hop_ip_address': '192.168.1.2'} + ], + 'default_route': {'next_hop_ip_address': '192.168.0.2'} + } + ``` + + :param cfg: Router config adhering to schema described in main docstring body + :type cfg: dict + :return: Configured router. + :rtype: Router + """ + router = Router( + hostname=cfg["hostname"], + num_ports=int(cfg.get("num_ports", "5")), + operating_state=NodeOperatingState.ON + if not (p := cfg.get("operating_state")) + else NodeOperatingState[p.upper()], + ) + if "ports" in cfg: + for port_num, port_cfg in cfg["ports"].items(): + router.configure_port( + port=port_num, + ip_address=port_cfg["ip_address"], + subnet_mask=IPv4Address(port_cfg.get("subnet_mask", "255.255.255.0")), + ) + if "acl" in cfg: + for r_num, r_cfg in cfg["acl"].items(): + router.acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_ip_address=r_cfg.get("dst_ip"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + if "routes" in cfg: + for route in cfg.get("routes"): + router.route_table.add_route( + address=IPv4Address(route.get("address")), + subnet_mask=IPv4Address(route.get("subnet_mask", "255.255.255.0")), + next_hop_ip_address=IPv4Address(route.get("next_hop_ip_address")), + metric=float(route.get("metric", 0)), + ) + if "default_route" in cfg: + next_hop_ip_address = cfg["default_route"].get("next_hop_ip_address", None) + if next_hop_ip_address: + router.route_table.set_default_route_next_hop_ip_address(next_hop_ip_address) + return router diff --git a/src/primaite/simulator/network/hardware/nodes/network/switch.py b/src/primaite/simulator/network/hardware/nodes/network/switch.py new file mode 100644 index 00000000..db1863e0 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/network/switch.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from typing import Dict, Optional + +from prettytable import MARKDOWN, PrettyTable + +from primaite import getLogger +from primaite.exceptions import NetworkError +from primaite.simulator.network.hardware.base import Link, WiredNetworkInterface +from primaite.simulator.network.hardware.nodes.network.network_node import NetworkNode +from primaite.simulator.network.transmission.data_link_layer import Frame + +_LOGGER = getLogger(__name__) + + +class SwitchPort(WiredNetworkInterface): + """ + Represents a Switch Port. + + Switch ports connect devices within the same network. They operate at the data link layer (Layer 2) of the OSI model + and are responsible for receiving and forwarding frames based on MAC addresses. Despite operating at Layer 2, + they are an essential part of network infrastructure, enabling LAN segmentation, bandwidth management, and + the creation of VLANs. + + Inherits from: + - WiredNetworkInterface: Provides properties and methods specific to wired connections. + + Switch ports typically do not have IP addresses assigned to them as they function at Layer 2, but managed switches + can have management IP addresses for remote management and configuration purposes. + """ + + _connected_node: Optional[Switch] = None + "The Switch to which the SwitchPort is connected." + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "enabled": self.enabled, + } + ) + return state + + def send_frame(self, frame: Frame) -> bool: + """ + Attempts to send a network frame through the interface. + + :param frame: The network frame to be sent. + :return: A boolean indicating whether the frame was successfully sent. + """ + if self.enabled: + self.pcap.capture_outbound(frame) + self._connected_link.transmit_frame(sender_nic=self, frame=frame) + return True + # Cannot send Frame as the SwitchPort is not enabled + return False + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.warning("Frame discarded as TTL limit reached") + return False + self.pcap.capture_inbound(frame) + self._connected_node.receive_frame(frame=frame, from_network_interface=self) + return True + return False + + +class Switch(NetworkNode): + """ + A class representing a Layer 2 network switch. + + :ivar num_ports: The number of ports on the switch. Default is 24. + """ + + num_ports: int = 24 + "The number of ports on the switch." + network_interfaces: Dict[str, SwitchPort] = {} + "The SwitchPorts on the Switch." + network_interface: Dict[int, SwitchPort] = {} + "The SwitchPorts on the Switch by port id." + mac_address_table: Dict[str, SwitchPort] = {} + "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." + + def __init__(self, **kwargs): + super().__init__(**kwargs) + for i in range(1, self.num_ports + 1): + self.connect_nic(SwitchPort()) + + def show(self, markdown: bool = False): + """ + Prints a table of the SwitchPorts on the Switch. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Switch Ports" + for port_num, port in self.network_interface.items(): + table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) + print(table) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state["ports"] = {port_num: port.describe_state() for port_num, port in self.network_interface.items()} + state["num_ports"] = self.num_ports # redundant? + state["mac_address_table"] = {mac: port.port_num for mac, port in self.mac_address_table.items()} + return state + + def _add_mac_table_entry(self, mac_address: str, switch_port: SwitchPort): + """ + Private method to add an entry to the MAC address table. + + :param mac_address: MAC address to be added. + :param switch_port: Corresponding SwitchPort object. + """ + mac_table_port = self.mac_address_table.get(mac_address) + if not mac_table_port: + self.mac_address_table[mac_address] = switch_port + self.sys_log.info(f"Added MAC table entry: Port {switch_port.port_num} -> {mac_address}") + else: + if mac_table_port != switch_port: + self.mac_address_table.pop(mac_address) + self.sys_log.info(f"Removed MAC table entry: Port {mac_table_port.port_num} -> {mac_address}") + self._add_mac_table_entry(mac_address, switch_port) + + def receive_frame(self, frame: Frame, from_network_interface: SwitchPort): + """ + Forward a frame to the appropriate port based on the destination MAC address. + + :param frame: The Frame being received. + :param from_network_interface: The SwitchPort that received the frame. + """ + src_mac = frame.ethernet.src_mac_addr + dst_mac = frame.ethernet.dst_mac_addr + self._add_mac_table_entry(src_mac, from_network_interface) + + outgoing_port = self.mac_address_table.get(dst_mac) + if outgoing_port and dst_mac.lower() != "ff:ff:ff:ff:ff:ff": + outgoing_port.send_frame(frame) + else: + # If the destination MAC is not in the table, flood to all ports except incoming + for port in self.network_interface.values(): + if port.enabled and port != from_network_interface: + port.send_frame(frame) + + def disconnect_link_from_port(self, link: Link, port_number: int): + """ + Disconnect a given link from the specified port number on the switch. + + :param link: The Link object to be disconnected. + :param port_number: The port number on the switch from where the link should be disconnected. + :raise NetworkError: When an invalid port number is provided or the link does not match the connection. + """ + port = self.network_interface.get(port_number) + if port is None: + msg = f"Invalid port number {port_number} on the switch" + _LOGGER.error(msg) + raise NetworkError(msg) + + if port._connected_link != link: + msg = f"The link does not match the connection at port number {port_number}" + _LOGGER.error(msg) + raise NetworkError(msg) + + port.disconnect_link() diff --git a/src/primaite/simulator/network/hardware/nodes/network/wireless_router.py b/src/primaite/simulator/network/hardware/nodes/network/wireless_router.py new file mode 100644 index 00000000..9e5d4dd4 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/network/wireless_router.py @@ -0,0 +1,283 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, Union + +from pydantic import validate_call + +from primaite.simulator.network.airspace import AirSpace, AirSpaceFrequency, IPWirelessNetworkInterface +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router, RouterInterface +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.utils.validators import IPV4Address + + +class WirelessAccessPoint(IPWirelessNetworkInterface): + """ + Represents a Wireless Access Point (AP) in a network. + + This class models a Wireless Access Point, a device that allows wireless devices to connect to a wired network + using Wi-Fi or other wireless standards. The Wireless Access Point bridges the wireless and wired segments of + the network, allowing wireless devices to communicate with other devices on the network. + + As an integral component of wireless networking, a Wireless Access Point provides functionalities for network + management, signal broadcasting, security enforcement, and connection handling. It also possesses Layer 3 + capabilities such as IP addressing and subnetting, allowing for network segmentation and routing. + + Inherits from: + - WirelessNetworkInterface: Provides basic properties and methods specific to wireless interfaces. + - Layer3Interface: Provides Layer 3 properties like ip_address and subnet_mask, enabling the device to manage + network traffic and routing. + + This class can be further specialised or extended to support specific features or standards related to wireless + networking, such as different Wi-Fi versions, frequency bands, or advanced security protocols. + """ + + def model_post_init(self, __context: Any) -> None: + """ + Performs post-initialisation checks to ensure the model's IP configuration is valid. + + This method is invoked after the initialisation of a network model object to validate its network settings, + particularly to ensure that the assigned IP address is not a network address. This validation is crucial for + maintaining the integrity of network simulations and avoiding configuration errors that could lead to + unrealistic or incorrect behavior. + + :param __context: Contextual information or parameters passed to the method, used for further initializing or + validating the model post-creation. + :raises ValueError: If the IP address is the same as the network address, indicating an incorrect configuration. + """ + if self.ip_network.network_address == self.ip_address: + raise ValueError(f"{self.ip_address}/{self.subnet_mask} must not be a network address") + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + return super().describe_state() + + def receive_frame(self, frame: Frame) -> bool: + """ + Receives a network frame on the interface. + + :param frame: The network frame being received. + :return: A boolean indicating whether the frame was successfully received. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.warning("Frame discarded as TTL limit reached") + return False + frame.set_received_timestamp() + self.pcap.capture_inbound(frame) + # If this destination or is broadcast + if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + self._connected_node.receive_frame(frame=frame, from_network_interface=self) + return True + return False + + def __str__(self) -> str: + """ + String representation of the NIC. + + :return: A string combining the port number, MAC address and IP address of the NIC. + """ + return ( + f"Port {self.port_name if self.port_name else self.port_num}: " + f"{self.mac_address}/{self.ip_address} ({self.frequency})" + ) + + +class WirelessRouter(Router): + """ + A WirelessRouter class that extends the functionality of a standard Router to include wireless capabilities. + + This class represents a network device that performs routing functions similar to a traditional router but also + includes the functionality of a wireless access point. This allows the WirelessRouter to not only direct traffic + between wired networks but also to manage and facilitate wireless network connections. + + A WirelessRouter is instantiated and configured with both wired and wireless interfaces. The wired interfaces are + managed similarly to those in a standard Router, while the wireless interfaces require additional configuration + specific to wireless settings, such as setting the frequency band (e.g., 2.4 GHz or 5 GHz for Wi-Fi). + + The WirelessRouter facilitates creating a network environment where devices can be interconnected via both + Ethernet (wired) and Wi-Fi (wireless), making it an essential component for simulating more complex and realistic + network topologies within PrimAITE. + + Example: + >>> wireless_router = WirelessRouter(hostname="wireless_router_1") + >>> wireless_router.configure_router_interface( + ... ip_address="192.168.1.1", + ... subnet_mask="255.255.255.0" + ... ) + >>> wireless_router.configure_wireless_access_point( + ... ip_address="10.10.10.1", + ... subnet_mask="255.255.255.0" + ... frequency=AirSpaceFrequency.WIFI_2_4 + ... ) + """ + + network_interfaces: Dict[str, Union[RouterInterface, WirelessAccessPoint]] = {} + network_interface: Dict[int, Union[RouterInterface, WirelessAccessPoint]] = {} + airspace: AirSpace + + def __init__(self, hostname: str, airspace: AirSpace, **kwargs): + super().__init__(hostname=hostname, num_ports=0, airspace=airspace, **kwargs) + + self.connect_nic( + WirelessAccessPoint(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0", airspace=airspace) + ) + + self.connect_nic(RouterInterface(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0")) + + @property + def wireless_access_point(self) -> WirelessAccessPoint: + """ + Retrieves the wireless access point interface associated with this wireless router. + + This property provides direct access to the WirelessAccessPoint interface of the router, facilitating wireless + communications. Specifically, it returns the interface configured on port 1, dedicated to establishing and + managing wireless network connections. This interface is essential for enabling wireless connectivity, + allowing devices within connect to the network wirelessly. + + :return: The WirelessAccessPoint instance representing the wireless connection interface on port 1 of the + wireless router. + """ + return self.network_interface[1] + + @validate_call() + def configure_wireless_access_point( + self, + ip_address: IPV4Address, + subnet_mask: IPV4Address, + frequency: AirSpaceFrequency = AirSpaceFrequency.WIFI_2_4, + ): + """ + Configures a wireless access point (WAP). + + Sets its IP address, subnet mask, and operating frequency. This method ensures the wireless access point is + properly set up to manage wireless communication over the specified frequency band. + + The method first disables the WAP to safely apply configuration changes. After configuring the IP settings, + it sets the WAP to operate on the specified frequency band and then re-enables the WAP for operation. + + :param ip_address: The IP address to be assigned to the wireless access point. + :param subnet_mask: The subnet mask associated with the IP address + :param frequency: The operating frequency of the wireless access point, defined by the AirSpaceFrequency + enum. This determines the frequency band (e.g., 2.4 GHz or 5 GHz) the access point will use for wireless + communication. Default is AirSpaceFrequency.WIFI_2_4. + """ + self.wireless_access_point.disable() # Temporarily disable the WAP for reconfiguration + network_interface = self.network_interface[1] + network_interface.ip_address = ip_address + network_interface.subnet_mask = subnet_mask + self.sys_log.info(f"Configured WAP {network_interface}") + self.wireless_access_point.frequency = frequency # Set operating frequency + self.wireless_access_point.enable() # Re-enable the WAP with new settings + + @property + def router_interface(self) -> RouterInterface: + """ + Retrieves the router interface associated with this wireless router. + + This property provides access to the router interface configured for wired connections. It specifically + returns the interface configured on port 2, which is reserved for wired LAN/WAN connections. + + :return: The RouterInterface instance representing the wired LAN/WAN connection on port 2 of the wireless + router. + """ + return self.network_interface[2] + + @validate_call() + def configure_router_interface( + self, + ip_address: IPV4Address, + subnet_mask: IPV4Address, + ): + """ + Configures a router interface. + + Sets its IP address and subnet mask. + + The method first disables the router interface to safely apply configuration changes. After configuring the IP + settings, it re-enables the router interface for operation. + + :param ip_address: The IP address to be assigned to the router interface. + :param subnet_mask: The subnet mask associated with the IP address + """ + self.router_interface.disable() # Temporarily disable the router interface for reconfiguration + super().configure_port(port=2, ip_address=ip_address, subnet_mask=subnet_mask) # Set IP configuration + self.router_interface.enable() # Re-enable the router interface with new settings + + def configure_port(self, port: int, ip_address: Union[IPV4Address, str], subnet_mask: Union[IPV4Address, str]): + """Not Implemented.""" + raise NotImplementedError( + "Please use the 'configure_wireless_access_point' and 'configure_router_interface' functions." + ) + + @classmethod + def from_config(cls, cfg: Dict, **kwargs) -> "WirelessRouter": + """Generate the wireless router from config. + + Schema: + - hostname (str): unique name for this router. + - router_interface (dict): The values should be another dict specifying + - ip_address (str) + - subnet_mask (str) + - wireless_access_point (dict): Dict with + - ip address, + - subnet mask, + - frequency, (string: either WIFI_2_4 or WIFI_5) + - acl (dict): Dict with integers from 1 - max_acl_rules as keys. The key defines the position within the ACL + where the rule will be added (lower number is resolved first). The values should describe valid ACL + Rules as: + - action (str): either PERMIT or DENY + - src_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - dst_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - protocol (str, optional): the named IP protocol such as ICMP, TCP, or UDP + - src_ip_address (str, optional): IP address octet written in base 10 + - dst_ip_address (str, optional): IP address octet written in base 10 + + :param cfg: Config dictionary + :type cfg: Dict + :return: WirelessRouter instance. + :rtype: WirelessRouter + """ + operating_state = ( + NodeOperatingState.ON if not (p := cfg.get("operating_state")) else NodeOperatingState[p.upper()] + ) + router = cls(hostname=cfg["hostname"], operating_state=operating_state, airspace=kwargs["airspace"]) + if "router_interface" in cfg: + ip_address = cfg["router_interface"]["ip_address"] + subnet_mask = cfg["router_interface"]["subnet_mask"] + router.configure_router_interface(ip_address=ip_address, subnet_mask=subnet_mask) + if "wireless_access_point" in cfg: + ip_address = cfg["wireless_access_point"]["ip_address"] + subnet_mask = cfg["wireless_access_point"]["subnet_mask"] + frequency = AirSpaceFrequency[cfg["wireless_access_point"]["frequency"]] + router.configure_wireless_access_point(ip_address=ip_address, subnet_mask=subnet_mask, frequency=frequency) + + if "acl" in cfg: + for r_num, r_cfg in cfg["acl"].items(): + router.acl.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("src_ip"), + dst_ip_address=r_cfg.get("dst_ip"), + src_wildcard_mask=r_cfg.get("src_wildcard_mask"), + dst_wildcard_mask=r_cfg.get("dst_wildcard_mask"), + position=r_num, + ) + if "routes" in cfg: + for route in cfg.get("routes"): + router.route_table.add_route( + address=IPv4Address(route.get("address")), + subnet_mask=IPv4Address(route.get("subnet_mask", "255.255.255.0")), + next_hop_ip_address=IPv4Address(route.get("next_hop_ip_address")), + metric=float(route.get("metric", 0)), + ) + return router diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py new file mode 100644 index 00000000..f3d43e5b --- /dev/null +++ b/src/primaite/simulator/network/networks.py @@ -0,0 +1,333 @@ +from ipaddress import IPv4Address + +import yaml + +from primaite import getLogger, PRIMAITE_PATHS +from primaite.game.game import PrimaiteGame +from primaite.simulator import LogLevel, SIM_OUTPUT +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.host_node import NIC +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.web_server.web_server import WebServer + +_LOGGER = getLogger(__name__) + + +def client_server_routed() -> Network: + """ + A basic Client/Server Network routed between subnets. + + +------------+ +------------+ +------------+ +------------+ +------------+ + | | | | | | | | | | + | client_1 +------+ switch_2 +------+ router_1 +------+ switch_1 +------+ server_1 | + | | | | | | | | | | + +------------+ +------------+ +------------+ +------------+ +------------+ + + IP Table: + + """ + network = Network() + + # Router 1 + router_1 = Router(hostname="router_1", num_ports=3) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.2.1", subnet_mask="255.255.255.0") + + # Switch 1 + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() + network.connect(endpoint_a=router_1.network_interface[1], endpoint_b=switch_1.network_interface[6]) + router_1.enable_port(1) + + # Switch 2 + switch_2 = Switch(hostname="switch_2", num_ports=6) + switch_2.power_on() + network.connect(endpoint_a=router_1.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router_1.enable_port(2) + + # Client 1 + client_1 = Computer( + hostname="client_1", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + client_1.power_on() + network.connect(endpoint_b=client_1.network_interface[1], endpoint_a=switch_2.network_interface[1]) + + # Server 1 + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + network.connect(endpoint_b=server_1.network_interface[1], endpoint_a=switch_1.network_interface[1]) + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + return network + + +def arcd_uc2_network() -> Network: + """ + Models the ARCD Use Case 2 Network. + + +------------+ + | domain_ | + +------------+ controller | + | | | + | +------------+ + | + | + +------------+ | +------------+ + | | | | | + | client_1 +---------+ | +---------+ web_server | + | | | | | | | + +------------+ | | | +------------+ + +--+---------+ +------------+ +------+--+--+ + | | | | | | + | switch_2 +------+ router_1 +------+ switch_1 | + | | | | | | + +--+------+--+ +------------+ +--+---+--+--+ + +------------+ | | | | | +------------+ + | | | | | | | | database | + | client_2 +---------+ | | | +---------+ _server | + | | | | | | | + +------------+ | | | +------------+ + | +------------+ | | + | | security | | | + +---------+ _suite +---------+ | +------------+ + | | | | backup_ | + +------------+ +------------+ server | + | | + +------------+ + + + + """ + network = Network() + + # Router 1 + router_1 = Router(hostname="router_1", num_ports=5, start_up_duration=0) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.10.1", subnet_mask="255.255.255.0") + + # Switch 1 + switch_1 = Switch(hostname="switch_1", num_ports=8, start_up_duration=0) + switch_1.power_on() + network.connect(endpoint_a=router_1.network_interface[1], endpoint_b=switch_1.network_interface[8]) + router_1.enable_port(1) + + # Switch 2 + switch_2 = Switch(hostname="switch_2", num_ports=8, start_up_duration=0) + switch_2.power_on() + network.connect(endpoint_a=router_1.network_interface[2], endpoint_b=switch_2.network_interface[8]) + router_1.enable_port(2) + + # Client 1 + client_1 = Computer( + hostname="client_1", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + client_1.power_on() + network.connect(endpoint_b=client_1.network_interface[1], endpoint_a=switch_2.network_interface[1]) + client_1.software_manager.install(DatabaseClient) + db_client_1: DatabaseClient = client_1.software_manager.software.get("DatabaseClient") + db_client_1.configure(server_ip_address=IPv4Address("192.168.1.14")) + db_client_1.run() + web_browser_1 = client_1.software_manager.software.get("WebBrowser") + web_browser_1.target_url = "http://arcd.com/users/" + client_1.software_manager.install(DataManipulationBot) + db_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + db_manipulation_bot.configure( + server_ip_address=IPv4Address("192.168.1.14"), + payload="DELETE", + port_scan_p_of_success=1.0, + data_manipulation_p_of_success=1.0, + ) + + # Client 2 + client_2 = Computer( + hostname="client_2", + ip_address="192.168.10.22", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + client_2.power_on() + client_2.software_manager.install(DatabaseClient) + db_client_2 = client_2.software_manager.software.get("DatabaseClient") + db_client_2.configure(server_ip_address=IPv4Address("192.168.1.14")) + db_client_2.run() + web_browser_2 = client_2.software_manager.software.get("WebBrowser") + web_browser_2.target_url = "http://arcd.com/users/" + network.connect(endpoint_b=client_2.network_interface[1], endpoint_a=switch_2.network_interface[2]) + + # Domain Controller + domain_controller = Server( + hostname="domain_controller", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + domain_controller.power_on() + domain_controller.software_manager.install(DNSServer) + + network.connect(endpoint_b=domain_controller.network_interface[1], endpoint_a=switch_1.network_interface[1]) + + # Database Server + database_server = Server( + hostname="database_server", + ip_address="192.168.1.14", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + database_server.power_on() + network.connect(endpoint_b=database_server.network_interface[1], endpoint_a=switch_1.network_interface[3]) + + database_server.software_manager.install(DatabaseService) + database_service: DatabaseService = database_server.software_manager.software.get("DatabaseService") # noqa + database_service.start() + database_service.configure_backup(backup_server=IPv4Address("192.168.1.16")) + + # Web Server + web_server = Server( + hostname="web_server", + ip_address="192.168.1.12", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + web_server.power_on() + web_server.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address("192.168.1.14")) + network.connect(endpoint_b=web_server.network_interface[1], endpoint_a=switch_1.network_interface[2]) + database_client.run() + database_client.connect() + + web_server.software_manager.install(WebServer) + + # register the web_server to a domain + dns_server_service: DNSServer = domain_controller.software_manager.software.get("DNSServer") # noqa + dns_server_service.dns_register("arcd.com", web_server.network_interface[1].ip_address) + + # Backup Server + backup_server = Server( + hostname="backup_server", + ip_address="192.168.1.16", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + backup_server.power_on() + backup_server.software_manager.install(FTPServer) + network.connect(endpoint_b=backup_server.network_interface[1], endpoint_a=switch_1.network_interface[4]) + + # Security Suite + security_suite = Server( + hostname="security_suite", + ip_address="192.168.1.110", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), + start_up_duration=0, + ) + security_suite.power_on() + network.connect(endpoint_b=security_suite.network_interface[1], endpoint_a=switch_1.network_interface[7]) + security_suite.connect_nic(NIC(ip_address="192.168.10.110", subnet_mask="255.255.255.0")) + network.connect(endpoint_b=security_suite.network_interface[2], endpoint_a=switch_2.network_interface[7]) + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Allow PostgreSQL requests + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + # Allow DNS requests + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.DNS, dst_port=Port.DNS, position=1) + + # Allow FTP requests + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.FTP, dst_port=Port.FTP, position=2) + + # Open port 80 for web server + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + + return network + + +def network_simulator_demo_example() -> Network: + """Returns a lightly modified version of the ARCD UC2 Network.""" + # Ensure that sys_log will be viewable for demo + SIM_OUTPUT.sys_log_level = LogLevel.DEBUG + SIM_OUTPUT.save_sys_logs = True + + network = arcd_uc2_network() + network.get_node_by_hostname("router_1").route_table.add_route( + address="192.168.10.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + return network + + +def _get_example_network(path: str) -> Network: + try: + with open(path, "r") as file: + cfg = yaml.safe_load(file) + except FileNotFoundError: + msg = f"Failed to locate example network config {path}. Run `primaite setup` to load the example config files." + _LOGGER.error(msg) + raise FileNotFoundError(msg) + game = PrimaiteGame.from_config(cfg) + + return game.simulation.network + + +def client_server_p2p_network_example() -> Network: + """Get the Client-Server P2P example network.""" + path = PRIMAITE_PATHS.user_config_path / "example_config" / "client_server_p2p_network_example.yaml" + return _get_example_network(path) + + +def basic_lan_network_example() -> Network: + """Get the basic LAN example network.""" + path = PRIMAITE_PATHS.user_config_path / "example_config" / "basic_lan_network_example.yaml" + return _get_example_network(path) + + +def multi_lan_internet_network_example() -> Network: + """Get Multi-LAN with Internet example network.""" + path = PRIMAITE_PATHS.user_config_path / "example_config" / "multi_lan_internet_network_example.yaml" + return _get_example_network(path) diff --git a/src/primaite/simulator/network/nmne.py b/src/primaite/simulator/network/nmne.py new file mode 100644 index 00000000..1b3d838d --- /dev/null +++ b/src/primaite/simulator/network/nmne.py @@ -0,0 +1,47 @@ +from typing import Dict, Final, List + +CAPTURE_NMNE: bool = True +"""Indicates whether Malicious Network Events (MNEs) should be captured. Default is True.""" + +NMNE_CAPTURE_KEYWORDS: List[str] = [] +"""List of keywords to identify malicious network events.""" + +# TODO: Remove final and make configurable after example layout when the NICObservation creates nmne structure dynamically +CAPTURE_BY_DIRECTION: Final[bool] = True +"""Flag to determine if captures should be organized by traffic direction (inbound/outbound).""" +CAPTURE_BY_IP_ADDRESS: Final[bool] = False +"""Flag to determine if captures should be organized by source or destination IP address.""" +CAPTURE_BY_PROTOCOL: Final[bool] = False +"""Flag to determine if captures should be organized by network protocol (e.g., TCP, UDP).""" +CAPTURE_BY_PORT: Final[bool] = False +"""Flag to determine if captures should be organized by source or destination port.""" +CAPTURE_BY_KEYWORD: Final[bool] = False +"""Flag to determine if captures should be filtered and categorised based on specific keywords.""" + + +def set_nmne_config(nmne_config: Dict): + """ + Sets the configuration for capturing Malicious Network Events (MNEs) based on a provided dictionary. + + This function updates global settings related to NMNE capture, including whether to capture NMNEs and what + keywords to use for identifying NMNEs. + + The function ensures that the settings are updated only if they are provided in the `nmne_config` dictionary, + and maintains type integrity by checking the types of the provided values. + + :param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys include: + "capture_nmne" (bool) to indicate whether NMNEs should be captured, "nmne_capture_keywords" (list of strings) + to specify keywords for NMNE identification. + """ + global NMNE_CAPTURE_KEYWORDS + global CAPTURE_NMNE + + # Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect + CAPTURE_NMNE = nmne_config.get("capture_nmne", False) + if not isinstance(CAPTURE_NMNE, bool): + CAPTURE_NMNE = True # Revert to default True if the provided value is not a boolean + + # Update the NMNE capture keywords, appending new keywords if provided + NMNE_CAPTURE_KEYWORDS += nmne_config.get("nmne_capture_keywords", []) + if not isinstance(NMNE_CAPTURE_KEYWORDS, list): + NMNE_CAPTURE_KEYWORDS = [] # Reset to empty list if the provided value is not a list diff --git a/src/primaite/simulator/network/protocols/__init__.py b/src/primaite/simulator/network/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/protocols/arp.py b/src/primaite/simulator/network/protocols/arp.py new file mode 100644 index 00000000..2e44884a --- /dev/null +++ b/src/primaite/simulator/network/protocols/arp.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Optional + +from pydantic import BaseModel + +from primaite.simulator.network.protocols.packet import DataPacket + + +class ARPEntry(BaseModel): + """ + Represents an entry in the ARP cache. + + :param mac_address: The MAC address associated with the IP address. + :param network_interface_uuid: The UIId of the Network Interface through which the NIC with the IP address is + reachable. + """ + + mac_address: str + network_interface_uuid: str + + +class ARPPacket(DataPacket): + """ + Represents the ARP layer of a network frame. + + :param request: ARP operation. True if a request, False if a reply. + :param sender_mac_addr: Sender MAC address. + :param sender_ip_address: Sender IP address. + :param target_mac_addr: Target MAC address. + :param target_ip_address: Target IP address. + + :Example: + + >>> arp_request = ARPPacket( + ... sender_mac_addr="aa:bb:cc:dd:ee:ff", + ... sender_ip_address=IPv4Address("192.168.0.1"), + ... target_ip_address=IPv4Address("192.168.0.2") + ... ) + >>> arp_response = ARPPacket( + ... sender_mac_addr="aa:bb:cc:dd:ee:ff", + ... sender_ip_address=IPv4Address("192.168.0.1"), + ... target_ip_address=IPv4Address("192.168.0.2") + ... ) + """ + + request: bool = True + "ARP operation. True if a request, False if a reply." + sender_mac_addr: str + "Sender MAC address." + sender_ip_address: IPv4Address + "Sender IP address." + target_mac_addr: Optional[str] = None + "Target MAC address." + target_ip_address: IPv4Address + "Target IP address." + + def generate_reply(self, mac_address: str) -> ARPPacket: + """ + Generate a new ARPPacket to be sent as a response with a given mac address. + + :param mac_address: The mac_address that was being sought after from the original target IP address. + :return: A new instance of ARPPacket. + """ + return ARPPacket( + request=False, + sender_ip_address=self.target_ip_address, + sender_mac_addr=mac_address, + target_ip_address=self.sender_ip_address, + target_mac_addr=self.sender_mac_addr, + ) diff --git a/src/primaite/simulator/network/protocols/dns.py b/src/primaite/simulator/network/protocols/dns.py new file mode 100644 index 00000000..4f9be51b --- /dev/null +++ b/src/primaite/simulator/network/protocols/dns.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Optional + +from pydantic import BaseModel + +from primaite.simulator.network.protocols.packet import DataPacket + + +class DNSRequest(BaseModel): + """Represents a DNS Request packet of a network frame. + + :param domain_name_request: Domain Name Request for IP address. + """ + + domain_name_request: str + "Domain Name Request for IP address." + + +class DNSReply(BaseModel): + """Represents a DNS Reply packet of a network frame. + + :param domain_name_ip_address: IP Address of the Domain Name requested. + """ + + domain_name_ip_address: Optional[IPv4Address] = None + "IP Address of the Domain Name requested." + + +class DNSPacket(DataPacket): + """ + Represents the DNS layer of a network frame. + + :param dns_request: DNS Request packet sent by DNS Client. + :param dns_reply: DNS Reply packet generated by DNS Server. + + :Example: + + >>> dns_request = DNSPacket( + ... domain_name_request=DNSRequest(domain_name_request="www.google.co.uk"), + ... dns_reply=None + ... ) + >>> dns_response = DNSPacket( + ... dns_request=DNSRequest(domain_name_request="www.google.co.uk"), + ... dns_reply=DNSReply(domain_name_ip_address=IPv4Address("142.250.179.227")) + ... ) + """ + + dns_request: DNSRequest + "DNS Request packet sent by DNS Client." + dns_reply: Optional[DNSReply] = None + "DNS Reply packet generated by DNS Server." + + def generate_reply(self, domain_ip_address: IPv4Address) -> DNSPacket: + """Generate a new DNSPacket to be sent as a response with a DNS Reply packet which contains the IP address. + + :param domain_ip_address: The IP address that was being sought after from the original target domain name. + :return: A new instance of DNSPacket. + """ + self.dns_reply = DNSReply(domain_name_ip_address=domain_ip_address) + + return self diff --git a/src/primaite/simulator/network/protocols/ftp.py b/src/primaite/simulator/network/protocols/ftp.py new file mode 100644 index 00000000..0fd3fe43 --- /dev/null +++ b/src/primaite/simulator/network/protocols/ftp.py @@ -0,0 +1,58 @@ +from enum import Enum +from typing import Any, Optional, Union + +from primaite.simulator.network.protocols.packet import DataPacket + + +class FTPCommand(Enum): + """FTP Commands that are allowed.""" + + PORT = "PORT" + """Set a port to be used for the FTP transfer.""" + + STOR = "STOR" + """Copy or put data to the FTP server.""" + + RETR = "RETR" + """Retrieve data from the FTP server.""" + + DELE = "DELE" + """Delete the file in the specified path.""" + + RMD = "RMD" + """Remove the directory in the specified path.""" + + MKD = "MKD" + """Make a directory in the specified path.""" + + LIST = "LIST" + """Return a list of files in the specified path.""" + + QUIT = "QUIT" + """Ends connection between client and server.""" + + +class FTPStatusCode(Enum): + """Status code of the current FTP request.""" + + NOT_FOUND = 14 + """Destination not found.""" + + OK = 200 + """Command successful.""" + + ERROR = 500 + """General error code.""" + + +class FTPPacket(DataPacket): + """Represents an FTP Packet.""" + + ftp_command: FTPCommand + """Command type of the packet.""" + + ftp_command_args: Optional[Any] = None + """Arguments for command.""" + + status_code: Union[FTPStatusCode, None] = None + """Status of the response.""" diff --git a/src/primaite/simulator/network/protocols/http.py b/src/primaite/simulator/network/protocols/http.py new file mode 100644 index 00000000..b88916a9 --- /dev/null +++ b/src/primaite/simulator/network/protocols/http.py @@ -0,0 +1,64 @@ +from enum import Enum, IntEnum + +from primaite.simulator.network.protocols.packet import DataPacket + + +class HttpRequestMethod(Enum): + """Enum list of HTTP Request methods that can be handled by the simulation.""" + + GET = "GET" + """HTTP GET Method. Requests using GET should only retrieve data.""" + + HEAD = "HEAD" + """Asks for a response identical to a GET request, but without the response body.""" + + POST = "POST" + """Submit an entity to the specified resource, often causing a change in state or side effects on the server.""" + + PUT = "PUT" + """Replace all current representations of the target resource with the request payload.""" + + DELETE = "DELETE" + """Delete the specified resource.""" + + PATCH = "PATCH" + """Apply partial modifications to a resource.""" + + +class HttpStatusCode(IntEnum): + """List of available HTTP Statuses.""" + + OK = 200 + """request has succeeded.""" + + BAD_REQUEST = 400 + """Payload cannot be parsed.""" + + UNAUTHORIZED = 401 + """Auth required.""" + + NOT_FOUND = 404 + """Item not found in server.""" + + METHOD_NOT_ALLOWED = 405 + """Method is not supported by server.""" + + INTERNAL_SERVER_ERROR = 500 + """Error on the server side.""" + + +class HttpRequestPacket(DataPacket): + """Class that represents an HTTP Request Packet.""" + + request_method: HttpRequestMethod + """The HTTP Request method.""" + + request_url: str + """URL of request.""" + + +class HttpResponsePacket(DataPacket): + """Class that reprensents an HTTP Response Packet.""" + + status_code: HttpStatusCode = None + """Status code of the HTTP response.""" diff --git a/src/primaite/simulator/network/protocols/icmp.py b/src/primaite/simulator/network/protocols/icmp.py new file mode 100644 index 00000000..35b0a05d --- /dev/null +++ b/src/primaite/simulator/network/protocols/icmp.py @@ -0,0 +1,114 @@ +import secrets +from enum import Enum +from typing import Union + +from pydantic import BaseModel, field_validator, validate_call +from pydantic_core.core_schema import FieldValidationInfo + +from primaite import getLogger + +_LOGGER = getLogger(__name__) + + +class ICMPType(Enum): + """Enumeration of common ICMP (Internet Control Message Protocol) types.""" + + ECHO_REPLY = 0 + "Echo Reply message." + DESTINATION_UNREACHABLE = 3 + "Destination Unreachable." + REDIRECT = 5 + "Redirect." + ECHO_REQUEST = 8 + "Echo Request (ping)." + ROUTER_ADVERTISEMENT = 9 + "Router Advertisement." + ROUTER_SOLICITATION = 10 + "Router discovery/selection/solicitation." + TIME_EXCEEDED = 11 + "Time Exceeded." + TIMESTAMP_REQUEST = 13 + "Timestamp Request." + TIMESTAMP_REPLY = 14 + "Timestamp Reply." + + +@validate_call +def get_icmp_type_code_description(icmp_type: ICMPType, icmp_code: int) -> Union[str, None]: + """ + Maps ICMPType and code pairings to their respective description. + + :param icmp_type: An ICMPType. + :param icmp_code: An icmp code. + :return: The icmp type and code pairing description if it exists, otherwise returns None. + """ + icmp_code_descriptions = { + ICMPType.ECHO_REPLY: {0: "Echo reply"}, + ICMPType.DESTINATION_UNREACHABLE: { + 0: "Destination network unreachable", + 1: "Destination host unreachable", + 2: "Destination protocol unreachable", + 3: "Destination port unreachable", + 4: "Fragmentation required", + 5: "Source route failed", + 6: "Destination network unknown", + 7: "Destination host unknown", + 8: "Source host isolated", + 9: "Network administratively prohibited", + 10: "Host administratively prohibited", + 11: "Network unreachable for ToS", + 12: "Host unreachable for ToS", + 13: "Communication administratively prohibited", + 14: "Host Precedence Violation", + 15: "Precedence cutoff in effect", + }, + ICMPType.REDIRECT: { + 0: "Redirect Datagram for the Network", + 1: "Redirect Datagram for the Host", + }, + ICMPType.ECHO_REQUEST: {0: "Echo request"}, + ICMPType.ROUTER_ADVERTISEMENT: {0: "Router Advertisement"}, + ICMPType.ROUTER_SOLICITATION: {0: "Router discovery/selection/solicitation"}, + ICMPType.TIME_EXCEEDED: {0: "TTL expired in transit", 1: "Fragment reassembly time exceeded"}, + ICMPType.TIMESTAMP_REQUEST: {0: "Timestamp Request"}, + ICMPType.TIMESTAMP_REPLY: {0: "Timestamp reply"}, + } + return icmp_code_descriptions[icmp_type].get(icmp_code) + + +class ICMPPacket(BaseModel): + """Models an ICMP Packet.""" + + icmp_type: ICMPType = ICMPType.ECHO_REQUEST + "ICMP Type." + icmp_code: int = 0 + "ICMP Code." + identifier: int + "ICMP identifier (16 bits randomly generated)." + sequence: int = 0 + "ICMP message sequence number." + + def __init__(self, **kwargs): + if not kwargs.get("identifier"): + kwargs["identifier"] = secrets.randbits(16) + super().__init__(**kwargs) + + @field_validator("icmp_code") # noqa + @classmethod + def _icmp_type_must_have_icmp_code(cls, v: int, info: FieldValidationInfo) -> int: + """Validates the icmp_type and icmp_code.""" + icmp_type = info.data["icmp_type"] + if get_icmp_type_code_description(icmp_type, v): + return v + msg = f"No Matching ICMP code for type:{icmp_type.name}, code:{v}" + _LOGGER.error(msg) + raise ValueError(msg) + + def code_description(self) -> str: + """The icmp_code description.""" + description = get_icmp_type_code_description(self.icmp_type, self.icmp_code) + if description: + return description + msg = f"No Matching ICMP code for type:{self.icmp_type.name}, code:{self.icmp_code}" + _LOGGER.error(msg) + raise ValueError(msg) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py new file mode 100644 index 00000000..55353265 --- /dev/null +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + +from primaite.simulator.network.protocols.packet import DataPacket + + +class NTPReply(BaseModel): + """Represents a NTP Reply packet.""" + + ntp_datetime: datetime + "NTP datetime object set by NTP Server." + + +class NTPPacket(DataPacket): + """ + Represents the NTP layer of a network frame. + + :param ntp_request: NTPRequest packet from NTP client. + :param ntp_reply: NTPReply packet from NTP Server. + """ + + ntp_reply: Optional[NTPReply] = None + + def generate_reply(self, ntp_server_time: datetime) -> NTPPacket: + """Generate a NTPPacket containing the time in a NTPReply object. + + :param time: datetime object representing the time from the NTP server. + :return: A new NTPPacket object. + """ + self.ntp_reply = NTPReply(ntp_datetime=ntp_server_time) + return self diff --git a/src/primaite/simulator/network/protocols/packet.py b/src/primaite/simulator/network/protocols/packet.py new file mode 100644 index 00000000..3c99aa68 --- /dev/null +++ b/src/primaite/simulator/network/protocols/packet.py @@ -0,0 +1,17 @@ +from typing import Any + +from pydantic import BaseModel + + +class DataPacket(BaseModel): + """Data packet abstract class.""" + + payload: Any = None + """Payload content of the packet.""" + + packet_payload_size: float = 0 + """Size of the packet.""" + + def get_packet_size(self) -> float: + """Returns the size of the packet header and payload.""" + return self.packet_payload_size + float(len(self.model_dump_json().encode("utf-8"))) diff --git a/src/primaite/simulator/network/transmission/__init__.py b/src/primaite/simulator/network/transmission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py new file mode 100644 index 00000000..e3189cd8 --- /dev/null +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -0,0 +1,177 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel + +from primaite import getLogger +from primaite.simulator.network.protocols.icmp import ICMPPacket +from primaite.simulator.network.protocols.packet import DataPacket +from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol +from primaite.simulator.network.transmission.primaite_layer import PrimaiteHeader +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader, UDPHeader +from primaite.simulator.network.utils import convert_bytes_to_megabits + +_LOGGER = getLogger(__name__) + + +class EthernetHeader(BaseModel): + """ + Represents the Ethernet layer of a network frame. + + :param src_mac_addr: Source MAC address. + :param dst_mac_addr: Destination MAC address. + + :Example: + + >>> ethernet = EthernetHeader( + ... src_mac_addr='AA:BB:CC:DD:EE:FF', + ... dst_mac_addr='11:22:33:44:55:66' + ... ) + """ + + src_mac_addr: str + "Source MAC address." + dst_mac_addr: str + "Destination MAC address." + + +class Frame(BaseModel): + """ + Represents a complete network frame with all layers. + + :param ethernet: Ethernet layer. + :param ip: IP layer. + :param tcp: TCP layer. + :param payload: Payload data in the frame. + + :Example: + + >>> from ipaddress import IPv4Address + >>> frame=Frame( + ... ethernet=EthernetHeader( + ... src_mac_addr='AA:BB:CC:DD:EE:FF', + ... dst_mac_addr='11:22:33:44:55:66' + ... ), + ... ip=IPPacket( + ... src_ip_address=IPv4Address('192.168.0.1'), + ... dst_ip_address=IPv4Address('10.0.0.1'), + ... ), + ... tcp=TCPHeader( + ... src_port=8080, + ... dst_port=80, + ... ), + ... payload=b"Hello, World!" + ... ) + """ + + def __init__(self, **kwargs): + if kwargs.get("tcp") and kwargs.get("udp"): + msg = "Network Frame cannot have both a TCP header and a UDP header" + _LOGGER.error(msg) + raise ValueError(msg) + if kwargs["ip"].protocol == IPProtocol.TCP and not kwargs.get("tcp"): + msg = "Cannot build a Frame using the TCP IP Protocol without a TCPHeader" + _LOGGER.error(msg) + raise ValueError(msg) + if kwargs["ip"].protocol == IPProtocol.UDP and not kwargs.get("udp"): + msg = "Cannot build a Frame using the UDP IP Protocol without a UDPHeader" + _LOGGER.error(msg) + raise ValueError(msg) + if kwargs["ip"].protocol == IPProtocol.ICMP and not kwargs.get("icmp"): + msg = "Cannot build a Frame using the ICMP IP Protocol without a ICMPPacket" + _LOGGER.error(msg) + raise ValueError(msg) + kwargs["primaite"] = PrimaiteHeader() + + super().__init__(**kwargs) + + ethernet: EthernetHeader + "Ethernet header." + ip: IPPacket + "IP packet." + tcp: Optional[TCPHeader] = None + "TCP header." + udp: Optional[UDPHeader] = None + "UDP header." + icmp: Optional[ICMPPacket] = None + "ICMP header." + primaite: PrimaiteHeader + "PrimAITE header." + payload: Optional[Any] = None + "Raw data payload." + sent_timestamp: Optional[datetime] = None + "The time the Frame was sent from the original source NIC." + received_timestamp: Optional[datetime] = None + "The time the Frame was received at the final destination NIC." + + def decrement_ttl(self): + """Decrement the IPPacket ttl by 1.""" + self.ip.ttl -= 1 + + @property + def can_transmit(self) -> bool: + """Informs whether the Frame can transmit based on the IPPacket tll being >= 1.""" + return self.ip.ttl >= 1 + + def set_sent_timestamp(self): + """Set the sent_timestamp.""" + if not self.sent_timestamp: + self.sent_timestamp = datetime.now() + + def set_received_timestamp(self): + """Set the received_timestamp.""" + if not self.received_timestamp: + self.received_timestamp = datetime.now() + + def transmission_duration(self) -> int: + """The transmission duration in milliseconds.""" + delta = self.received_timestamp - self.sent_timestamp + return int(delta.microseconds / 1000) + + @property + def size(self) -> float: # noqa - Keep it as MBits as this is how they're expressed + """The size of the Frame in Bytes.""" + # get the payload size if it is a data packet + if isinstance(self.payload, DataPacket): + return self.payload.get_packet_size() + + return float(len(self.model_dump_json().encode("utf-8"))) + + @property + def size_Mbits(self) -> float: # noqa - Keep it as MBits as this is how they're expressed + """The daa transfer size of the Frame in Mbits.""" + return convert_bytes_to_megabits(self.size) + + @property + def is_broadcast(self) -> bool: + """ + Determines if the Frame is a broadcast frame. + + A Frame is considered a broadcast frame if the destination MAC address is set to the broadcast address + "ff:ff:ff:ff:ff:ff". + + :return: True if the destination MAC address is a broadcast address, otherwise False. + """ + return self.ethernet.dst_mac_addr.lower() == "ff:ff:ff:ff:ff:ff" + + @property + def is_arp(self) -> bool: + """ + Checks if the Frame is an ARP (Address Resolution Protocol) packet. + + This is determined by checking if the destination port of the TCP header is equal to the ARP port. + + :return: True if the Frame is an ARP packet, otherwise False. + """ + return self.udp.dst_port == Port.ARP + + @property + def is_icmp(self) -> bool: + """ + Determines if the Frame is an ICMP (Internet Control Message Protocol) packet. + + This check is performed by verifying if the 'icmp' attribute of the Frame instance is present (not None). + + :return: True if the Frame is an ICMP packet (i.e., has an ICMP header), otherwise False. + """ + return self.icmp is not None diff --git a/src/primaite/simulator/network/transmission/network_layer.py b/src/primaite/simulator/network/transmission/network_layer.py new file mode 100644 index 00000000..8ee0b4af --- /dev/null +++ b/src/primaite/simulator/network/transmission/network_layer.py @@ -0,0 +1,94 @@ +from enum import Enum + +from pydantic import BaseModel + +from primaite import getLogger +from primaite.utils.validators import IPV4Address + +_LOGGER = getLogger(__name__) + + +class IPProtocol(Enum): + """ + Enum representing transport layer protocols in IP header. + + .. _List of IPProtocols: + """ + + NONE = "none" + """Placeholder for a non-protocol.""" + TCP = "tcp" + """Transmission Control Protocol.""" + UDP = "udp" + """User Datagram Protocol.""" + ICMP = "icmp" + """Internet Control Message Protocol.""" + + +class Precedence(Enum): + """ + Enum representing the Precedence levels in Quality of Service (QoS) for IP packets. + + Precedence values range from 0 to 7, indicating different levels of priority. + + Members: + - ROUTINE: 0 - Lowest priority level, used for ordinary data traffic that does not require special treatment. + - PRIORITY: 1 - Higher priority than ROUTINE, used for traffic that needs a bit more importance. + - IMMEDIATE: 2 - Used for more urgent traffic that requires immediate handling and minimal delay. + - FLASH: 3 - Used for highly urgent and important traffic that should be processed with high priority. + - FLASH_OVERRIDE: 4 - Higher priority than FLASH, used for critical traffic that takes precedence over most traffic. + - CRITICAL: 5 - Reserved for critical commands or control messages that are vital to the operation of the network. + - INTERNET: 6 - Used for network control messages, such as routing updates, for maintaining network operations. + - NETWORK: 7 - Highest priority for the most critical network control messages, such as routing protocol hellos. + """ + + ROUTINE = 0 + "Lowest priority level, used for ordinary data traffic that does not require special treatment." + PRIORITY = 1 + "Higher priority than ROUTINE, used for traffic that needs a bit more importance." + IMMEDIATE = 2 + "Used for more urgent traffic that requires immediate handling and minimal delay." + FLASH = 3 + "Used for highly urgent and important traffic that should be processed with high priority." + FLASH_OVERRIDE = 4 + "Has higher priority than FLASH, used for critical traffic that takes precedence over most other traffic." + CRITICAL = 5 + "Reserved for critical commands or emergency control messages that are vital to the operation of the network." + INTERNET = 6 + "Used for network control messages, such as routing updates, essential for maintaining network operations." + NETWORK = 7 + "Highest priority level, used for the most critical network control messages, such as routing protocol hellos." + + +class IPPacket(BaseModel): + """ + Represents the IP layer of a network frame. + + :param src_ip_address: Source IP address. + :param dst_ip_address: Destination IP address. + :param protocol: The IP protocol (default is TCP). + :param ttl: Time to Live (TTL) for the packet. + :param precedence: Precedence level for Quality of Service (QoS). + + :Example: + + >>> from ipaddress import IPv4Address + >>> ip_packet = IPPacket( + ... src_ip_address=IPv4Address('192.168.0.1'), + ... dst_ip_address=IPv4Address('10.0.0.1'), + ... protocol=IPProtocol.TCP, + ... ttl=64, + ... precedence=Precedence.CRITICAL + ... ) + """ + + src_ip_address: IPV4Address + "Source IP address." + dst_ip_address: IPV4Address + "Destination IP address." + protocol: IPProtocol = IPProtocol.TCP + "IPProtocol." + ttl: int = 64 + "Time to Live (TTL) for the packet." + precedence: Precedence = Precedence.ROUTINE + "Precedence level for Quality of Service (default is Precedence.ROUTINE)." diff --git a/src/primaite/simulator/network/transmission/primaite_layer.py b/src/primaite/simulator/network/transmission/primaite_layer.py new file mode 100644 index 00000000..4c90c14c --- /dev/null +++ b/src/primaite/simulator/network/transmission/primaite_layer.py @@ -0,0 +1,40 @@ +from enum import Enum + +from pydantic import BaseModel + + +class DataStatus(Enum): + """ + The status of the data in transmission. + + Members: + - GOOD: 1 + - COMPROMISED: 2 + - CORRUPT: 3 + """ + + GOOD = 1 + COMPROMISED = 2 + CORRUPT = 3 + + +class AgentSource(Enum): + """ + The agent source of the transmission. + + Members: + - RED: 1 + - GREEN: 2 + - BLUE: 3 + """ + + RED = 1 + GREEN = 2 + BLUE = 3 + + +class PrimaiteHeader(BaseModel): + """A custom header for carrying PrimAITE transmission metadata required for RL.""" + + agent_source: AgentSource = AgentSource.GREEN + data_status: DataStatus = DataStatus.GOOD diff --git a/src/primaite/simulator/network/transmission/transport_layer.py b/src/primaite/simulator/network/transmission/transport_layer.py new file mode 100644 index 00000000..bf739ad1 --- /dev/null +++ b/src/primaite/simulator/network/transmission/transport_layer.py @@ -0,0 +1,132 @@ +from enum import Enum +from typing import List, Union + +from pydantic import BaseModel + + +class Port(Enum): + """ + Enumeration of common known TCP/UDP ports used by protocols for operation of network applications. + + .. _List of Ports: + """ + + UNUSED = -1 + "An unused port stub." + + NONE = 0 + "Place holder for a non-port." + WOL = 9 + "Wake-on-Lan (WOL) - Used to turn or awaken a computer from sleep mode by a network message." + FTP_DATA = 20 + "File Transfer [Default Data]" + FTP = 21 + "File Transfer Protocol (FTP) - FTP control (command)" + SSH = 22 + "Secure Shell (SSH) - Used for secure remote access and command execution." + SMTP = 25 + "Simple Mail Transfer Protocol (SMTP) - Used for email delivery between servers." + DNS = 53 + "Domain Name System (DNS) - Used for translating domain names to IP addresses." + HTTP = 80 + "HyperText Transfer Protocol (HTTP) - Used for web traffic." + POP3 = 110 + "Post Office Protocol version 3 (POP3) - Used for retrieving emails from a mail server." + SFTP = 115 + "Secure File Transfer Protocol (SFTP) - Used for secure file transfer over SSH." + NTP = 123 + "Network Time Protocol (NTP) - Used for clock synchronization between computer systems." + IMAP = 143 + "Internet Message Access Protocol (IMAP) - Used for retrieving emails from a mail server." + SNMP = 161 + "Simple Network Management Protocol (SNMP) - Used for network device management." + SNMP_TRAP = 162 + "SNMP Trap - Used for sending SNMP notifications (traps) to a network management system." + ARP = 219 + "Address resolution Protocol - Used to connect a MAC address to an IP address." + LDAP = 389 + "Lightweight Directory Access Protocol (LDAP) - Used for accessing and modifying directory information." + HTTPS = 443 + "HyperText Transfer Protocol Secure (HTTPS) - Used for secure web traffic." + SMB = 445 + "Server Message Block (SMB) - Used for file sharing and printer sharing in Windows environments." + IPP = 631 + "Internet Printing Protocol (IPP) - Used for printing over the internet or an intranet." + SQL_SERVER = 1433 + "Microsoft SQL Server Database Engine - Used for communication with the SQL Server." + MYSQL = 3306 + "MySQL Database Server - Used for MySQL database communication." + RDP = 3389 + "Remote Desktop Protocol (RDP) - Used for remote desktop access to Windows machines." + RTP = 5004 + "Real-time Transport Protocol (RTP) - Used for transmitting real-time media, e.g., audio and video." + RTP_ALT = 5005 + "Alternative port for RTP (RTP_ALT) - Used in some configurations for transmitting real-time media." + DNS_ALT = 5353 + "Alternative port for DNS (DNS_ALT) - Used in some configurations for DNS service." + HTTP_ALT = 8080 + "Alternative port for HTTP (HTTP_ALT) - Often used as an alternative HTTP port for web applications." + HTTPS_ALT = 8443 + "Alternative port for HTTPS (HTTPS_ALT) - Used in some configurations for secure web traffic." + POSTGRES_SERVER = 5432 + "Postgres SQL Server." + + +class UDPHeader(BaseModel): + """ + Represents a UDP header for the transport layer of a Network Frame. + + :param src_port: Source port. + :param dst_port: Destination port. + + :Example: + + >>> udp_header = UDPHeader( + ... src_port=Port.HTTP_ALT, + ... dst_port=Port.HTTP, + ... ) + """ + + src_port: Union[Port, int] + dst_port: Union[Port, int] + + +class TCPFlags(Enum): + """ + Enum representing TCP control flags used in a TCP connection. + + Flags are used to indicate a particular state of the connection or provide additional information. + + Members: + - SYN: (1) - Used in the first step of connection establishment phase or 3-way handshake process between two hosts. + - ACK: (2) - Used to acknowledge packets that are successfully received by the host. + - FIN: (4) - Used to request connection termination when there is no more data from the sender. + - RST: (8) - Used to terminate the connection if there is an issue with the TCP connection. + """ + + SYN = 1 + ACK = 2 + FIN = 4 + RST = 8 + + +class TCPHeader(BaseModel): + """ + Represents a TCP header for the transport layer of a Network Frame. + + :param src_port: Source port. + :param dst_port: Destination port. + :param flags: TCP flags (list of TCPFlags members). + + :Example: + + >>> tcp_header = TCPHeader( + ... src_port=Port.HTTP_ALT, + ... dst_port=Port.HTTP, + ... flags=[TCPFlags.SYN, TCPFlags.ACK] + ... ) + """ + + src_port: Port + dst_port: Port + flags: List[TCPFlags] = [TCPFlags.SYN] diff --git a/src/primaite/simulator/network/utils.py b/src/primaite/simulator/network/utils.py new file mode 100644 index 00000000..33085bd6 --- /dev/null +++ b/src/primaite/simulator/network/utils.py @@ -0,0 +1,29 @@ +from typing import Union + + +def convert_bytes_to_megabits(B: Union[int, float]) -> float: # noqa - Keep it as B as this is how Bytes are expressed + """ + Convert Bytes (file size) to Megabits (data transfer). + + Technically Mebibits - but for simplicity sake, we'll call it megabit + + :param B: The file size in Bytes. + :return: File bits to transfer in Megabits. + """ + if isinstance(B, int): + B = float(B) + bits = B * 8.0 + return bits / 1024.0**2.0 + + +def convert_megabits_to_bytes(Mbits: Union[int, float]) -> float: # noqa - The same for Mbits + """ + Convert Megabits (data transfer) to Bytes (file size). + + :param Mbits bits to transfer in Megabits. + :return: The file size in Bytes. + """ + if isinstance(Mbits, int): + Mbits = float(Mbits) + bits = Mbits * 1024.0**2.0 + return bits / 8 diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py new file mode 100644 index 00000000..9e2e5da4 --- /dev/null +++ b/src/primaite/simulator/sim_container.py @@ -0,0 +1,70 @@ +from typing import Dict + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType, SimComponent +from primaite.simulator.domain.controller import DomainController +from primaite.simulator.network.container import Network + + +class Simulation(SimComponent): + """Top-level simulation object which holds a reference to all other parts of the simulation.""" + + network: Network + # domain: DomainController + + def __init__(self, **kwargs): + """Initialise the Simulation.""" + if not kwargs.get("network"): + kwargs["network"] = Network() + + if not kwargs.get("domain"): + kwargs["domain"] = DomainController() + + super().__init__(**kwargs) + + def setup_for_episode(self, episode: int): + """Reset the original state of the SimComponent.""" + self.network.setup_for_episode(episode=episode) + + 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() + # pass through network requests to the network objects + rm.add_request("network", RequestType(func=self.network._request_manager)) + # pass through domain requests to the domain object + rm.add_request("domain", RequestType(func=self.domain._request_manager)) + # if 'do_nothing' is requested, just return a success + rm.add_request("do_nothing", RequestType(func=lambda request, context: RequestResponse(status="success"))) + return rm + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "network": self.network.describe_state(), + "domain": self.domain.describe_state(), + } + ) + return state + + def apply_timestep(self, timestep: int) -> None: + """Apply a timestep to the simulation.""" + super().apply_timestep(timestep) + self.network.apply_timestep(timestep) + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + self.network.pre_timestep(timestep) diff --git a/src/primaite/simulator/system/__init__.py b/src/primaite/simulator/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/applications/__init__.py b/src/primaite/simulator/system/applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py new file mode 100644 index 00000000..294de27b --- /dev/null +++ b/src/primaite/simulator/system/applications/application.py @@ -0,0 +1,154 @@ +from abc import abstractmethod +from enum import Enum +from typing import Any, Dict, Optional, Set + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.system.software import IOSoftware, SoftwareHealthState + + +class ApplicationOperatingState(Enum): + """Enumeration of Application Operating States.""" + + RUNNING = 1 + "The application is running." + CLOSED = 2 + "The application is closed or not running." + INSTALLING = 3 + "The application is being installed or updated." + + +class Application(IOSoftware): + """ + Represents an Application in the simulation environment. + + Applications are user-facing programs that may perform input/output operations. + """ + + operating_state: ApplicationOperatingState = ApplicationOperatingState.CLOSED + "The current operating state of the Application." + execution_control_status: str = "manual" + "Control status of the application's execution. It could be 'manual' or 'automatic'." + num_executions: int = 0 + "The number of times the application has been executed. Default is 0." + groups: Set[str] = set() + "The set of groups to which the application belongs." + install_duration: int = 2 + "How long it takes to install the application." + install_countdown: Optional[int] = None + "The countdown to the end of the installation process. None if not currently installing" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + rm.add_request("close", RequestType(func=lambda request, context: RequestResponse.from_bool(self.close()))) + return rm + + @abstractmethod + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "operating_state": self.operating_state.value, + "execution_control_status": self.execution_control_status, + "num_executions": self.num_executions, + "groups": list(self.groups), + } + ) + return state + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the application. + + :param timestep: The current timestep of the simulation. + """ + super().apply_timestep(timestep=timestep) + if self.operating_state is ApplicationOperatingState.INSTALLING: + self.install_countdown -= 1 + if self.install_countdown <= 0: + self.operating_state = ApplicationOperatingState.RUNNING + self.health_state_actual = SoftwareHealthState.GOOD + self.install_countdown = None + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + self.num_executions = 0 + + def _can_perform_action(self) -> bool: + """ + Checks if the application can perform actions. + + This is done by checking if the application is operating properly or the node it is installed + in is operational. + + Returns true if the software can perform actions. + """ + if not super()._can_perform_action(): + return False + + if self.operating_state is not self.operating_state.RUNNING: + # service is not running + self.sys_log.error(f"Cannot perform action: {self.name} is {self.operating_state.name}") + return False + + return True + + def run(self) -> None: + """Open the Application.""" + if not super()._can_perform_action(): + return + + if self.operating_state == ApplicationOperatingState.CLOSED: + self.sys_log.info(f"Running Application {self.name}") + self.operating_state = ApplicationOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) + + def _application_loop(self): + """The main application loop.""" + pass + + def close(self) -> bool: + """Close the Application.""" + if self.operating_state == ApplicationOperatingState.RUNNING: + self.sys_log.info(f"Closed Application{self.name}") + self.operating_state = ApplicationOperatingState.CLOSED + return True + + def install(self) -> None: + """Install Application.""" + super().install() + if self.operating_state == ApplicationOperatingState.CLOSED: + self.operating_state = ApplicationOperatingState.INSTALLING + self.install_countdown = self.install_duration + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to receive. + :return: True if successful, False otherwise. + """ + return super().receive(payload=payload, session_id=session_id, **kwargs) diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py new file mode 100644 index 00000000..c9661272 --- /dev/null +++ b/src/primaite/simulator/system/applications/database_client.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Any, Dict, Optional +from uuid import uuid4 + +from prettytable import MARKDOWN, PrettyTable +from pydantic import BaseModel + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode +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.software_manager import SoftwareManager + + +class DatabaseClientConnection(BaseModel): + """ + DatabaseClientConnection Class. + + This class is used to record current DatabaseConnections within the DatabaseClient class. + """ + + connection_id: str + """Connection UUID.""" + + parent_node: HostNode + """The parent Node that this connection was created on.""" + + is_active: bool = True + """Flag to state whether the connection is still active or not.""" + + @property + def client(self) -> Optional[DatabaseClient]: + """The DatabaseClient that holds this connection.""" + return self.parent_node.software_manager.software.get("DatabaseClient") + + def query(self, sql: str) -> bool: + """ + Query the databaseserver. + + :return: Boolean value + """ + if self.is_active and self.client: + return self.client._query(connection_id=self.connection_id, sql=sql) # noqa + return False + + def disconnect(self): + """Disconnect the connection.""" + if self.client and self.is_active: + self.client._disconnect(self.connection_id) # noqa + + +class DatabaseClient(Application): + """ + A DatabaseClient application. + + Extends the Application class to provide functionality for connecting, querying, and disconnecting from a + Database Service. It mainly operates over TCP protocol. + + :ivar server_ip_address: The IPv4 address of the Database Service server, defaults to None. + """ + + server_ip_address: Optional[IPv4Address] = None + server_password: Optional[str] = None + _last_connection_successful: Optional[bool] = None + _query_success_tracker: Dict[str, bool] = {} + """Keep track of connections that were established or verified during this step. Used for rewards.""" + last_query_response: Optional[Dict] = None + """Keep track of the latest query response. Used to determine rewards.""" + _server_connection_id: Optional[str] = None + """Connection ID to the Database Server.""" + client_connections: Dict[str, DatabaseClientConnection] = {} + """Keep track of active connections to Database Server.""" + _client_connection_requests: Dict[str, Optional[str]] = {} + """Dictionary of connection requests to Database Server.""" + connected: bool = False + """Boolean Value for whether connected to DB Server.""" + native_connection: Optional[DatabaseClientConnection] = None + """Native Client Connection for using the client directly (similar to psql in a terminal).""" + + def __init__(self, **kwargs): + kwargs["name"] = "DatabaseClient" + kwargs["port"] = Port.POSTGRES_SERVER + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request("execute", RequestType(func=lambda request, context: RequestResponse.from_bool(self.execute()))) + return rm + + def execute(self) -> bool: + """Execution definition for db client: perform a select query.""" + if not self._can_perform_action(): + return False + + self.num_executions += 1 # trying to connect counts as an execution + + if not self.native_connection: + self.connect() + + if self.native_connection: + return self.check_connection(connection_id=self.native_connection.connection_id) + + return False + + def describe_state(self) -> Dict: + """ + Describes the current state of the ACLRule. + + :return: A dictionary representing the current state. + """ + state = super().describe_state() + # list of connections that were established or verified during this step. + state["last_connection_successful"] = self._last_connection_successful + return state + + def show(self, markdown: bool = False): + """ + Display the client connections in tabular format. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["Connection ID", "Active"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} {self.name} Client Connections" + if self.native_connection: + table.add_row([self.native_connection.connection_id, self.native_connection.is_active]) + for connection_id, connection in self.client_connections.items(): + table.add_row([connection_id, connection.is_active]) + print(table.get_string(sortby="Connection ID")) + + def configure(self, server_ip_address: IPv4Address, server_password: Optional[str] = None): + """ + Configure the DatabaseClient to communicate with a DatabaseService. + + :param server_ip_address: The IP address of the Node the DatabaseService is on. + :param server_password: The password on the DatabaseService. + """ + self.server_ip_address = server_ip_address + self.server_password = server_password + self.sys_log.info(f"{self.name}: Configured the {self.name} with {server_ip_address=}, {server_password=}.") + + def connect(self) -> bool: + """Connect the native client connection.""" + if self.native_connection: + return True + self.native_connection = self.get_new_connection() + return self.native_connection is not None + + def disconnect(self): + """Disconnect the native client connection.""" + if self.native_connection: + self._disconnect(self.native_connection.connection_id) + self.native_connection = None + + def check_connection(self, connection_id: str) -> bool: + """Check whether the connection can be successfully re-established. + + :param connection_id: connection ID to check + :type connection_id: str + :return: Whether the connection was successfully re-established. + :rtype: bool + """ + if not self._can_perform_action(): + return False + return self._query("SELECT * FROM pg_stat_activity", connection_id=connection_id) + + def _check_client_connection(self, connection_id: str) -> bool: + """Check that client_connection_id is valid.""" + return True if connection_id in self._client_connection_requests else False + + def _connect( + self, + server_ip_address: IPv4Address, + connection_request_id: str, + password: Optional[str] = None, + is_reattempt: bool = False, + ) -> Optional[DatabaseClientConnection]: + """ + Connects the DatabaseClient to the DatabaseServer. + + :param: server_ip_address: IP address of the database server + :type: server_ip_address: IPv4Address + + :param: password: Password used to connect to the database server. Optional. + :type: password: Optional[str] + + :param: is_reattempt: True if the connect request has been reattempted. Default False + :type: is_reattempt: Optional[bool] + """ + if is_reattempt: + valid_connection = self._check_client_connection(connection_id=connection_request_id) + if valid_connection: + database_client_connection = self._client_connection_requests.pop(connection_request_id) + self.sys_log.info( + f"{self.name}: DatabaseClient connection to {server_ip_address} authorised." + f"Connection Request ID was {connection_request_id}." + ) + self.connected = True + self._last_connection_successful = True + return database_client_connection + else: + self.sys_log.warning( + f"{self.name}: DatabaseClient connection to {server_ip_address} declined." + f"Connection Request ID was {connection_request_id}." + ) + self._last_connection_successful = False + return None + payload = {"type": "connect_request", "password": password, "connection_request_id": connection_request_id} + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=server_ip_address, dest_port=self.port + ) + return self._connect( + server_ip_address=server_ip_address, + password=password, + is_reattempt=True, + connection_request_id=connection_request_id, + ) + + def _disconnect(self, connection_id: str) -> bool: + """Disconnect from the Database Service. + + If no connection_id is provided, connect from first ID in + self.client_connections. + + :param: connection_id: connection ID to disconnect. + :type: connection_id: str + + :return: bool + """ + if not self._can_perform_action(): + return False + + # if there are no connections - nothing to disconnect + if len(self.client_connections) == 0: + self.sys_log.warning(f"{self.name}: Unable to disconnect, no active connections.") + return False + if not self.client_connections.get(connection_id): + return False + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_id}, + dest_ip_address=self.server_ip_address, + dest_port=self.port, + ) + connection = self.client_connections.pop(connection_id) + self.terminate_connection(connection_id=connection_id) + + connection.is_active = False + + self.sys_log.info(f"{self.name}: DatabaseClient disconnected {connection_id} from {self.server_ip_address}") + self.connected = False + return True + + def uninstall(self) -> None: + """ + Uninstall the DatabaseClient. + + Calls disconnect on all client connections to ensure that both client and server connections are killed. + """ + while self.client_connections.values(): + client_connection = self.client_connections[next(iter(self.client_connections.keys()))] + client_connection.disconnect() + super().uninstall() + + def get_new_connection(self) -> Optional[DatabaseClientConnection]: + """Get a new connection to the DatabaseServer. + + :return: DatabaseClientConnection object + """ + if not self._can_perform_action(): + return None + connection_request_id = str(uuid4()) + self._client_connection_requests[connection_request_id] = None + + return self._connect( + server_ip_address=self.server_ip_address, + password=self.server_password, + connection_request_id=connection_request_id, + ) + + def _create_client_connection(self, connection_id: str, connection_request_id: str) -> None: + """Create a new DatabaseClientConnection Object.""" + client_connection = DatabaseClientConnection( + connection_id=connection_id, client=self, parent_node=self.software_manager.node + ) + self.client_connections[connection_id] = client_connection + self._client_connection_requests[connection_request_id] = client_connection + + def _query(self, sql: str, connection_id: str, query_id: Optional[str] = False, is_reattempt: bool = False) -> bool: + """ + Send a query to the connected database server. + + :param: sql: SQL query to send to the database server. + :type: sql: str + + :param: query_id: ID of the query, used as reference + :type: query_id: str + + :param: connection_id: ID of the connection to the database server. + :type: connection_id: str + + :param: is_reattempt: True if the query request has been reattempted. Default False + :type: is_reattempt: Optional[bool] + """ + if not query_id: + query_id = str(uuid4()) + if is_reattempt: + success = self._query_success_tracker.get(query_id) + if success: + self.sys_log.info(f"{self.name}: Query successful {sql}") + self._last_connection_successful = True + return True + self.sys_log.error(f"{self.name}: Unable to run query {sql}") + self._last_connection_successful = False + return False + else: + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "sql", "sql": sql, "uuid": query_id, "connection_id": connection_id}, + dest_ip_address=self.server_ip_address, + dest_port=self.port, + ) + return self._query(sql=sql, query_id=query_id, connection_id=connection_id, is_reattempt=True) + + def run(self) -> None: + """Run the DatabaseClient.""" + super().run() + + def query(self, sql: str) -> bool: + """ + Send a query to the Database Service. + + :param: sql: The SQL query. + :type: sql: str + + :return: True if the query was successful, otherwise False. + """ + if not self._can_perform_action(): + return False + + if not self.native_connection: + return False + + # reset last query response + self.last_query_response = None + + uuid = str(uuid4()) + self._query_success_tracker[uuid] = False + return self.native_connection.query(sql) + + def receive(self, session_id: str, payload: Any, **kwargs) -> bool: + """ + Receive a payload from the Software Manager. + + :param payload: A payload to receive. + :param session_id: The session id the payload relates to. + :return: True. + """ + if not self._can_perform_action(): + return False + if isinstance(payload, dict) and payload.get("type"): + if payload["type"] == "connect_response": + if payload["response"] is True: + # add connection + connection_id = payload["connection_id"] + self._create_client_connection( + connection_id=connection_id, connection_request_id=payload["connection_request_id"] + ) + elif payload["type"] == "sql": + self.last_query_response = payload + query_id = payload.get("uuid") + status_code = payload.get("status_code") + self._query_success_tracker[query_id] = status_code == 200 + if self._query_success_tracker[query_id]: + self.sys_log.debug(f"Received {payload=}") + elif payload["type"] == "disconnect": + connection_id = payload["connection_id"] + self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from the server") + self._disconnect(payload["connection_id"]) + return True diff --git a/src/primaite/simulator/system/applications/red_applications/__init__.py b/src/primaite/simulator/system/applications/red_applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py new file mode 100644 index 00000000..44ffda09 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py @@ -0,0 +1,239 @@ +from enum import IntEnum +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite import getLogger +from primaite.game.science import simulate_trial +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection + +_LOGGER = getLogger(__name__) + + +class DataManipulationAttackStage(IntEnum): + """ + Enumeration representing different stages of a data manipulation attack. + + This enumeration defines the various stages a data manipulation attack can be in during its lifecycle in the + simulation. Each stage represents a specific phase in the attack process. + """ + + NOT_STARTED = 0 + "Indicates that the attack has not started yet." + LOGON = 1 + "The stage where logon procedures are simulated." + PORT_SCAN = 2 + "Represents the stage of performing a horizontal port scan on the target." + ATTACKING = 3 + "Stage of actively attacking the target." + SUCCEEDED = 4 + "Indicates the attack has been successfully completed." + FAILED = 5 + "Signifies that the attack has failed." + + +class DataManipulationBot(Application): + """A bot that simulates a script which performs a SQL injection attack.""" + + payload: Optional[str] = None + port_scan_p_of_success: float = 0.1 + data_manipulation_p_of_success: float = 0.1 + + attack_stage: DataManipulationAttackStage = DataManipulationAttackStage.NOT_STARTED + repeat: bool = False + "Whether to repeat attacking once finished." + + def __init__(self, **kwargs): + kwargs["name"] = "DataManipulationBot" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + + super().__init__(**kwargs) + self._db_connection: Optional[DatabaseClientConnection] = None + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + return state + + @property + def _host_db_client(self) -> DatabaseClient: + """Return the database client that is installed on the same machine as the DataManipulationBot.""" + db_client = self.software_manager.software.get("DatabaseClient") + if db_client is None: + self.sys_log.warning(f"{self.__class__.__name__} cannot find a database client on its host.") + return db_client + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + rm.add_request( + name="execute", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.attack())), + ) + + return rm + + def configure( + self, + server_ip_address: IPv4Address, + server_password: Optional[str] = None, + payload: Optional[str] = None, + port_scan_p_of_success: float = 0.1, + data_manipulation_p_of_success: float = 0.1, + repeat: bool = True, + ): + """ + Configure the DataManipulatorBot to communicate with a DatabaseService. + + :param server_ip_address: The IP address of the Node the DatabaseService is on. + :param server_password: The password on the DatabaseService. + :param payload: The data manipulation query payload. + :param port_scan_p_of_success: The probability of success for the port scan stage. + :param data_manipulation_p_of_success: The probability of success for the data manipulation stage. + :param repeat: Whether to repeat attacking once finished. + """ + self.server_ip_address = server_ip_address + self.server_password = server_password + self.payload = payload + self.port_scan_p_of_success = port_scan_p_of_success + self.data_manipulation_p_of_success = data_manipulation_p_of_success + self.repeat = repeat + self.sys_log.info( + f"{self.name}: Configured the {self.name} with {server_ip_address=}, {payload=}, {server_password=}, " + f"{repeat=}." + ) + + def _logon(self): + """ + Simulate the logon process as the initial stage of the attack. + + Advances the attack stage to `LOGON` if successful. + """ + if self.attack_stage == DataManipulationAttackStage.NOT_STARTED: + # Bypass this stage as we're not dealing with logon for now + self.sys_log.debug(f"{self.name}: ") + self.attack_stage = DataManipulationAttackStage.LOGON + + def _perform_port_scan(self, p_of_success: Optional[float] = 0.1): + """ + Perform a simulated port scan to check for open SQL ports. + + Advances the attack stage to `PORT_SCAN` if successful. + + :param p_of_success: Probability of successful port scan, by default 0.1. + """ + if self.attack_stage == DataManipulationAttackStage.LOGON: + # perform a port scan to identify that the SQL port is open on the server + if simulate_trial(p_of_success): + self.sys_log.info(f"{self.name}: Performing port scan") + # perform the port scan + port_is_open = True # Temporary; later we can implement NMAP port scan. + if port_is_open: + self.sys_log.debug(f"{self.name}: ") + self.attack_stage = DataManipulationAttackStage.PORT_SCAN + + def _establish_db_connection(self) -> bool: + """Establish a db connection to the Database Server.""" + self._db_connection = self._host_db_client.get_new_connection() + return True if self._db_connection else False + + def _perform_data_manipulation(self, p_of_success: Optional[float] = 0.1): + """ + Execute the data manipulation attack on the target. + + Advances the attack stage to `COMPLETE` if successful, or 'FAILED' if unsuccessful. + + :param p_of_success: Probability of successfully performing data manipulation, by default 0.1. + """ + if self._host_db_client is None: + self.attack_stage = DataManipulationAttackStage.FAILED + return + + self._host_db_client.server_ip_address = self.server_ip_address + self._host_db_client.server_password = self.server_password + if self.attack_stage == DataManipulationAttackStage.PORT_SCAN: + # perform the actual data manipulation attack + if simulate_trial(p_of_success): + self.sys_log.info(f"{self.name}: Performing data manipulation") + # perform the attack + if not self._db_connection: + self._establish_db_connection() + if self._db_connection: + attack_successful = self._db_connection.query(self.payload) + self.sys_log.info(f"{self.name} payload delivered: {self.payload}") + if attack_successful: + self.sys_log.info(f"{self.name}: Data manipulation successful") + self.attack_stage = DataManipulationAttackStage.SUCCEEDED + else: + self.sys_log.warning(f"{self.name}: Data manipulation failed") + self.attack_stage = DataManipulationAttackStage.FAILED + + def run(self): + """ + Run the Data Manipulation Bot. + + Calls the parent classes execute method before starting the application loop. + """ + super().run() + + def attack(self) -> bool: + """Perform the attack steps after opening the application.""" + if not self._can_perform_action(): + self.sys_log.warning( + "Data manipulation application attempted to execute but it cannot perform actions right now." + ) + self.run() + + self.num_executions += 1 + return self._application_loop() + + def _application_loop(self) -> bool: + """ + The main application loop of the bot, handling the attack process. + + This is the core loop where the bot sequentially goes through the stages of the attack. + """ + if not self._can_perform_action(): + return False + if self.server_ip_address and self.payload: + self.sys_log.debug(f"{self.name}: Running") + self._logon() + self._perform_port_scan(p_of_success=self.port_scan_p_of_success) + self._perform_data_manipulation(p_of_success=self.data_manipulation_p_of_success) + + if self.repeat and self.attack_stage in ( + DataManipulationAttackStage.SUCCEEDED, + DataManipulationAttackStage.FAILED, + ): + self.attack_stage = DataManipulationAttackStage.NOT_STARTED + + return True + + else: + self.sys_log.warning(f"{self.name}: Failed to start as it requires both a target_ip_address and payload.") + return False + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the bot, triggering the application loop. + + :param timestep: The timestep value to update the bot's state. + """ + pass diff --git a/src/primaite/simulator/system/applications/red_applications/dos_bot.py b/src/primaite/simulator/system/applications/red_applications/dos_bot.py new file mode 100644 index 00000000..0e45aad9 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/dos_bot.py @@ -0,0 +1,181 @@ +from enum import IntEnum +from ipaddress import IPv4Address +from typing import Optional + +from primaite import getLogger +from primaite.game.science import simulate_trial +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClient + +_LOGGER = getLogger(__name__) + + +class DoSAttackStage(IntEnum): + """Enum representing the different stages of a Denial of Service attack.""" + + NOT_STARTED = 0 + "Attack not yet started." + + PORT_SCAN = 1 + "Attack is in discovery stage - checking if provided ip and port are open." + + ATTACKING = 2 + "Denial of Service attack is in progress." + + COMPLETED = 3 + "Attack is completed." + + +class DoSBot(DatabaseClient): + """A bot that simulates a Denial of Service attack.""" + + target_ip_address: Optional[IPv4Address] = None + """IP address of the target service.""" + + target_port: Optional[Port] = None + """Port of the target service.""" + + payload: Optional[str] = None + """Payload to deliver to the target service as part of the denial of service attack.""" + + repeat: bool = False + """If true, the Denial of Service bot will keep performing the attack.""" + + attack_stage: DoSAttackStage = DoSAttackStage.NOT_STARTED + """Current stage of the DoS kill chain.""" + + port_scan_p_of_success: float = 0.1 + """Probability of port scanning being sucessful.""" + + dos_intensity: float = 1.0 + """How much of the max sessions will be used by the DoS when attacking.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.name = "DoSBot" + self.max_sessions = 1000 # override normal max sessions + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + + rm.add_request( + name="execute", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.run())), + ) + + return rm + + def configure( + self, + target_ip_address: IPv4Address, + target_port: Optional[Port] = Port.POSTGRES_SERVER, + payload: Optional[str] = None, + repeat: bool = False, + port_scan_p_of_success: float = 0.1, + dos_intensity: float = 1.0, + max_sessions: int = 1000, + ): + """ + Configure the Denial of Service bot. + + :param: target_ip_address: The IP address of the Node containing the target service. + :param: target_port: The port of the target service. Optional - Default is `Port.HTTP` + :param: payload: The payload the DoS Bot will throw at the target service. Optional - Default is `None` + :param: repeat: If True, the bot will maintain the attack. Optional - Default is `True` + :param: port_scan_p_of_success: The chance of the port scan being sucessful. Optional - Default is 0.1 (10%) + :param: dos_intensity: The intensity of the DoS attack. + Multiplied with the application's max session - Default is 1.0 + :param: max_sessions: The maximum number of sessions the DoS bot will attack with. Optional - Default is 1000 + """ + self.target_ip_address = target_ip_address + self.target_port = target_port + self.payload = payload + self.repeat = repeat + self.port_scan_p_of_success = port_scan_p_of_success + self.dos_intensity = dos_intensity + self.max_sessions = max_sessions + self.sys_log.info( + f"{self.name}: Configured the {self.name} with {target_ip_address=}, {target_port=}, {payload=}, " + f"{repeat=}, {port_scan_p_of_success=}, {dos_intensity=}, {max_sessions=}." + ) + + def run(self) -> bool: + """Run the Denial of Service Bot.""" + super().run() + return self._application_loop() + + def _application_loop(self) -> bool: + """ + The main application loop for the Denial of Service bot. + + The loop goes through the stages of a DoS attack. + """ + if not self._can_perform_action(): + return False + + # DoS bot cannot do anything without a target + if not self.target_ip_address or not self.target_port: + self.sys_log.warning( + f"{self.name} is not properly configured. {self.target_ip_address=}, {self.target_port=}" + ) + return True + + self.clear_connections() + self._perform_port_scan(p_of_success=self.port_scan_p_of_success) + self._perform_dos() + + if self.repeat and self.attack_stage is DoSAttackStage.ATTACKING: + self.attack_stage = DoSAttackStage.NOT_STARTED + else: + self.attack_stage = DoSAttackStage.COMPLETED + return True + + def _perform_port_scan(self, p_of_success: Optional[float] = 0.1): + """ + Perform a simulated port scan to check for open SQL ports. + + Advances the attack stage to `PORT_SCAN` if successful. + + :param p_of_success: Probability of successful port scan, by default 0.1. + """ + if self.attack_stage == DoSAttackStage.NOT_STARTED: + # perform a port scan to identify that the SQL port is open on the server + if simulate_trial(p_of_success): + self.sys_log.info(f"{self.name}: Performing port scan") + # perform the port scan + port_is_open = True # Temporary; later we can implement NMAP port scan. + if port_is_open: + self.sys_log.debug(f"{self.name}: ") + self.attack_stage = DoSAttackStage.PORT_SCAN + + def _perform_dos(self): + """ + Perform the Denial of Service attack. + + DoSBot does this by clogging up the available connections to a service. + """ + if not self.attack_stage == DoSAttackStage.PORT_SCAN: + return + self.attack_stage = DoSAttackStage.ATTACKING + self.server_ip_address = self.target_ip_address + self.port = self.target_port + + dos_sessions = int(float(self.max_sessions) * self.dos_intensity) + for i in range(dos_sessions): + self.connect() + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the bot, iterate through the application loop. + + :param timestep: The timestep value to update the bot's state. + """ + super().apply_timestep(timestep=timestep) + self._application_loop() diff --git a/src/primaite/simulator/system/applications/red_applications/ransomware_script.py b/src/primaite/simulator/system/applications/red_applications/ransomware_script.py new file mode 100644 index 00000000..8acc07b4 --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/ransomware_script.py @@ -0,0 +1,310 @@ +from enum import IntEnum +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite.game.science import simulate_trial +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection + + +class RansomwareAttackStage(IntEnum): + """ + Enumeration representing different attack stages of the ransomware script. + + This enumeration defines the various stages a data manipulation attack can be in during its lifecycle + in the simulation. + Each stage represents a specific phase in the attack process. + """ + + NOT_STARTED = 0 + "Indicates that the attack has not started yet." + DOWNLOAD = 1 + "Installing the Encryption Script - Testing" + INSTALL = 2 + "The stage where logon procedures are simulated." + ACTIVATE = 3 + "Operating Status Changes" + PROPAGATE = 4 + "Represents the stage of performing a horizontal port scan on the target." + COMMAND_AND_CONTROL = 5 + "Represents the stage of setting up a rely C2 Beacon (Not Implemented)" + PAYLOAD = 6 + "Stage of actively attacking the target." + SUCCEEDED = 7 + "Indicates the attack has been successfully completed." + FAILED = 8 + "Signifies that the attack has failed." + + +class RansomwareScript(Application): + """Ransomware Kill Chain - Designed to be used by the TAP001 Agent on the example layout Network. + + :ivar payload: The attack stage query payload. (Default Corrupt) + :ivar target_scan_p_of_success: The probability of success for the target scan stage. + :ivar c2_beacon_p_of_success: The probability of success for the c2_beacon stage + :ivar ransomware_encrypt_p_of_success: The probability of success for the ransomware 'attack' (encrypt) stage. + :ivar repeat: Whether to repeat attacking once finished. + """ + + server_ip_address: Optional[IPv4Address] = None + """IP address of node which hosts the database.""" + server_password: Optional[str] = None + """Password required to access the database.""" + payload: Optional[str] = "ENCRYPT" + "Payload String for the payload stage" + target_scan_p_of_success: float = 0.9 + "Probability of the target scan succeeding: Default 0.9" + c2_beacon_p_of_success: float = 0.9 + "Probability of the c2 beacon setup stage succeeding: Default 0.9" + ransomware_encrypt_p_of_success: float = 0.9 + "Probability of the ransomware attack succeeding: Default 0.9" + repeat: bool = False + "If true, the Denial of Service bot will keep performing the attack." + attack_stage: RansomwareAttackStage = RansomwareAttackStage.NOT_STARTED + "The ransomware attack stage. See RansomwareAttackStage Class" + + def __init__(self, **kwargs): + kwargs["name"] = "RansomwareScript" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.NONE + + super().__init__(**kwargs) + self._db_connection: Optional[DatabaseClientConnection] = None + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + return state + + @property + def _host_db_client(self) -> DatabaseClient: + """Return the database client that is installed on the same machine as the Ransomware Script.""" + db_client = self.software_manager.software.get("DatabaseClient") + if db_client is None: + self.sys_log.warning(f"{self.__class__.__name__} cannot find a database client on its host.") + return db_client + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + name="execute", + request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.attack())), + ) + return rm + + def _activate(self): + """ + Simulate the install process as the initial stage of the attack. + + Advances the attack stage to 'ACTIVATE' attack state. + """ + if self.attack_stage == RansomwareAttackStage.INSTALL: + self.sys_log.info(f"{self.name}: Activated!") + self.attack_stage = RansomwareAttackStage.ACTIVATE + + def run(self) -> bool: + """Calls the parent classes execute method before starting the application loop.""" + super().run() + return True + + def _application_loop(self) -> bool: + """ + The main application loop of the script, handling the attack process. + + This is the core loop where the bot sequentially goes through the stages of the attack. + """ + if not self._can_perform_action(): + return False + if self.server_ip_address and self.payload: + self.sys_log.info(f"{self.name}: Running") + self.attack_stage = RansomwareAttackStage.NOT_STARTED + self._local_download() + self._install() + self._activate() + self._perform_target_scan() + self._setup_beacon() + self._perform_ransomware_encrypt() + + if self.repeat and self.attack_stage in ( + RansomwareAttackStage.SUCCEEDED, + RansomwareAttackStage.FAILED, + ): + self.attack_stage = RansomwareAttackStage.NOT_STARTED + return True + else: + self.sys_log.warning(f"{self.name}: Failed to start as it requires both a target_ip_address and payload.") + return False + + def configure( + self, + server_ip_address: IPv4Address, + server_password: Optional[str] = None, + payload: Optional[str] = None, + target_scan_p_of_success: Optional[float] = None, + c2_beacon_p_of_success: Optional[float] = None, + ransomware_encrypt_p_of_success: Optional[float] = None, + repeat: bool = True, + ): + """ + Configure the Ransomware Script to communicate with a DatabaseService. + + :param server_ip_address: The IP address of the Node the DatabaseService is on. + :param server_password: The password on the DatabaseService. + :param payload: The attack stage query (Encrypt / Delete) + :param target_scan_p_of_success: The probability of success for the target scan stage. + :param c2_beacon_p_of_success: The probability of success for the c2_beacon stage + :param ransomware_encrypt_p_of_success: The probability of success for the ransomware 'attack' (encrypt) stage. + :param repeat: Whether to repeat attacking once finished. + """ + if server_ip_address: + self.server_ip_address = server_ip_address + if server_password: + self.server_password = server_password + if payload: + self.payload = payload + if target_scan_p_of_success: + self.target_scan_p_of_success = target_scan_p_of_success + if c2_beacon_p_of_success: + self.c2_beacon_p_of_success = c2_beacon_p_of_success + if ransomware_encrypt_p_of_success: + self.ransomware_encrypt_p_of_success = ransomware_encrypt_p_of_success + if repeat: + self.repeat = repeat + self.sys_log.info( + f"{self.name}: Configured the {self.name} with {server_ip_address=}, {payload=}, {server_password=}, " + f"{repeat=}." + ) + + def _install(self): + """ + Simulate the install stage in the kill-chain. + + Advances the attack stage to 'ACTIVATE' if successful. + + From this attack stage onwards. + the ransomware application is now visible from this point onwardin the observation space. + """ + if self.attack_stage == RansomwareAttackStage.DOWNLOAD: + self.sys_log.info(f"{self.name}: Malware installed on the local file system") + downloads_folder = self.file_system.get_folder(folder_name="downloads") + ransomware_file = downloads_folder.get_file(file_name="ransom_script.pdf") + ransomware_file.num_access += 1 + self.attack_stage = RansomwareAttackStage.INSTALL + + def _setup_beacon(self): + """ + Simulates setting up a c2 beacon; currently a pseudo step for increasing red variance. + + Advances the attack stage to 'COMMAND AND CONTROL` if successful. + + :param p_of_sucess: Probability of a successful c2 setup (Advancing this step), + by default the success rate is 0.5 + """ + if self.attack_stage == RansomwareAttackStage.PROPAGATE: + self.sys_log.info(f"{self.name} Attempting to set up C&C Beacon - Scan 1/2") + if simulate_trial(self.c2_beacon_p_of_success): + self.sys_log.info(f"{self.name} C&C Successful setup - Scan 2/2") + c2c_setup = True # TODO Implement the c2c step via an FTP Application/Service + if c2c_setup: + self.attack_stage = RansomwareAttackStage.COMMAND_AND_CONTROL + + def _perform_target_scan(self): + """ + Perform a simulated port scan to check for open SQL ports. + + Advances the attack stage to `PROPAGATE` if successful. + + :param p_of_success: Probability of successful port scan, by default 0.1. + """ + if self.attack_stage == RansomwareAttackStage.ACTIVATE: + # perform a port scan to identify that the SQL port is open on the server + self.sys_log.info(f"{self.name}: Scanning for vulnerable databases - Scan 0/2") + if simulate_trial(self.target_scan_p_of_success): + self.sys_log.info(f"{self.name}: Found a target database! Scan 1/2") + port_is_open = True # TODO Implement a NNME Triggering scan as a seperate Red Application + if port_is_open: + self.attack_stage = RansomwareAttackStage.PROPAGATE + + def attack(self) -> bool: + """Perform the attack steps after opening the application.""" + if not self._can_perform_action(): + self.sys_log.warning("Ransomware application is unable to perform it's actions.") + self.run() + self.num_executions += 1 + return self._application_loop() + + def _establish_db_connection(self) -> bool: + """Establish a db connection to the Database Server.""" + self._db_connection = self._host_db_client.get_new_connection() + return True if self._db_connection else False + + def _perform_ransomware_encrypt(self): + """ + Execute the Ransomware Encrypt payload on the target. + + Advances the attack stage to `COMPLETE` if successful, or 'FAILED' if unsuccessful. + :param p_of_success: Probability of successfully performing ransomware encryption, by default 0.1. + """ + if self._host_db_client is None: + self.sys_log.info(f"{self.name}: Failed to connect to db_client - Ransomware Script") + self.attack_stage = RansomwareAttackStage.FAILED + return + + self._host_db_client.server_ip_address = self.server_ip_address + self._host_db_client.server_password = self.server_password + if self.attack_stage == RansomwareAttackStage.COMMAND_AND_CONTROL: + if simulate_trial(self.ransomware_encrypt_p_of_success): + self.sys_log.info(f"{self.name}: Attempting to launch payload") + if not self._db_connection: + self._establish_db_connection() + if self._db_connection: + attack_successful = self._db_connection.query(self.payload) + self.sys_log.info(f"{self.name} Payload delivered: {self.payload}") + if attack_successful: + self.sys_log.info(f"{self.name}: Payload Successful") + self.attack_stage = RansomwareAttackStage.SUCCEEDED + else: + self.sys_log.info(f"{self.name}: Payload failed") + self.attack_stage = RansomwareAttackStage.FAILED + else: + self.sys_log.warning("Attack Attempted to launch too quickly") + self.attack_stage = RansomwareAttackStage.FAILED + + def _local_download(self): + """Downloads itself via the onto the local file_system.""" + if self.attack_stage == RansomwareAttackStage.NOT_STARTED: + if self._local_download_verify(): + self.attack_stage = RansomwareAttackStage.DOWNLOAD + else: + self.sys_log.info("Malware failed to create a installation location") + self.attack_stage = RansomwareAttackStage.FAILED + else: + self.sys_log.info("Malware failed to download") + self.attack_stage = RansomwareAttackStage.FAILED + + def _local_download_verify(self) -> bool: + """Verifies a download location - Creates one if needed.""" + for folder in self.file_system.folders: + if self.file_system.folders[folder].name == "downloads": + self.file_system.num_file_creations += 1 + return True + + self.file_system.create_folder("downloads") + self.file_system.create_file(folder_name="downloads", file_name="ransom_script.pdf") + return True diff --git a/src/primaite/simulator/system/applications/web_browser.py b/src/primaite/simulator/system/applications/web_browser.py new file mode 100644 index 00000000..0e6fec00 --- /dev/null +++ b/src/primaite/simulator/system/applications/web_browser.py @@ -0,0 +1,218 @@ +from enum import Enum +from ipaddress import IPv4Address +from typing import Dict, List, Optional +from urllib.parse import urlparse + +from pydantic import BaseModel, ConfigDict + +from primaite import getLogger +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.protocols.http import ( + HttpRequestMethod, + HttpRequestPacket, + HttpResponsePacket, + HttpStatusCode, +) +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.services.dns.dns_client import DNSClient + +_LOGGER = getLogger(__name__) + + +class WebBrowser(Application): + """ + Represents a web browser in the simulation environment. + + The application requests and loads web pages using its domain name and requesting IP addresses using DNS. + """ + + target_url: Optional[str] = None + + domain_name_ip_address: Optional[IPv4Address] = None + "The IP address of the domain name for the webpage." + + latest_response: Optional[HttpResponsePacket] = None + """Keeps track of the latest HTTP response.""" + + history: List["BrowserHistoryItem"] = [] + """Keep a log of visited websites and information about the visit, such as response code.""" + + def __init__(self, **kwargs): + kwargs["name"] = "WebBrowser" + kwargs["protocol"] = IPProtocol.TCP + # default for web is port 80 + if kwargs.get("port") is None: + kwargs["port"] = Port.HTTP + + super().__init__(**kwargs) + self.run() + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + name="execute", + request_type=RequestType( + func=lambda request, context: RequestResponse.from_bool(self.get_webpage()) + ), # noqa + ) + + return rm + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of the WebBrowser. + + :return: A dictionary capturing the current state of the WebBrowser and its child objects. + """ + state = super().describe_state() + state["history"] = [hist_item.state() for hist_item in self.history] + return state + + def get_webpage(self, url: Optional[str] = None) -> bool: + """ + Retrieve the webpage. + + This should send a request to the web server which also requests for a list of users + + :param: url: The address of the web page the browser requests + :type: url: str + """ + url = url or self.target_url + if not self._can_perform_action(): + return False + + self.num_executions += 1 # trying to connect counts as an execution + + # reset latest response + self.latest_response = HttpResponsePacket(status_code=HttpStatusCode.NOT_FOUND) + + try: + parsed_url = urlparse(url) + except Exception: + self.sys_log.warning(f"{url} is not a valid URL") + return False + + # get the IP address of the domain name via DNS + dns_client: DNSClient = self.software_manager.software.get("DNSClient") + domain_exists = dns_client.check_domain_exists(target_domain=parsed_url.hostname) + + # if domain does not exist, the request fails + if domain_exists: + # set current domain name IP address + self.domain_name_ip_address = dns_client.dns_cache[parsed_url.hostname] + else: + # check if url is an ip address + try: + self.domain_name_ip_address = IPv4Address(parsed_url.hostname) + except Exception: + # unable to deal with this request + self.sys_log.warning(f"{self.name}: Unable to resolve URL {url}") + return False + + # create HTTPRequest payload + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url=url) + + # send request - As part of the self.send call, a response will be received and stored in the + # self.latest_response variable + if self.send( + payload=payload, + dest_ip_address=self.domain_name_ip_address, + dest_port=parsed_url.port if parsed_url.port else Port.HTTP, + ): + self.sys_log.info( + f"{self.name}: Received HTTP {payload.request_method.name} " + f"Response {payload.request_url} - {self.latest_response.status_code.value}" + ) + self.history.append( + WebBrowser.BrowserHistoryItem( + url=url, + status=self.BrowserHistoryItem._HistoryItemStatus.LOADED, + response_code=self.latest_response.status_code, + ) + ) + return self.latest_response.status_code is HttpStatusCode.OK + else: + self.sys_log.warning(f"{self.name}: Error sending Http Packet") + self.sys_log.debug(f"{self.name}: {payload=}") + self.history.append( + WebBrowser.BrowserHistoryItem( + url=url, status=self.BrowserHistoryItem._HistoryItemStatus.SERVER_UNREACHABLE + ) + ) + return False + + def send( + self, + payload: HttpRequestPacket, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = Port.HTTP, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + :param payload: The payload to be sent. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + :param session_id: The Session ID the payload is to originate from. Optional. + + :return: True if successful, False otherwise. + """ + self.sys_log.info(f"{self.name}: Sending HTTP {payload.request_method.name} {payload.request_url}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + + def receive(self, payload: HttpResponsePacket, session_id: Optional[str] = None, **kwargs) -> bool: + """ + Receives a payload from the SessionManager. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :return: True if successful, False otherwise. + """ + if not isinstance(payload, HttpResponsePacket): + self.sys_log.warning(f"{self.name} received a packet that is not an HttpResponsePacket") + self.sys_log.debug(f"{self.name}: {payload=}") + return False + self.sys_log.info(f"{self.name}: Received HTTP {payload.status_code.value}") + self.latest_response = payload + return True + + class BrowserHistoryItem(BaseModel): + """Simple representation of browser history, used for tracking success of web requests to calculate rewards.""" + + model_config = ConfigDict(extra="forbid") + """Error if incorrect specification.""" + + url: str + """The URL that was attempted to be fetched by the browser""" + + class _HistoryItemStatus(Enum): + NOT_SENT = "NOT_SENT" + PENDING = "PENDING" + SERVER_UNREACHABLE = "SERVER_UNREACHABLE" + LOADED = "LOADED" + + status: _HistoryItemStatus = _HistoryItemStatus.PENDING + + response_code: Optional[HttpStatusCode] = None + """HTTP response code that was received, or PENDING if a response was not yet received.""" + + def state(self) -> Dict: + """Return the contents of this dataclass as a dict for use with describe_state method.""" + if self.status == self._HistoryItemStatus.LOADED: + outcome = self.response_code.value + else: + outcome = self.status.value + return {"url": self.url, "outcome": outcome} diff --git a/src/primaite/simulator/system/core/__init__.py b/src/primaite/simulator/system/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/core/packet_capture.py b/src/primaite/simulator/system/core/packet_capture.py new file mode 100644 index 00000000..bc8a0584 --- /dev/null +++ b/src/primaite/simulator/system/core/packet_capture.py @@ -0,0 +1,138 @@ +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from primaite.simulator import SIM_OUTPUT + + +class _JSONFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + """Filter logs that start and end with '{' and '}' (JSON-like messages).""" + return record.getMessage().startswith("{") and record.getMessage().endswith("}") + + +class PacketCapture: + """ + Represents a PacketCapture component on a Node in the simulation environment. + + PacketCapture is a service that logs Frames as json strings; It's Wireshark for PrimAITE. + + The PCAPs are logged to: //__pcap.log + """ + + _logger_instances: List[logging.Logger] = [] + + def __init__( + self, + hostname: str, + ip_address: Optional[str] = None, + port_num: Optional[int] = None, + port_name: Optional[str] = None, + ): + """ + Initialize the PacketCapture process. + + :param hostname: The hostname for which PCAP logs are being recorded. + :param ip_address: The IP address associated with the PCAP logs. + """ + self.hostname: str = hostname + "The hostname for which PCAP logs are being recorded." + self.ip_address: str = ip_address + "The IP address associated with the PCAP logs." + self.port_num = port_num + "The interface num on the Node." + + self.port_name = port_name + "The interface name on the Node." + + self.inbound_logger = None + self.outbound_logger = None + + self.current_episode: int = 1 + + if SIM_OUTPUT.save_pcap_logs: + self.setup_logger(outbound=False) + self.setup_logger(outbound=True) + + def setup_logger(self, outbound: bool = False): + """Set up the logger configuration.""" + log_path = self._get_log_path(outbound) + + file_handler = logging.FileHandler(filename=log_path) + file_handler.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs + + log_format = "%(message)s" + file_handler.setFormatter(logging.Formatter(log_format)) + + if outbound: + self.outbound_logger = logging.getLogger(self._get_logger_name(outbound)) + PacketCapture._logger_instances.append(self.outbound_logger) + logger = self.outbound_logger + else: + self.inbound_logger = logging.getLogger(self._get_logger_name(outbound)) + logger = self.inbound_logger + PacketCapture._logger_instances.append(self.inbound_logger) + + logger.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs + logger.addHandler(file_handler) + + logger.addFilter(_JSONFilter()) + + def read(self) -> List[Dict[str, Any]]: + """ + Read packet capture logs and return them as a list of dictionaries. + + :return: List of frames captured, represented as dictionaries. + """ + frames = [] + with open(self._get_log_path(), "r") as file: + while line := file.readline(): + frames.append(json.loads(line.rstrip())) + return frames + + def _get_logger_name(self, outbound: bool = False) -> str: + """Get PCAP the logger name.""" + if self.port_name: + return f"{self.hostname}_{self.port_name}_{'outbound' if outbound else 'inbound'}_pcap" + if self.ip_address: + return f"{self.hostname}_{self.ip_address}_{'outbound' if outbound else 'inbound'}_pcap" + if self.port_num: + return f"{self.hostname}_port-{self.port_num}_{'outbound' if outbound else 'inbound'}_pcap" + return f"{self.hostname}_{'outbound' if outbound else 'inbound'}_pcap" + + def _get_log_path(self, outbound: bool = False) -> Path: + """Get the path for the log file.""" + root = SIM_OUTPUT.path / f"episode_{self.current_episode}" / self.hostname + root.mkdir(exist_ok=True, parents=True) + return root / f"{self._get_logger_name(outbound)}.log" + + def capture_inbound(self, frame): # noqa - I'll have a circular import and cant use if TYPE_CHECKING ;( + """ + Capture an inbound Frame and log it. + + :param frame: The PCAP frame to capture. + """ + if SIM_OUTPUT.save_pcap_logs: + msg = frame.model_dump_json() + self.inbound_logger.log(level=60, msg=msg) # Log at custom log level > CRITICAL + + def capture_outbound(self, frame): # noqa - I'll have a circular import and cant use if TYPE_CHECKING ;( + """ + Capture an outbound Frame and log it. + + :param frame: The PCAP frame to capture. + """ + if SIM_OUTPUT.save_pcap_logs: + msg = frame.model_dump_json() + self.outbound_logger.log(level=60, msg=msg) # Log at custom log level > CRITICAL + + @staticmethod + def clear(): + """Close all open PCAP file handlers.""" + for logger in PacketCapture._logger_instances: + handlers = logger.handlers[:] + for handler in handlers: + logger.removeHandler(handler) + handler.close() + PacketCapture._logger_instances = [] diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py new file mode 100644 index 00000000..68f44dca --- /dev/null +++ b/src/primaite/simulator/system/core/session_manager.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union + +from prettytable import MARKDOWN, PrettyTable + +from primaite.simulator.core import SimComponent +from primaite.simulator.network.protocols.arp import ARPPacket +from primaite.simulator.network.protocols.icmp import ICMPPacket +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader, UDPHeader + +if TYPE_CHECKING: + from primaite.simulator.network.hardware.base import NetworkInterface + from primaite.simulator.system.core.software_manager import SoftwareManager + from primaite.simulator.system.core.sys_log import SysLog + + +class Session(SimComponent): + """ + Models a network session. + + Encapsulates information related to communication between two network endpoints, including the protocol, + source and destination IPs and ports. + + :param protocol: The IP protocol used in the session. + :param src_ip_address: The source IP address. + :param dst_ip_address: The destination IP address. + :param src_port: The source port number (optional). + :param dst_port: The destination port number (optional). + :param connected: A flag indicating whether the session is connected. + """ + + protocol: IPProtocol + with_ip_address: IPv4Address + src_port: Optional[Port] + dst_port: Optional[Port] + connected: bool = False + + @classmethod + def from_session_key(cls, session_key: Tuple[IPProtocol, IPv4Address, Optional[Port], Optional[Port]]) -> Session: + """ + Create a Session instance from a session key tuple. + + :param session_key: Tuple containing the session details. + :return: A Session instance. + """ + protocol, with_ip_address, src_port, dst_port = session_key + return Session( + protocol=protocol, + with_ip_address=with_ip_address, + src_port=src_port, + dst_port=dst_port, + ) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + pass + + +class SessionManager: + """ + Manages network sessions, including session creation, lookup, and communication with other components. + + :param sys_log: A reference to the system log component. + """ + + def __init__(self, sys_log: SysLog): + self.sessions_by_key: Dict[ + Tuple[IPProtocol, IPv4Address, IPv4Address, Optional[Port], Optional[Port]], Session + ] = {} + self.sessions_by_uuid: Dict[str, Session] = {} + self.sys_log: SysLog = sys_log + self.software_manager: SoftwareManager = None # Noqa + self.node: Node = None # noqa + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + pass + + def clear(self): + """Clears the sessions.""" + self.sessions_by_key.clear() + self.sessions_by_uuid.clear() + + @staticmethod + def _get_session_key( + frame: Frame, inbound_frame: bool = True + ) -> Tuple[IPProtocol, IPv4Address, Optional[Port], Optional[Port]]: + """ + Extracts the session key from the given frame. + + The session key is a tuple containing the following elements: + - IPProtocol: The transport protocol (e.g. TCP, UDP, ICMP). + - IPv4Address: The source IP address. + - IPv4Address: The destination IP address. + - Optional[Port]: The source port number (if applicable). + - Optional[Port]: The destination port number (if applicable). + + :param frame: The network frame from which to extract the session key. + :return: A tuple containing the session key. + """ + protocol = frame.ip.protocol + with_ip_address = frame.ip.src_ip_address + if protocol == IPProtocol.TCP: + if inbound_frame: + src_port = frame.tcp.src_port + dst_port = frame.tcp.dst_port + else: + dst_port = frame.tcp.src_port + src_port = frame.tcp.dst_port + with_ip_address = frame.ip.dst_ip_address + elif protocol == IPProtocol.UDP: + if inbound_frame: + src_port = frame.udp.src_port + dst_port = frame.udp.dst_port + else: + dst_port = frame.udp.src_port + src_port = frame.udp.dst_port + with_ip_address = frame.ip.dst_ip_address + else: + src_port = None + dst_port = None + return protocol, with_ip_address, src_port, dst_port + + def resolve_outbound_network_interface(self, dst_ip_address: IPv4Address) -> Optional["NetworkInterface"]: + """ + Resolves the appropriate outbound network interface for a given destination IP address. + + This method determines the most suitable network interface for sending a packet to the specified + destination IP address. It considers only enabled network interfaces and checks if the destination + IP address falls within the subnet of each interface. If no suitable local network interface is found, + the method defaults to using the network interface associated with the default gateway. + + The search process prioritises local network interfaces based on the IP network to which they belong. + If the destination IP address does not match any local subnet, the method assumes that the destination + is outside the local network and hence, routes the packet through the default gateway's network interface. + + :param dst_ip_address: The destination IP address for which the outbound interface is to be resolved. + :type dst_ip_address: IPv4Address + :return: The network interface through which the packet should be sent to reach the destination IP address, + or the default gateway's network interface if the destination is not within any local subnet. + :rtype: Optional["NetworkInterface"] + """ + for network_interface in self.node.network_interfaces.values(): + if dst_ip_address in network_interface.ip_network and network_interface.enabled: + return network_interface + return self.software_manager.arp.get_default_gateway_network_interface() + + def resolve_outbound_transmission_details( + self, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + protocol: Optional[IPProtocol] = None, + session_id: Optional[str] = None, + ) -> Tuple[ + Optional["NetworkInterface"], + Optional[str], + IPv4Address, + Optional[Port], + Optional[Port], + Optional[IPProtocol], + bool, + ]: + """ + Resolves the necessary details for outbound transmission based on the provided parameters. + + This method determines whether the payload should be broadcast or unicast based on the destination IP address + and resolves the outbound network interface and destination MAC address accordingly. + + The method first checks if `session_id` is provided and uses the session details if available. For broadcast + transmissions, it finds a suitable network interface and uses a broadcast MAC address. For unicast + transmissions, it attempts to resolve the destination MAC address using ARP and finds the appropriate + outbound network interface. If the destination IP address is outside the local network and no specific MAC + address is resolved, it uses the default gateway for the transmission. + + :param dst_ip_address: The destination IP address or network. If an IPv4Network is provided, the method + treats the transmission as a broadcast to that network. Optional. + :type dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] + :param src_port: The source port number for the transmission. Optional. + :type src_port: Optional[Port] + :param dst_port: The destination port number for the transmission. Optional. + :type dst_port: Optional[Port] + :param protocol: The IP protocol to be used for the transmission. Optional. + :type protocol: Optional[IPProtocol] + :param session_id: The session ID associated with the transmission. If provided, the session details override + other parameters. Optional. + :type session_id: Optional[str] + :return: A tuple containing the resolved outbound network interface, destination MAC address, destination IP + address, source port, destination port, protocol, and a boolean indicating whether the transmission is a + broadcast. + :rtype: Tuple[Optional["NetworkInterface"], Optional[str], IPv4Address, Optional[Port], Optional[Port], + Optional[IPProtocol], bool] + """ + if dst_ip_address and not isinstance(dst_ip_address, (IPv4Address, IPv4Network)): + dst_ip_address = IPv4Address(dst_ip_address) + is_broadcast = False + outbound_network_interface = None + dst_mac_address = None + + # Use session details if session_id is provided + if session_id: + session = self.sessions_by_uuid[session_id] + + dst_ip_address = session.with_ip_address + protocol = session.protocol + src_port = session.src_port + dst_port = session.dst_port + + # Determine if the payload is for broadcast or unicast + + # Handle broadcast transmission + if isinstance(dst_ip_address, IPv4Network): + is_broadcast = True + dst_ip_address = dst_ip_address.broadcast_address + if dst_ip_address: + # Find a suitable NIC for the broadcast + for network_interface in self.node.network_interfaces.values(): + if dst_ip_address in network_interface.ip_network and network_interface.enabled: + dst_mac_address = "ff:ff:ff:ff:ff:ff" + outbound_network_interface = network_interface + break + else: + # Resolve MAC address for unicast transmission + use_default_gateway = True + for network_interface in self.node.network_interfaces.values(): + if dst_ip_address in network_interface.ip_network and network_interface.enabled: + dst_mac_address = self.software_manager.arp.get_arp_cache_mac_address(dst_ip_address) + break + + if dst_mac_address: + use_default_gateway = False + outbound_network_interface = self.software_manager.arp.get_arp_cache_network_interface(dst_ip_address) + + if use_default_gateway: + dst_mac_address = self.software_manager.arp.get_default_gateway_mac_address() + outbound_network_interface = self.software_manager.arp.get_default_gateway_network_interface() + return outbound_network_interface, dst_mac_address, dst_ip_address, src_port, dst_port, protocol, is_broadcast + + def receive_payload_from_software_manager( + self, + payload: Any, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + session_id: Optional[str] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + icmp_packet: Optional[ICMPPacket] = None, + ) -> Union[Any, None]: + """ + Receive a payload from the SoftwareManager and send it to the appropriate NIC for transmission. + + This method supports both unicast and Layer 3 broadcast transmissions. If `dst_ip_address` is an + IPv4Network, a broadcast is initiated. For unicast, the destination MAC address is resolved via ARP. + A new session is established if `session_id` is not provided, and an existing session is used otherwise. + + :param payload: The payload to be sent. + :param dst_ip_address: The destination IP address or network for broadcast. Optional. + :param dst_port: The destination port for the TCP packet. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: The outcome of sending the frame, or None if sending was unsuccessful. + """ + if isinstance(payload, ARPPacket): + # ARP requests need to be handles differently + if payload.request: + dst_mac_address = "ff:ff:ff:ff:ff:ff" + else: + dst_mac_address = payload.target_mac_addr + outbound_network_interface = self.resolve_outbound_network_interface(payload.target_ip_address) + is_broadcast = payload.request + ip_protocol = IPProtocol.UDP + else: + vals = self.resolve_outbound_transmission_details( + dst_ip_address=dst_ip_address, + src_port=src_port, + dst_port=dst_port, + protocol=ip_protocol, + session_id=session_id, + ) + ( + outbound_network_interface, + dst_mac_address, + dst_ip_address, + src_port, + dst_port, + protocol, + is_broadcast, + ) = vals + if protocol: + ip_protocol = protocol + + # Check if outbound NIC and destination MAC address are resolved + if not outbound_network_interface or not dst_mac_address: + return False + + if not (src_port or dst_port): + raise ValueError( + "Failed to resolve src or dst port. Have you sent the port from the service or application?" + ) + + tcp_header = None + udp_header = None + if ip_protocol == IPProtocol.TCP: + tcp_header = TCPHeader( + src_port=dst_port, + dst_port=dst_port, + ) + elif ip_protocol == IPProtocol.UDP: + udp_header = UDPHeader( + src_port=dst_port, + dst_port=dst_port, + ) + # TODO: Only create IP packet if not ARP + # ip_packet = None + # if dst_port != Port.ARP: + # IPPacket( + # src_ip_address=outbound_network_interface.ip_address, + # dst_ip_address=dst_ip_address, + # protocol=ip_protocol + # ) + # Construct the frame for transmission + frame = Frame( + ethernet=EthernetHeader(src_mac_addr=outbound_network_interface.mac_address, dst_mac_addr=dst_mac_address), + ip=IPPacket( + src_ip_address=outbound_network_interface.ip_address, + dst_ip_address=dst_ip_address, + protocol=ip_protocol, + ), + tcp=tcp_header, + udp=udp_header, + icmp=icmp_packet, + payload=payload, + ) + + # Manage session for unicast transmission + # TODO: Only create sessions for TCP + if not (is_broadcast and session_id): + session_key = self._get_session_key(frame, inbound_frame=False) + session = self.sessions_by_key.get(session_key) + if not session: + # Create a new session if it doesn't exist + session = Session.from_session_key(session_key) + self.sessions_by_key[session_key] = session + self.sessions_by_uuid[session.uuid] = session + + # Send the frame through the NIC + return outbound_network_interface.send_frame(frame) + + def receive_frame(self, frame: Frame, from_network_interface: "NetworkInterface"): + """ + Receive a Frame. + + Extract the session key using the _get_session_key method, and forward the payload to the appropriate + session. If the session does not exist, a new one is created. + + :param frame: The frame being received. + """ + # TODO: Only create sessions for TCP + session_key = self._get_session_key(frame, inbound_frame=True) + session: Session = self.sessions_by_key.get(session_key) + if not session: + # Create new session + session = Session.from_session_key(session_key) + self.sessions_by_key[session_key] = session + self.sessions_by_uuid[session.uuid] = session + dst_port = None + if frame.tcp: + dst_port = frame.tcp.dst_port + elif frame.udp: + dst_port = frame.udp.dst_port + elif frame.icmp: + dst_port = Port.NONE + self.software_manager.receive_payload_from_session_manager( + payload=frame.payload, + port=dst_port, + protocol=frame.ip.protocol, + session_id=session.uuid, + from_network_interface=from_network_interface, + frame=frame, + ) + + def show(self, markdown: bool = False): + """ + Print tables describing the SessionManager. + + Generate and print PrettyTable instances that show details about + session's destination IP Address, destination Ports and the protocol to use. + Output can be in Markdown format. + + :param markdown: Use Markdown style in table output. Defaults to False. + """ + table = PrettyTable(["Destination IP", "Port", "Protocol"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} Session Manager" + for session in self.sessions_by_key.values(): + table.add_row([session.dst_ip_address, session.dst_port.value, session.protocol.name]) + print(table) diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py new file mode 100644 index 00000000..0487cb7b --- /dev/null +++ b/src/primaite/simulator/system/core/software_manager.py @@ -0,0 +1,225 @@ +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union + +from prettytable import MARKDOWN, PrettyTable + +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application, ApplicationOperatingState +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.services.service import Service, ServiceOperatingState +from primaite.simulator.system.software import IOSoftware + +if TYPE_CHECKING: + from primaite.simulator.system.core.session_manager import SessionManager + from primaite.simulator.system.core.sys_log import SysLog + from primaite.simulator.network.hardware.base import Node, NIC + from primaite.simulator.system.services.arp.arp import ARP + from primaite.simulator.system.services.icmp.icmp import ICMP + +from typing import Type, TypeVar + +IOSoftwareClass = TypeVar("IOSoftwareClass", bound=IOSoftware) + + +class SoftwareManager: + """ + Manages all running services and applications on a network node and facilitates their communication. + + This class is responsible for installing, uninstalling, and managing the operational state of various network + services and applications. It acts as a bridge between the node's session manager and its software components, + ensuring that incoming and outgoing network payloads are correctly routed to and from the appropriate services + or applications. + """ + + def __init__( + self, + parent_node: "Node", + session_manager: "SessionManager", + sys_log: SysLog, + file_system: FileSystem, + dns_server: Optional[IPv4Address], + ): + """ + Initialize a new instance of SoftwareManager. + + :param session_manager: The session manager handling network communications. + """ + self.node = parent_node + self.session_manager = session_manager + self.software: Dict[str, Union[Service, Application]] = {} + self._software_class_to_name_map: Dict[Type[IOSoftwareClass], str] = {} + self.port_protocol_mapping: Dict[Tuple[Port, IPProtocol], Union[Service, Application]] = {} + self.sys_log: SysLog = sys_log + self.file_system: FileSystem = file_system + self.dns_server: Optional[IPv4Address] = dns_server + + @property + def arp(self) -> "ARP": + """Provides access to the ARP service instance, if installed.""" + return self.software.get("ARP") # noqa + + @property + def icmp(self) -> "ICMP": + """Provides access to the ICMP service instance, if installed.""" + return self.software.get("ICMP") # noqa + + def get_open_ports(self) -> List[Port]: + """ + Get a list of open ports. + + :return: A list of all open ports on the Node. + """ + open_ports = [] + for software in self.port_protocol_mapping.values(): + if software.operating_state in {ApplicationOperatingState.RUNNING, ServiceOperatingState.RUNNING}: + open_ports.append(software.port) + return open_ports + + def install(self, software_class: Type[IOSoftwareClass]): + """ + Install an Application or Service. + + :param software_class: The software class. + """ + # TODO: Software manager and node itself both have an install method. Need to refactor to have more logical + # separation of concerns. + if software_class in self._software_class_to_name_map: + self.sys_log.warning(f"Cannot install {software_class} as it is already installed") + return + software = software_class( + software_manager=self, sys_log=self.sys_log, file_system=self.file_system, dns_server=self.dns_server + ) + if isinstance(software, Application): + software.install() + software.software_manager = self + self.software[software.name] = software + self.port_protocol_mapping[(software.port, software.protocol)] = software + if isinstance(software, Application): + software.operating_state = ApplicationOperatingState.CLOSED + + # add the software to the node's registry after it has been fully initialized + if isinstance(software, Service): + self.node.install_service(software) + elif isinstance(software, Application): + self.node.install_application(software) + + def uninstall(self, software_name: str): + """ + Uninstall an Application or Service. + + :param software_name: The software name. + """ + if software_name in self.software: + self.software[software_name].uninstall() + software = self.software.pop(software_name) # noqa + if isinstance(software, Application): + self.node.uninstall_application(software) + elif isinstance(software, Service): + self.node.uninstall_service(software) + for key, value in self.port_protocol_mapping.items(): + if value.name == software_name: + self.port_protocol_mapping.pop(key) + break + for key, value in self._software_class_to_name_map.items(): + if value == software_name: + self._software_class_to_name_map.pop(key) + break + del software + self.sys_log.info(f"Uninstalled {software_name}") + return + self.sys_log.error(f"Cannot uninstall {software_name} as it is not installed") + + def send_internal_payload(self, target_software: str, payload: Any): + """ + Send a payload to a specific service or application. + + :param target_software: The name of the target service or application. + :param payload: The data to be sent. + """ + receiver = self.software.get(target_software) + + if receiver: + receiver.receive_payload(payload) + else: + self.sys_log.warning(f"No Service of Application found with the name {target_software}") + + def send_payload_to_session_manager( + self, + payload: Any, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + dest_port: Optional[Port] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + session_id: Optional[str] = None, + ) -> bool: + """ + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. + + :param payload: The payload to be sent. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. + """ + return self.session_manager.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=dest_ip_address, + dst_port=dest_port, + ip_protocol=ip_protocol, + session_id=session_id, + ) + + def receive_payload_from_session_manager( + self, + payload: Any, + port: Port, + protocol: IPProtocol, + session_id: str, + from_network_interface: "NIC", + frame: Frame, + ): + """ + Receive a payload from the SessionManager and forward it to the corresponding service or application. + + :param payload: The payload being received. + :param session: The transport session the payload originates from. + """ + receiver: Optional[Union[Service, Application]] = self.port_protocol_mapping.get((port, protocol), None) + if receiver: + receiver.receive( + payload=payload, session_id=session_id, from_network_interface=from_network_interface, frame=frame + ) + else: + self.sys_log.warning(f"No service or application found for port {port} and protocol {protocol}") + pass + + def show(self, markdown: bool = False): + """ + Prints a table of the SwitchPorts on the Switch. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["Name", "Type", "Operating State", "Health State", "Port", "Protocol"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} Software Manager" + for software in self.port_protocol_mapping.values(): + software_type = "Service" if isinstance(software, Service) else "Application" + table.add_row( + [ + software.name, + software_type, + software.operating_state.name, + software.health_state_actual.name, + software.port.value if software.port != Port.NONE else None, + software.protocol.value, + ] + ) + print(table) diff --git a/src/primaite/simulator/system/core/sys_log.py b/src/primaite/simulator/system/core/sys_log.py new file mode 100644 index 00000000..cf68b674 --- /dev/null +++ b/src/primaite/simulator/system/core/sys_log.py @@ -0,0 +1,165 @@ +import logging +from pathlib import Path + +from prettytable import MARKDOWN, PrettyTable + +from primaite.simulator import LogLevel, SIM_OUTPUT + + +class _NotJSONFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + """ + Determines if a log message does not start and end with '{' and '}' (i.e., it is not a JSON-like message). + + :param record: LogRecord object containing all the information pertinent to the event being logged. + :return: True if log message is not JSON-like, False otherwise. + """ + return not record.getMessage().startswith("{") and not record.getMessage().endswith("}") + + +class SysLog: + """ + A SysLog class is a simple logger dedicated to managing and writing system logs for a Node. + + Each log message is written to a file located at: //_sys.log + """ + + def __init__(self, hostname: str): + """ + Constructs a SysLog instance for a given hostname. + + :param hostname: The hostname associated with the system logs being recorded. + """ + self.hostname = hostname + self.current_episode: int = 1 + self.setup_logger() + + def setup_logger(self): + """ + Configures the logger for this SysLog instance. + + The logger is set to the DEBUG level, and is equipped with a handler that writes to a file and filters out + JSON-like messages. + """ + if not SIM_OUTPUT.save_sys_logs: + return + + log_path = self._get_log_path() + file_handler = logging.FileHandler(filename=log_path) + file_handler.setLevel(logging.DEBUG) + + log_format = "%(asctime)s::%(levelname)s::%(message)s" + file_handler.setFormatter(logging.Formatter(log_format)) + + self.logger = logging.getLogger(f"{self.hostname}_sys_log") + for handler in self.logger.handlers: + self.logger.removeHandler(handler) + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(file_handler) + + self.logger.addFilter(_NotJSONFilter()) + + def show(self, last_n: int = 10, markdown: bool = False): + """ + Print the Node Sys Log as a table. + + Generate and print PrettyTable instance that shows the Nodes Sys Log, with columns Timestamp, Level, + and Massage. + + :param markdown: Use Markdown style in table output. Defaults to False. + """ + table = PrettyTable(["Timestamp", "Level", "Message"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Sys Log" + if self._get_log_path().exists(): + with open(self._get_log_path()) as file: + lines = file.readlines() + for line in lines[-last_n:]: + table.add_row(line.strip().split("::")) + print(table) + + def _get_log_path(self) -> Path: + """ + Constructs the path for the log file based on the hostname. + + :return: Path object representing the location of the log file. + """ + root = SIM_OUTPUT.path / f"episode_{self.current_episode}" / self.hostname + root.mkdir(exist_ok=True, parents=True) + return root / f"{self.hostname}_sys.log" + + def _write_to_terminal(self, msg: str, level: str, to_terminal: bool = False): + if to_terminal or SIM_OUTPUT.write_sys_log_to_terminal: + print(f"{self.hostname}: ({level}) {msg}") + + def debug(self, msg: str, to_terminal: bool = False): + """ + Logs a message with the DEBUG level. + + :param msg: The message to be logged. + :param to_terminal: If True, prints to the terminal too. + """ + if SIM_OUTPUT.sys_log_level > LogLevel.DEBUG: + return + + if SIM_OUTPUT.save_sys_logs: + self.logger.debug(msg) + self._write_to_terminal(msg, "DEBUG", to_terminal) + + def info(self, msg: str, to_terminal: bool = False): + """ + Logs a message with the INFO level. + + :param msg: The message to be logged. + :param to_terminal: If True, prints to the terminal too. + """ + if SIM_OUTPUT.sys_log_level > LogLevel.INFO: + return + + if SIM_OUTPUT.save_sys_logs: + self.logger.info(msg) + self._write_to_terminal(msg, "INFO", to_terminal) + + def warning(self, msg: str, to_terminal: bool = False): + """ + Logs a message with the WARNING level. + + :param msg: The message to be logged. + :param to_terminal: If True, prints to the terminal too. + """ + if SIM_OUTPUT.sys_log_level > LogLevel.WARNING: + return + + if SIM_OUTPUT.save_sys_logs: + self.logger.warning(msg) + self._write_to_terminal(msg, "WARNING", to_terminal) + + def error(self, msg: str, to_terminal: bool = False): + """ + Logs a message with the ERROR level. + + :param msg: The message to be logged. + :param to_terminal: If True, prints to the terminal too. + """ + if SIM_OUTPUT.sys_log_level > LogLevel.ERROR: + return + + if SIM_OUTPUT.save_sys_logs: + self.logger.error(msg) + self._write_to_terminal(msg, "ERROR", to_terminal) + + def critical(self, msg: str, to_terminal: bool = False): + """ + Logs a message with the CRITICAL level. + + :param msg: The message to be logged. + :param to_terminal: If True, prints to the terminal too. + """ + if LogLevel.CRITICAL < SIM_OUTPUT.sys_log_level: + return + + if SIM_OUTPUT.save_sys_logs: + self.logger.critical(msg) + self._write_to_terminal(msg, "CRITICAL", to_terminal) diff --git a/src/primaite/simulator/system/processes/__init__.py b/src/primaite/simulator/system/processes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py new file mode 100644 index 00000000..458a6b5c --- /dev/null +++ b/src/primaite/simulator/system/processes/process.py @@ -0,0 +1,39 @@ +from abc import abstractmethod +from enum import Enum +from typing import Dict + +from primaite.simulator.system.software import Software + + +class ProcessOperatingState(Enum): + """Enumeration of Process Operating States.""" + + RUNNING = 1 + "The process is running." + PAUSED = 2 + "The process is temporarily paused." + + +class Process(Software): + """ + Represents a Process, a program in execution, in the simulation environment. + + Processes are executed by a Node and do not have the ability to performing input/output operations. + """ + + operating_state: ProcessOperatingState + "The current operating state of the Process." + + @abstractmethod + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update({"operating_state": self.operating_state.value}) + return state diff --git a/src/primaite/simulator/system/services/__init__.py b/src/primaite/simulator/system/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/arp/__init__.py b/src/primaite/simulator/system/services/arp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/arp/arp.py b/src/primaite/simulator/system/services/arp/arp.py new file mode 100644 index 00000000..bfbc8c9c --- /dev/null +++ b/src/primaite/simulator/system/services/arp/arp.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Any, Dict, Optional, Union + +from prettytable import MARKDOWN, PrettyTable + +from primaite.simulator.network.hardware.base import NetworkInterface +from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service +from primaite.utils.validators import IPV4Address + + +class ARP(Service): + """ + The ARP (Address Resolution Protocol) Service. + + Manages ARP for resolving network layer addresses into link layer addresses. It maintains an ARP cache, + sends ARP requests and replies, and processes incoming ARP packets. + """ + + arp: Dict[IPV4Address, ARPEntry] = {} + + def __init__(self, **kwargs): + kwargs["name"] = "ARP" + kwargs["port"] = Port.ARP + kwargs["protocol"] = IPProtocol.UDP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + state = super().describe_state() + state.update({str(ip): arp_entry.mac_address for ip, arp_entry in self.arp.items()}) + + return super().describe_state() + + def show(self, markdown: bool = False): + """ + Prints the current state of the ARP cache in a table format. + + :param markdown: If True, format the output as Markdown. Otherwise, use plain text. + """ + table = PrettyTable(["IP Address", "MAC Address", "Via"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} ARP Cache" + for ip, arp in self.arp.items(): + table.add_row( + [ + str(ip), + arp.mac_address, + self.software_manager.node.network_interfaces[arp.network_interface_uuid].mac_address, + ] + ) + print(table) + + def clear(self): + """Clears the arp cache.""" + self.arp.clear() + + def get_default_gateway_network_interface(self) -> Optional[NetworkInterface]: + """Not used at the parent ARP level. Should return None when there is no override by child class.""" + return None + + def add_arp_cache_entry( + self, ip_address: IPV4Address, mac_address: str, network_interface: NetworkInterface, override: bool = False + ): + """ + Add an ARP entry to the cache. + + If an entry for the given IP address already exists, the entry is only updated if the `override` parameter is + set to True. + + :param ip_address: The IP address to be added to the cache. + :param mac_address: The MAC address associated with the IP address. + :param network_interface: The NIC through which the NIC with the IP address is reachable. + :param override: If True, an existing entry for the IP address will be overridden. Default is False. + """ + for _network_interface in self.software_manager.node.network_interfaces.values(): + if _network_interface.ip_address == ip_address: + return + if override or not self.arp.get(ip_address): + self.sys_log.info(f"Adding ARP cache entry for {mac_address}/{ip_address} via NIC {network_interface}") + arp_entry = ARPEntry(mac_address=mac_address, network_interface_uuid=network_interface.uuid) + + self.arp[ip_address] = arp_entry + + @abstractmethod + def get_arp_cache_mac_address(self, ip_address: IPV4Address) -> Optional[str]: + """ + Retrieves the MAC address associated with a given IP address from the ARP cache. + + :param ip_address: The IP address to look up. + :return: The associated MAC address, if found. Otherwise, returns None. + """ + pass + + @abstractmethod + def get_arp_cache_network_interface(self, ip_address: IPV4Address) -> Optional[NetworkInterface]: + """ + Retrieves the NIC associated with a given IP address from the ARP cache. + + :param ip_address: The IP address to look up. + :return: The associated NIC, if found. Otherwise, returns None. + """ + pass + + def send_arp_request(self, target_ip_address: Union[IPV4Address, str]): + """ + Sends an ARP request to resolve the MAC address of a target IP address. + + :param target_ip_address: The target IP address for which the MAC address is being requested. + """ + if target_ip_address in self.arp: + return + + use_default_gateway = True + for network_interface in self.software_manager.node.network_interfaces.values(): + if target_ip_address in network_interface.ip_network: + use_default_gateway = False + break + + if use_default_gateway: + if self.software_manager.node.default_gateway: + target_ip_address = self.software_manager.node.default_gateway + else: + return + + outbound_network_interface = self.software_manager.session_manager.resolve_outbound_network_interface( + target_ip_address + ) + if outbound_network_interface: + self.sys_log.info(f"Sending ARP request from NIC {outbound_network_interface} for ip {target_ip_address}") + arp_packet = ARPPacket( + sender_ip_address=outbound_network_interface.ip_address, + sender_mac_addr=outbound_network_interface.mac_address, + target_ip_address=target_ip_address, + ) + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=arp_packet, dst_ip_address=target_ip_address, dst_port=self.port, ip_protocol=self.protocol + ) + else: + self.sys_log.warning( + "Cannot send ARP request as there is no outbound Network Interface to use. Try configuring the default " + "gateway." + ) + + def send_arp_reply(self, arp_reply: ARPPacket): + """ + Sends an ARP reply in response to an ARP request. + + :param arp_reply: The ARP packet containing the reply. + """ + outbound_network_interface = self.software_manager.session_manager.resolve_outbound_network_interface( + arp_reply.target_ip_address + ) + if outbound_network_interface: + self.sys_log.info( + f"Sending ARP reply from {arp_reply.sender_mac_addr}/{arp_reply.sender_ip_address} " + f"to {arp_reply.target_ip_address}/{arp_reply.target_mac_addr} " + ) + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=arp_reply, + dst_ip_address=arp_reply.target_ip_address, + dst_port=self.port, + ip_protocol=self.protocol, + ) + else: + self.sys_log.warning( + "Cannot send ARP reply as there is no outbound Network Interface to use. Try configuring the default " + "gateway." + ) + + @abstractmethod + def _process_arp_request(self, arp_packet: ARPPacket, from_network_interface: NetworkInterface): + """ + Processes an incoming ARP request. + + :param arp_packet: The ARP packet containing the request. + :param from_network_interface: The NIC that received the ARP request. + """ + self.sys_log.info( + f"Received ARP request for {arp_packet.target_ip_address} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip_address} " + ) + + def _process_arp_reply(self, arp_packet: ARPPacket, from_network_interface: NetworkInterface): + """ + Processes an incoming ARP reply. + + :param arp_packet: The ARP packet containing the reply. + :param from_network_interface: The NIC that received the ARP reply. + """ + self.sys_log.info( + f"Received ARP response for {arp_packet.sender_ip_address} " + f"from {arp_packet.sender_mac_addr} via Network Interface {from_network_interface}" + ) + self.add_arp_cache_entry( + ip_address=arp_packet.sender_ip_address, + mac_address=arp_packet.sender_mac_addr, + network_interface=from_network_interface, + ) + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Processes received data, handling ARP packets. + + :param payload: The payload received. + :param session_id: The session ID associated with the received data. + :param kwargs: Additional keyword arguments. + :return: True if the payload was processed successfully, otherwise False. + """ + if not super().receive(payload, session_id, **kwargs): + return False + + from_network_interface = kwargs["from_network_interface"] + if payload.request: + self._process_arp_request(arp_packet=payload, from_network_interface=from_network_interface) + else: + self._process_arp_reply(arp_packet=payload, from_network_interface=from_network_interface) + return True + + def __contains__(self, item: Any) -> bool: + """ + Checks if an item is in the ARP cache. + + :param item: The item to check. + :return: True if the item is in the cache, otherwise False. + """ + return item in self.arp diff --git a/src/primaite/simulator/system/services/database/__init__.py b/src/primaite/simulator/system/services/database/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py new file mode 100644 index 00000000..861b5c7d --- /dev/null +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -0,0 +1,401 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import uuid4 + +from primaite import getLogger +from primaite.simulator.file_system.file_system import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.service import Service, ServiceOperatingState +from primaite.simulator.system.software import SoftwareHealthState + +_LOGGER = getLogger(__name__) + + +class DatabaseService(Service): + """ + A class for simulating a generic SQL Server service. + + This class inherits from the `Service` class and provides methods to simulate a SQL database. + """ + + password: Optional[str] = None + """Password that needs to be provided by clients if they want to connect to the DatabaseService.""" + + backup_server_ip: IPv4Address = None + """IP address of the backup server.""" + + latest_backup_directory: str = None + """Directory of latest backup.""" + + latest_backup_file_name: str = None + """File name of latest backup.""" + + def __init__(self, **kwargs): + kwargs["name"] = "DatabaseService" + kwargs["port"] = Port.POSTGRES_SERVER + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self._create_db_file() + + def install(self): + """ + Perform first-time setup of the DatabaseService. + + Installs an instance of FTPClient on the Node to enable database backup if it isn't installed already. + """ + super().install() + + if not self.parent.software_manager.software.get("FTPClient"): + self.parent.sys_log.info(f"{self.name}: Installing FTPClient to enable database backups") + self.parent.software_manager.install(FTPClient) + + def configure_backup(self, backup_server: IPv4Address): + """ + Set up the database backup. + + :param: backup_server_ip: The IP address of the backup server + """ + self.backup_server_ip = backup_server + + def backup_database(self) -> bool: + """Create a backup of the database to the configured backup server.""" + # check if this action can be performed + if not self._can_perform_action(): + return False + + # check if the backup server was configured + if self.backup_server_ip is None: + self.sys_log.warning(f"{self.name} - {self.sys_log.hostname}: not configured.") + return False + + software_manager: SoftwareManager = self.software_manager + ftp_client_service: FTPClient = software_manager.software.get("FTPClient") + + if not ftp_client_service: + self.sys_log.error( + f"{self.name}: Failed to perform database backup as the FTPClient software is not installed" + ) + return False + + # send backup copy of database file to FTP server + if not self.db_file: + self.sys_log.error(f"{self.name}: Attempted to backup database file but it doesn't exist.") + return False + + response = ftp_client_service.send_file( + dest_ip_address=self.backup_server_ip, + src_file_name=self.db_file.name, + src_folder_name="database", + dest_folder_name=str(self.uuid), + dest_file_name="database.db", + ) + + if response: + return True + + self.sys_log.error("Unable to create database backup.") + return False + + def restore_backup(self) -> bool: + """Restore a backup from backup server.""" + # check if this action can be performed + if not self._can_perform_action(): + return False + + software_manager: SoftwareManager = self.software_manager + ftp_client_service: FTPClient = software_manager.software.get("FTPClient") + + if not ftp_client_service: + self.sys_log.error( + f"{self.name}: Failed to restore database backup as the FTPClient software is not installed" + ) + return False + + # retrieve backup file from backup server + response = ftp_client_service.request_file( + src_folder_name=str(self.uuid), + src_file_name="database.db", + dest_folder_name="downloads", + dest_file_name="database.db", + dest_ip_address=self.backup_server_ip, + ) + + if not response: + self.sys_log.error("Unable to restore database backup.") + return False + + old_visible_state = SoftwareHealthState.GOOD + + # get db file regardless of whether or not it was deleted + db_file = self.file_system.get_file(folder_name="database", file_name="database.db", include_deleted=True) + + if db_file is None: + self.sys_log.warning("Database file not initialised.") + return False + + # if the file was deleted, get the old visible health state + if db_file.deleted: + old_visible_state = db_file.visible_health_status + else: + old_visible_state = self.db_file.visible_health_status + self.file_system.delete_file(folder_name="database", file_name="database.db") + + # replace db file + self.file_system.copy_file(src_folder_name="downloads", src_file_name="database.db", dst_folder_name="database") + + if self.db_file is None: + self.sys_log.error("Copying database backup failed.") + return False + + self.db_file.visible_health_status = old_visible_state + self.set_health_state(SoftwareHealthState.GOOD) + + return True + + def _create_db_file(self): + """Creates the Simulation File and sqlite file in the file system.""" + self.file_system.create_file(folder_name="database", file_name="database.db") + + @property + def db_file(self) -> File: + """Returns the database file.""" + return self.file_system.get_file(folder_name="database", file_name="database.db") + + def _return_database_folder(self) -> Folder: + """Returns the database folder.""" + return self.file_system.get_folder_by_id(self.db_file.folder_id) + + def _generate_connection_id(self) -> str: + """Generate a unique connection ID.""" + return str(uuid4()) + + def _process_connect( + self, + src_ip: IPv4Address, + connection_request_id: str, + password: Optional[str] = None, + session_id: Optional[str] = None, + ) -> Dict[str, Union[int, Dict[str, bool]]]: + """Process an incoming connection request. + + :param connection_id: A unique identifier for the connection + :type connection_id: str + :param password: Supplied password. It must match self.password for connection success, defaults to None + :type password: Optional[str], optional + :return: Response to connection request containing success info. + :rtype: Dict[str, Union[int, Dict[str, bool]]] + """ + status_code = 500 # Default internal server error + connection_id = None + if self.operating_state == ServiceOperatingState.RUNNING: + status_code = 503 # service unavailable + if self.health_state_actual == SoftwareHealthState.OVERWHELMED: + self.sys_log.error(f"{self.name}: Connect request for {src_ip=} declined. Service is at capacity.") + if self.health_state_actual == SoftwareHealthState.GOOD: + if self.password == password: + status_code = 200 # ok + connection_id = self._generate_connection_id() + # try to create connection + if not self.add_connection(connection_id=connection_id, session_id=session_id): + status_code = 500 + self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined") + else: + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + else: + status_code = 401 # Unauthorised + self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined") + else: + status_code = 404 # service not found + return { + "status_code": status_code, + "type": "connect_response", + "response": status_code == 200, + "connection_id": connection_id, + "connection_request_id": connection_request_id, + } + + def _process_sql( + self, + query: Literal["SELECT", "DELETE", "INSERT", "ENCRYPT"], + query_id: str, + connection_id: Optional[str] = None, + ) -> Dict[str, Union[int, List[Any]]]: + """ + Executes the given SQL query and returns the result. + + Possible queries: + - SELECT : returns the data + - DELETE : deletes the data + - INSERT : inserts the data + - ENCRYPT : corrupts the data + + :param query: The SQL query to be executed. + :return: Dictionary containing status code and data fetched. + """ + self.sys_log.info(f"{self.name}: Running {query}") + + if not self.db_file: + self.sys_log.error(f"{self.name}: Failed to run {query} because the database file is missing.") + return {"status_code": 404, "type": "sql", "data": False} + + if query == "SELECT": + if self.db_file.health_status == FileSystemItemHealthStatus.CORRUPT: + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } + elif self.db_file.health_status == FileSystemItemHealthStatus.GOOD: + return { + "status_code": 200, + "type": "sql", + "data": True, + "uuid": query_id, + "connection_id": connection_id, + } + else: + return {"status_code": 404, "type": "sql", "data": False} + elif query == "DELETE": + self.db_file.health_status = FileSystemItemHealthStatus.COMPROMISED + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } + elif query == "ENCRYPT": + self.file_system.num_file_creations += 1 + self.db_file.health_status = FileSystemItemHealthStatus.CORRUPT + self.db_file.num_access += 1 + database_folder = self._return_database_folder() + database_folder.health_status = FileSystemItemHealthStatus.CORRUPT + self.file_system.num_file_deletions += 1 + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } + elif query == "INSERT": + if self.health_state_actual == SoftwareHealthState.GOOD: + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } + else: + return {"status_code": 404, "type": "sql", "data": False} + elif query == "SELECT * FROM pg_stat_activity": + # Check if the connection is active. + if self.health_state_actual == SoftwareHealthState.GOOD: + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } + else: + return {"status_code": 401, "data": False} + else: + # Invalid query + self.sys_log.warning(f"{self.name}: Invalid {query}") + return {"status_code": 500, "data": False} + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + return super().describe_state() + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Processes the incoming SQL payload and sends the result back. + + :param payload: The SQL query to be executed. + :param session_id: The session identifier. + :return: True if the Status Code is 200, otherwise False. + """ + result = {"status_code": 500, "data": []} + # if server service is down, return error + if not self._can_perform_action(): + return False + + if isinstance(payload, dict) and payload.get("type"): + if payload["type"] == "connect_request": + src_ip = kwargs.get("frame").ip.src_ip_address + result = self._process_connect( + src_ip=src_ip, + password=payload.get("password"), + connection_request_id=payload.get("connection_request_id"), + session_id=session_id, + ) + elif payload["type"] == "disconnect": + if payload["connection_id"] in self.connections: + connection_id = payload["connection_id"] + connected_ip_address = self.connections[connection_id]["ip_address"] + frame = kwargs.get("frame") + if connected_ip_address == frame.ip.src_ip_address: + self.sys_log.info( + f"{self.name}: Received disconnect command for {connection_id=} from {connected_ip_address}" + ) + self.terminate_connection(connection_id=payload["connection_id"], send_disconnect=False) + else: + self.sys_log.warning( + f"{self.name}: Ignoring disconnect command for {connection_id=} as the command source " + f"({frame.ip.src_ip_address}) doesn't match the connection source ({connected_ip_address})" + ) + elif payload["type"] == "sql": + if payload.get("connection_id") in self.connections: + result = self._process_sql( + query=payload["sql"], query_id=payload["uuid"], connection_id=payload["connection_id"] + ) + else: + result = {"status_code": 401, "type": "sql"} + self.send(payload=result, session_id=session_id) + return True + + def send(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Send a SQL response back down to the SessionManager. + + :param payload: The SQL query results. + :param session_id: The session identifier. + :return: True if the Status Code is 200, otherwise False. + """ + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) + + return payload["status_code"] == 200 + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep of simulation dynamics to this service. + + Here at the first step, the database backup is created, in addition to normal service update logic. + """ + if timestep == 1: + self.backup_database() + return super().apply_timestep(timestep) + + def _update_fix_status(self) -> None: + """Perform a database restore when the FIXING countdown is finished.""" + super()._update_fix_status() + if self._fixing_countdown is None: + self.restore_backup() diff --git a/src/primaite/simulator/system/services/dns/__init__.py b/src/primaite/simulator/system/services/dns/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/dns/dns_client.py b/src/primaite/simulator/system/services/dns/dns_client.py new file mode 100644 index 00000000..063ff74f --- /dev/null +++ b/src/primaite/simulator/system/services/dns/dns_client.py @@ -0,0 +1,161 @@ +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.dns import DNSPacket, DNSRequest +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.services.service import Service + +_LOGGER = getLogger(__name__) + + +class DNSClient(Service): + """Represents a DNS Client as a Service.""" + + dns_cache: Dict[str, IPv4Address] = {} + "A dict of known mappings between domain/URLs names and IPv4 addresses." + dns_server: Optional[IPv4Address] = None + "The DNS Server the client sends requests to." + + def __init__(self, **kwargs): + kwargs["name"] = "DNSClient" + kwargs["port"] = Port.DNS + # DNS uses UDP by default + # it switches to TCP when the bytes exceed 512 (or 4096) bytes + # TCP for now + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current state of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def add_domain_to_cache(self, domain_name: str, ip_address: IPv4Address) -> bool: + """ + Adds a domain name to the DNS Client cache. + + :param: domain_name: The domain name to save to cache + :param: ip_address: The IP Address to attach the domain name to + """ + if not self._can_perform_action(): + return False + + self.dns_cache[domain_name] = ip_address + return True + + def check_domain_exists( + self, + target_domain: str, + session_id: Optional[str] = None, + is_reattempt: bool = False, + ) -> bool: + """Function to check if domain name exists. + + :param: target_domain: The domain requested for an IP address. + :param: session_id: The Session ID the payload is to originate from. Optional. + :param: is_reattempt: Checks if the request has been reattempted. Default is False. + """ + if not self._can_perform_action(): + return False + + # check if DNS server is configured + if self.dns_server is None: + self.sys_log.warning(f"{self.name}: DNS Server is not configured") + return False + + # check if the target domain is in the client's DNS cache + payload = DNSPacket(dns_request=DNSRequest(domain_name_request=target_domain)) + + # check if the domain is already in the DNS cache + if target_domain in self.dns_cache: + self.sys_log.info( + f"{self.name}: Domain lookup for {target_domain} successful," + f"resolves to {self.dns_cache[target_domain]}" + ) + return True + else: + # return False if already reattempted + if is_reattempt: + self.sys_log.warning(f"{self.name}: Domain lookup for {target_domain} failed") + return False + else: + # send a request to check if domain name exists in the DNS Server + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=self.dns_server, dest_port=Port.DNS + ) + + # recursively re-call the function passing is_reattempt=True + return self.check_domain_exists( + target_domain=target_domain, + session_id=session_id, + is_reattempt=True, + ) + + def send( + self, + payload: DNSPacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + :param payload: The payload to be sent. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + :param session_id: The Session ID the payload is to originate from. Optional. + + :return: True if successful, False otherwise. + """ + self.sys_log.info(f"{self.name}: Sending DNS request to resolve {payload.dns_request.domain_name_request}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + + def receive( + self, + payload: DNSPacket, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a payload from the SessionManager. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :return: True if successful, False otherwise. + """ + # The payload should be a DNS packet + if not isinstance(payload, DNSPacket): + self.sys_log.warning(f"{self.name}: Payload is not a DNSPacket") + self.sys_log.debug(f"{self.name}: {payload}") + return False + + if payload.dns_reply is not None: + # add the IP address to the client cache + if payload.dns_reply.domain_name_ip_address: + self.sys_log.info( + f"{self.name}: Resolved domain name {payload.dns_request.domain_name_request} " + f"to {payload.dns_reply.domain_name_ip_address}" + ) + self.dns_cache[payload.dns_request.domain_name_request] = payload.dns_reply.domain_name_ip_address + return True + + self.sys_log.warning(f"Failed to resolve domain name {payload.dns_request.domain_name_request}") + return False diff --git a/src/primaite/simulator/system/services/dns/dns_server.py b/src/primaite/simulator/system/services/dns/dns_server.py new file mode 100644 index 00000000..7dbc5d60 --- /dev/null +++ b/src/primaite/simulator/system/services/dns/dns_server.py @@ -0,0 +1,125 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, Optional + +from prettytable import MARKDOWN, PrettyTable + +from primaite import getLogger +from primaite.simulator.network.protocols.dns import DNSPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service + +_LOGGER = getLogger(__name__) + + +class DNSServer(Service): + """Represents a DNS Server as a Service.""" + + dns_table: Dict[str, IPv4Address] = {} + "A dict of mappings between domain names and IPv4 addresses." + + def __init__(self, **kwargs): + kwargs["name"] = "DNSServer" + kwargs["port"] = Port.DNS + # DNS uses UDP by default + # it switches to TCP when the bytes exceed 512 (or 4096) bytes + # TCP for now + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current state of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def dns_lookup(self, target_domain: str) -> Optional[IPv4Address]: + """ + Attempts to find the IP address for a domain name. + + :param target_domain: The single domain name requested by a DNS client. + :return ip_address: The IP address of that domain name or None. + """ + if not self._can_perform_action(): + return + + return self.dns_table.get(target_domain) + + def dns_register(self, domain_name: str, domain_ip_address: IPv4Address): + """ + Register a domain name and its IP address. + + :param: domain_name: The domain name to register + :type: domain_name: str + + :param: domain_ip_address: The IP address that the domain should route to + :type: domain_ip_address: IPv4Address + """ + if not self._can_perform_action(): + return + + self.dns_table[domain_name] = domain_ip_address + + def receive( + self, + payload: Any, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param: payload: The payload to send. + :param: session_id: The id of the session. Optional. + + :return: True if DNS request returns a valid IP, otherwise, False + """ + if not super().receive(payload=payload, session_id=session_id, **kwargs): + return False + + # The payload should be a DNS packet + if not isinstance(payload, DNSPacket): + self.sys_log.warning(f"{payload} is not a DNSPacket") + self.sys_log.debug(f"{payload} is not a DNSPacket") + return False + + # cast payload into a DNS packet + payload: DNSPacket = payload + if payload.dns_request is not None: + self.sys_log.info( + f"{self.name}: Received domain lookup request for {payload.dns_request.domain_name_request} " + f"from session {session_id}" + ) + # generate a reply with the correct DNS IP address + payload = payload.generate_reply(self.dns_lookup(payload.dns_request.domain_name_request)) + self.sys_log.info( + f"{self.name}: Responding to domain lookup request for {payload.dns_request.domain_name_request} " + f"with ip address: {payload.dns_reply.domain_name_ip_address}" + ) + # send reply + self.send(payload, session_id) + return payload.dns_reply.domain_name_ip_address is not None + + return False + + def show(self, markdown: bool = False): + """Prints a table of DNS Lookup table.""" + table = PrettyTable(["Domain Name", "IP Address"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} DNS Lookup table" + for dns in self.dns_table.items(): + table.add_row([dns[0], dns[1]]) + print(table) diff --git a/src/primaite/simulator/system/services/ftp/__init__.py b/src/primaite/simulator/system/services/ftp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py new file mode 100644 index 00000000..4eb18f6a --- /dev/null +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -0,0 +1,283 @@ +from ipaddress import IPv4Address +from typing import Optional + +from primaite import getLogger +from primaite.simulator.file_system.file_system import File +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.services.ftp.ftp_service import FTPServiceABC + +_LOGGER = getLogger(__name__) + + +class FTPClient(FTPServiceABC): + """ + A class for simulating an FTP client service. + + This class inherits from the `Service` class and provides methods to emulate FTP + RFC 959: https://datatracker.ietf.org/doc/html/rfc959 + """ + + def __init__(self, **kwargs): + kwargs["name"] = "FTPClient" + kwargs["port"] = Port.FTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: + """ + Process the command in the FTP Packet. + + :param: payload: The FTP Packet to process + :type: payload: FTPPacket + :param: session_id: session ID linked to the FTP Packet. Optional. + :type: session_id: Optional[str] + """ + # if client service is down, return error + if not self._can_perform_action(): + payload.status_code = FTPStatusCode.ERROR + return payload + + self.sys_log.info(f"{self.name}: Received FTP {payload.ftp_command.name} {payload.ftp_command_args}") + + # process client specific commands, otherwise call super + return super()._process_ftp_command(payload=payload, session_id=session_id, **kwargs) + + def _connect_to_server( + self, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = Port.FTP, + session_id: Optional[str] = None, + is_reattempt: Optional[bool] = False, + ) -> bool: + """ + Connects the client to a given FTP server. + + :param: dest_ip_address: IP address of the FTP server the client needs to connect to. Optional. + :type: dest_ip_address: Optional[IPv4Address] + :param: dest_port: Port of the FTP server the client needs to connect to. Optional. + :type: dest_port: Optional[Port] + :param: is_reattempt: Set to True if attempt to connect to FTP Server has been attempted. Default False. + :type: is_reattempt: Optional[bool] + """ + # make sure the service is running before attempting + if not self._can_perform_action(): + return False + + # normally FTP will choose a random port for the transfer, but using the FTP command port will do for now + # create FTP packet + payload: FTPPacket = FTPPacket(ftp_command=FTPCommand.PORT, ftp_command_args=Port.FTP) + + if self.send(payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id): + if payload.status_code == FTPStatusCode.OK: + self.sys_log.info( + f"{self.name}: Successfully connected to FTP Server " + f"{dest_ip_address} via port {payload.ftp_command_args.value}" + ) + self.add_connection(connection_id="server_connection", session_id=session_id) + return True + else: + if is_reattempt: + # reattempt failed + self.sys_log.warning( + f"{self.name}: Unable to connect to FTP Server " + f"{dest_ip_address} via port {payload.ftp_command_args.value}" + ) + return False + else: + # try again + self._connect_to_server( + dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, is_reattempt=True + ) + else: + self.sys_log.warning(f"{self.name}: Unable to send FTPPacket") + return False + + def _disconnect_from_server( + self, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = Port.FTP + ) -> bool: + """ + Connects the client from a given FTP server. + + :param: dest_ip_address: IP address of the FTP server the client needs to disconnect from. Optional. + :type: dest_ip_address: Optional[IPv4Address] + :param: dest_port: Port of the FTP server the client needs to disconnect from. Optional. + :type: dest_port: Optional[Port] + :param: is_reattempt: Set to True if attempt to disconnect from FTP Server has been attempted. Default False. + :type: is_reattempt: Optional[bool] + """ + # send a disconnect request payload to FTP server + payload: FTPPacket = FTPPacket(ftp_command=FTPCommand.QUIT) + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port + ) + return payload.status_code == FTPStatusCode.OK + + def send_file( + self, + dest_ip_address: IPv4Address, + src_folder_name: str, + src_file_name: str, + dest_folder_name: str, + dest_file_name: str, + dest_port: Optional[Port] = Port.FTP, + session_id: Optional[str] = None, + ) -> bool: + """ + Send a file to a target IP address. + + The function checks if the file exists in the FTP Client host. + The STOR command is then sent to the FTP Server. + + :param: dest_ip_address: The IP address of the machine that hosts the FTP Server. + :type: dest_ip_address: IPv4Address + + :param: src_folder_name: The name of the folder that contains the file to send to the FTP Server. + :type: src_folder_name: str + + :param: src_file_name: The name of the file to send to the FTP Server. + :type: src_file_name: str + + :param: dest_folder_name: The name of the folder where the file will be stored in the FTP Server. + :type: dest_folder_name: str + + :param: dest_file_name: The name of the file to be saved on the FTP Server. + :type: dest_file_name: str + + :param: dest_port: The open port of the machine that hosts the FTP Server. Default is Port.FTP. + :type: dest_port: Optional[Port] + + :param: session_id: The id of the session + :type: session_id: Optional[str] + """ + # check if the file to transfer exists on the client + file_to_transfer: File = self.file_system.get_file(folder_name=src_folder_name, file_name=src_file_name) + if not file_to_transfer: + self.sys_log.warning(f"Unable to send file that does not exist: {src_folder_name}/{src_file_name}") + return False + + # check if FTP is currently connected to IP + self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) + + if not len(self.connections): + return False + else: + self.sys_log.info(f"Sending file {src_folder_name}/{src_file_name} to {str(dest_ip_address)}") + # send STOR request + if self._send_data( + file=file_to_transfer, + dest_folder_name=dest_folder_name, + dest_file_name=dest_file_name, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + ): + return self._disconnect_from_server(dest_ip_address=dest_ip_address, dest_port=dest_port) + + return False + + def request_file( + self, + dest_ip_address: IPv4Address, + src_folder_name: str, + src_file_name: str, + dest_folder_name: str, + dest_file_name: str, + dest_port: Optional[Port] = Port.FTP, + ) -> bool: + """ + Request a file from a target IP address. + + Sends a RETR command to the FTP Server. + + :param: dest_ip_address: The IP address of the machine that hosts the FTP Server. + :type: dest_ip_address: IPv4Address + + :param: src_folder_name: The name of the folder that contains the file to send to the FTP Server. + :type: src_folder_name: str + + :param: src_file_name: The name of the file to send to the FTP Server. + :type: src_file_name: str + + :param: dest_folder_name: The name of the folder where the file will be stored in the FTP Server. + :type: dest_folder_name: str + + :param: dest_file_name: The name of the file to be saved on the FTP Server. + :type: dest_file_name: str + + :param: dest_port: The open port of the machine that hosts the FTP Server. Default is Port.FTP. + :type: dest_port: Optional[Port] + """ + # check if FTP is currently connected to IP + self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) + + if not len(self.connections): + return False + else: + # send retrieve request + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.RETR, + ftp_command_args={ + "src_folder_name": src_folder_name, + "src_file_name": src_file_name, + "dest_file_name": dest_file_name, + "dest_folder_name": dest_folder_name, + }, + ) + self.sys_log.info(f"Requesting file {src_folder_name}/{src_file_name} from {str(dest_ip_address)}") + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port + ) + + # the payload should have ok status code + if payload.status_code == FTPStatusCode.OK: + self.sys_log.info(f"{self.name}: File {src_folder_name}/{src_file_name} found in FTP server.") + return True + else: + self.sys_log.error(f"{self.name}: File {src_folder_name}/{src_file_name} does not exist in FTP server") + return False + + def receive(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> bool: + """ + Receives a payload from the SessionManager. + + :param: payload: FTPPacket payload. + :type: payload: FTPPacket + + :param: session_id: ID of the session. Optional. + :type: session_id: Optional[str] + """ + if not isinstance(payload, FTPPacket): + self.sys_log.warning(f"{self.name}: Payload is not an FTP packet") + self.sys_log.debug(f"{self.name}: {payload}") + return False + + """ + Ignore ftp payload if status code is None. + + This helps prevent an FTP request loop - FTP client and servers can exist on + the same node. + """ + if not self._can_perform_action(): + return False + + if payload.status_code is None: + self.sys_log.error(f"FTP Server could not be found - Error Code: {FTPStatusCode.NOT_FOUND.value}") + return False + + # if PORT succeeded, add the connection as an active connection list + if payload.ftp_command is FTPCommand.PORT and payload.status_code is FTPStatusCode.OK: + self.add_connection(connection_id=session_id, session_id=session_id) + + # if QUIT succeeded, remove the session from active connection list + if payload.ftp_command is FTPCommand.QUIT and payload.status_code is FTPStatusCode.OK: + self.terminate_connection(connection_id=session_id) + + self.sys_log.info(f"{self.name}: Received FTP Response {payload.ftp_command.name} {payload.status_code.value}") + + self._process_ftp_command(payload=payload, session_id=session_id) + return True diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py new file mode 100644 index 00000000..a361b0ee --- /dev/null +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -0,0 +1,91 @@ +from typing import Any, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ftp.ftp_service import FTPServiceABC + +_LOGGER = getLogger(__name__) + + +class FTPServer(FTPServiceABC): + """ + A class for simulating an FTP server service. + + This class inherits from the `Service` class and provides methods to emulate FTP + RFC 959: https://datatracker.ietf.org/doc/html/rfc959 + """ + + server_password: Optional[str] = None + """Password needed to connect to FTP server. Default is None.""" + + def __init__(self, **kwargs): + kwargs["name"] = "FTPServer" + kwargs["port"] = Port.FTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: + """ + Process the command in the FTP Packet. + + :param: payload: The FTP Packet to process + :type: payload: FTPPacket + :param: session_id: session ID linked to the FTP Packet. Optional. + :type: session_id: Optional[str] + """ + # error code by default + payload.status_code = FTPStatusCode.ERROR + + # if server service is down, return error + if not self._can_perform_action(): + return payload + + self.sys_log.info(f"{self.name}: Received FTP {payload.ftp_command.name} {payload.ftp_command_args}") + + if payload.ftp_command is not None: + self.sys_log.info(f"Received FTP {payload.ftp_command.name} command.") + + # process server specific commands, otherwise call super + if payload.ftp_command == FTPCommand.PORT: + # check that the port is valid + if isinstance(payload.ftp_command_args, Port) and payload.ftp_command_args.value in range(0, 65535): + # return successful connection + self.add_connection(connection_id=session_id, session_id=session_id) + payload.status_code = FTPStatusCode.OK + return payload + + self.sys_log.error(f"Invalid Port {payload.ftp_command_args}") + return payload + + if payload.ftp_command == FTPCommand.QUIT: + self.terminate_connection(connection_id=session_id) + payload.status_code = FTPStatusCode.OK + return payload + + return super()._process_ftp_command(payload=payload, session_id=session_id, **kwargs) + + def receive(self, payload: Any, session_id: Optional[str] = None, **kwargs) -> bool: + """Receives a payload from the SessionManager.""" + if not isinstance(payload, FTPPacket): + self.sys_log.warning(f"{self.name}: Payload is not an FTP packet") + self.sys_log.debug(f"{self.name}: {payload}") + return False + + if not super().receive(payload=payload, session_id=session_id, **kwargs): + return False + + """ + Ignore ftp payload if status code is defined. + + This means that an FTP server has already handled the packet and + prevents an FTP request loop - FTP client and servers can exist on + the same node. + """ + if payload.status_code is not None: + return False + + self._process_ftp_command(payload=payload, session_id=session_id) + return True diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py new file mode 100644 index 00000000..b89ae1a2 --- /dev/null +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -0,0 +1,187 @@ +from abc import ABC +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite.simulator.file_system.file_system import File +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service + + +class FTPServiceABC(Service, ABC): + """ + Abstract Base Class for FTP Client and Service. + + Contains shared methods between both classes. + """ + + def describe_state(self) -> Dict: + """Returns a Dict of the FTPService state.""" + return super().describe_state() + + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: + """ + Process the command in the FTP Packet. + + :param: payload: The FTP Packet to process + :type: payload: FTPPacket + :param: session_id: session ID linked to the FTP Packet. Optional. + :type: session_id: Optional[str] + """ + if payload.ftp_command is not None: + self.sys_log.info(f"Received FTP {payload.ftp_command.name} command.") + + # handle STOR request + if payload.ftp_command == FTPCommand.STOR: + # check that the file is created in the computed hosting the FTP server + if self._store_data(payload=payload): + payload.status_code = FTPStatusCode.OK + + if payload.ftp_command == FTPCommand.RETR: + if self._retrieve_data(payload=payload, session_id=session_id): + payload.status_code = FTPStatusCode.OK + + return payload + + def _store_data(self, payload: FTPPacket) -> bool: + """ + Stores the data in the FTP Service's host machine. + + :param: payload: The FTP Packet that contains the file data + :type: FTPPacket + """ + try: + file_name = payload.ftp_command_args["dest_file_name"] + folder_name = payload.ftp_command_args["dest_folder_name"] + file_size = payload.ftp_command_args["file_size"] + health_status = payload.ftp_command_args["health_status"] + file = self.file_system.create_file( + file_name=file_name, + folder_name=folder_name, + size=file_size, + ) + file.health_status = health_status + self.sys_log.info( + f"{self.name}: Created item in {self.sys_log.hostname}: {payload.ftp_command_args['dest_folder_name']}/" + f"{payload.ftp_command_args['dest_file_name']}" + ) + # file should exist + return self.file_system.get_file(file_name=file_name, folder_name=folder_name) is not None + except Exception as e: + self.sys_log.error(f"Unable to create file in {self.sys_log.hostname}: {e}") + return False + + def _send_data( + self, + file: File, + dest_folder_name: str, + dest_file_name: str, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + session_id: Optional[str] = None, + is_response: bool = False, + ) -> bool: + """ + Sends data from the host FTP Service's machine to another FTP Service's host machine. + + :param: file: File to send to the target FTP Service. + :type: file: File + + :param: dest_folder_name: The name of the folder where the file will be stored in the FTP Server. + :type: dest_folder_name: str + + :param: dest_file_name: The name of the file to be saved on the FTP Server. + :type: dest_file_name: str + + :param: dest_ip_address: The IP address of the machine that hosts the FTP Server. + :type: dest_ip_address: Optional[IPv4Address] + + :param: dest_port: The open port of the machine that hosts the FTP Server. Default is Port.FTP. + :type: dest_port: Optional[Port] + + :param: session_id: session ID linked to the FTP Packet. Optional. + :type: session_id: Optional[str] + + :param: is_response: is true if the data being sent is in response to a request. Default False. + :type: is_response: bool + """ + # send STOR request + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.STOR, + ftp_command_args={ + "dest_folder_name": dest_folder_name, + "dest_file_name": dest_file_name, + "file_size": file.sim_size, + "health_status": file.health_status, + }, + packet_payload_size=file.sim_size, + status_code=FTPStatusCode.OK if is_response else None, + ) + self.sys_log.info(f"{self.name}: Sending file {file.folder.name}/{file.name}") + response = self.send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id + ) + + if response and payload.status_code == FTPStatusCode.OK: + return True + + return False + + def _retrieve_data(self, payload: FTPPacket, session_id: Optional[str] = None) -> bool: + """ + Handle the transfer of data from Server to Client. + + :param: payload: The FTP Packet that contains the file data + :type: FTPPacket + """ + try: + # find the file + file_name = payload.ftp_command_args["src_file_name"] + folder_name = payload.ftp_command_args["src_folder_name"] + dest_folder_name = payload.ftp_command_args["dest_folder_name"] + dest_file_name = payload.ftp_command_args["dest_file_name"] + retrieved_file: File = self.file_system.get_file(folder_name=folder_name, file_name=file_name) + + # if file does not exist, return an error + if not retrieved_file: + self.sys_log.error( + f"File {payload.ftp_command_args['dest_folder_name']}/" + f"{payload.ftp_command_args['dest_file_name']} does not exist in {self.sys_log.hostname}" + ) + return False + else: + # send requested data + return self._send_data( + file=retrieved_file, + dest_file_name=dest_file_name, + dest_folder_name=dest_folder_name, + session_id=session_id, + is_response=True, + ) + except Exception as e: + self.sys_log.error(f"Unable to retrieve file from {self.sys_log.hostname}: {e}") + return False + + def send( + self, + payload: FTPPacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + :param payload: The payload to be sent. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + :param session_id: The Session ID the payload is to originate from. Optional. + + :return: True if successful, False otherwise. + """ + self.sys_log.info(f"{self.name}: Sending FTP {payload.ftp_command.name} {payload.ftp_command_args}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) diff --git a/src/primaite/simulator/system/services/icmp/__init__.py b/src/primaite/simulator/system/services/icmp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/icmp/icmp.py b/src/primaite/simulator/system/services/icmp/icmp.py new file mode 100644 index 00000000..c4b4173f --- /dev/null +++ b/src/primaite/simulator/system/services/icmp/icmp.py @@ -0,0 +1,194 @@ +import secrets +from ipaddress import IPv4Address +from typing import Any, Dict, Optional, Tuple, Union + +from primaite import getLogger +from primaite.simulator.network.hardware.base import NetworkInterface +from primaite.simulator.network.protocols.icmp import ICMPPacket, ICMPType +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.services.service import Service + +_LOGGER = getLogger(__name__) + + +class ICMP(Service): + """ + The Internet Control Message Protocol (ICMP) service. + + Enables the sending and receiving of ICMP messages such as echo requests and replies. This is typically used for + network diagnostics, notably the ping command. + """ + + request_replies: Dict = {} + + def __init__(self, **kwargs): + kwargs["name"] = "ICMP" + kwargs["port"] = Port.NONE + kwargs["protocol"] = IPProtocol.ICMP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + return super().describe_state() + + def clear(self): + """ + Clears the ICMP request and reply tracker. + + This is typically used to reset the state of the service, removing all tracked ICMP echo requests and their + corresponding replies. + """ + self.request_replies.clear() + + def ping(self, target_ip_address: Union[IPv4Address, str], pings: int = 4) -> bool: + """ + Pings a target IP address by sending an ICMP echo request and waiting for a reply. + + :param target_ip_address: The IP address to be pinged. + :param pings: The number of echo requests to send. Defaults to 4. + :return: True if the ping was successful (i.e., if a reply was received for every request sent), otherwise + False. + """ + if not self._can_perform_action(): + return False + if target_ip_address.is_loopback: + self.sys_log.info("Pinging loopback address") + return any(network_interface.enabled for network_interface in self.network_interfaces.values()) + self.sys_log.info(f"Pinging {target_ip_address}:", to_terminal=True) + sequence, identifier = 0, None + while sequence < pings: + sequence, identifier = self._send_icmp_echo_request(target_ip_address, sequence, identifier, pings) + request_replies = self.software_manager.icmp.request_replies.get(identifier) + passed = request_replies == pings + if request_replies: + self.software_manager.icmp.request_replies.pop(identifier) + else: + request_replies = 0 + output = ( + f"Ping statistics for {target_ip_address}: " + f"Packets: Sent = {pings}, " + f"Received = {request_replies}, " + f"Lost = {pings - request_replies} ({(pings - request_replies) / pings * 100}% loss)" + ) + self.sys_log.info(output, to_terminal=True) + + return passed + + def _send_icmp_echo_request( + self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None, pings: int = 4 + ) -> Tuple[int, Union[int, None]]: + """ + Sends an ICMP echo request to a specified target IP address. + + :param target_ip_address: The target IP address for the echo request. + :param sequence: The sequence number of the echo request. + :param identifier: The identifier for the ICMP packet. If None, a default identifier is used. + :param pings: The number of pings to send. Defaults to 4. + :return: A tuple containing the next sequence number and the identifier. + """ + network_interface = self.software_manager.session_manager.resolve_outbound_network_interface(target_ip_address) + + if not network_interface: + self.sys_log.warning( + "Cannot send ICMP echo request as there is no outbound Network Interface to use. Try configuring the " + "default gateway." + ) + return pings, None + + sequence += 1 + + icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size + + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=target_ip_address, + dst_port=self.port, + ip_protocol=self.protocol, + icmp_packet=icmp_packet, + ) + return sequence, icmp_packet.identifier + + def _process_icmp_echo_request(self, frame: Frame, from_network_interface: NetworkInterface): + """ + Processes an ICMP echo request received by the service. + + :param frame: The network frame containing the ICMP echo request. + """ + if frame.ip.dst_ip_address != from_network_interface.ip_address: + return + self.sys_log.info(f"Received echo request from {frame.ip.src_ip_address}") + + network_interface = self.software_manager.session_manager.resolve_outbound_network_interface( + frame.ip.src_ip_address + ) + + if not network_interface: + self.sys_log.warning( + "Cannot send ICMP echo reply as there is no outbound Network Interface to use. Try configuring the " + "default gateway." + ) + return + + icmp_packet = ICMPPacket( + icmp_type=ICMPType.ECHO_REPLY, + icmp_code=0, + identifier=frame.icmp.identifier, + sequence=frame.icmp.sequence + 1, + ) + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") + + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=frame.ip.src_ip_address, + dst_port=self.port, + ip_protocol=self.protocol, + icmp_packet=icmp_packet, + ) + + def _process_icmp_echo_reply(self, frame: Frame): + """ + Processes an ICMP echo reply received by the service, logging the reply details. + + :param frame: The network frame containing the ICMP echo reply. + """ + time = frame.transmission_duration() + time_str = f"{time}ms" if time > 0 else "<1ms" + self.sys_log.info( + f"Reply from {frame.ip.src_ip_address}: " + f"bytes={len(frame.payload)}, " + f"time={time_str}, " + f"TTL={frame.ip.ttl}", + to_terminal=True, + ) + if not self.request_replies.get(frame.icmp.identifier): + self.request_replies[frame.icmp.identifier] = 0 + self.request_replies[frame.icmp.identifier] += 1 + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Processes received data, handling ICMP echo requests and replies. + + :param payload: The payload received. + :param session_id: The session ID associated with the received data. + :param kwargs: Additional keyword arguments. + :return: True if the payload was processed successfully, otherwise False. + """ + frame: Frame = kwargs["frame"] + from_network_interface = kwargs["from_network_interface"] + + if not frame.icmp: + return False + + if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: + self._process_icmp_echo_request(frame, from_network_interface) + elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: + self._process_icmp_echo_reply(frame) + return True diff --git a/src/primaite/simulator/system/services/icmp/router_icmp.py b/src/primaite/simulator/system/services/icmp/router_icmp.py new file mode 100644 index 00000000..5dcba3f1 --- /dev/null +++ b/src/primaite/simulator/system/services/icmp/router_icmp.py @@ -0,0 +1,90 @@ +# class RouterICMP(ICMP): +# """ +# A class to represent a router's Internet Control Message Protocol (ICMP) handler. +# +# :param sys_log: System log for logging network events and errors. +# :type sys_log: SysLog +# :param arp_cache: The ARP cache for resolving MAC addresses. +# :type arp_cache: ARPCache +# :param router: The router to which this ICMP handler belongs. +# :type router: Router +# """ +# +# router: Router +# +# def __init__(self, sys_log: SysLog, arp_cache: ARPCache, router: Router): +# super().__init__(sys_log, arp_cache) +# self.router = router +# +# def process_icmp(self, frame: Frame, from_network_interface: NIC, is_reattempt: bool = False): +# """ +# Process incoming ICMP frames based on ICMP type. +# +# :param frame: The incoming frame to process. +# :param from_network_interface: The network interface where the frame is coming from. +# :param is_reattempt: Flag to indicate if the process is a reattempt. +# """ +# if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: +# # determine if request is for router interface or whether it needs to be routed +# +# for network_interface in self.router.network_interfaces.values(): +# if network_interface.ip_address == frame.ip.dst_ip_address: +# if network_interface.enabled: +# # reply to the request +# if not is_reattempt: +# self.sys_log.info(f"Received echo request from {frame.ip.src_ip_address}") +# target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip_address) +# src_nic = self.arp.get_arp_cache_network_interface(frame.ip.src_ip_address) +# tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) +# +# # Network Layer +# ip_packet = IPPacket( +# src_ip_address=network_interface.ip_address, +# dst_ip_address=frame.ip.src_ip_address, +# protocol=IPProtocol.ICMP, +# ) +# # Data Link Layer +# ethernet_header = EthernetHeader( +# src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address +# ) +# icmp_reply_packet = ICMPPacket( +# icmp_type=ICMPType.ECHO_REPLY, +# icmp_code=0, +# identifier=frame.icmp.identifier, +# sequence=frame.icmp.sequence + 1, +# ) +# payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size +# frame = Frame( +# ethernet=ethernet_header, +# ip=ip_packet, +# tcp=tcp_header, +# icmp=icmp_reply_packet, +# payload=payload, +# ) +# self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") +# +# src_nic.send_frame(frame) +# return +# +# # Route the frame +# self.router.process_frame(frame, from_network_interface) +# +# elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: +# for network_interface in self.router.network_interfaces.values(): +# if network_interface.ip_address == frame.ip.dst_ip_address: +# if network_interface.enabled: +# time = frame.transmission_duration() +# time_str = f"{time}ms" if time > 0 else "<1ms" +# self.sys_log.info( +# f"Reply from {frame.ip.src_ip_address}: " +# f"bytes={len(frame.payload)}, " +# f"time={time_str}, " +# f"TTL={frame.ip.ttl}" +# ) +# if not self.request_replies.get(frame.icmp.identifier): +# self.request_replies[frame.icmp.identifier] = 0 +# self.request_replies[frame.icmp.identifier] += 1 +# +# return +# # Route the frame +# self.router.process_frame(frame, from_network_interface) diff --git a/src/primaite/simulator/system/services/ntp/__init__.py b/src/primaite/simulator/system/services/ntp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py new file mode 100644 index 00000000..dcc502c7 --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -0,0 +1,121 @@ +from datetime import datetime +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.ntp import NTPPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service, ServiceOperatingState + +_LOGGER = getLogger(__name__) + + +class NTPClient(Service): + """Represents a NTP client as a service.""" + + ntp_server: Optional[IPv4Address] = None + "The NTP server the client sends requests to." + time: Optional[datetime] = None + + def __init__(self, **kwargs): + kwargs["name"] = "NTPClient" + kwargs["port"] = Port.NTP + kwargs["protocol"] = IPProtocol.UDP + super().__init__(**kwargs) + self.start() + + def configure(self, ntp_server_ip_address: IPv4Address) -> None: + """ + Set the IP address for the NTP server. + + :param ntp_server_ip_address: IPv4 address of NTP server. + :param ntp_client_ip_Address: IPv4 address of NTP client. + """ + self.ntp_server = ntp_server_ip_address + self.sys_log.info(f"{self.name}: ntp_server: {self.ntp_server}") + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current state + of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def send( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + dest_ip_address: IPv4Address = None, + dest_port: Port = Port.NTP, + **kwargs, + ) -> bool: + """Requests NTP data from NTP server. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + + :return: True if successful, False otherwise. + """ + return super().send( + payload=payload, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + session_id=session_id, + **kwargs, + ) + + def receive( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """Receives time data from server. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :return: True if successful, False otherwise. + """ + if not isinstance(payload, NTPPacket): + self.sys_log.warning(f"{self.name}: Failed to parse NTP update") + return False + if payload.ntp_reply.ntp_datetime: + self.time = payload.ntp_reply.ntp_datetime + return True + + def request_time(self) -> None: + """Send request to ntp_server.""" + if self.ntp_server: + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=NTPPacket(), + dst_ip_address=self.ntp_server, + src_port=self.port, + dst_port=self.port, + ip_protocol=self.protocol, + ) + + def apply_timestep(self, timestep: int) -> None: + """ + For each timestep request the time from the NTP server. + + In this instance, if any multi-timestep processes are currently + occurring (such as restarting or installation), then they are brought one step closer to + being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + super().apply_timestep(timestep) + if self.operating_state == ServiceOperatingState.RUNNING: + # request time from server + self.request_time() diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py new file mode 100644 index 00000000..01d10b84 --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -0,0 +1,66 @@ +from datetime import datetime +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.ntp import NTPPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service + +_LOGGER = getLogger(__name__) + + +class NTPServer(Service): + """Represents a NTP server as a service.""" + + def __init__(self, **kwargs): + kwargs["name"] = "NTPServer" + kwargs["port"] = Port.NTP + kwargs["protocol"] = IPProtocol.UDP + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current + state of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def receive( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a request from NTPClient. + + Check that request has a valid IP address. + + :param payload: The payload to send. + :param session_id: Id of the session (Optional). + + :return: True if valid NTP request else False. + """ + if not (isinstance(payload, NTPPacket)): + self.sys_log.warning(f"{self.name}: Payload is not a NTPPacket") + self.sys_log.debug(f"{self.name}: {payload}") + return False + payload: NTPPacket = payload + + # generate a reply with the current time + time = datetime.now() + payload = payload.generate_reply(time) + # send reply + self.software_manager.session_manager.receive_payload_from_software_manager( + payload=payload, src_port=self.port, dst_port=self.port, ip_protocol=self.protocol, session_id=session_id + ) + return True diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py new file mode 100644 index 00000000..caaefc06 --- /dev/null +++ b/src/primaite/simulator/system/services/service.py @@ -0,0 +1,192 @@ +from abc import abstractmethod +from enum import Enum +from typing import Any, Dict, Optional + +from primaite import getLogger +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.system.software import IOSoftware, SoftwareHealthState + +_LOGGER = getLogger(__name__) + + +class ServiceOperatingState(Enum): + """Enumeration of Service Operating States.""" + + RUNNING = 1 + "The service is currently running." + STOPPED = 2 + "The service is not running." + PAUSED = 3 + "The service is temporarily paused." + DISABLED = 4 + "The service is disabled and cannot be started." + INSTALLING = 5 + "The service is being installed or updated." + RESTARTING = 6 + "The service is in the process of restarting." + + +class Service(IOSoftware): + """ + Represents a Service in the simulation environment. + + Services are programs that run in the background and may perform input/output operations. + """ + + operating_state: ServiceOperatingState = ServiceOperatingState.STOPPED + "The current operating state of the Service." + + restart_duration: int = 5 + "How many timesteps does it take to restart this service." + restart_countdown: Optional[int] = None + "If currently restarting, how many timesteps remain until the restart is finished." + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _can_perform_action(self) -> bool: + """ + Checks if the service can perform actions. + + This is done by checking if the service is operating properly or the node it is installed + in is operational. + + Returns true if the software can perform actions. + """ + if not super()._can_perform_action(): + return False + + if self.operating_state is not ServiceOperatingState.RUNNING: + # service is not running + self.sys_log.debug(f"Cannot perform action: {self.name} is {self.operating_state.name}") + return False + + return True + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + + :param payload: The payload to receive. + :param session_id: The identifier of the session that the payload is associated with. + :param kwargs: Additional keyword arguments specific to the implementation. + :return: True if the payload was successfully received and processed, False otherwise. + """ + return super().receive(payload=payload, session_id=session_id, **kwargs) + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request("scan", RequestType(func=lambda request, context: RequestResponse.from_bool(self.scan()))) + rm.add_request("stop", RequestType(func=lambda request, context: RequestResponse.from_bool(self.stop()))) + rm.add_request("start", RequestType(func=lambda request, context: RequestResponse.from_bool(self.start()))) + rm.add_request("pause", RequestType(func=lambda request, context: RequestResponse.from_bool(self.pause()))) + rm.add_request("resume", RequestType(func=lambda request, context: RequestResponse.from_bool(self.resume()))) + rm.add_request("restart", RequestType(func=lambda request, context: RequestResponse.from_bool(self.restart()))) + rm.add_request("disable", RequestType(func=lambda request, context: RequestResponse.from_bool(self.disable()))) + rm.add_request("enable", RequestType(func=lambda request, context: RequestResponse.from_bool(self.enable()))) + return rm + + @abstractmethod + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state["operating_state"] = self.operating_state.value + state["health_state_actual"] = self.health_state_actual.value + state["health_state_visible"] = self.health_state_visible.value + return state + + def stop(self) -> bool: + """Stop the service.""" + if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.sys_log.info(f"Stopping service {self.name}") + self.operating_state = ServiceOperatingState.STOPPED + return True + return False + + def start(self, **kwargs) -> bool: + """Start the service.""" + # cant start the service if the node it is on is off + if not super()._can_perform_action(): + return False + + if self.operating_state == ServiceOperatingState.STOPPED: + self.sys_log.info(f"Starting service {self.name}") + self.operating_state = ServiceOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) + return True + return False + + def pause(self) -> bool: + """Pause the service.""" + if self.operating_state == ServiceOperatingState.RUNNING: + self.sys_log.info(f"Pausing service {self.name}") + self.operating_state = ServiceOperatingState.PAUSED + return True + return False + + def resume(self) -> bool: + """Resume paused service.""" + if self.operating_state == ServiceOperatingState.PAUSED: + self.sys_log.info(f"Resuming service {self.name}") + self.operating_state = ServiceOperatingState.RUNNING + return True + return False + + def restart(self) -> bool: + """Restart running service.""" + if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.sys_log.info(f"Pausing service {self.name}") + self.operating_state = ServiceOperatingState.RESTARTING + self.restart_countdown = self.restart_duration + return True + return False + + def disable(self) -> bool: + """Disable the service.""" + self.sys_log.info(f"Disabling Application {self.name}") + self.operating_state = ServiceOperatingState.DISABLED + return True + + def enable(self) -> bool: + """Enable the disabled service.""" + if self.operating_state == ServiceOperatingState.DISABLED: + self.sys_log.info(f"Enabling Application {self.name}") + self.operating_state = ServiceOperatingState.STOPPED + return True + return False + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep of simulation dynamics to this service. + + In this instance, if any multi-timestep processes are currently occurring (such as restarting or installation), + then they are brought one step closer to being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + super().apply_timestep(timestep) + if self.operating_state == ServiceOperatingState.RESTARTING: + if self.restart_countdown <= 0: + self.sys_log.debug(f"Restarting finished for service {self.name}") + self.operating_state = ServiceOperatingState.RUNNING + self.restart_countdown -= 1 diff --git a/src/primaite/simulator/system/services/web_server/__init__.py b/src/primaite/simulator/system/services/web_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/web_server/web_server.py b/src/primaite/simulator/system/services/web_server/web_server.py new file mode 100644 index 00000000..3141a697 --- /dev/null +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -0,0 +1,182 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from primaite import getLogger +from primaite.simulator.network.protocols.http import ( + HttpRequestMethod, + HttpRequestPacket, + HttpResponsePacket, + HttpStatusCode, +) +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClientConnection +from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import SoftwareHealthState + +_LOGGER = getLogger(__name__) + + +class WebServer(Service): + """Class used to represent a Web Server Service in simulation.""" + + last_response_status_code: Optional[HttpStatusCode] = None + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state["last_response_status_code"] = ( + self.last_response_status_code.value if isinstance(self.last_response_status_code, HttpStatusCode) else None + ) + return state + + def __init__(self, **kwargs): + kwargs["name"] = "WebServer" + kwargs["protocol"] = IPProtocol.TCP + # default for web is port 80 + if kwargs.get("port") is None: + kwargs["port"] = Port.HTTP + + super().__init__(**kwargs) + self._install_web_files() + self.start() + self.db_connection: Optional[DatabaseClientConnection] = None + + def _install_web_files(self): + """ + Installs the files hosted by the web service. + + This is usually HTML, CSS, JS or PHP files requested by browsers to display the webpage. + """ + # index HTML main file + self.file_system.create_file(file_name="index.html", folder_name="primaite") + + def _process_http_request(self, payload: HttpRequestPacket, session_id: Optional[str] = None) -> bool: + """ + Parse the HttpRequestPacket. + + :param: payload: Payload containing th HttpRequestPacket + :type: payload: HttpRequestPacket + + :param: session_id: Session id of the http request + :type: session_id: Optional[str] + """ + response = HttpResponsePacket() + + self.sys_log.info(f"{self.name}: Received HTTP {payload.request_method.name} {payload.request_url}") + + # check the type of HTTP request + if payload.request_method == HttpRequestMethod.GET: + response = self._handle_get_request(payload=payload) + + elif payload.request_method == HttpRequestMethod.POST: + pass + + else: + # send a method not allowed response + response.status_code = HttpStatusCode.METHOD_NOT_ALLOWED + + # send response to web client + self.send(payload=response, session_id=session_id) + + # return true if response is OK + self.last_response_status_code = response.status_code + return response.status_code == HttpStatusCode.OK + + def _handle_get_request(self, payload: HttpRequestPacket) -> HttpResponsePacket: + """ + Handle a GET HTTP request. + + :param: payload: HTTP request payload + :type: payload: HttpRequestPacket + """ + response = HttpResponsePacket(status_code=HttpStatusCode.NOT_FOUND, payload=payload) + try: + parsed_url = urlparse(payload.request_url) + path = parsed_url.path.strip("/") + + if len(path) < 1: + # query succeeded + response.status_code = HttpStatusCode.OK + + if path.startswith("users"): + # get data from DatabaseServer + # get all users + if not self.db_connection: + self._establish_db_connection() + + if self.db_connection.query("SELECT"): + # query succeeded + self.set_health_state(SoftwareHealthState.GOOD) + response.status_code = HttpStatusCode.OK + else: + self.set_health_state(SoftwareHealthState.COMPROMISED) + + return response + except Exception: # TODO: refactor this. Likely to cause silent bugs. (ADO ticket #2345 ) + # something went wrong on the server + response.status_code = HttpStatusCode.INTERNAL_SERVER_ERROR + return response + + def _establish_db_connection(self) -> None: + """Establish a connection to db.""" + db_client = self.software_manager.software.get("DatabaseClient") + self.db_connection: DatabaseClientConnection = db_client.get_new_connection() + + def send( + self, + payload: HttpResponsePacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param: payload: The payload to send. + :param: session_id: The id of the session + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + + :return: True if successful, False otherwise. + """ + self.sys_log.info(f"{self.name}: Sending HTTP Response {payload.status_code}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + + def receive( + self, + payload: Any, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a payload from the SessionManager. + + :param: payload: The payload to send. + :param: session_id: The id of the session. Optional. + """ + if not super().receive(payload=payload, session_id=session_id, **kwargs): + return False + + # check if the payload is an HTTPPacket + if not isinstance(payload, HttpRequestPacket): + self.sys_log.warning(f"{self.name}: Payload is not an HTTPPacket") + self.sys_log.debug(f"{self.name}: {payload}") + return False + + return self._process_http_request(payload=payload, session_id=session_id) diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py new file mode 100644 index 00000000..b533f7c0 --- /dev/null +++ b/src/primaite/simulator/system/software.py @@ -0,0 +1,428 @@ +import copy +from abc import abstractmethod +from datetime import datetime +from enum import Enum +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, Optional, TYPE_CHECKING, Union + +from prettytable import MARKDOWN, PrettyTable + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager, RequestType, SimComponent +from primaite.simulator.file_system.file_system import FileSystem, Folder +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.session_manager import Session +from primaite.simulator.system.core.sys_log import SysLog + +if TYPE_CHECKING: + from primaite.simulator.system.core.software_manager import SoftwareManager + + +class SoftwareType(Enum): + """ + An enumeration representing the different types of software within a simulated environment. + + Members: + - APPLICATION: User-facing programs that may perform input/output operations. + - SERVICE: Represents programs that run in the background and may perform input/output operations. + - PROCESS: Software executed by a Node that does not have the ability to performing input/output operations. + """ + + APPLICATION = 1 + "User-facing software that may perform input/output operations." + SERVICE = 2 + "Software that runs in the background and may perform input/output operations." + PROCESS = 3 + "Software executed by a Node that does not have the ability to performing input/output operations." + + +class SoftwareHealthState(Enum): + """Enumeration of the Software Health States.""" + + UNUSED = 0 + "Unused state." + GOOD = 1 + "The software is in a good and healthy condition." + FIXING = 2 + "The software is undergoing FIXING or updates." + COMPROMISED = 3 + "The software's security has been compromised." + OVERWHELMED = 4 + "he software is overwhelmed and not functioning properly." + + +class SoftwareCriticality(Enum): + """Enumeration of Software Criticality Levels.""" + + LOWEST = 1 + "The lowest level of criticality." + LOW = 2 + "A low level of criticality." + MEDIUM = 3 + "A medium level of criticality." + HIGH = 4 + "A high level of criticality." + HIGHEST = 5 + "The highest level of criticality." + + +class Software(SimComponent): + """ + A base class representing software in a simulator environment. + + This class is intended to be subclassed by specific types of software entities. + It outlines the fundamental attributes and behaviors expected of any software in the simulation. + """ + + name: str + "The name of the software." + health_state_actual: SoftwareHealthState = SoftwareHealthState.UNUSED + "The actual health state of the software." + health_state_visible: SoftwareHealthState = SoftwareHealthState.UNUSED + "The health state of the software visible to the red agent." + criticality: SoftwareCriticality = SoftwareCriticality.LOWEST + "The criticality level of the software." + fixing_count: int = 0 + "The count of patches applied to the software, defaults to 0." + scanning_count: int = 0 + "The count of times the software has been scanned, defaults to 0." + revealed_to_red: bool = False + "Indicates if the software has been revealed to red agent, defaults is False." + software_manager: Optional["SoftwareManager"] = None + "An instance of Software Manager that is used by the parent node." + sys_log: SysLog = None + "An instance of SysLog that is used by the parent node." + file_system: FileSystem + "The FileSystem of the Node the Software is installed on." + folder: Optional[Folder] = None + "The folder on the file system the Software uses." + fixing_duration: int = 2 + "The number of ticks it takes to patch the software." + _fixing_countdown: Optional[int] = None + "Current number of ticks left to patch the software." + + def _init_request_manager(self) -> RequestManager: + """ + Initialise the request manager. + + More information in user guide and docstring for SimComponent._init_request_manager. + """ + rm = super()._init_request_manager() + rm.add_request( + "compromise", + RequestType( + func=lambda request, context: RequestResponse.from_bool( + self.set_health_state(SoftwareHealthState.COMPROMISED) + ), + ), + ) + rm.add_request( + "fix", + RequestType( + func=lambda request, context: RequestResponse.from_bool(self.fix()), + ), + ) + rm.add_request("scan", RequestType(func=lambda request, context: RequestResponse.from_bool(self.scan()))) + return rm + + def _get_session_details(self, session_id: str) -> Session: + """ + Returns the Session object from the given session id. + + :param: session_id: ID of the session that needs details retrieved + """ + return self.software_manager.session_manager.sessions_by_uuid[session_id] + + @abstractmethod + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "health_state_actual": self.health_state_actual.value, + "health_state_visible": self.health_state_visible.value, + "criticality": self.criticality.value, + "fixing_count": self.fixing_count, + "scanning_count": self.scanning_count, + "revealed_to_red": self.revealed_to_red, + } + ) + return state + + def set_health_state(self, health_state: SoftwareHealthState) -> bool: + """ + Assign a new health state to this software. + + Note: this should only be possible when the software is currently running, but the software base class has no + operating state, only subclasses do. So subclasses will need to implement this check. TODO: check if this should + be changed so that the base Software class has a running attr. + + :param health_state: New health state to assign to the software + :type health_state: SoftwareHealthState + """ + self.health_state_actual = health_state + return True + + def install(self) -> None: + """ + Perform first-time setup of this service on a node. + + This is an abstract class that should be overwritten by specific applications or services. It must be called + after the service is already associate with a node. For example, a service may need to authenticate with a + server during installation, or create files in the node's filesystem. + """ + pass + + def uninstall(self) -> None: + """Uninstall this service from a node. + + This is an abstract class that should be overwritten by applications or services. It must be called after the + `install` method has already been run on that node. It should undo any installation steps, for example by + deleting files, or contacting a server. + """ + pass + + def scan(self) -> bool: + """Update the observed health status to match the actual health status.""" + self.health_state_visible = self.health_state_actual + return True + + def fix(self) -> bool: + """Perform a fix on the software.""" + if self.health_state_actual in (SoftwareHealthState.COMPROMISED, SoftwareHealthState.GOOD): + self._fixing_countdown = self.fixing_duration + self.set_health_state(SoftwareHealthState.FIXING) + return True + return False + + def _update_fix_status(self) -> None: + """Update the fix status of the software.""" + self._fixing_countdown -= 1 + if self._fixing_countdown <= 0: + self.set_health_state(SoftwareHealthState.GOOD) + self._fixing_countdown = None + self.fixing_count += 1 + + def reveal_to_red(self) -> None: + """Reveals the software to the red agent.""" + self.revealed_to_red = True + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep to the software. + + :param timestep: The current timestep of the simulation. + """ + super().apply_timestep(timestep) + if self.health_state_actual == SoftwareHealthState.FIXING: + self._update_fix_status() + + def pre_timestep(self, timestep: int) -> None: + """Apply pre-timestep logic.""" + super().pre_timestep(timestep) + + +class IOSoftware(Software): + """ + Represents software in a simulator environment that is capable of input/output operations. + + This base class is meant to be sub-classed by Application and Service classes. It provides the blueprint for + Applications and Services that can receive payloads from a Node's SessionManager (corresponding to layer 5 in the + OSI Model), process them according to their internals, and send a response payload back to the SessionManager if + required. + """ + + installing_count: int = 0 + "The number of times the software has been installed. Default is 0." + max_sessions: int = 100 + "The maximum number of sessions that the software can handle simultaneously. Default is 0." + tcp: bool = True + "Indicates if the software uses TCP protocol for communication. Default is True." + udp: bool = True + "Indicates if the software uses UDP protocol for communication. Default is True." + port: Port + "The port to which the software is connected." + protocol: IPProtocol + "The IP Protocol the Software operates on." + _connections: Dict[str, Dict] = {} + "Active connections." + + @abstractmethod + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "installing_count": self.installing_count, + "max_sessions": self.max_sessions, + "tcp": self.tcp, + "udp": self.udp, + "port": self.port.value, + } + ) + return state + + @abstractmethod + def _can_perform_action(self) -> bool: + """ + Checks if the software can perform actions. + + This is done by checking if the software is operating properly or the node it is installed + in is operational. + + Returns true if the software can perform actions. + """ + 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." + ) + return False + return True + + @property + def connections(self) -> Dict[str, Dict]: + """Return the public version of connections.""" + return copy.copy(self._connections) + + def add_connection(self, connection_id: Union[str, int], session_id: Optional[str] = None) -> bool: + """ + Create a new connection to this service. + + Returns true if connection successfully created + + :param: connection_id: UUID of the connection to create + :type: string + """ + # if over or at capacity, set to overwhelmed + if len(self._connections) >= self.max_sessions: + self.set_health_state(SoftwareHealthState.OVERWHELMED) + self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") + return False + else: + # if service was previously overwhelmed, set to good because there is enough space for connections + if self.health_state_actual == SoftwareHealthState.OVERWHELMED: + self.set_health_state(SoftwareHealthState.GOOD) + + # check that connection already doesn't exist + if not self._connections.get(connection_id): + session_details = None + if session_id: + session_details = self._get_session_details(session_id) + self._connections[connection_id] = { + "session_id": session_id, + "ip_address": session_details.with_ip_address if session_details else None, + "time": datetime.now(), + } + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + return True + # connection with given id already exists + self.sys_log.warning( + f"{self.name}: Connect request for {connection_id=} declined. Connection already exists." + ) + return False + + def terminate_connection(self, connection_id: str, send_disconnect: bool = True) -> bool: + """ + Terminates a connection from this service. + + Returns true if connection successfully removed + + :param: connection_id: UUID of the connection to create + :param send_disconnect: If True, sends a disconnect payload to the ip address of the associated session. + :type: string + """ + if self.connections.get(connection_id): + connection_dict = self._connections.pop(connection_id) + if send_disconnect: + self.software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_id}, + session_id=connection_dict["session_id"], + ) + self.sys_log.info(f"{self.name}: Connection {connection_id=} terminated") + return True + return False + + def show_connections(self, markdown: bool = False): + """ + Display the connections in tabular format. + + :param markdown: Whether to display the table in Markdown format or not. Default is `False`. + """ + table = PrettyTable(["IP Address", "Connection ID", "Creation Timestamp"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} {self.name} Connections" + for connection_id, data in self.connections.items(): + table.add_row([data["ip_address"], connection_id, str(data["time"])]) + print(table.get_string(sortby="Creation Timestamp")) + + def clear_connections(self): + """Clears all the connections from the software.""" + self._connections = {} + + def send( + self, + payload: Any, + session_id: Optional[str] = None, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + dest_port: Optional[Port] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. It delegates the actual sending process to the SoftwareManager. + + :param payload: The payload to be sent. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. + """ + if not self._can_perform_action(): + return False + + return self.software_manager.send_payload_to_session_manager( + payload=payload, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + ip_protocol=ip_protocol, + session_id=session_id, + ) + + @abstractmethod + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + + :param payload: The payload to receive. + :param session_id: The identifier of the session that the payload is associated with. + :param kwargs: Additional keyword arguments specific to the implementation. + :return: True if the payload was successfully received and processed, False otherwise. + """ + # return false if not allowed to perform actions + return self._can_perform_action() diff --git a/src/primaite/transactions/__init__.py b/src/primaite/transactions/__init__.py deleted file mode 100644 index 505c5080..00000000 --- a/src/primaite/transactions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Record data of the system's state and agent's observations and actions.""" diff --git a/src/primaite/transactions/transaction.py b/src/primaite/transactions/transaction.py deleted file mode 100644 index 7d5f747c..00000000 --- a/src/primaite/transactions/transaction.py +++ /dev/null @@ -1,102 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""The Transaction class.""" -from datetime import datetime -from typing import List, Optional, Tuple, TYPE_CHECKING, Union - -from primaite.common.enums import AgentIdentifier - -if TYPE_CHECKING: - import numpy as np - from gym import spaces - - -class Transaction(object): - """Transaction class.""" - - def __init__(self, agent_identifier: AgentIdentifier, episode_number: int, step_number: int) -> None: - """ - Transaction constructor. - - :param agent_identifier: An identifier for the agent in use - :param episode_number: The episode number - :param step_number: The step number - """ - self.timestamp: datetime = datetime.now() - "The datetime of the transaction" - self.agent_identifier: AgentIdentifier = agent_identifier - "The agent identifier" - self.episode_number: int = episode_number - "The episode number" - self.step_number: int = step_number - "The step number" - self.obs_space: "spaces.Space" = None - "The observation space (pre)" - self.obs_space_pre: Optional[Union["np.ndarray", Tuple["np.ndarray"]]] = None - "The observation space before any actions are taken" - self.obs_space_post: Optional[Union["np.ndarray", Tuple["np.ndarray"]]] = None - "The observation space after any actions are taken" - self.reward: Optional[float] = None - "The reward value" - self.action_space: Optional[int] = None - "The action space invoked by the agent" - self.obs_space_description: Optional[List[str]] = None - "The env observation space description" - - def as_csv_data(self) -> Tuple[List, List]: - """ - Converts the Transaction to a csv data row and provides a header. - - :return: A tuple consisting of (header, data). - """ - if isinstance(self.action_space, int): - action_length = self.action_space - else: - action_length = self.action_space.size - - # Create the action space headers array - action_header = [] - for x in range(action_length): - action_header.append("AS_" + str(x)) - - # Open up a csv file - header = ["Timestamp", "Episode", "Step", "Reward"] - header = header + action_header + self.obs_space_description - - row = [ - str(self.timestamp), - str(self.episode_number), - str(self.step_number), - str(self.reward), - ] - row = row + _turn_action_space_to_array(self.action_space) + self.obs_space.tolist() - return header, row - - -def _turn_action_space_to_array(action_space: Union[int, List[int]]) -> List[str]: - """ - Turns action space into a string array so it can be saved to csv. - - :param action_space: The action space - :return: The action space as an array of strings - """ - if isinstance(action_space, list): - return [str(i) for i in action_space] - else: - return [str(action_space)] - - -def _turn_obs_space_to_array(obs_space: "np.ndarray", obs_assets: int, obs_features: int) -> List[str]: - """ - Turns observation space into a string array so it can be saved to csv. - - :param obs_space: The observation space - :param obs_assets: The number of assets (i.e. nodes or links) in the observation space - :param obs_features: The number of features associated with the asset - :return: The observation space as an array of strings - """ - return_array = [] - for x in range(obs_assets): - for y in range(obs_features): - return_array.append(str(obs_space[x][y])) - - return return_array diff --git a/src/primaite/utils/cli/__init__.py b/src/primaite/utils/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/utils/cli/dev_cli.py b/src/primaite/utils/cli/dev_cli.py new file mode 100644 index 00000000..d2c8e370 --- /dev/null +++ b/src/primaite/utils/cli/dev_cli.py @@ -0,0 +1,171 @@ +import click +import typer +from rich import print +from rich.table import Table +from typing_extensions import Annotated + +from primaite import _PRIMAITE_ROOT, PRIMAITE_CONFIG +from primaite.simulator import LogLevel +from primaite.utils.cli.primaite_config_utils import is_dev_mode, update_primaite_application_config + +dev = typer.Typer() + +PRODUCTION_MODE_MESSAGE = ( + "\n[green]:rocket::rocket::rocket: " + " PrimAITE is running in Production mode " + " :rocket::rocket::rocket: [/green]\n" +) + +DEVELOPER_MODE_MESSAGE = ( + "\n[yellow] :construction::construction::construction: " + " PrimAITE is running in Development mode " + " :construction::construction::construction: [/yellow]\n" +) + + +def dev_mode(): + """ + CLI commands relevant to the dev-mode for PrimAITE. + + The dev-mode contains tools that help with the ease of developing or debugging PrimAITE. + + By default, PrimAITE will be in production mode. + + To enable development mode, use `primaite dev-mode enable` + """ + + +@dev.command() +def show(): + """Show if PrimAITE is in development mode or production mode.""" + # print if dev mode is enabled + print(DEVELOPER_MODE_MESSAGE if is_dev_mode() else PRODUCTION_MODE_MESSAGE) + + table = Table(title="Current Dev-Mode Settings") + table.add_column("Setting", style="cyan") + table.add_column("Value", style="default") + for setting, value in PRIMAITE_CONFIG["developer_mode"].items(): + table.add_row(setting, str(value)) + + print(table) + print("\nTo see available options, use [cyan]`primaite dev-mode --help`[/cyan]\n") + + +@dev.command() +def enable(): + """Enable the development mode for PrimAITE.""" + # enable dev mode + PRIMAITE_CONFIG["developer_mode"]["enabled"] = True + update_primaite_application_config() + print(DEVELOPER_MODE_MESSAGE) + + +@dev.command() +def disable(): + """Disable the development mode for PrimAITE.""" + # disable dev mode + PRIMAITE_CONFIG["developer_mode"]["enabled"] = False + update_primaite_application_config() + print(PRODUCTION_MODE_MESSAGE) + + +def config_callback( + ctx: typer.Context, + sys_log_level: Annotated[ + LogLevel, + typer.Option( + "--sys-log-level", + "-level", + click_type=click.Choice(LogLevel._member_names_, case_sensitive=False), + help="The level of system logs to output.", + show_default=False, + ), + ] = None, + output_sys_logs: Annotated[ + bool, + typer.Option( + "--output-sys-logs/--no-sys-logs", "-sys/-nsys", help="Output system logs to file.", show_default=False + ), + ] = None, + output_pcap_logs: Annotated[ + bool, + typer.Option( + "--output-pcap-logs/--no-pcap-logs", + "-pcap/-npcap", + help="Output network packet capture logs to file.", + show_default=False, + ), + ] = None, + output_to_terminal: Annotated[ + bool, + typer.Option( + "--output-to-terminal/--no-terminal", "-t/-nt", help="Output system logs to terminal.", show_default=False + ), + ] = None, +): + """Configure the development tools and environment.""" + if ctx.params.get("sys_log_level") is not None: + PRIMAITE_CONFIG["developer_mode"]["sys_log_level"] = ctx.params.get("sys_log_level") + print(f"PrimAITE dev-mode config updated sys_log_level={ctx.params.get('sys_log_level')}") + + if output_sys_logs is not None: + PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] = output_sys_logs + print(f"PrimAITE dev-mode config updated {output_sys_logs=}") + + if output_pcap_logs is not None: + PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] = output_pcap_logs + print(f"PrimAITE dev-mode config updated {output_pcap_logs=}") + + if output_to_terminal is not None: + PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] = output_to_terminal + print(f"PrimAITE dev-mode config updated {output_to_terminal=}") + + # update application config + update_primaite_application_config() + + +config_typer = typer.Typer( + callback=config_callback, + name="config", + no_args_is_help=True, + invoke_without_command=True, +) +dev.add_typer(config_typer) + + +@config_typer.command() +def path( + directory: Annotated[ + str, + typer.Argument( + help="Directory where the system logs and PCAP logs will be output. By default, this will be where the" + "root of the PrimAITE repository is located.", + show_default=False, + ), + ] = None, + default: Annotated[ + bool, + typer.Option( + "--default", + "-root", + help="Set PrimAITE to output system logs and pcap logs to the PrimAITE repository root.", + ), + ] = None, +): + """Set the output directory for the PrimAITE system and PCAP logs.""" + if default: + PRIMAITE_CONFIG["developer_mode"]["output_dir"] = None + # update application config + update_primaite_application_config() + print( + f"PrimAITE dev-mode output_dir [cyan]" + f"{str(_PRIMAITE_ROOT.parent.parent / 'simulation_output')}" + f"[/cyan]" + ) + return + + if directory: + PRIMAITE_CONFIG["developer_mode"]["output_dir"] = directory + # update application config + update_primaite_application_config() + print(f"PrimAITE dev-mode output_dir [cyan]{directory}[/cyan]") diff --git a/src/primaite/utils/cli/primaite_config_utils.py b/src/primaite/utils/cli/primaite_config_utils.py new file mode 100644 index 00000000..fa9b68ff --- /dev/null +++ b/src/primaite/utils/cli/primaite_config_utils.py @@ -0,0 +1,22 @@ +from typing import Dict, Optional + +import yaml + +from primaite import PRIMAITE_CONFIG, PRIMAITE_PATHS + + +def is_dev_mode() -> bool: + """Returns True if PrimAITE is currently running in developer mode.""" + return PRIMAITE_CONFIG.get("developer_mode", {}).get("enabled", False) + + +def update_primaite_application_config(config: Optional[Dict] = None) -> None: + """ + Update the PrimAITE application config file. + + :params: config: Leave empty so that PRIMAITE_CONFIG is used - otherwise provide the Dict + """ + with open(PRIMAITE_PATHS.app_config_file_path, "w") as file: + if not config: + config = PRIMAITE_CONFIG + yaml.dump(config, file) diff --git a/src/primaite/utils/session_metadata_parser.py b/src/primaite/utils/session_metadata_parser.py index 2548a8b6..083d55ba 100644 --- a/src/primaite/utils/session_metadata_parser.py +++ b/src/primaite/utils/session_metadata_parser.py @@ -1,3 +1,7 @@ +# flake8: noqa +raise DeprecationWarning( + "Benchmarking depends on deprecated functionality and it has not been updated to primaite v3 yet." +) # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK import json from pathlib import Path diff --git a/src/primaite/utils/session_output_reader.py b/src/primaite/utils/session_output_reader.py index 30febff1..c6eb2f7b 100644 --- a/src/primaite/utils/session_output_reader.py +++ b/src/primaite/utils/session_output_reader.py @@ -1,4 +1,8 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK +# flake8: noqa +raise DeprecationWarning( + "Benchmarking depends on deprecated functionality and it has not been updated to primaite v3 yet." +) +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from pathlib import Path from typing import Any, Dict, Tuple, Union @@ -7,16 +11,16 @@ from typing import Any, Dict, Tuple, Union import polars as pl -def av_rewards_dict(av_rewards_csv_file: Union[str, Path]) -> Dict[int, float]: +def total_rewards_dict(total_rewards_csv_file: Union[str, Path]) -> Dict[int, float]: """ Read an average rewards per episode csv file and return as a dict. The dictionary keys are the episode number, and the values are the mean reward that episode. - :param av_rewards_csv_file: The average rewards per episode csv file path. + :param total_rewards_csv_file: The average rewards per episode csv file path. :return: The average rewards per episode csv as a dict. """ - df_dict = pl.read_csv(av_rewards_csv_file).to_dict() + df_dict = pl.read_csv(total_rewards_csv_file).to_dict() return {int(v): df_dict["Average Reward"][i] for i, v in enumerate(df_dict["Episode"])} diff --git a/src/primaite/utils/session_output_writer.py b/src/primaite/utils/session_output_writer.py index 0eb18038..a5acdee6 100644 --- a/src/primaite/utils/session_output_writer.py +++ b/src/primaite/utils/session_output_writer.py @@ -1,4 +1,8 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK +# flake8: noqa +raise DeprecationWarning( + "Benchmarking depends on deprecated functionality and it has not been updated to primaite v3 yet." +) +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK import csv from logging import Logger from typing import Final, List, Tuple, TYPE_CHECKING, Union @@ -22,9 +26,9 @@ class SessionOutputWriter: Is used to write session outputs to csv file. """ - _AV_REWARD_PER_EPISODE_HEADER: Final[List[str]] = [ + _TOTAL_REWARD_PER_EPISODE_HEADER: Final[List[str]] = [ "Episode", - "Average Reward", + "Total Reward", ] def __init__( @@ -39,7 +43,7 @@ class SessionOutputWriter: :param env: PrimAITE gym environment. :type env: Primaite :param transaction_writer: If `true`, this will output a full account of every transaction taken by the agent. - If `false` it will output the average reward per episode, defaults to False + If `false` it will output the total reward per episode, defaults to False :type transaction_writer: bool, optional :param learning_session: Set to `true` to indicate that the current session is a training session. This determines the name of the folder which contains the final output csv. Defaults to True @@ -52,7 +56,7 @@ class SessionOutputWriter: if self.transaction_writer: fn = f"all_transactions_{self._env.timestamp_str}.csv" else: - fn = f"average_reward_per_episode_{self._env.timestamp_str}.csv" + fn = f"total_reward_per_episode_{self._env.timestamp_str}.csv" self._csv_file_path: "Path" if self.learning_session: @@ -90,7 +94,7 @@ class SessionOutputWriter: if isinstance(data, Transaction): header, data = data.as_csv_data() else: - header = self._AV_REWARD_PER_EPISODE_HEADER + header = self._TOTAL_REWARD_PER_EPISODE_HEADER if self._first_write: self._init_csv_writer() diff --git a/src/primaite/utils/validators.py b/src/primaite/utils/validators.py new file mode 100644 index 00000000..fb7abb29 --- /dev/null +++ b/src/primaite/utils/validators.py @@ -0,0 +1,38 @@ +from ipaddress import IPv4Address +from typing import Any, Final + +from pydantic import BeforeValidator +from typing_extensions import Annotated + + +def ipv4_validator(v: Any) -> IPv4Address: + """ + Validate the input and ensure it can be converted to an IPv4Address instance. + + This function takes an input `v`, and if it's not already an instance of IPv4Address, it tries to convert it to one. + If the conversion is successful, the IPv4Address instance is returned. This is useful for ensuring that any input + data is strictly in the format of an IPv4 address. + + :param v: The input value that needs to be validated or converted to IPv4Address. + :return: An instance of IPv4Address. + :raises ValueError: If `v` is not a valid IPv4 address and cannot be converted to an instance of IPv4Address. + """ + if isinstance(v, IPv4Address): + return v + + return IPv4Address(v) + + +# Define a custom type IPV4Address using the typing_extensions.Annotated. +# Annotated is used to attach metadata to type hints. In this case, it's used to associate the ipv4_validator +# with the IPv4Address type, ensuring that any usage of IPV4Address undergoes validation before assignment. +IPV4Address: Final[Annotated] = Annotated[IPv4Address, BeforeValidator(ipv4_validator)] +""" +IPv4Address with with IPv4Address with with pre-validation and auto-conversion from str using ipv4_validator.. + +This type is essentially an IPv4Address from the standard library's ipaddress module, +but with added validation logic. If you use this custom type, the ipv4_validator function +will automatically check and convert the input value to an instance of IPv4Address before +any Pydantic model uses it. This ensures that any field marked with this type is not just +an IPv4Address in form, but also valid according to the rules defined in ipv4_validator. +""" diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml new file mode 100644 index 00000000..231c69ab --- /dev/null +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -0,0 +1,705 @@ +game: + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + options: + nodes: + - node_name: client_2 + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_nics_per_node: 2 + max_acl_rules: 10 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + - type: NODE_FILE_DELETE + - type: NODE_FILE_CORRUPT + - type: NODE_OS_SCAN + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 1 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 1 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 1 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 2 + 19: # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 20: + action: "NODE_STARTUP" + options: + node_id: 5 + 21: + action: "NODE_RESET" + options: + node_id: 5 + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 24: # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 25: # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 26: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 27: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 28: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 29: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 30: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 31: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 32: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 33: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 34: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 35: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 36: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 37: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 38: + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 39: + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 40: + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 41: + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 42: + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 43: + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 44: + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 45: + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 46: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 47: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 48: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 49: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 50: + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 51: + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 52: + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 53: + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + - node_name: database_server + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + # ... + + + + + +simulation: + network: + nodes: + + - type: router + hostname: router_1 + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 0: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 1: + action: PERMIT + src_port: DNS + dst_port: DNS + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - type: switch + hostname: switch_1 + num_ports: 8 + + - type: switch + hostname: switch_2 + num_ports: 8 + + - type: server + hostname: domain_controller + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - type: server + hostname: web_server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - type: server + hostname: database_server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + + - type: server + hostname: backup_server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - type: server + hostname: security_suite + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - type: computer + hostname: client_1 + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.1 + data_manipulation_p_of_success: 0.1 + payload: "DELETE" + server_ip: 192.168.1.14 + services: + - type: DNSClient + + - type: computer + hostname: client_2 + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + services: + - type: DNSClient + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/basic_firewall.yaml b/tests/assets/configs/basic_firewall.yaml new file mode 100644 index 00000000..0253a4d2 --- /dev/null +++ b/tests/assets/configs/basic_firewall.yaml @@ -0,0 +1,159 @@ +# Basic Switched network +# +# -------------- -------------- -------------- +# | client_1 |------| switch_1 |------| client_2 | +# -------------- -------------- -------------- +# + +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 5 + frequency: 4 + variance: 3 + +simulation: + network: + nodes: + + - type: firewall + hostname: firewall + start_up_duration: 0 + shut_down_duration: 0 + ports: + external_port: # port 1 + ip_address: 192.168.20.1 + subnet_mask: 255.255.255.0 + internal_port: # port 2 + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + acl: + internal_inbound_acl: + 21: + action: PERMIT + protocol: TCP + 22: + action: PERMIT + protocol: UDP + 23: + action: PERMIT + protocol: ICMP + internal_outbound_acl: + 21: + action: PERMIT + protocol: TCP + 22: + action: PERMIT + protocol: UDP + 23: + action: PERMIT + protocol: ICMP + dmz_inbound_acl: + 21: + action: PERMIT + protocol: TCP + 22: + action: PERMIT + protocol: UDP + 23: + action: PERMIT + protocol: ICMP + dmz_outbound_acl: + 21: + action: PERMIT + protocol: TCP + 22: + action: PERMIT + protocol: UDP + 23: + action: PERMIT + protocol: ICMP + + - type: switch + hostname: switch_1 + num_ports: 8 + - type: switch + hostname: switch_2 + num_ports: 8 + + - type: computer + hostname: client_1 + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + # pre installed services and applications + - type: computer + hostname: client_2 + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + # pre installed services and applications + + links: + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: firewall + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: firewall + endpoint_b_port: 2 diff --git a/tests/assets/configs/basic_switched_network.yaml b/tests/assets/configs/basic_switched_network.yaml new file mode 100644 index 00000000..0cbaefdb --- /dev/null +++ b/tests/assets/configs/basic_switched_network.yaml @@ -0,0 +1,242 @@ +# Basic Switched network +# +# -------------- -------------- -------------- +# | client_1 |------| switch_1 |------| client_2 | +# -------------- -------------- -------------- +# +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + sys_log_level: WARNING + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 5 + frequency: 4 + variance: 3 + + + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: client_1 + - hostname: client_2 + - hostname: client_3 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.23 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - switch_1:eth-1<->client_1:eth-1 + - switch_1:eth-2<->client_2:eth-1 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + + action_map: + 0: + action: DONOTHING + options: {} + options: + nodes: + - node_name: switch + - node_name: client_1 + - node_name: client_2 + - node_name: client_3 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.23 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + flatten_obs: true + +simulation: + network: + nodes: + + - type: switch + hostname: switch_1 + num_ports: 8 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: RansomwareScript + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.10 + server_password: arcd + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.21 + server_password: arcd + - type: DoSBot + options: + target_ip_address: 192.168.10.21 + payload: SPOOF DATA + port_scan_p_of_success: 0.8 + services: + - type: DNSClient + options: + dns_server: 192.168.1.10 + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.10 + - type: DatabaseService + options: + backup_server_ip: 192.168.1.10 + - type: WebServer + - type: FTPServer + options: + server_password: arcd + - type: NTPClient + options: + ntp_server_ip: 192.168.1.10 + - type: NTPServer + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + # pre installed services and applications + - hostname: client_3 + type: computer + ip_address: 192.168.10.23 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + start_up_duration: 0 + shut_down_duration: 0 + operating_state: "OFF" + # pre installed services and applications + + links: + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + bandwidth: 200 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + bandwidth: 200 diff --git a/tests/assets/configs/dmz_network.yaml b/tests/assets/configs/dmz_network.yaml new file mode 100644 index 00000000..52316260 --- /dev/null +++ b/tests/assets/configs/dmz_network.yaml @@ -0,0 +1,276 @@ +# Network with DMZ +# +# An example network configuration with an internal network, a DMZ network and a couple of external networks. +# +# ............................................................................ +# . . +# . Internal Network . +# . . +# . -------------- -------------- -------------- . +# . | client_1 |------| switch_1 |--------| router_1 | . +# . -------------- -------------- -------------- . +# . (Computer) | . +# ........................................................|................... +# | +# | +# ........................................................|................... +# . | . +# . DMZ Network | . +# . | . +# . ---------------- -------------- -------------- . +# . | dmz_server |------| switch_2 |------| firewall | . +# . ---------------- -------------- -------------- . +# . (Server) | . +# ........................................................|................... +# | +# External Network | +# | +# | +# ----------------------- -------------- --------------------- +# | external_computer |------| switch_3 |------| external_server | +# ----------------------- -------------- --------------------- +# +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 5 + frequency: 4 + variance: 3 + + +simulation: + network: + nodes: + - type: computer + hostname: client_1 + ip_address: 192.168.0.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.0.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: switch + hostname: switch_1 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: router + hostname: router_1 + num_ports: 5 + start_up_duration: 0 + shut_down_duration: 0 + ports: + 1: + ip_address: 192.168.0.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + routes: + - address: 192.168.10.10 # route to dmz_server + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + - address: 192.168.20.10 # route to external_computer + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + - address: 192.168.20.11 # route to external_server + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + + - type: server + hostname: dmz_server + ip_address: 192.168.10.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: switch + hostname: switch_2 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: firewall + hostname: firewall + start_up_duration: 0 + shut_down_duration: 0 + ports: + external_port: # port 1 + ip_address: 192.168.20.1 + subnet_mask: 255.255.255.0 + internal_port: # port 2 + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + dmz_port: # port 3 + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + internal_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + internal_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + dmz_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + dmz_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + external_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + external_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + routes: + - address: 192.168.0.10 # route to client_1 + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.1 + metric: 0 + + - type: switch + hostname: switch_3 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: computer + hostname: external_computer + ip_address: 192.168.20.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.20.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: server + hostname: external_server + ip_address: 192.168.20.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.20.1 + start_up_duration: 0 + shut_down_duration: 0 + services: + - type: DNSServer + links: + - endpoint_a_hostname: client_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: firewall + endpoint_a_port: 2 # internal firewall port + endpoint_b_hostname: router_1 + endpoint_b_port: 2 + - endpoint_a_hostname: firewall + endpoint_a_port: 3 # dmz firewall port + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: dmz_server + endpoint_a_port: 1 + endpoint_b_hostname: switch_2 + endpoint_b_port: 1 + - endpoint_a_hostname: firewall + endpoint_a_port: 1 # external firewall port + endpoint_b_hostname: switch_3 + endpoint_b_port: 8 + - endpoint_a_hostname: external_computer + endpoint_a_port: 1 + endpoint_b_hostname: switch_3 + endpoint_b_port: 1 + - endpoint_a_hostname: external_server + endpoint_a_port: 1 + endpoint_b_hostname: switch_3 + endpoint_b_port: 2 diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml new file mode 100644 index 00000000..4cf0e68c --- /dev/null +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -0,0 +1,717 @@ +game: + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + action_map: + 0: + action: DONOTHING + options: {} + options: + nodes: + - node_name: client_2 + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_nics_per_node: 2 + max_acl_rules: 10 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + - type: NODE_FILE_DELETE + - type: NODE_FILE_CORRUPT + - type: NODE_OS_SCAN + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 1 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 1 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 1 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 2 + 19: # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 20: + action: "NODE_STARTUP" + options: + node_id: 5 + 21: + action: "NODE_RESET" + options: + node_id: 5 + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 24: # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 25: # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 26: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 27: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 28: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 29: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 30: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 31: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 32: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 33: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 34: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 35: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 36: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 37: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 38: + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 39: + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 40: + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 41: + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 42: + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 43: + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 44: + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 45: + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 46: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 47: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 48: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 49: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 50: + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 51: + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 52: + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 53: + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + - node_name: database_server + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + # ... + + + + + +simulation: + network: + nodes: + + - type: router + hostname: router_1 + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 0: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 1: + action: PERMIT + src_port: DNS + dst_port: DNS + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - type: switch + hostname: switch_1 + num_ports: 8 + + - type: switch + hostname: switch_2 + num_ports: 8 + + - type: server + hostname: domain_controller + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - type: server + hostname: web_server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - type: server + hostname: database_server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + + - type: server + hostname: backup_server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - type: server + hostname: security_suite + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - type: computer + hostname: client_1 + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.1 + data_manipulation_p_of_success: 0.1 + payload: "DELETE" + server_ip: 192.168.1.14 + services: + - type: DNSClient + + - type: computer + hostname: client_2 + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + services: + - type: DNSClient + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/firewall_actions_network.yaml b/tests/assets/configs/firewall_actions_network.yaml new file mode 100644 index 00000000..fd5b1bf8 --- /dev/null +++ b/tests/assets/configs/firewall_actions_network.yaml @@ -0,0 +1,462 @@ +# Network with DMZ +# +# An example network configuration with an internal network, a DMZ network and a couple of external networks. +# +# ............................................................................ +# . . +# . Internal Network . +# . . +# . -------------- -------------- -------------- . +# . | client_1 |------| switch_1 |--------| router_1 | . +# . -------------- -------------- -------------- . +# . (Computer) | . +# ........................................................|................... +# | +# | +# ........................................................|................... +# . | . +# . DMZ Network | . +# . | . +# . ---------------- -------------- -------------- . +# . | dmz_server |------| switch_2 |------| firewall | . +# . ---------------- -------------- -------------- . +# . (Server) | . +# ........................................................|................... +# | +# External Network | +# | +# | +# ----------------------- -------------- --------------------- +# | external_computer |------| switch_3 |------| external_server | +# ----------------------- -------------- --------------------- +# +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: client_1 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.0.10 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - client_1:eth-1<->switch_1:eth-1 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: FIREWALL_ACL_ADDRULE + - type: FIREWALL_ACL_REMOVERULE + - type: NETWORK_PORT_DISABLE + - type: NETWORK_PORT_ENABLE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: internal + firewall_port_direction: inbound + position: 1 + permission: 1 + source_ip_id: 2 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 2: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: internal + firewall_port_direction: inbound + position: 1 + 3: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: internal + firewall_port_direction: outbound + position: 1 + permission: 2 + source_ip_id: 2 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 2 + dest_port_id: 3 + protocol_id: 2 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 4: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: internal + firewall_port_direction: outbound + position: 1 + 5: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: dmz + firewall_port_direction: inbound + position: 1 + permission: 2 + source_ip_id: 3 # dmz_server + dest_ip_id: 2 # client_1 + source_port_id: 4 + dest_port_id: 4 + protocol_id: 4 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 6: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: dmz + firewall_port_direction: inbound + position: 1 + 7: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: dmz + firewall_port_direction: outbound + position: 2 + permission: 2 + source_ip_id: 3 # dmz_server + dest_ip_id: 2 # client_1 + source_port_id: 4 + dest_port_id: 4 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 8: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: dmz + firewall_port_direction: outbound + position: 2 + 9: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: external + firewall_port_direction: inbound + position: 10 + permission: 2 + source_ip_id: 4 # external_computer + dest_ip_id: 3 # dmz + source_port_id: 5 + dest_port_id: 5 + protocol_id: 2 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 10: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: external + firewall_port_direction: inbound + position: 10 + 11: + action: FIREWALL_ACL_ADDRULE + options: + target_firewall_nodename: firewall + firewall_port_name: external + firewall_port_direction: outbound + position: 1 + permission: 2 + source_ip_id: 4 # external_computer + dest_ip_id: 2 # client_1 + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 12: + action: FIREWALL_ACL_REMOVERULE + options: + target_firewall_nodename: firewall + firewall_port_name: external + firewall_port_direction: outbound + position: 1 + 13: + action: NETWORK_PORT_DISABLE + options: + target_nodename: firewall + port_id: 3 + 14: + action: NETWORK_PORT_ENABLE + options: + target_nodename: firewall + port_id: 3 + options: + nodes: + - node_name: client_1 + - node_name: dmz_server + - node_name: external_computer + ip_list: + - 192.168.0.10 + - 192.168.10.10 + - 192.168.20.10 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 5 + frequency: 4 + variance: 3 + + + +simulation: + network: + nodes: + - type: computer + hostname: client_1 + ip_address: 192.168.0.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.0.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: switch + hostname: switch_1 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: router + hostname: router_1 + num_ports: 5 + start_up_duration: 0 + shut_down_duration: 0 + ports: + 1: + ip_address: 192.168.0.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + routes: + - address: 192.168.10.10 # route to dmz_server + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + - address: 192.168.20.10 # route to external_computer + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + - address: 192.168.20.11 # route to external_server + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + + - type: server + hostname: dmz_server + ip_address: 192.168.10.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: switch + hostname: switch_2 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: firewall + hostname: firewall + start_up_duration: 0 + shut_down_duration: 0 + ports: + external_port: # port 1 + ip_address: 192.168.20.1 + subnet_mask: 255.255.255.0 + internal_port: # port 2 + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + dmz_port: # port 3 + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + internal_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + internal_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + dmz_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + dmz_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + external_inbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + external_outbound_acl: + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + routes: + - address: 192.168.0.10 # route to client_1 + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.1 + metric: 0 + + - type: switch + hostname: switch_3 + num_ports: 8 + start_up_duration: 0 + shut_down_duration: 0 + + - type: computer + hostname: external_computer + ip_address: 192.168.20.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.20.1 + dns_server: 192.168.20.11 + start_up_duration: 0 + shut_down_duration: 0 + + - type: server + hostname: external_server + ip_address: 192.168.20.11 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.20.1 + start_up_duration: 0 + shut_down_duration: 0 + services: + - type: DNSServer + links: + - endpoint_a_hostname: client_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: firewall + endpoint_a_port: 2 # internal firewall port + endpoint_b_hostname: router_1 + endpoint_b_port: 2 + - endpoint_a_hostname: firewall + endpoint_a_port: 3 # dmz firewall port + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: dmz_server + endpoint_a_port: 1 + endpoint_b_hostname: switch_2 + endpoint_b_port: 1 + - endpoint_a_hostname: firewall + endpoint_a_port: 1 # external firewall port + endpoint_b_hostname: switch_3 + endpoint_b_port: 8 + - endpoint_a_hostname: external_computer + endpoint_a_port: 1 + endpoint_b_hostname: switch_3 + endpoint_b_port: 1 + - endpoint_a_hostname: external_server + endpoint_a_port: 1 + endpoint_b_hostname: switch_3 + endpoint_b_port: 2 diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml new file mode 100644 index 00000000..0c89bef4 --- /dev/null +++ b/tests/assets/configs/multi_agent_session.yaml @@ -0,0 +1,1096 @@ +game: + max_episode_length: 128 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: PeriodicAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + + options: + nodes: + - node_name: client_2 + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_nics_per_node: 2 + max_acl_rules: 10 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + - type: NODE_FILE_DELETE + - type: NODE_FILE_CORRUPT + - type: NODE_OS_SCAN + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender1 + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 1 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 1 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 1 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 2 + 19: # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 20: + action: "NODE_STARTUP" + options: + node_id: 5 + 21: + action: "NODE_RESET" + options: + node_id: 5 + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 24: # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 25: # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 26: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 27: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 28: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 29: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 30: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 31: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 32: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 33: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 34: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 35: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 36: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 37: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 38: + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 39: + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 40: + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 41: + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 42: + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 43: + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 44: + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 45: + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 46: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 47: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 48: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 49: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 50: + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 51: + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 52: + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 53: + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + - node_name: database_server + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + # ... + + - ref: defender2 + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 1 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 1 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 1 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 2 + 19: # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 20: + action: "NODE_STARTUP" + options: + node_id: 5 + 21: + action: "NODE_RESET" + options: + node_id: 5 + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 24: # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 25: # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 26: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 27: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 28: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 29: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 30: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 31: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 32: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 33: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 34: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 35: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 36: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 37: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + - node_name: database_server + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + # ... + + + + + +simulation: + network: + nodes: + + - type: router + hostname: router_1 + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 0: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 1: + action: PERMIT + src_port: DNS + dst_port: DNS + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - type: switch + hostname: switch_1 + num_ports: 8 + + - type: switch + hostname: switch_2 + num_ports: 8 + + - type: server + hostname: domain_controller + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - type: server + hostname: web_server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + - type: server + hostname: database_server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + + - type: server + hostname: backup_server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - type: server + hostname: security_suite + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - type: computer + hostname: client_1 + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.1 + data_manipulation_p_of_success: 0.1 + payload: "DELETE" + server_ip: 192.168.1.14 + services: + - type: DNSClient + + - type: computer + hostname: client_2 + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + services: + - type: DNSClient + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/no_nodes_links_agents_network.yaml b/tests/assets/configs/no_nodes_links_agents_network.yaml new file mode 100644 index 00000000..b20835bc --- /dev/null +++ b/tests/assets/configs/no_nodes_links_agents_network.yaml @@ -0,0 +1,17 @@ +io_settings: + save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true + + +game: + max_episode_length: 256 + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP diff --git a/tests/assets/configs/scenario_with_placeholders/greens_0.yaml b/tests/assets/configs/scenario_with_placeholders/greens_0.yaml new file mode 100644 index 00000000..f31c52fa --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/greens_0.yaml @@ -0,0 +1,2 @@ +# No green agents present +greens: &greens [] diff --git a/tests/assets/configs/scenario_with_placeholders/greens_1.yaml b/tests/assets/configs/scenario_with_placeholders/greens_1.yaml new file mode 100644 index 00000000..98d2392a --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/greens_1.yaml @@ -0,0 +1,34 @@ +agents: &greens + - ref: green_A + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.2 + 1: 0.8 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DatabaseClient + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + + reward_function: + reward_components: + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 1.0 + options: + node_hostname: client diff --git a/tests/assets/configs/scenario_with_placeholders/greens_2.yaml b/tests/assets/configs/scenario_with_placeholders/greens_2.yaml new file mode 100644 index 00000000..17a5977b --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/greens_2.yaml @@ -0,0 +1,34 @@ +agents: &greens + - ref: green_B + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.95 + 1: 0.05 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DatabaseClient + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + + reward_function: + reward_components: + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 1.0 + options: + node_hostname: client diff --git a/tests/assets/configs/scenario_with_placeholders/reds_0.yaml b/tests/assets/configs/scenario_with_placeholders/reds_0.yaml new file mode 100644 index 00000000..878aba97 --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/reds_0.yaml @@ -0,0 +1,2 @@ +# No red agents present +reds: &reds [] diff --git a/tests/assets/configs/scenario_with_placeholders/reds_1.yaml b/tests/assets/configs/scenario_with_placeholders/reds_1.yaml new file mode 100644 index 00000000..31675a0b --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/reds_1.yaml @@ -0,0 +1,26 @@ +reds: &reds + - ref: red_A + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DataManipulationBot + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 10 + frequency: 10 + variance: 0 diff --git a/tests/assets/configs/scenario_with_placeholders/reds_2.yaml b/tests/assets/configs/scenario_with_placeholders/reds_2.yaml new file mode 100644 index 00000000..c5572b89 --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/reds_2.yaml @@ -0,0 +1,26 @@ +reds: &reds + - ref: red_B + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client + applications: + - application_name: DataManipulationBot + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: + start_settings: + start_step: 3 + frequency: 2 + variance: 1 diff --git a/tests/assets/configs/scenario_with_placeholders/scenario.yaml b/tests/assets/configs/scenario_with_placeholders/scenario.yaml new file mode 100644 index 00000000..81848b2d --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/scenario.yaml @@ -0,0 +1,168 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + + +game: + max_episode_length: 128 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - *greens + - *reds + + - ref: defender + team: BLUE + type: ProxyAgent + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + routers: [] + hosts: + - hostname: client + - hostname: server + num_services: 1 + num_applications: 1 + num_folders: 1 + num_files: 1 + num_nics: 1 + include_num_access: false + include_nmne: true + + - type: LINKS + label: LINKS + options: + link_references: + - client:eth-1<->switch_1:eth-1 + - server:eth-1<->switch_1:eth-2 + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_SHUTDOWN + options: + node_id: 0 + 2: + action: NODE_SHUTDOWN + options: + node_id: 1 + 3: + action: NODE_STARTUP + options: + node_id: 0 + 4: + action: NODE_STARTUP + options: + node_id: 1 + 5: + action: HOST_NIC_DISABLE + options: + node_id: 0 + nic_id: 0 + 6: + action: HOST_NIC_DISABLE + options: + node_id: 1 + nic_id: 0 + 7: + action: HOST_NIC_ENABLE + options: + node_id: 0 + nic_id: 0 + 8: + action: HOST_NIC_ENABLE + options: + node_id: 1 + nic_id: 0 + options: + nodes: + - node_name: client + - node_name: server + + max_folders_per_node: 0 + max_files_per_folder: 0 + max_services_per_node: 0 + max_nics_per_node: 1 + max_acl_rules: 0 + ip_list: + - 192.168.1.2 + - 192.168.1.3 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + agent_settings: + flatten_obs: false + + +simulation: + network: + nodes: + - hostname: client + type: computer + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.3 + - type: DataManipulationBot + options: + server_ip: 192.168.1.3 + payload: "DELETE" + + - hostname: switch_1 + type: switch + num_ports: 2 + + - hostname: server + type: server + ip_address: 192.168.1.3 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DatabaseService + + links: + - endpoint_a_hostname: client + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 1 + + - endpoint_a_hostname: server + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 2 diff --git a/tests/assets/configs/scenario_with_placeholders/schedule.yaml b/tests/assets/configs/scenario_with_placeholders/schedule.yaml new file mode 100644 index 00000000..07ee4e50 --- /dev/null +++ b/tests/assets/configs/scenario_with_placeholders/schedule.yaml @@ -0,0 +1,14 @@ +base_scenario: scenario.yaml +schedule: + 0: + - greens_0.yaml + - reds_0.yaml + 1: + - greens_0.yaml + - reds_1.yaml + 2: + - greens_1.yaml + - reds_1.yaml + 3: + - greens_2.yaml + - reds_2.yaml diff --git a/tests/assets/configs/shared_rewards.yaml b/tests/assets/configs/shared_rewards.yaml new file mode 100644 index 00000000..c90e1cc2 --- /dev/null +++ b/tests/assets/configs/shared_rewards.yaml @@ -0,0 +1,927 @@ +io_settings: + save_agent_actions: false + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + + +game: + max_episode_length: 256 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_1 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_1 + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + - node_name: client_2 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + + reward_function: + reward_components: + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + + + agent_settings: + flatten_obs: true + + + + + +simulation: + network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE + nodes: + + - hostname: router_1 + type: router + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + 18: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 19: + action: PERMIT + src_port: DNS + dst_port: DNS + 20: + action: PERMIT + src_port: FTP + dst_port: FTP + 21: + action: PERMIT + src_port: HTTP + dst_port: HTTP + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - hostname: switch_1 + type: switch + num_ports: 8 + + - hostname: switch_2 + type: switch + num_ports: 8 + + - hostname: domain_controller + type: server + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - hostname: web_server + type: server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - hostname: database_server + type: server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + options: + backup_server_ip: 192.168.1.16 + - type: FTPClient + + - hostname: backup_server + type: server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - hostname: security_suite + type: server + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/test_application_install.yaml b/tests/assets/configs/test_application_install.yaml new file mode 100644 index 00000000..87402f73 --- /dev/null +++ b/tests/assets/configs/test_application_install.yaml @@ -0,0 +1,963 @@ +io_settings: + save_agent_actions: true + save_step_metadata: false + save_pcap_logs: false + save_sys_logs: false + + +game: + max_episode_length: 128 + ports: + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + thresholds: + nmne: + high: 10 + medium: 5 + low: 0 + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_2 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_2 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_2 + + - ref: client_1_green_user + team: GREEN + type: ProbabilisticAgent + agent_settings: + action_probabilities: + 0: 0.3 + 1: 0.6 + 2: 0.1 + observation_space: null + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: WebBrowser + - application_name: DatabaseClient + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_applications_per_node: 2 + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + 2: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 1 + + reward_function: + reward_components: + - type: WEBPAGE_UNAVAILABLE_PENALTY + weight: 0.25 + options: + node_hostname: client_1 + - type: GREEN_ADMIN_DATABASE_UNREACHABLE_PENALTY + weight: 0.05 + options: + node_hostname: client_1 + + + + + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + - node_name: client_2 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + - type: NODE_APPLICATION_INSTALL + - type: NODE_APPLICATION_REMOVE + - type: NODE_APPLICATION_EXECUTE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 10: + action: "NODE_FILE_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 0 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 0 + 15: + action: "NODE_FOLDER_SCAN" # CHECKHASH replaced by SCAN - but the behaviour is the same in this context. + options: + node_id: 2 + folder_id: 0 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 0 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 0 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 0 + 19: + action: "NODE_SHUTDOWN" + options: + node_id: 0 + 20: + action: NODE_STARTUP + options: + node_id: 0 + 21: + action: NODE_RESET + options: + node_id: 0 + 22: + action: "NODE_OS_SCAN" + options: + node_id: 1 + 23: + action: "NODE_SHUTDOWN" + options: + node_id: 1 + 24: + action: NODE_STARTUP + options: + node_id: 1 + 25: + action: NODE_RESET + options: + node_id: 1 + 26: # old action num: 18 + action: "NODE_OS_SCAN" + options: + node_id: 2 + 27: + action: "NODE_SHUTDOWN" + options: + node_id: 2 + 28: + action: NODE_STARTUP + options: + node_id: 2 + 29: + action: NODE_RESET + options: + node_id: 2 + 30: + action: "NODE_OS_SCAN" + options: + node_id: 3 + 31: + action: "NODE_SHUTDOWN" + options: + node_id: 3 + 32: + action: NODE_STARTUP + options: + node_id: 3 + 33: + action: NODE_RESET + options: + node_id: 3 + 34: + action: "NODE_OS_SCAN" + options: + node_id: 4 + 35: + action: "NODE_SHUTDOWN" + options: + node_id: 4 + 36: + action: NODE_STARTUP + options: + node_id: 4 + 37: + action: NODE_RESET + options: + node_id: 4 + 38: + action: "NODE_OS_SCAN" + options: + node_id: 5 + 39: # old action num: 19 # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 40: # old action num: 20 + action: NODE_STARTUP + options: + node_id: 5 + 41: # old action num: 21 + action: NODE_RESET + options: + node_id: 5 + 42: + action: "NODE_OS_SCAN" + options: + node_id: 6 + 43: + action: "NODE_SHUTDOWN" + options: + node_id: 6 + 44: + action: NODE_STARTUP + options: + node_id: 6 + 45: + action: NODE_RESET + options: + node_id: 6 + + 46: # old action num: 22 # "ACL: ADDRULE - Block outgoing traffic from client 1" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 47: # old action num: 23 # "ACL: ADDRULE - Block outgoing traffic from client 2" + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 48: # old action num: 24 # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 49: # old action num: 25 # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 50: # old action num: 26 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 51: # old action num: 27 + action: "ROUTER_ACL_ADDRULE" + options: + target_router_hostname: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 52: # old action num: 28 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 0 + 53: # old action num: 29 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 1 + 54: # old action num: 30 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 2 + 55: # old action num: 31 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 3 + 56: # old action num: 32 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 4 + 57: # old action num: 33 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 5 + 58: # old action num: 34 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 6 + 59: # old action num: 35 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 7 + 60: # old action num: 36 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 8 + 61: # old action num: 37 + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_hostname: router_1 + position: 9 + 62: # old action num: 38 + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 63: # old action num: 39 + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 64: # old action num: 40 + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 65: # old action num: 41 + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 66: # old action num: 42 + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 67: # old action num: 43 + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 68: # old action num: 44 + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 69: # old action num: 45 + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 70: # old action num: 46 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 71: # old action num: 47 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 72: # old action num: 48 + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 73: # old action num: 49 + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 74: # old action num: 50 + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 75: # old action num: 51 + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 76: # old action num: 52 + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 77: # old action num: 53 + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + 78: + action: NODE_APPLICATION_INSTALL + options: + node_id: 0 + application_name: DoSBot + ip_address: 192.168.1.14 + 79: + action: NODE_APPLICATION_REMOVE + options: + node_id: 0 + application_name: DoSBot + 80: + action: NODE_APPLICATION_REMOVE + options: + node_id: 0 + application_name: WebBrowser + 81: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + + + + options: + nodes: + - node_name: domain_controller + applications: + - application_name: DoSBot + - node_name: web_server + applications: + - application_name: DatabaseClient + services: + - service_name: WebServer + - node_name: database_server + folders: + - folder_name: database + files: + - file_name: database.db + services: + - service_name: DatabaseService + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.40 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_1_green_user + - type: SHARED_REWARD + weight: 1.0 + options: + agent_name: client_2_green_user + + + + agent_settings: + flatten_obs: true + + + + + +simulation: + network: + nmne_config: + capture_nmne: true + nmne_capture_keywords: + - DELETE + nodes: + + - hostname: router_1 + type: router + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.10.1 + subnet_mask: 255.255.255.0 + acl: + 18: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 19: + action: PERMIT + src_port: DNS + dst_port: DNS + 20: + action: PERMIT + src_port: FTP + dst_port: FTP + 21: + action: PERMIT + src_port: HTTP + dst_port: HTTP + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - hostname: switch_1 + type: switch + num_ports: 8 + + - hostname: switch_2 + type: switch + num_ports: 8 + + - hostname: domain_controller + type: server + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - hostname: web_server + type: server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - hostname: database_server + type: server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + options: + backup_server_ip: 192.168.1.16 + - type: FTPClient + + - hostname: backup_server + type: server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - hostname: security_suite + type: server + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - hostname: client_1 + type: computer + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + - hostname: client_2 + type: computer + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + options: + target_url: http://arcd.com/users/ + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.8 + data_manipulation_p_of_success: 0.8 + payload: "DELETE" + server_ip: 192.168.1.14 + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + services: + - type: DNSClient + + + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml new file mode 100644 index 00000000..f41ef475 --- /dev/null +++ b/tests/assets/configs/test_primaite_session.yaml @@ -0,0 +1,756 @@ +io_settings: + save_agent_actions: true + save_step_metadata: true + save_pcap_logs: true + save_sys_logs: true + + + +game: + ports: + - ARP + - DNS + - HTTP + - POSTGRES_SERVER + protocols: + - ICMP + - TCP + - UDP + +agents: + - ref: client_2_green_user + team: GREEN + type: ProbabilisticAgent + observation_space: null + action_space: + action_list: + - type: DONOTHING + action_map: + 0: + action: DONOTHING + options: {} + options: + nodes: + - node_name: client_2 + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + max_nics_per_node: 2 + max_acl_rules: 10 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: data_manipulation_attacker + team: RED + type: RedDatabaseCorruptingAgent + + observation_space: null + + action_space: + action_list: + - type: DONOTHING + - type: NODE_APPLICATION_EXECUTE + - type: NODE_FILE_DELETE + - type: NODE_FILE_CORRUPT + - type: NODE_OS_SCAN + action_map: + 0: + action: DONOTHING + options: {} + 1: + action: NODE_APPLICATION_EXECUTE + options: + node_id: 0 + application_id: 0 + options: + nodes: + - node_name: client_1 + applications: + - application_name: DataManipulationBot + max_folders_per_node: 1 + max_files_per_folder: 1 + max_services_per_node: 1 + + reward_function: + reward_components: + - type: DUMMY + + agent_settings: # options specific to this particular agent type, basically args of __init__(self) + start_settings: + start_step: 25 + frequency: 20 + variance: 5 + + - ref: defender + team: BLUE + type: ProxyAgent + + observation_space: + type: CUSTOM + options: + components: + - type: NODES + label: NODES + options: + hosts: + - hostname: domain_controller + - hostname: web_server + services: + - service_name: WebServer + - hostname: database_server + folders: + - folder_name: database + files: + - file_name: database.db + - hostname: backup_server + - hostname: security_suite + - hostname: client_1 + - hostname: client_2 + num_services: 1 + num_applications: 0 + num_folders: 1 + num_files: 1 + num_nics: 2 + include_num_access: false + include_nmne: true + routers: + - hostname: router_1 + num_ports: 0 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + wildcard_list: + - 0.0.0.1 + port_list: + - 80 + - 5432 + protocol_list: + - ICMP + - TCP + - UDP + num_rules: 10 + + - type: LINKS + label: LINKS + options: + link_references: + - router_1:eth-1<->switch_1:eth-8 + - router_1:eth-2<->switch_2:eth-8 + - switch_1:eth-1<->domain_controller:eth-1 + - switch_1:eth-2<->web_server:eth-1 + - switch_1:eth-3<->database_server:eth-1 + - switch_1:eth-4<->backup_server:eth-1 + - switch_1:eth-7<->security_suite:eth-1 + - switch_2:eth-1<->client_1:eth-1 + - switch_2:eth-2<->client_2:eth-1 + - switch_2:eth-7<->security_suite:eth-2 + - type: "NONE" + label: ICS + options: {} + + action_space: + action_list: + - type: DONOTHING + - type: NODE_SERVICE_SCAN + - type: NODE_SERVICE_STOP + - type: NODE_SERVICE_START + - type: NODE_SERVICE_PAUSE + - type: NODE_SERVICE_RESUME + - type: NODE_SERVICE_RESTART + - type: NODE_SERVICE_DISABLE + - type: NODE_SERVICE_ENABLE + - type: NODE_SERVICE_FIX + - type: NODE_FILE_SCAN + - type: NODE_FILE_CHECKHASH + - type: NODE_FILE_DELETE + - type: NODE_FILE_REPAIR + - type: NODE_FILE_RESTORE + - type: NODE_FOLDER_SCAN + - type: NODE_FOLDER_CHECKHASH + - type: NODE_FOLDER_REPAIR + - type: NODE_FOLDER_RESTORE + - type: NODE_OS_SCAN + - type: NODE_SHUTDOWN + - type: NODE_STARTUP + - type: NODE_RESET + - type: ROUTER_ACL_ADDRULE + - type: ROUTER_ACL_REMOVERULE + - type: HOST_NIC_ENABLE + - type: HOST_NIC_DISABLE + + action_map: + 0: + action: DONOTHING + options: {} + # scan webapp service + 1: + action: NODE_SERVICE_SCAN + options: + node_id: 1 + service_id: 0 + # stop webapp service + 2: + action: NODE_SERVICE_STOP + options: + node_id: 1 + service_id: 0 + # start webapp service + 3: + action: "NODE_SERVICE_START" + options: + node_id: 1 + service_id: 0 + 4: + action: "NODE_SERVICE_PAUSE" + options: + node_id: 1 + service_id: 0 + 5: + action: "NODE_SERVICE_RESUME" + options: + node_id: 1 + service_id: 0 + 6: + action: "NODE_SERVICE_RESTART" + options: + node_id: 1 + service_id: 0 + 7: + action: "NODE_SERVICE_DISABLE" + options: + node_id: 1 + service_id: 0 + 8: + action: "NODE_SERVICE_ENABLE" + options: + node_id: 1 + service_id: 0 + 9: # check database.db file + action: "NODE_FILE_SCAN" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 10: + action: "NODE_FILE_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 11: + action: "NODE_FILE_DELETE" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 12: + action: "NODE_FILE_REPAIR" + options: + node_id: 2 + folder_id: 1 + file_id: 0 + 13: + action: "NODE_SERVICE_FIX" + options: + node_id: 2 + service_id: 0 + 14: + action: "NODE_FOLDER_SCAN" + options: + node_id: 2 + folder_id: 1 + 15: + action: "NODE_FOLDER_CHECKHASH" + options: + node_id: 2 + folder_id: 1 + 16: + action: "NODE_FOLDER_REPAIR" + options: + node_id: 2 + folder_id: 1 + 17: + action: "NODE_FOLDER_RESTORE" + options: + node_id: 2 + folder_id: 1 + 18: + action: "NODE_OS_SCAN" + options: + node_id: 2 + 19: # shutdown client 1 + action: "NODE_SHUTDOWN" + options: + node_id: 5 + 20: + action: "NODE_STARTUP" + options: + node_id: 5 + 21: + action: "NODE_RESET" + options: + node_id: 5 + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 1 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 2 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL + source_port_id: 1 + dest_port_id: 1 + protocol_id: 1 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 24: # block tcp traffic from client 1 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 3 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 25: # block tcp traffic from client 2 to web app + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 4 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 26: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 5 + permission: 2 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 27: + action: "ROUTER_ACL_ADDRULE" + options: + target_router_nodename: router_1 + position: 6 + permission: 2 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database + source_port_id: 1 + dest_port_id: 1 + protocol_id: 3 + source_wildcard_id: 0 + dest_wildcard_id: 0 + 28: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 0 + 29: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 1 + 30: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 2 + 31: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 3 + 32: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 4 + 33: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 5 + 34: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 6 + 35: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 7 + 36: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 8 + 37: + action: "ROUTER_ACL_REMOVERULE" + options: + target_router_nodename: router_1 + position: 9 + 38: + action: "HOST_NIC_DISABLE" + options: + node_id: 0 + nic_id: 0 + 39: + action: "HOST_NIC_ENABLE" + options: + node_id: 0 + nic_id: 0 + 40: + action: "HOST_NIC_DISABLE" + options: + node_id: 1 + nic_id: 0 + 41: + action: "HOST_NIC_ENABLE" + options: + node_id: 1 + nic_id: 0 + 42: + action: "HOST_NIC_DISABLE" + options: + node_id: 2 + nic_id: 0 + 43: + action: "HOST_NIC_ENABLE" + options: + node_id: 2 + nic_id: 0 + 44: + action: "HOST_NIC_DISABLE" + options: + node_id: 3 + nic_id: 0 + 45: + action: "HOST_NIC_ENABLE" + options: + node_id: 3 + nic_id: 0 + 46: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 0 + 47: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 0 + 48: + action: "HOST_NIC_DISABLE" + options: + node_id: 4 + nic_id: 1 + 49: + action: "HOST_NIC_ENABLE" + options: + node_id: 4 + nic_id: 1 + 50: + action: "HOST_NIC_DISABLE" + options: + node_id: 5 + nic_id: 0 + 51: + action: "HOST_NIC_ENABLE" + options: + node_id: 5 + nic_id: 0 + 52: + action: "HOST_NIC_DISABLE" + options: + node_id: 6 + nic_id: 0 + 53: + action: "HOST_NIC_ENABLE" + options: + node_id: 6 + nic_id: 0 + + + options: + nodes: + - node_name: domain_controller + - node_name: web_server + - node_name: database_server + - node_name: backup_server + - node_name: security_suite + - node_name: client_1 + - node_name: client_2 + max_folders_per_node: 2 + max_files_per_folder: 2 + max_services_per_node: 2 + max_nics_per_node: 8 + max_acl_rules: 10 + ip_list: + - 192.168.1.10 + - 192.168.1.12 + - 192.168.1.14 + - 192.168.1.16 + - 192.168.1.110 + - 192.168.10.21 + - 192.168.10.22 + - 192.168.10.110 + + reward_function: + reward_components: + - type: DATABASE_FILE_INTEGRITY + weight: 0.5 + options: + node_hostname: database_server + folder_name: database + file_name: database.db + + + - type: WEB_SERVER_404_PENALTY + weight: 0.5 + options: + node_hostname: web_server + service_name: web_server_web_service + + + agent_settings: + flatten_obs: true + + + + + +simulation: + network: + nodes: + + - type: router + hostname: router_1 + num_ports: 5 + ports: + 1: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + 2: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + acl: + 0: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 1: + action: PERMIT + src_port: DNS + dst_port: DNS + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + - type: switch + hostname: switch_1 + num_ports: 8 + + - type: switch + hostname: switch_2 + num_ports: 8 + + - type: server + hostname: domain_controller + ip_address: 192.168.1.10 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + services: + - type: DNSServer + options: + domain_mapping: + arcd.com: 192.168.1.12 # web server + + - type: server + hostname: web_server + ip_address: 192.168.1.12 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: WebServer + applications: + - type: DatabaseClient + options: + db_server_ip: 192.168.1.14 + + + - type: server + hostname: database_server + ip_address: 192.168.1.14 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: DatabaseService + + - type: server + hostname: backup_server + ip_address: 192.168.1.16 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + services: + - type: FTPServer + + - type: server + hostname: security_suite + ip_address: 192.168.1.110 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.1.1 + dns_server: 192.168.1.10 + network_interfaces: + 2: # unfortunately this number is currently meaningless, they're just added in order and take up the next available slot + ip_address: 192.168.10.110 + subnet_mask: 255.255.255.0 + + - type: computer + hostname: client_1 + ip_address: 192.168.10.21 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: DataManipulationBot + options: + port_scan_p_of_success: 0.1 + data_manipulation_p_of_success: 0.1 + payload: "DELETE" + server_ip: 192.168.1.14 + services: + - type: DNSClient + + - type: computer + hostname: client_2 + ip_address: 192.168.10.22 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.10.1 + dns_server: 192.168.1.10 + applications: + - type: WebBrowser + services: + - type: DNSClient + + - type: printer + hostname: HP_LaserJet_Pro_4102fdn_printer + ip_address: 192.168.10.99 + subnet_mask: 255.255.255.0 + + - type: wireless_router + hostname: router_2 + router_interface: + ip_address: 192.169.1.1 + subnet_mask: 255.255.255.0 + wireless_access_point: + ip_address: 192.170.1.1 + subnet_mask: 255.255.255.0 + frequency: WIFI_2_4 + acl: + 0: + action: PERMIT + src_port: POSTGRES_SERVER + dst_port: POSTGRES_SERVER + 1: + action: PERMIT + src_port: DNS + dst_port: DNS + 22: + action: PERMIT + src_port: ARP + dst_port: ARP + 23: + action: PERMIT + protocol: ICMP + + links: + - endpoint_a_hostname: router_1 + endpoint_a_port: 1 + endpoint_b_hostname: switch_1 + endpoint_b_port: 8 + - endpoint_a_hostname: router_1 + endpoint_a_port: 2 + endpoint_b_hostname: switch_2 + endpoint_b_port: 8 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 1 + endpoint_b_hostname: domain_controller + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 2 + endpoint_b_hostname: web_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 3 + endpoint_b_hostname: database_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 4 + endpoint_b_hostname: backup_server + endpoint_b_port: 1 + - endpoint_a_hostname: switch_1 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 1 + endpoint_b_hostname: client_1 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 2 + endpoint_b_hostname: client_2 + endpoint_b_port: 1 + - endpoint_a_hostname: switch_2 + endpoint_a_port: 7 + endpoint_b_hostname: security_suite + endpoint_b_port: 2 diff --git a/tests/assets/configs/wireless_wan_network_config.yaml b/tests/assets/configs/wireless_wan_network_config.yaml new file mode 100644 index 00000000..c8f61bad --- /dev/null +++ b/tests/assets/configs/wireless_wan_network_config.yaml @@ -0,0 +1,77 @@ +game: + max_episode_length: 256 + ports: + - ARP + protocols: + - ICMP + - TCP + - UDP + +simulation: + network: + nodes: + - type: computer + hostname: pc_a + ip_address: 192.168.0.2 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.0.1 + start_up_duration: 0 + + - type: computer + hostname: pc_b + ip_address: 192.168.2.2 + subnet_mask: 255.255.255.0 + default_gateway: 192.168.2.1 + start_up_duration: 0 + + - type: wireless_router + hostname: router_1 + start_up_duration: 0 + + router_interface: + ip_address: 192.168.0.1 + subnet_mask: 255.255.255.0 + + wireless_access_point: + ip_address: 192.168.1.1 + subnet_mask: 255.255.255.0 + frequency: WIFI_2_4 + acl: + 1: + action: PERMIT + routes: + - address: 192.168.2.0 # PC B subnet + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.2 + metric: 0 + + - type: wireless_router + hostname: router_2 + start_up_duration: 0 + + router_interface: + ip_address: 192.168.2.1 + subnet_mask: 255.255.255.0 + + wireless_access_point: + ip_address: 192.168.1.2 + subnet_mask: 255.255.255.0 + frequency: WIFI_2_4 + acl: + 1: + action: PERMIT + routes: + - address: 192.168.0.0 # PC A subnet + subnet_mask: 255.255.255.0 + next_hop_ip_address: 192.168.1.1 + metric: 0 + links: + - endpoint_a_hostname: pc_a + endpoint_a_port: 1 + endpoint_b_hostname: router_1 + endpoint_b_port: 2 + + - endpoint_a_hostname: pc_b + endpoint_a_port: 1 + endpoint_b_hostname: router_2 + endpoint_b_port: 2 diff --git a/tests/assets/example_sb3_agent_session/session_metadata.json b/tests/assets/example_sb3_agent_session/session_metadata.json index c0968ba7..085e20cc 100644 --- a/tests/assets/example_sb3_agent_session/session_metadata.json +++ b/tests/assets/example_sb3_agent_session/session_metadata.json @@ -1 +1 @@ -{ "uuid": "301874d3-2e14-43c2-ba7f-e2b03ad05dde", "start_datetime": "2023-07-14T09:48:22.973005", "end_datetime": "2023-07-14T09:48:34.182715", "learning": { "total_episodes": 10, "total_time_steps": 2560 }, "evaluation": { "total_episodes": 5, "total_time_steps": 1280 }, "env": { "training_config": { "agent_framework": "SB3", "deep_learning_framework": "TF2", "agent_identifier": "PPO", "hard_coded_agent_view": "FULL", "random_red_agent": false, "action_type": "NODE", "num_train_episodes": 10, "num_train_steps": 256, "num_eval_episodes": 5, "num_eval_steps": 256, "checkpoint_every_n_episodes": 10, "observation_space": { "components": [ { "name": "NODE_LINK_TABLE" } ] }, "time_delay": 5, "session_type": "TRAIN_EVAL", "load_agent": false, "agent_load_file": null, "observation_space_high_value": 1000000000, "sb3_output_verbose_level": "NONE", "all_ok": 0, "off_should_be_on": -0.001, "off_should_be_resetting": -0.0005, "on_should_be_off": -0.0002, "on_should_be_resetting": -0.0005, "resetting_should_be_on": -0.0005, "resetting_should_be_off": -0.0002, "resetting": -0.0003, "good_should_be_patching": 0.0002, "good_should_be_compromised": 0.0005, "good_should_be_overwhelmed": 0.0005, "patching_should_be_good": -0.0005, "patching_should_be_compromised": 0.0002, "patching_should_be_overwhelmed": 0.0002, "patching": -0.0003, "compromised_should_be_good": -0.002, "compromised_should_be_patching": -0.002, "compromised_should_be_overwhelmed": -0.002, "compromised": -0.002, "overwhelmed_should_be_good": -0.002, "overwhelmed_should_be_patching": -0.002, "overwhelmed_should_be_compromised": -0.002, "overwhelmed": -0.002, "good_should_be_repairing": 0.0002, "good_should_be_restoring": 0.0002, "good_should_be_corrupt": 0.0005, "good_should_be_destroyed": 0.001, "repairing_should_be_good": -0.0005, "repairing_should_be_restoring": 0.0002, "repairing_should_be_corrupt": 0.0002, "repairing_should_be_destroyed": 0.0, "repairing": -0.0003, "restoring_should_be_good": -0.001, "restoring_should_be_repairing": -0.0002, "restoring_should_be_corrupt": 0.0001, "restoring_should_be_destroyed": 0.0002, "restoring": -0.0006, "corrupt_should_be_good": -0.001, "corrupt_should_be_repairing": -0.001, "corrupt_should_be_restoring": -0.001, "corrupt_should_be_destroyed": 0.0002, "corrupt": -0.001, "destroyed_should_be_good": -0.002, "destroyed_should_be_repairing": -0.002, "destroyed_should_be_restoring": -0.002, "destroyed_should_be_corrupt": -0.002, "destroyed": -0.002, "scanning": -0.0002, "red_ier_running": -0.0005, "green_ier_blocked": -0.001, "os_patching_duration": 5, "node_reset_duration": 5, "node_booting_duration": 3, "node_shutdown_duration": 2, "service_patching_duration": 5, "file_system_repairing_limit": 5, "file_system_restoring_limit": 5, "file_system_scanning_limit": 5, "deterministic": true, "seed": 12345 }, "lay_down_config": [ { "item_type": "PORTS", "ports_list": [ { "port": "80" } ] }, { "item_type": "SERVICES", "service_list": [ { "name": "TCP" } ] }, { "item_type": "NODE", "node_id": "1", "name": "PC1", "node_class": "SERVICE", "node_type": "COMPUTER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.2", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "NODE", "node_id": "2", "name": "PC2", "node_class": "SERVICE", "node_type": "COMPUTER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.3", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "NODE", "node_id": "3", "name": "SWITCH1", "node_class": "ACTIVE", "node_type": "SWITCH", "priority": "P2", "hardware_state": "ON", "ip_address": "192.168.1.1", "software_state": "GOOD", "file_system_state": "GOOD" }, { "item_type": "NODE", "node_id": "4", "name": "SERVER1", "node_class": "SERVICE", "node_type": "SERVER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.4", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "LINK", "id": "5", "name": "link1", "bandwidth": 1000000000, "source": "1", "destination": "3" }, { "item_type": "LINK", "id": "6", "name": "link2", "bandwidth": 1000000000, "source": "2", "destination": "3" }, { "item_type": "LINK", "id": "7", "name": "link3", "bandwidth": 1000000000, "source": "3", "destination": "4" }, { "item_type": "GREEN_IER", "id": "8", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "1", "destination": "4", "mission_criticality": 1 }, { "item_type": "GREEN_IER", "id": "9", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "2", "destination": "4", "mission_criticality": 1 }, { "item_type": "GREEN_IER", "id": "10", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "4", "destination": "2", "mission_criticality": 5 }, { "item_type": "ACL_RULE", "id": "11", "permission": "ALLOW", "source": "192.168.1.2", "destination": "192.168.1.4", "protocol": "TCP", "port": 80, "position": 0 }, { "item_type": "ACL_RULE", "id": "12", "permission": "ALLOW", "source": "192.168.1.3", "destination": "192.168.1.4", "protocol": "TCP", "port": 80, "position": 1 }, { "item_type": "ACL_RULE", "id": "13", "permission": "ALLOW", "source": "192.168.1.4", "destination": "192.168.1.3", "protocol": "TCP", "port": 80, "position": 2 }, { "item_type": "RED_POL", "id": "14", "start_step": 20, "end_step": 20, "targetNodeId": "1", "initiator": "DIRECT", "type": "SERVICE", "protocol": "TCP", "state": "COMPROMISED", "sourceNodeId": "NA", "sourceNodeService": "NA", "sourceNodeServiceState": "NA" }, { "item_type": "RED_IER", "id": "15", "start_step": 30, "end_step": 256, "load": 10000000, "protocol": "TCP", "port": "80", "source": "1", "destination": "4", "mission_criticality": 0 }, { "item_type": "RED_POL", "id": "16", "start_step": 40, "end_step": 40, "targetNodeId": "4", "initiator": "IER", "type": "SERVICE", "protocol": "TCP", "state": "OVERWHELMED", "sourceNodeId": "NA", "sourceNodeService": "NA", "sourceNodeServiceState": "NA" } ] } } +{ "uuid": "301874d3-2e14-43c2-ba7f-e2b03ad05dde", "start_datetime": "2023-07-14T09:48:22.973005", "end_datetime": "2023-07-14T09:48:34.182715", "learning": { "total_episodes": 10, "total_time_steps": 2560 }, "evaluation": { "total_episodes": 5, "total_time_steps": 1280 }, "env": { "training_config": { "agent_framework": "SB3", "deep_learning_framework": "TF2", "agent_identifier": "PPO", "hard_coded_agent_view": "FULL", "random_red_agent": false, "action_type": "NODE", "num_train_episodes": 10, "num_train_steps": 256, "num_eval_episodes": 5, "num_eval_steps": 256, "checkpoint_every_n_episodes": 10, "observation_space": { "components": [ { "name": "NODE_LINK_TABLE" } ] }, "time_delay": 5, "session_type": "TRAIN_EVAL", "load_agent": false, "agent_load_file": null, "observation_space_high_value": 1000000000, "sb3_output_verbose_level": "NONE", "all_ok": 0, "off_should_be_on": -0.001, "off_should_be_resetting": -0.0005, "on_should_be_off": -0.0002, "on_should_be_resetting": -0.0005, "resetting_should_be_on": -0.0005, "resetting_should_be_off": -0.0002, "resetting": -0.0003, "good_should_be_patching": 0.0002, "good_should_be_compromised": 0.0005, "good_should_be_overwhelmed": 0.0005, "patching_should_be_good": -0.0005, "patching_should_be_compromised": 0.0002, "patching_should_be_overwhelmed": 0.0002, "patching": -0.0003, "compromised_should_be_good": -0.002, "compromised_should_be_patching": -0.002, "compromised_should_be_overwhelmed": -0.002, "compromised": -0.002, "overwhelmed_should_be_good": -0.002, "overwhelmed_should_be_patching": -0.002, "overwhelmed_should_be_compromised": -0.002, "overwhelmed": -0.002, "good_should_be_repairing": 0.0002, "good_should_be_restoring": 0.0002, "good_should_be_corrupt": 0.0005, "good_should_be_destroyed": 0.001, "repairing_should_be_good": -0.0005, "repairing_should_be_restoring": 0.0002, "repairing_should_be_corrupt": 0.0002, "repairing_should_be_destroyed": 0.0, "repairing": -0.0003, "restoring_should_be_good": -0.001, "restoring_should_be_repairing": -0.0002, "restoring_should_be_corrupt": 0.0001, "restoring_should_be_destroyed": 0.0002, "restoring": -0.0006, "corrupt_should_be_good": -0.001, "corrupt_should_be_repairing": -0.001, "corrupt_should_be_restoring": -0.001, "corrupt_should_be_destroyed": 0.0002, "corrupt": -0.001, "destroyed_should_be_good": -0.002, "destroyed_should_be_repairing": -0.002, "destroyed_should_be_restoring": -0.002, "destroyed_should_be_corrupt": -0.002, "destroyed": -0.002, "scanning": -0.0002, "red_ier_running": -0.0005, "green_ier_blocked": -0.001, "os_patching_duration": 5, "node_reset_duration": 5, "node_booting_duration": 3, "node_shutdown_duration": 2, "service_fixing_duration": 5, "file_system_repairing_limit": 5, "file_system_restoring_limit": 5, "file_system_scanning_limit": 5, "deterministic": true, "seed": 12345 }, "lay_down_config": [ { "item_type": "PORTS", "ports_list": [ { "port": "80" } ] }, { "item_type": "SERVICES", "service_list": [ { "name": "TCP" } ] }, { "item_type": "NODE", "node_id": "1", "name": "PC1", "node_class": "SERVICE", "node_type": "COMPUTER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.2", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "NODE", "node_id": "2", "name": "PC2", "node_class": "SERVICE", "node_type": "COMPUTER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.3", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "NODE", "node_id": "3", "name": "SWITCH1", "node_class": "ACTIVE", "node_type": "SWITCH", "priority": "P2", "hardware_state": "ON", "ip_address": "192.168.1.1", "software_state": "GOOD", "file_system_state": "GOOD" }, { "item_type": "NODE", "node_id": "4", "name": "SERVER1", "node_class": "SERVICE", "node_type": "SERVER", "priority": "P5", "hardware_state": "ON", "ip_address": "192.168.1.4", "software_state": "GOOD", "file_system_state": "GOOD", "services": [ { "name": "TCP", "port": "80", "state": "GOOD" } ] }, { "item_type": "LINK", "id": "5", "name": "link1", "bandwidth": 1000000000, "source": "1", "destination": "3" }, { "item_type": "LINK", "id": "6", "name": "link2", "bandwidth": 1000000000, "source": "2", "destination": "3" }, { "item_type": "LINK", "id": "7", "name": "link3", "bandwidth": 1000000000, "source": "3", "destination": "4" }, { "item_type": "GREEN_IER", "id": "8", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "1", "destination": "4", "mission_criticality": 1 }, { "item_type": "GREEN_IER", "id": "9", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "2", "destination": "4", "mission_criticality": 1 }, { "item_type": "GREEN_IER", "id": "10", "start_step": 1, "end_step": 256, "load": 10000, "protocol": "TCP", "port": "80", "source": "4", "destination": "2", "mission_criticality": 5 }, { "item_type": "ACL_RULE", "id": "11", "permission": "ALLOW", "source": "192.168.1.2", "destination": "192.168.1.4", "protocol": "TCP", "port": 80, "position": 0 }, { "item_type": "ACL_RULE", "id": "12", "permission": "ALLOW", "source": "192.168.1.3", "destination": "192.168.1.4", "protocol": "TCP", "port": 80, "position": 1 }, { "item_type": "ACL_RULE", "id": "13", "permission": "ALLOW", "source": "192.168.1.4", "destination": "192.168.1.3", "protocol": "TCP", "port": 80, "position": 2 }, { "item_type": "RED_POL", "id": "14", "start_step": 20, "end_step": 20, "targetNodeId": "1", "initiator": "DIRECT", "type": "SERVICE", "protocol": "TCP", "state": "COMPROMISED", "sourceNodeId": "NA", "sourceNodeService": "NA", "sourceNodeServiceState": "NA" }, { "item_type": "RED_IER", "id": "15", "start_step": 30, "end_step": 256, "load": 10000000, "protocol": "TCP", "port": "80", "source": "1", "destination": "4", "mission_criticality": 0 }, { "item_type": "RED_POL", "id": "16", "start_step": 40, "end_step": 40, "targetNodeId": "4", "initiator": "IER", "type": "SERVICE", "protocol": "TCP", "state": "OVERWHELMED", "sourceNodeId": "NA", "sourceNodeService": "NA", "sourceNodeServiceState": "NA" } ] } } diff --git a/tests/config/legacy_conversion/legacy_training_config.yaml b/tests/config/legacy_conversion/legacy_training_config.yaml deleted file mode 100644 index 3477e6e0..00000000 --- a/tests/config/legacy_conversion/legacy_training_config.yaml +++ /dev/null @@ -1,92 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Main Config File - -# Generic config values -# Choose one of these (dependent on Agent being trained) -# "STABLE_BASELINES3_PPO" -# "STABLE_BASELINES3_A2C" -# "GENERIC" -agentIdentifier: STABLE_BASELINES3_A2C -# Number of episodes to run per session -numEpisodes: 10 -# Time delay between steps (for generic agents) -timeDelay: 10 -# Filename of the scenario / laydown -configFilename: config_5_DATA_MANIPULATION.yaml -# Type of session to be run (TRAINING or EVALUATION) -sessionType: TRAINING -# Determine whether to load an agent from file -loadAgent: False -# File path and file name of agent if you're loading one in -agentLoadFile: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observationSpaceHighValue: 1000000000 - -# Reward values -# Generic -allOk: 0 -# Node Hardware State -offShouldBeOn: -10 -offShouldBeResetting: -5 -onShouldBeOff: -2 -onShouldBeResetting: -5 -resettingShouldBeOn: -5 -resettingShouldBeOff: -2 -resetting: -3 -# Node Software or Service State -goodShouldBePatching: 2 -goodShouldBeCompromised: 5 -goodShouldBeOverwhelmed: 5 -patchingShouldBeGood: -5 -patchingShouldBeCompromised: 2 -patchingShouldBeOverwhelmed: 2 -patching: -3 -compromisedShouldBeGood: -20 -compromisedShouldBePatching: -20 -compromisedShouldBeOverwhelmed: -20 -compromised: -20 -overwhelmedShouldBeGood: -20 -overwhelmedShouldBePatching: -20 -overwhelmedShouldBeCompromised: -20 -overwhelmed: -20 -# Node File System State -goodShouldBeRepairing: 2 -goodShouldBeRestoring: 2 -goodShouldBeCorrupt: 5 -goodShouldBeDestroyed: 10 -repairingShouldBeGood: -5 -repairingShouldBeRestoring: 2 -repairingShouldBeCorrupt: 2 -repairingShouldBeDestroyed: 0 -repairing: -3 -restoringShouldBeGood: -10 -restoringShouldBeRepairing: -2 -restoringShouldBeCorrupt: 1 -restoringShouldBeDestroyed: 2 -restoring: -6 -corruptShouldBeGood: -10 -corruptShouldBeRepairing: -10 -corruptShouldBeRestoring: -10 -corruptShouldBeDestroyed: 2 -corrupt: -10 -destroyedShouldBeGood: -20 -destroyedShouldBeRepairing: -20 -destroyedShouldBeRestoring: -20 -destroyedShouldBeCorrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -redIerRunning: -5 -greenIerBlocked: -10 - -# Patching / Reset durations -osPatchingDuration: 5 # The time taken to patch the OS -nodeResetDuration: 5 # The time taken to reset a node (hardware) -nodeBootingDuration: 3 # The Time taken to turn on the node -nodeShutdownDuration: 2 # The time taken to turn off the node -servicePatchingDuration: 5 # The time taken to patch a service -fileSystemRepairingLimit: 5 # The time take to repair the file system -fileSystemRestoringLimit: 5 # The time take to restore the file system -fileSystemScanningLimit: 5 # The time taken to scan the file system diff --git a/tests/config/legacy_conversion/new_training_config.yaml b/tests/config/legacy_conversion/new_training_config.yaml deleted file mode 100644 index 1ec36e97..00000000 --- a/tests/config/legacy_conversion/new_training_config.yaml +++ /dev/null @@ -1,106 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Main Config File - -# Generic config values - -# Sets which agent algorithm framework will be used: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray[RLlib]) -# "NONE" (Custom Agent) -agent_framework: SB3 - -# Sets which Red Agent algo/class will be used: -# "PPO" (Proximal Policy Optimization) -# "A2C" (Advantage Actor Critic) -# "HARDCODED" (Custom Agent) -# "RANDOM" (Random Action) -agent_identifier: PPO - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Time delay between steps (for generic agents) -time_delay: 10 -# Type of session to be run (TRAINING or EVALUATION) -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/obs_tests/laydown.yaml b/tests/config/obs_tests/laydown.yaml deleted file mode 100644 index e358d0d2..00000000 --- a/tests/config/obs_tests/laydown.yaml +++ /dev/null @@ -1,103 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -- item_type: PORTS - ports_list: - - port: '80' - - port: '53' -- item_type: SERVICES - service_list: - - name: TCP - - name: UDP - -######################################## -# Nodes -- item_type: NODE - node_id: '1' - name: PC1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.1 - software_state: COMPROMISED - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: UDP - port: '53' - state: GOOD -- item_type: NODE - node_id: '2' - name: SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.2 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: UDP - port: '53' - state: OVERWHELMED -- item_type: NODE - node_id: '3' - name: SWITCH1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.3 - software_state: GOOD - file_system_state: GOOD - -######################################## -# Links -- item_type: LINK - id: '4' - name: link1 - bandwidth: 1000 - source: '1' - destination: '3' -- item_type: LINK - id: '5' - name: link2 - bandwidth: 1000 - source: '3' - destination: '2' - -######################################### -# IERS -- item_type: GREEN_IER - id: '5' - start_step: 0 - end_step: 5 - load: 999 - protocol: TCP - port: '80' - source: '1' - destination: '2' - mission_criticality: 5 - -######################################### -# ACL Rules -- item_type: ACL_RULE - id: '6' - permission: ALLOW - source: 192.168.1.1 - destination: 192.168.1.2 - protocol: TCP - port: 80 - position: 0 -- item_type: ACL_RULE - id: '7' - permission: ALLOW - source: 192.168.1.2 - destination: 192.168.1.1 - protocol: TCP - port: 80 - position: 0 diff --git a/tests/config/obs_tests/laydown_ACL.yaml b/tests/config/obs_tests/laydown_ACL.yaml deleted file mode 100644 index cffd8b1c..00000000 --- a/tests/config/obs_tests/laydown_ACL.yaml +++ /dev/null @@ -1,86 +0,0 @@ -- item_type: PORTS - ports_list: - - port: '80' - - port: '21' -- item_type: SERVICES - service_list: - - name: TCP - - name: FTP - -######################################## -# Nodes -- item_type: NODE - node_id: '1' - name: PC1 - node_class: SERVICE - node_type: COMPUTER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.1 - software_state: COMPROMISED - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: FTP - port: '21' - state: GOOD -- item_type: NODE - node_id: '2' - name: SERVER - node_class: SERVICE - node_type: SERVER - priority: P5 - hardware_state: 'ON' - ip_address: 192.168.1.2 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: GOOD - - name: FTP - port: '21' - state: OVERWHELMED -- item_type: NODE - node_id: '3' - name: SWITCH1 - node_class: ACTIVE - node_type: SWITCH - priority: P2 - hardware_state: 'ON' - ip_address: 192.168.1.3 - software_state: GOOD - file_system_state: GOOD - -######################################## -# Links -- item_type: LINK - id: '4' - name: link1 - bandwidth: 1000 - source: '1' - destination: '3' -- item_type: LINK - id: '5' - name: link2 - bandwidth: 1000 - source: '3' - destination: '2' - -######################################### -# IERS -- item_type: GREEN_IER - id: '5' - start_step: 0 - end_step: 5 - load: 999 - protocol: TCP - port: '80' - source: '1' - destination: '2' - mission_criticality: 5 - -######################################### -# ACL Rules diff --git a/tests/config/obs_tests/main_config_ACCESS_CONTROL_LIST.yaml b/tests/config/obs_tests/main_config_ACCESS_CONTROL_LIST.yaml deleted file mode 100644 index 927c9f44..00000000 --- a/tests/config/obs_tests/main_config_ACCESS_CONTROL_LIST.yaml +++ /dev/null @@ -1,106 +0,0 @@ -# Main Config File - -# Generic config values -# Choose one of these (dependent on Agent being trained) -# "STABLE_BASELINES3_PPO" -# "STABLE_BASELINES3_A2C" -# "GENERIC" -agent_framework: SB3 -agent_identifier: PPO -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 -# Number of time_steps for training per episode -num_train_steps: 5 - -# Implicit ACL firewall rule at end of lists to be default action or no rule can be selected (ALLOW or DENY) -implicit_acl_rule: DENY -# Total number of ACL rules allowed in the environment -max_number_acl_rules: 3 - -observation_space: - components: - - name: ACCESS_CONTROL_LIST - -# Time delay between steps (for generic agents) -time_delay: 1 - -# Type of session to be run (TRAINING or EVALUATION) -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1_000_000_000 - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/obs_tests/main_config_LINK_TRAFFIC_LEVELS.yaml b/tests/config/obs_tests/main_config_LINK_TRAFFIC_LEVELS.yaml deleted file mode 100644 index 805ab31e..00000000 --- a/tests/config/obs_tests/main_config_LINK_TRAFFIC_LEVELS.yaml +++ /dev/null @@ -1,121 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: A2C - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 - -# Number of time_steps for training per episode -num_train_steps: 5 - -observation_space: - components: - - name: LINK_TRAFFIC_LEVELS - options: - combine_service_traffic: false - quantisation_levels: 8 - -# Time delay between steps (for generic agents) -time_delay: 1 - -# Implicit ACL firewall rule at end of lists to be default action or no rule can be selected (ALLOW or DENY) -implicit_acl_rule: ALLOW -# Total number of ACL rules allowed in the environment -max_number_acl_rules: 4 - -# Type of session to be run (TRAINING or EVALUATION) -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1_000_000_000 - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/obs_tests/main_config_NODE_LINK_TABLE.yaml b/tests/config/obs_tests/main_config_NODE_LINK_TABLE.yaml deleted file mode 100644 index 535558aa..00000000 --- a/tests/config/obs_tests/main_config_NODE_LINK_TABLE.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: RANDOM - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 - -# Number of time_steps for training per episode -num_train_steps: 5 - -observation_space: - components: - - name: NODE_LINK_TABLE - -# Time delay between steps (for generic agents) -time_delay: 1 -# Filename of the scenario / laydown - -# Implicit ACL firewall rule at end of lists to be default action or no rule can be selected (ALLOW or DENY) -implicit_acl_rule: ALLOW -# Total number of ACL rules allowed in the environment -max_number_acl_rules: 4 - -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1_000_000_000 - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/obs_tests/main_config_NODE_STATUSES.yaml b/tests/config/obs_tests/main_config_NODE_STATUSES.yaml deleted file mode 100644 index d1319c35..00000000 --- a/tests/config/obs_tests/main_config_NODE_STATUSES.yaml +++ /dev/null @@ -1,115 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: RANDOM - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 - -# Number of time_steps for training per episode -num_train_steps: 5 - - -observation_space: - components: - - name: NODE_STATUSES - - -# Time delay between steps (for generic agents) -time_delay: 1 - -# Type of session to be run (TRAINING or EVALUATION) -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1_000_000_000 - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/obs_tests/main_config_without_obs.yaml b/tests/config/obs_tests/main_config_without_obs.yaml deleted file mode 100644 index 26457c84..00000000 --- a/tests/config/obs_tests/main_config_without_obs.yaml +++ /dev/null @@ -1,108 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: RANDOM - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 - -# Number of time_steps for training per episode -num_train_steps: 5 -# Time delay between steps (for generic agents) -time_delay: 1 -# Type of session to be run (TRAINING or EVALUATION) -session_type: TRAIN -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1_000_000_000 -# Implicit ACL firewall rule at end of lists to be default action or no rule can be selected (ALLOW or DENY) -implicit_acl_rule: DENY -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/one_node_states_on_off_lay_down_config.yaml b/tests/config/one_node_states_on_off_lay_down_config.yaml deleted file mode 100644 index 0f572d8d..00000000 --- a/tests/config/one_node_states_on_off_lay_down_config.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -- item_type: PORTS - ports_list: - - port: '21' -- item_type: SERVICES - service_list: - - name: ftp -- item_type: NODE - node_id: '1' - name: node - node_class: SERVICE - node_type: COMPUTER - priority: P1 - hardware_state: 'ON' - ip_address: 192.168.0.1 - software_state: GOOD - file_system_state: GOOD - services: - - name: ftp - port: '21' - state: GOOD -- item_type: RED_POL - id: '1' - start_step: 1 - end_step: 3 - targetNodeId: '1' - initiator: DIRECT - type: FILE - protocol: NA - state: CORRUPT - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '2' - start_step: 3 - end_step: 15 - targetNodeId: '1' - initiator: DIRECT - type: FILE - protocol: NA - state: GOOD - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '3' - start_step: 4 - end_step: 6 - targetNodeId: '1' - initiator: DIRECT - type: OPERATING - protocol: NA - state: 'OFF' - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '4' - start_step: 6 - end_step: 15 - targetNodeId: '1' - initiator: DIRECT - type: OPERATING - protocol: NA - state: 'ON' - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '5' - start_step: 7 - end_step: 9 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: ftp - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '6' - start_step: 9 - end_step: 15 - targetNodeId: '1' - initiator: DIRECT - type: SERVICE - protocol: ftp - state: GOOD - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '7' - start_step: 10 - end_step: 12 - targetNodeId: '1' - initiator: DIRECT - type: OS - protocol: NA - state: COMPROMISED - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA -- item_type: RED_POL - id: '8' - start_step: 12 - end_step: 15 - targetNodeId: '1' - initiator: DIRECT - type: OS - protocol: NA - state: GOOD - sourceNodeId: NA - sourceNodeService: NA - sourceNodeServiceState: NA diff --git a/tests/config/one_node_states_on_off_main_config.yaml b/tests/config/one_node_states_on_off_main_config.yaml deleted file mode 100644 index 10af7a1f..00000000 --- a/tests/config/one_node_states_on_off_main_config.yaml +++ /dev/null @@ -1,166 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: DUMMY - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: null - -# Set whether the agent will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - # flatten: true - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - - -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 1 - -# Number of time_steps for evaluation per episode -num_eval_steps: 15 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 10 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -implicit_acl_rule: DENY -max_number_acl_rules: 10 -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/ppo_not_seeded_training_config.yaml b/tests/config/ppo_not_seeded_training_config.yaml deleted file mode 100644 index fac2fe95..00000000 --- a/tests/config/ppo_not_seeded_training_config.yaml +++ /dev/null @@ -1,162 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: None - -# Set whether the agent evaluation will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - # - name: ACCESS_CONTROL_LIST -# Number of episodes to run per session -num_train_episodes: 10 - -# Number of time_steps per episode -num_train_steps: 256 - -# Number of episodes to run per session -num_eval_episodes: 10 - -# Number of time_steps per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 0 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0.0000 -# Node Hardware State -off_should_be_on: -0.001 -off_should_be_resetting: -0.0005 -on_should_be_off: -0.0002 -on_should_be_resetting: -0.0005 -resetting_should_be_on: -0.0005 -resetting_should_be_off: -0.0002 -resetting: -0.0003 -# Node Software or Service State -good_should_be_patching: 0.0002 -good_should_be_compromised: 0.0005 -good_should_be_overwhelmed: 0.0005 -patching_should_be_good: -0.0005 -patching_should_be_compromised: 0.0002 -patching_should_be_overwhelmed: 0.0002 -patching: -0.0003 -compromised_should_be_good: -0.002 -compromised_should_be_patching: -0.002 -compromised_should_be_overwhelmed: -0.002 -compromised: -0.002 -overwhelmed_should_be_good: -0.002 -overwhelmed_should_be_patching: -0.002 -overwhelmed_should_be_compromised: -0.002 -overwhelmed: -0.002 -# Node File System State -good_should_be_repairing: 0.0002 -good_should_be_restoring: 0.0002 -good_should_be_corrupt: 0.0005 -good_should_be_destroyed: 0.001 -repairing_should_be_good: -0.0005 -repairing_should_be_restoring: 0.0002 -repairing_should_be_corrupt: 0.0002 -repairing_should_be_destroyed: 0.0000 -repairing: -0.0003 -restoring_should_be_good: -0.001 -restoring_should_be_repairing: -0.0002 -restoring_should_be_corrupt: 0.0001 -restoring_should_be_destroyed: 0.0002 -restoring: -0.0006 -corrupt_should_be_good: -0.001 -corrupt_should_be_repairing: -0.001 -corrupt_should_be_restoring: -0.001 -corrupt_should_be_destroyed: 0.0002 -corrupt: -0.001 -destroyed_should_be_good: -0.002 -destroyed_should_be_repairing: -0.002 -destroyed_should_be_restoring: -0.002 -destroyed_should_be_corrupt: -0.002 -destroyed: -0.002 -scanning: -0.0002 -# IER status -red_ier_running: -0.0005 -green_ier_blocked: -0.001 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/ppo_seeded_training_config.yaml b/tests/config/ppo_seeded_training_config.yaml deleted file mode 100644 index e4d4fe5b..00000000 --- a/tests/config/ppo_seeded_training_config.yaml +++ /dev/null @@ -1,161 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: 67890 - -# Set whether the agent evaluation will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: True - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS -# Number of episodes to run per session -num_train_episodes: 10 - -# Number of time_steps per episode -num_train_steps: 256 - -# Number of episodes to run per session -num_eval_episodes: 1 - -# Number of time_steps per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 0 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/session_test/training_config_main_rllib.yaml b/tests/config/session_test/training_config_main_rllib.yaml deleted file mode 100644 index 374c6ac5..00000000 --- a/tests/config/session_test/training_config_main_rllib.yaml +++ /dev/null @@ -1,164 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: RLLIB - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: null - -# Set whether the agent will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - # flatten: true - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - - -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 3 - -# Number of time_steps for evaluation per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 10 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -0.001 -off_should_be_resetting: -0.0005 -on_should_be_off: -0.0002 -on_should_be_resetting: -0.0005 -resetting_should_be_on: -0.0005 -resetting_should_be_off: -0.0002 -resetting: -0.0003 -# Node Software or Service State -good_should_be_patching: 0.0002 -good_should_be_compromised: 0.0005 -good_should_be_overwhelmed: 0.0005 -patching_should_be_good: -0.0005 -patching_should_be_compromised: 0.0002 -patching_should_be_overwhelmed: 0.0002 -patching: -0.0003 -compromised_should_be_good: -0.002 -compromised_should_be_patching: -0.002 -compromised_should_be_overwhelmed: -0.002 -compromised: -0.002 -overwhelmed_should_be_good: -0.002 -overwhelmed_should_be_patching: -0.002 -overwhelmed_should_be_compromised: -0.002 -overwhelmed: -0.002 -# Node File System State -good_should_be_repairing: 0.0002 -good_should_be_restoring: 0.0002 -good_should_be_corrupt: 0.0005 -good_should_be_destroyed: 0.001 -repairing_should_be_good: -0.0005 -repairing_should_be_restoring: 0.0002 -repairing_should_be_corrupt: 0.0002 -repairing_should_be_destroyed: 0.0000 -repairing: -0.0003 -restoring_should_be_good: -0.001 -restoring_should_be_repairing: -0.0002 -restoring_should_be_corrupt: 0.0001 -restoring_should_be_destroyed: 0.0002 -restoring: -0.0006 -corrupt_should_be_good: -0.001 -corrupt_should_be_repairing: -0.001 -corrupt_should_be_restoring: -0.001 -corrupt_should_be_destroyed: 0.0002 -corrupt: -0.001 -destroyed_should_be_good: -0.002 -destroyed_should_be_repairing: -0.002 -destroyed_should_be_restoring: -0.002 -destroyed_should_be_corrupt: -0.002 -destroyed: -0.002 -scanning: -0.0002 -# IER status -red_ier_running: -0.0005 -green_ier_blocked: -0.001 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/session_test/training_config_main_sb3.yaml b/tests/config/session_test/training_config_main_sb3.yaml deleted file mode 100644 index 733105ea..00000000 --- a/tests/config/session_test/training_config_main_sb3.yaml +++ /dev/null @@ -1,164 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: null - -# Set whether the agent will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - # flatten: true - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - - -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 3 - -# Number of time_steps for evaluation per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 10 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -0.001 -off_should_be_resetting: -0.0005 -on_should_be_off: -0.0002 -on_should_be_resetting: -0.0005 -resetting_should_be_on: -0.0005 -resetting_should_be_off: -0.0002 -resetting: -0.0003 -# Node Software or Service State -good_should_be_patching: 0.0002 -good_should_be_compromised: 0.0005 -good_should_be_overwhelmed: 0.0005 -patching_should_be_good: -0.0005 -patching_should_be_compromised: 0.0002 -patching_should_be_overwhelmed: 0.0002 -patching: -0.0003 -compromised_should_be_good: -0.002 -compromised_should_be_patching: -0.002 -compromised_should_be_overwhelmed: -0.002 -compromised: -0.002 -overwhelmed_should_be_good: -0.002 -overwhelmed_should_be_patching: -0.002 -overwhelmed_should_be_compromised: -0.002 -overwhelmed: -0.002 -# Node File System State -good_should_be_repairing: 0.0002 -good_should_be_restoring: 0.0002 -good_should_be_corrupt: 0.0005 -good_should_be_destroyed: 0.001 -repairing_should_be_good: -0.0005 -repairing_should_be_restoring: 0.0002 -repairing_should_be_corrupt: 0.0002 -repairing_should_be_destroyed: 0.0000 -repairing: -0.0003 -restoring_should_be_good: -0.001 -restoring_should_be_repairing: -0.0002 -restoring_should_be_corrupt: 0.0001 -restoring_should_be_destroyed: 0.0002 -restoring: -0.0006 -corrupt_should_be_good: -0.001 -corrupt_should_be_repairing: -0.001 -corrupt_should_be_restoring: -0.001 -corrupt_should_be_destroyed: 0.0002 -corrupt: -0.001 -destroyed_should_be_good: -0.002 -destroyed_should_be_repairing: -0.002 -destroyed_should_be_restoring: -0.002 -destroyed_should_be_corrupt: -0.002 -destroyed: -0.002 -scanning: -0.0002 -# IER status -red_ier_running: -0.0005 -green_ier_blocked: -0.001 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/single_action_space_fixed_blue_actions_main_config.yaml b/tests/config/single_action_space_fixed_blue_actions_main_config.yaml deleted file mode 100644 index 6210cf3e..00000000 --- a/tests/config/single_action_space_fixed_blue_actions_main_config.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: RANDOM - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 1 - -# Number of time_steps for training per episode -num_train_steps: 15 - -# Time delay between steps (for generic agents) -time_delay: 1 -# Type of session to be run (TRAINING or EVALUATION) -session_type: EVAL -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Implicit ACL firewall rule at end of lists to be default action or no rule can be selected (ALLOW or DENY) -implicit_acl_rule: DENY -# Total number of ACL rules allowed in the environment -max_number_acl_rules: 10 - -observation_space: - components: - - name: ACCESS_CONTROL_LIST - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# Reward values -# Generic -all_ok: 0 -# Node Operating State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node O/S or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/single_action_space_lay_down_config.yaml b/tests/config/single_action_space_lay_down_config.yaml deleted file mode 100644 index 9103e2b7..00000000 --- a/tests/config/single_action_space_lay_down_config.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -- item_type: PORTS - ports_list: - - port: '80' -- item_type: SERVICES - service_list: - - name: TCP -- item_type: NODE - node_id: '1' - name: node - node_class: SERVICE - node_type: COMPUTER - priority: P1 - hardware_state: 'ON' - ip_address: 192.168.0.14 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: COMPROMISED -- item_type: NODE - node_id: '2' - name: server_1 - node_class: SERVICE - node_type: SERVER - priority: P1 - hardware_state: 'ON' - ip_address: 192.168.0.1 - software_state: GOOD - file_system_state: GOOD - services: - - name: TCP - port: '80' - state: COMPROMISED -- item_type: RED_IER - id: '3' - start_step: 2 - end_step: 15 - load: 1000 - protocol: TCP - port: CORRUPT - source: '1' - destination: '2' - mission_criticality: 0 diff --git a/tests/config/single_action_space_main_config.yaml b/tests/config/single_action_space_main_config.yaml deleted file mode 100644 index 67eaf49d..00000000 --- a/tests/config/single_action_space_main_config.yaml +++ /dev/null @@ -1,116 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: CUSTOM - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: RANDOM - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: ANY -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 10 - -# Number of time_steps for evaluation per episode -num_eval_steps: 256 -# Time delay between steps (for generic agents) -time_delay: 1 -# Type of session to be run (TRAINING or EVALUATION) -session_type: EVAL -# Determine whether to load an agent from file -load_agent: False -# File path and file name of agent if you're loading one in -agent_load_file: C:\[Path]\[agent_saved_filename.zip] - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# Choice whether to have an ALLOW or DENY implicit rule or not (TRUE or FALSE) -implicit_acl_rule: DENY -max_number_acl_rules: 10 -# Reward values -# Generic -all_ok: 0 -# Node Operating State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node O/S or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/test_random_red_main_config.yaml b/tests/config/test_random_red_main_config.yaml deleted file mode 100644 index 310c9dc6..00000000 --- a/tests/config/test_random_red_main_config.yaml +++ /dev/null @@ -1,164 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: True - -# The (integer) seed to be used in random number generation -# Default is None (null) -seed: null - -# Set whether the agent will be deterministic instead of stochastic -# Options are: -# True -# False -deterministic: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - # flatten: true - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - - -# Number of episodes for training to run per session -num_train_episodes: 10 - -# Number of time_steps for training per episode -num_train_steps: 256 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 1 - -# Number of time_steps for evaluation per episode -num_eval_steps: 256 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 10 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -0.001 -off_should_be_resetting: -0.0005 -on_should_be_off: -0.0002 -on_should_be_resetting: -0.0005 -resetting_should_be_on: -0.0005 -resetting_should_be_off: -0.0002 -resetting: -0.0003 -# Node Software or Service State -good_should_be_patching: 0.0002 -good_should_be_compromised: 0.0005 -good_should_be_overwhelmed: 0.0005 -patching_should_be_good: -0.0005 -patching_should_be_compromised: 0.0002 -patching_should_be_overwhelmed: 0.0002 -patching: -0.0003 -compromised_should_be_good: -0.002 -compromised_should_be_patching: -0.002 -compromised_should_be_overwhelmed: -0.002 -compromised: -0.002 -overwhelmed_should_be_good: -0.002 -overwhelmed_should_be_patching: -0.002 -overwhelmed_should_be_compromised: -0.002 -overwhelmed: -0.002 -# Node File System State -good_should_be_repairing: 0.0002 -good_should_be_restoring: 0.0002 -good_should_be_corrupt: 0.0005 -good_should_be_destroyed: 0.001 -repairing_should_be_good: -0.0005 -repairing_should_be_restoring: 0.0002 -repairing_should_be_corrupt: 0.0002 -repairing_should_be_destroyed: 0.0000 -repairing: -0.0003 -restoring_should_be_good: -0.001 -restoring_should_be_repairing: -0.0002 -restoring_should_be_corrupt: 0.0001 -restoring_should_be_destroyed: 0.0002 -restoring: -0.0006 -corrupt_should_be_good: -0.001 -corrupt_should_be_repairing: -0.001 -corrupt_should_be_restoring: -0.001 -corrupt_should_be_destroyed: 0.0002 -corrupt: -0.001 -destroyed_should_be_good: -0.002 -destroyed_should_be_repairing: -0.002 -destroyed_should_be_restoring: -0.002 -destroyed_should_be_corrupt: -0.002 -destroyed: -0.002 -scanning: -0.0002 -# IER status -red_ier_running: -0.0005 -green_ier_blocked: -0.001 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/config/train_episode_step.yaml b/tests/config/train_episode_step.yaml deleted file mode 100644 index a86e0f62..00000000 --- a/tests/config/train_episode_step.yaml +++ /dev/null @@ -1,154 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -# Training Config File - -# Sets which agent algorithm framework will be used. -# Options are: -# "SB3" (Stable Baselines3) -# "RLLIB" (Ray RLlib) -# "CUSTOM" (Custom Agent) -agent_framework: SB3 - -# Sets which deep learning framework will be used (by RLlib ONLY). -# Default is TF (Tensorflow). -# Options are: -# "TF" (Tensorflow) -# TF2 (Tensorflow 2.X) -# TORCH (PyTorch) -deep_learning_framework: TF2 - -# Sets which Agent class will be used. -# Options are: -# "A2C" (Advantage Actor Critic coupled with either SB3 or RLLIB agent_framework) -# "PPO" (Proximal Policy Optimization coupled with either SB3 or RLLIB agent_framework) -# "HARDCODED" (The HardCoded agents coupled with an ACL or NODE action_type) -# "DO_NOTHING" (The DoNothing agents coupled with an ACL or NODE action_type) -# "RANDOM" (primaite.agents.simple.RandomAgent) -# "DUMMY" (primaite.agents.simple.DummyAgent) -agent_identifier: PPO - -# Sets whether Red Agent POL and IER is randomised. -# Options are: -# True -# False -random_red_agent: False - -# Sets what view of the environment the deterministic hardcoded agent has. The default is BASIC. -# Options are: -# "BASIC" (The current observation space only) -# "FULL" (Full environment view with actions taken and reward feedback) -hard_coded_agent_view: FULL - -# Sets How the Action Space is defined: -# "NODE" -# "ACL" -# "ANY" node and acl actions -action_type: NODE -# observation space -observation_space: - # flatten: true - components: - - name: NODE_LINK_TABLE - # - name: NODE_STATUSES - # - name: LINK_TRAFFIC_LEVELS - - -# Number of episodes for training to run per session -num_train_episodes: 3 - -# Number of time_steps for training per episode -num_train_steps: 25 - -# Number of episodes for evaluation to run per session -num_eval_episodes: 1 - -# Number of time_steps for evaluation per episode -num_eval_steps: 17 - -# Sets how often the agent will save a checkpoint (every n time episodes). -# Set to 0 if no checkpoints are required. Default is 10 -checkpoint_every_n_episodes: 0 - -# Time delay (milliseconds) between steps for CUSTOM agents. -time_delay: 5 - -# Type of session to be run. Options are: -# "TRAIN" (Trains an agent) -# "EVAL" (Evaluates an agent) -# "TRAIN_EVAL" (Trains then evaluates an agent) -session_type: TRAIN_EVAL - -# Environment config values -# The high value for the observation space -observation_space_high_value: 1000000000 - -# The Stable Baselines3 learn/eval output verbosity level: -# Options are: -# "NONE" (No Output) -# "INFO" (Info Messages (such as devices and wrappers used)) -# "DEBUG" (All Messages) -sb3_output_verbose_level: NONE - -# Reward values -# Generic -all_ok: 0 -# Node Hardware State -off_should_be_on: -10 -off_should_be_resetting: -5 -on_should_be_off: -2 -on_should_be_resetting: -5 -resetting_should_be_on: -5 -resetting_should_be_off: -2 -resetting: -3 -# Node Software or Service State -good_should_be_patching: 2 -good_should_be_compromised: 5 -good_should_be_overwhelmed: 5 -patching_should_be_good: -5 -patching_should_be_compromised: 2 -patching_should_be_overwhelmed: 2 -patching: -3 -compromised_should_be_good: -20 -compromised_should_be_patching: -20 -compromised_should_be_overwhelmed: -20 -compromised: -20 -overwhelmed_should_be_good: -20 -overwhelmed_should_be_patching: -20 -overwhelmed_should_be_compromised: -20 -overwhelmed: -20 -# Node File System State -good_should_be_repairing: 2 -good_should_be_restoring: 2 -good_should_be_corrupt: 5 -good_should_be_destroyed: 10 -repairing_should_be_good: -5 -repairing_should_be_restoring: 2 -repairing_should_be_corrupt: 2 -repairing_should_be_destroyed: 0 -repairing: -3 -restoring_should_be_good: -10 -restoring_should_be_repairing: -2 -restoring_should_be_corrupt: 1 -restoring_should_be_destroyed: 2 -restoring: -6 -corrupt_should_be_good: -10 -corrupt_should_be_repairing: -10 -corrupt_should_be_restoring: -10 -corrupt_should_be_destroyed: 2 -corrupt: -10 -destroyed_should_be_good: -20 -destroyed_should_be_repairing: -20 -destroyed_should_be_restoring: -20 -destroyed_should_be_corrupt: -20 -destroyed: -20 -scanning: -2 -# IER status -red_ier_running: -5 -green_ier_blocked: -10 - -# Patching / Reset durations -os_patching_duration: 5 # The time taken to patch the OS -node_reset_duration: 5 # The time taken to reset a node (hardware) -service_patching_duration: 5 # The time taken to patch a service -file_system_repairing_limit: 5 # The time take to repair the file system -file_system_restoring_limit: 5 # The time take to restore the file system -file_system_scanning_limit: 5 # The time taken to scan the file system diff --git a/tests/conftest.py b/tests/conftest.py index f40b0b94..a20822f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,33 @@ # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import datetime -import shutil -import tempfile -from datetime import datetime -from pathlib import Path -from typing import Union -from unittest.mock import patch +from typing import Any, Dict, Tuple import pytest +import yaml -from primaite import getLogger -from primaite.environment.primaite_env import Primaite -from primaite.primaite_session import PrimaiteSession -from tests.mock_and_patch.get_session_path_mock import get_temp_session_path +from primaite import getLogger, PRIMAITE_PATHS +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.interface import AbstractAgent +from primaite.game.agent.observations.observation_manager import NestedObservation, ObservationManager +from primaite.game.agent.rewards import RewardFunction +from primaite.game.game import PrimaiteGame +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.networks import arcd_uc2_network +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.service import Service +from primaite.simulator.system.services.web_server.web_server import WebServer +from tests import TEST_ASSETS_ROOT ACTION_SPACE_NODE_VALUES = 1 ACTION_SPACE_NODE_ACTION_VALUES = 1 @@ -20,99 +35,468 @@ ACTION_SPACE_NODE_ACTION_VALUES = 1 _LOGGER = getLogger(__name__) -class TempPrimaiteSession(PrimaiteSession): - """ - A temporary PrimaiteSession class. +class TestService(Service): + """Test Service class""" - Uses context manager for deletion of files upon exit. + def describe_state(self) -> Dict: + return super().describe_state() + + def __init__(self, **kwargs): + kwargs["name"] = "TestService" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + pass + + +class TestApplication(Application): + """Test Application class""" + + def __init__(self, **kwargs): + kwargs["name"] = "TestApplication" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + return super().describe_state() + + +@pytest.fixture(scope="function") +def uc2_network() -> Network: + with open(PRIMAITE_PATHS.user_config_path / "example_config" / "data_manipulation.yaml") as f: + cfg = yaml.safe_load(f) + game = PrimaiteGame.from_config(cfg) + return game.simulation.network + + +@pytest.fixture(scope="function") +def service(file_system) -> TestService: + return TestService( + name="TestService", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="test_service") + ) + + +@pytest.fixture(scope="function") +def service_class(): + return TestService + + +@pytest.fixture(scope="function") +def application(file_system) -> TestApplication: + return TestApplication( + name="TestApplication", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="test_application") + ) + + +@pytest.fixture(scope="function") +def application_class(): + return TestApplication + + +@pytest.fixture(scope="function") +def file_system() -> FileSystem: + computer = Computer(hostname="fs_node", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + computer.power_on() + return computer.file_system + + +@pytest.fixture(scope="function") +def client_server() -> Tuple[Computer, Server]: + network = Network() + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Connect Computer and Server + network.connect(computer.network_interface[1], server.network_interface[1]) + + # Should be linked + assert next(iter(network.links.values())).is_up + + return computer, server + + +@pytest.fixture(scope="function") +def client_switch_server() -> Tuple[Computer, Switch, Server]: + network = Network() + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + switch = Switch(hostname="switch", start_up_duration=0) + switch.power_on() + + network.connect(endpoint_a=computer.network_interface[1], endpoint_b=switch.network_interface[1]) + network.connect(endpoint_a=server.network_interface[1], endpoint_b=switch.network_interface[2]) + + assert all(link.is_up for link in network.links.values()) + + return computer, switch, server + + +@pytest.fixture(scope="function") +def example_network() -> Network: """ + Create the network used for testing. + + Should only contain the nodes and links. + This would act as the base network and services and applications are installed in the relevant test file, + + -------------- -------------- + | client_1 |----- ----| server_1 | + -------------- | -------------- -------------- -------------- | -------------- + ------| switch_2 |------| router_1 |------| switch_1 |------ + -------------- | -------------- -------------- -------------- | -------------- + | client_2 |---- ----| server_2 | + -------------- -------------- + """ + network = Network() + + # Router 1 + router_1 = Router(hostname="router_1", start_up_duration=0) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.10.1", subnet_mask="255.255.255.0") + + # Switch 1 + switch_1 = Switch(hostname="switch_1", num_ports=8, start_up_duration=0) + switch_1.power_on() + + network.connect(endpoint_a=router_1.network_interface[1], endpoint_b=switch_1.network_interface[8]) + router_1.enable_port(1) + + # Switch 2 + switch_2 = Switch(hostname="switch_2", num_ports=8, start_up_duration=0) + switch_2.power_on() + network.connect(endpoint_a=router_1.network_interface[2], endpoint_b=switch_2.network_interface[8]) + router_1.enable_port(2) + + # Client 1 + client_1 = Computer( + hostname="client_1", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + start_up_duration=0, + ) + client_1.power_on() + network.connect(endpoint_b=client_1.network_interface[1], endpoint_a=switch_2.network_interface[1]) + + # Client 2 + client_2 = Computer( + hostname="client_2", + ip_address="192.168.10.22", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + start_up_duration=0, + ) + client_2.power_on() + network.connect(endpoint_b=client_2.network_interface[1], endpoint_a=switch_2.network_interface[2]) + + # Server 1 + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + network.connect(endpoint_b=server_1.network_interface[1], endpoint_a=switch_1.network_interface[1]) + + # DServer 2 + server_2 = Server( + hostname="server_2", + ip_address="192.168.1.14", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_2.power_on() + network.connect(endpoint_b=server_2.network_interface[1], endpoint_a=switch_1.network_interface[2]) + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + assert all(link.is_up for link in network.links.values()) + + return network + + +class ControlledAgent(AbstractAgent): + """Agent that can be controlled by the tests.""" def __init__( self, - training_config_path: Union[str, Path], - lay_down_config_path: Union[str, Path], - ): - super().__init__(training_config_path, lay_down_config_path) - self.setup() - - @property - def env(self) -> Primaite: - """Direct access to the env for ease of testing.""" - return self._agent_session._env # noqa - - def __enter__(self): - return self - - def __exit__(self, type, value, tb): - shutil.rmtree(self.session_path) - _LOGGER.debug(f"Deleted temp session directory: {self.session_path}") - - -@pytest.fixture -def temp_primaite_session(request): - """ - Provides a temporary PrimaiteSession instance. - - It's temporary as it uses a temporary directory as the session path. - - To use this fixture you need to: - - - parametrize your test function with: - - - "temp_primaite_session" - - [[path to training config, path to lay down config]] - - Include the temp_primaite_session fixture as a param in your test - function. - - use the temp_primaite_session as a context manager assigning is the - name 'session'. - - .. code:: python - - from primaite.config.lay_down_config import dos_very_basic_config_path - from primaite.config.training_config import main_training_config_path - @pytest.mark.parametrize( - "temp_primaite_session", - [ - [main_training_config_path(), dos_very_basic_config_path()] - ], - indirect=True + agent_name: str, + action_space: ActionManager, + observation_space: ObservationManager, + reward_function: RewardFunction, + ) -> None: + super().__init__( + agent_name=agent_name, + action_space=action_space, + observation_space=observation_space, + reward_function=reward_function, ) - def test_primaite_session(temp_primaite_session): - with temp_primaite_session as session: - # Learning outputs are saved in session.learning_path - session.learn() + self.most_recent_action: Tuple[str, Dict] - # Evaluation outputs are saved in session.evaluation_path - session.evaluate() + def get_action(self, obs: None, timestep: int = 0) -> Tuple[str, Dict]: + """Return the agent's most recent action, formatted in CAOS format.""" + return self.most_recent_action - # To ensure that all files are written, you must call .close() - session.close() + def store_action(self, action: Tuple[str, Dict]): + """Store the most recent action.""" + self.most_recent_action = action - # If you need to inspect any session outputs, it must be done - # inside the context manager - # Now that we've exited the context manager, the - # session.session_path directory and its contents are deleted - """ - training_config_path = request.param[0] - lay_down_config_path = request.param[1] - with patch("primaite.agents.agent_abc.get_session_path", get_temp_session_path) as mck: - mck.session_timestamp = datetime.now() +def install_stuff_to_sim(sim: Simulation): + """Create a simulation with a computer, two servers, two switches, and a router.""" - return TempPrimaiteSession(training_config_path, lay_down_config_path) + # 0: Pull out the network + network = sim.network + + # 1: Set up network hardware + # 1.1: Configure the router + router = Router(hostname="router", num_ports=3, start_up_duration=0) + router.power_on() + router.configure_port(port=1, ip_address="10.0.1.1", subnet_mask="255.255.255.0") + router.configure_port(port=2, ip_address="10.0.2.1", subnet_mask="255.255.255.0") + + # 1.2: Create and connect switches + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + network.connect(endpoint_a=router.network_interface[1], endpoint_b=switch_1.network_interface[6]) + router.enable_port(1) + switch_2 = Switch(hostname="switch_2", num_ports=6, start_up_duration=0) + switch_2.power_on() + network.connect(endpoint_a=router.network_interface[2], endpoint_b=switch_2.network_interface[6]) + router.enable_port(2) + + # 1.3: Create and connect computer + client_1 = Computer( + hostname="client_1", + ip_address="10.0.1.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.1.1", + start_up_duration=0, + ) + client_1.power_on() + network.connect( + endpoint_a=client_1.network_interface[1], + endpoint_b=switch_1.network_interface[1], + ) + + # 1.4: Create and connect servers + server_1 = Server( + hostname="server_1", + ip_address="10.0.2.2", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_1.power_on() + network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_2.network_interface[1]) + + server_2 = Server( + hostname="server_2", + ip_address="10.0.2.3", + subnet_mask="255.255.255.0", + default_gateway="10.0.2.1", + start_up_duration=0, + ) + server_2.power_on() + network.connect(endpoint_a=server_2.network_interface[1], endpoint_b=switch_2.network_interface[2]) + + # 2: Configure base ACL + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.DNS, dst_port=Port.DNS, position=1) + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + + # 3: Install server software + server_1.software_manager.install(DNSServer) + dns_service: DNSServer = server_1.software_manager.software.get("DNSServer") # noqa + dns_service.dns_register("www.example.com", server_2.network_interface[1].ip_address) + server_2.software_manager.install(WebServer) + + # 3.1: Ensure that the dns clients are configured correctly + client_1.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address + server_2.software_manager.software.get("DNSClient").dns_server = server_1.network_interface[1].ip_address + + # 4: Check that client came pre-installed with web browser and dns client + assert isinstance(client_1.software_manager.software.get("WebBrowser"), WebBrowser) + assert isinstance(client_1.software_manager.software.get("DNSClient"), DNSClient) + + # 4.1: Create a file on the computer + client_1.file_system.create_file("cat.png", 300, folder_name="downloads") + + # 5: Assert that the simulation starts off in the state that we expect + assert len(sim.network.nodes) == 6 + assert len(sim.network.links) == 5 + # 5.1: Assert the router is correctly configured + r = sim.network.router_nodes[0] + for i, acl_rule in enumerate(r.acl.acl): + if i == 1: + assert acl_rule.src_port == acl_rule.dst_port == Port.DNS + elif i == 3: + assert acl_rule.src_port == acl_rule.dst_port == Port.HTTP + elif i == 22: + assert acl_rule.src_port == acl_rule.dst_port == Port.ARP + elif i == 23: + assert acl_rule.protocol == IPProtocol.ICMP + elif i == 24: + ... + else: + assert acl_rule is None + + # 5.2: Assert the client is correctly configured + c: Computer = [node for node in sim.network.nodes.values() if node.hostname == "client_1"][0] + assert c.software_manager.software.get("WebBrowser") is not None + assert c.software_manager.software.get("DNSClient") is not None + assert str(c.network_interface[1].ip_address) == "10.0.1.2" + + # 5.3: Assert that server_1 is correctly configured + s1: Server = [node for node in sim.network.nodes.values() if node.hostname == "server_1"][0] + assert str(s1.network_interface[1].ip_address) == "10.0.2.2" + assert s1.software_manager.software.get("DNSServer") is not None + + # 5.4: Assert that server_2 is correctly configured + s2: Server = [node for node in sim.network.nodes.values() if node.hostname == "server_2"][0] + assert str(s2.network_interface[1].ip_address) == "10.0.2.3" + assert s2.software_manager.software.get("WebServer") is not None + + # 6: Return the simulation + return sim @pytest.fixture -def temp_session_path() -> Path: - """ - Get a temp directory session path the test session will output to. +def game_and_agent(): + """Create a game with a simple agent that can be controlled by the tests.""" + game = PrimaiteGame() + sim = game.simulation + install_stuff_to_sim(sim) - :return: The session directory path. - """ - session_timestamp = datetime.now() - date_dir = session_timestamp.strftime("%Y-%m-%d") - session_path = session_timestamp.strftime("%Y-%m-%d_%H-%M-%S") - session_path = Path(tempfile.gettempdir()) / "primaite" / date_dir / session_path - session_path.mkdir(exist_ok=True, parents=True) + actions = [ + {"type": "DONOTHING"}, + {"type": "NODE_SERVICE_SCAN"}, + {"type": "NODE_SERVICE_STOP"}, + {"type": "NODE_SERVICE_START"}, + {"type": "NODE_SERVICE_PAUSE"}, + {"type": "NODE_SERVICE_RESUME"}, + {"type": "NODE_SERVICE_RESTART"}, + {"type": "NODE_SERVICE_DISABLE"}, + {"type": "NODE_SERVICE_ENABLE"}, + {"type": "NODE_SERVICE_FIX"}, + {"type": "NODE_APPLICATION_EXECUTE"}, + {"type": "NODE_APPLICATION_SCAN"}, + {"type": "NODE_APPLICATION_CLOSE"}, + {"type": "NODE_APPLICATION_FIX"}, + {"type": "NODE_APPLICATION_INSTALL"}, + {"type": "NODE_APPLICATION_REMOVE"}, + {"type": "NODE_FILE_CREATE"}, + {"type": "NODE_FILE_SCAN"}, + {"type": "NODE_FILE_CHECKHASH"}, + {"type": "NODE_FILE_DELETE"}, + {"type": "NODE_FILE_REPAIR"}, + {"type": "NODE_FILE_RESTORE"}, + {"type": "NODE_FILE_CORRUPT"}, + {"type": "NODE_FILE_ACCESS"}, + {"type": "NODE_FOLDER_CREATE"}, + {"type": "NODE_FOLDER_SCAN"}, + {"type": "NODE_FOLDER_CHECKHASH"}, + {"type": "NODE_FOLDER_REPAIR"}, + {"type": "NODE_FOLDER_RESTORE"}, + {"type": "NODE_OS_SCAN"}, + {"type": "NODE_SHUTDOWN"}, + {"type": "NODE_STARTUP"}, + {"type": "NODE_RESET"}, + {"type": "ROUTER_ACL_ADDRULE"}, + {"type": "ROUTER_ACL_REMOVERULE"}, + {"type": "HOST_NIC_ENABLE"}, + {"type": "HOST_NIC_DISABLE"}, + {"type": "NETWORK_PORT_ENABLE"}, + {"type": "NETWORK_PORT_DISABLE"}, + ] - return session_path + action_space = ActionManager( + actions=actions, # ALL POSSIBLE ACTIONS + nodes=[ + { + "node_name": "client_1", + "applications": [ + {"application_name": "WebBrowser"}, + {"application_name": "DoSBot"}, + ], + "folders": [{"folder_name": "downloads", "files": [{"file_name": "cat.png"}]}], + }, + { + "node_name": "server_1", + "services": [{"service_name": "DNSServer"}], + }, + {"node_name": "server_2", "services": [{"service_name": "WebServer"}]}, + {"node_name": "router"}, + ], + max_folders_per_node=2, + max_files_per_folder=2, + max_services_per_node=2, + max_applications_per_node=2, + max_nics_per_node=2, + max_acl_rules=10, + protocols=["TCP", "UDP", "ICMP"], + ports=["HTTP", "DNS", "ARP"], + ip_list=["10.0.1.1", "10.0.1.2", "10.0.2.1", "10.0.2.2", "10.0.2.3"], + act_map={}, + ) + observation_space = ObservationManager(NestedObservation(components={})) + reward_function = RewardFunction() + + test_agent = ControlledAgent( + agent_name="test_agent", + action_space=action_space, + observation_space=observation_space, + reward_function=reward_function, + ) + + game.agents["test_agent"] = test_agent + + game.setup_reward_sharing() + + return (game, test_agent) diff --git a/tests/e2e_integration_tests/__init__.py b/tests/e2e_integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e_integration_tests/environments/__init__.py b/tests/e2e_integration_tests/environments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e_integration_tests/environments/test_rllib_multi_agent_environment.py b/tests/e2e_integration_tests/environments/test_rllib_multi_agent_environment.py new file mode 100644 index 00000000..9b550dd2 --- /dev/null +++ b/tests/e2e_integration_tests/environments/test_rllib_multi_agent_environment.py @@ -0,0 +1,40 @@ +import ray +import yaml +from ray import air, tune +from ray.rllib.algorithms.ppo import PPOConfig + +from primaite.session.ray_envs import PrimaiteRayMARLEnv +from tests import TEST_ASSETS_ROOT + +MULTI_AGENT_PATH = TEST_ASSETS_ROOT / "configs/multi_agent_session.yaml" + + +def test_rllib_multi_agent_compatibility(): + """Test that the PrimaiteRayEnv class can be used with a multi agent RLLIB system.""" + + with open(MULTI_AGENT_PATH, "r") as f: + cfg = yaml.safe_load(f) + + ray.init() + + config = ( + PPOConfig() + .environment(env=PrimaiteRayMARLEnv, env_config=cfg) + .rollouts(num_rollout_workers=0) + .multi_agent( + policies={agent["ref"] for agent in cfg["agents"]}, + policy_mapping_fn=lambda agent_id, episode, worker, **kw: agent_id, + ) + .training(train_batch_size=128) + ) + + tune.Tuner( + "PPO", + run_config=air.RunConfig( + stop={"training_iteration": 128}, + checkpoint_config=air.CheckpointConfig( + checkpoint_frequency=10, + ), + ), + param_space=config, + ).fit() diff --git a/tests/e2e_integration_tests/environments/test_rllib_single_agent_environment.py b/tests/e2e_integration_tests/environments/test_rllib_single_agent_environment.py new file mode 100644 index 00000000..f56f0f85 --- /dev/null +++ b/tests/e2e_integration_tests/environments/test_rllib_single_agent_environment.py @@ -0,0 +1,42 @@ +import tempfile +from pathlib import Path + +import pytest +import ray +import yaml +from ray.rllib.algorithms import ppo + +from primaite.config.load import data_manipulation_config_path +from primaite.game.game import PrimaiteGame +from primaite.session.ray_envs import PrimaiteRayEnv + + +@pytest.mark.skip(reason="Slow, reenable later") +def test_rllib_single_agent_compatibility(): + """Test that the PrimaiteRayEnv class can be used with a single agent RLLIB system.""" + with open(data_manipulation_config_path(), "r") as f: + cfg = yaml.safe_load(f) + + game = PrimaiteGame.from_config(cfg) + + ray.shutdown() + ray.init() + + env_config = {"game": game} + config = { + "env": PrimaiteRayEnv, + "env_config": env_config, + "disable_env_checking": True, + "num_rollout_workers": 0, + } + + algo = ppo.PPO(config=config) + + for i in range(5): + result = algo.train() + + save_file = Path(tempfile.gettempdir()) / "ray/" + algo.save(save_file) + assert save_file.exists() + + save_file.unlink() # clean up diff --git a/tests/e2e_integration_tests/environments/test_sb3_environment.py b/tests/e2e_integration_tests/environments/test_sb3_environment.py new file mode 100644 index 00000000..f654234b --- /dev/null +++ b/tests/e2e_integration_tests/environments/test_sb3_environment.py @@ -0,0 +1,28 @@ +"""Test that we can create a primaite environment and train sb3 agent with no crash.""" +import tempfile +from pathlib import Path + +import pytest +import yaml +from stable_baselines3 import PPO + +from primaite.config.load import data_manipulation_config_path +from primaite.game.game import PrimaiteGame +from primaite.session.environment import PrimaiteGymEnv + + +def test_sb3_compatibility(): + """Test that the Gymnasium environment can be used with an SB3 agent.""" + with open(data_manipulation_config_path(), "r") as f: + cfg = yaml.safe_load(f) + + gym = PrimaiteGymEnv(env_config=cfg) + model = PPO("MlpPolicy", gym) + + model.learn(total_timesteps=1000) + + save_path = Path(tempfile.gettempdir()) / "model.zip" + model.save(save_path) + + assert (save_path).exists() + save_path.unlink() # clean up diff --git a/tests/e2e_integration_tests/test_environment.py b/tests/e2e_integration_tests/test_environment.py new file mode 100644 index 00000000..0a2c6add --- /dev/null +++ b/tests/e2e_integration_tests/test_environment.py @@ -0,0 +1,92 @@ +import pydantic +import pytest +import yaml +from gymnasium.core import ObsType +from numpy import ndarray + +from primaite.session.environment import PrimaiteGymEnv +from primaite.session.ray_envs import PrimaiteRayMARLEnv +from primaite.simulator.network.hardware.nodes.host.server import Printer +from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter +from tests import TEST_ASSETS_ROOT + +CFG_PATH = TEST_ASSETS_ROOT / "configs/test_primaite_session.yaml" +TRAINING_ONLY_PATH = TEST_ASSETS_ROOT / "configs/train_only_primaite_session.yaml" +EVAL_ONLY_PATH = TEST_ASSETS_ROOT / "configs/eval_only_primaite_session.yaml" +MISCONFIGURED_PATH = TEST_ASSETS_ROOT / "configs/bad_primaite_session.yaml" +MULTI_AGENT_PATH = TEST_ASSETS_ROOT / "configs/multi_agent_session.yaml" + + +class TestPrimaiteEnvironment: + def test_creating_env(self): + """Check that environment loads correctly from config and it can be reset.""" + with open(CFG_PATH, "r") as f: + cfg = yaml.safe_load(f) + env = PrimaiteGymEnv(env_config=cfg) + + def env_checks(): + assert env is not None + assert env.game.simulation + assert len(env.game.agents) == 3 + assert len(env.game.rl_agents) == 1 + + assert env.game.simulation.network + assert len(env.game.simulation.network.nodes) == 12 + wireless = env.game.simulation.network.get_node_by_hostname("router_2") + assert isinstance(wireless, WirelessRouter) + printer = env.game.simulation.network.get_node_by_hostname("HP_LaserJet_Pro_4102fdn_printer") + assert isinstance(printer, Printer) + + env_checks() + env.reset() + env_checks() + + def test_step_env(self): + """Make sure you can go all the way through the session without errors.""" + with open(CFG_PATH, "r") as f: + cfg = yaml.safe_load(f) + env = PrimaiteGymEnv(env_config=cfg) + + assert (num_actions := len(env.agent.action_manager.action_map)) == 54 + # run every action and make sure there's no crash + for act in range(num_actions): + env.step(act) + # try running action number outside the action map to check that it fails. + with pytest.raises(KeyError): + env.step(num_actions) + + obs, rew, trunc, term, info = env.step(0) + assert isinstance(obs, ndarray) + + def test_multi_agent_env(self): + """Check that we can run a training session with a multi agent system.""" + with open(MULTI_AGENT_PATH, "r") as f: + cfg = yaml.safe_load(f) + env = PrimaiteRayMARLEnv(env_config=cfg) + + assert set(env._agent_ids) == {"defender1", "defender2"} + + assert len(env.agents) == 2 + defender1 = env.agents["defender1"] + defender2 = env.agents["defender2"] + assert (num_actions_1 := len(defender1.action_manager.action_map)) == 54 + assert (num_actions_2 := len(defender2.action_manager.action_map)) == 38 + + # ensure we can run all valid actions without error + for act_1 in range(num_actions_1): + env.step({"defender1": act_1, "defender2": 0}) + for act_2 in range(num_actions_2): + env.step({"defender1": 0, "defender2": act_2}) + + # ensure we get error when taking an invalid action + with pytest.raises(KeyError): + env.step({"defender1": num_actions_1, "defender2": 0}) + with pytest.raises(KeyError): + env.step({"defender1": 0, "defender2": num_actions_2}) + + def test_error_thrown_on_bad_configuration(self): + """Make sure we throw an error when the config is bad.""" + with open(MISCONFIGURED_PATH, "r") as f: + cfg = yaml.safe_load(f) + with pytest.raises(pydantic.ValidationError): + env = PrimaiteGymEnv(env_config=cfg) diff --git a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py new file mode 100644 index 00000000..5df8f964 --- /dev/null +++ b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py @@ -0,0 +1,85 @@ +import yaml + +from primaite.game.game import PrimaiteGame +from primaite.session.environment import PrimaiteGymEnv +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.services.database.database_service import DatabaseService +from tests import TEST_ASSETS_ROOT + + +def test_data_manipulation(uc2_network): + """Tests the UC2 data manipulation scenario end-to-end. Is a work in progress.""" + client_1: Computer = uc2_network.get_node_by_hostname("client_1") + db_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + + database_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = database_server.software_manager.software.get("DatabaseService") + + web_server: Server = uc2_network.get_node_by_hostname("web_server") + db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") + db_connection: DatabaseClientConnection = db_client.get_new_connection() + db_service.backup_database() + + # First check that the DB client on the web_server can successfully query the users table on the database + assert db_connection.query("SELECT") + + db_manipulation_bot.data_manipulation_p_of_success = 1.0 + db_manipulation_bot.port_scan_p_of_success = 1.0 + + # Now we run the DataManipulationBot + db_manipulation_bot.attack() + + # Now check that the DB client on the web_server cannot query the users table on the database + assert not db_connection.query("SELECT") + + # Now restore the database + db_service.restore_backup() + + # Now check that the DB client on the web_server can successfully query the users table on the database + assert db_connection.query("SELECT") + + +def test_application_install_uninstall_on_uc2(): + """Test Application install and uninstall via agent actions mid episode.""" + with open(TEST_ASSETS_ROOT / "configs/test_application_install.yaml", "r") as f: + cfg = yaml.safe_load(f) + + env = PrimaiteGymEnv(env_config=cfg) + env.agent.flatten_obs = False + env.reset() + + _, _, _, _, _ = env.step(0) + domcon = env.game.simulation.network.get_node_by_hostname("domain_controller") + + # Test we cannot execute the DoSBot app as it is not installed yet + _, _, _, _, info = env.step(81) + assert info["agent_actions"]["defender"].response.status == "unreachable" + + # Test we can Install the DoSBot app + _, _, _, _, info = env.step(78) + assert "DoSBot" in domcon.software_manager.software + + # installing takes 3 steps so let's wait for 3 steps + env.step(0) + env.step(0) + env.step(0) + + # Test we can now execute the DoSBot app + _, _, _, _, info = env.step(81) + assert info["agent_actions"]["defender"].response.status == "success" + + # Test we can Uninstall the DoSBot app + _, _, _, _, info = env.step(79) + assert "DoSBot" not in domcon.software_manager.software + + # Test we cannot execute the DoSBot app as it was uninstalled + _, _, _, _, info = env.step(81) + assert info["agent_actions"]["defender"].response.status == "unreachable" + + # Test we can uninstall one of the default apps (WebBrowser) + assert "WebBrowser" in domcon.software_manager.software + _, _, _, _, info = env.step(80) + assert "WebBrowser" not in domcon.software_manager.software diff --git a/tests/integration_tests/__init__.py b/tests/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/cli/__init__.py b/tests/integration_tests/cli/__init__.py new file mode 100644 index 00000000..07487650 --- /dev/null +++ b/tests/integration_tests/cli/__init__.py @@ -0,0 +1,11 @@ +from typing import List + +from typer.testing import CliRunner, Result + +from primaite.cli import app + + +def cli(args: List[str]) -> Result: + """Pass in a list of arguments and it will return the result.""" + runner = CliRunner() + return runner.invoke(app, args) diff --git a/tests/integration_tests/cli/test_dev_cli.py b/tests/integration_tests/cli/test_dev_cli.py new file mode 100644 index 00000000..8f1bdec6 --- /dev/null +++ b/tests/integration_tests/cli/test_dev_cli.py @@ -0,0 +1,171 @@ +import os +import shutil +import tempfile +from pathlib import Path + +import pkg_resources +import pytest +import yaml + +from primaite import PRIMAITE_CONFIG +from primaite.utils.cli.primaite_config_utils import update_primaite_application_config +from tests.integration_tests.cli import cli + + +@pytest.fixture(autouse=True) +def test_setup(): + """ + Setup this test by using the default primaite app config in package + """ + global PRIMAITE_CONFIG + current_config = PRIMAITE_CONFIG.copy() # store the config before test + + pkg_config_path = Path(pkg_resources.resource_filename("primaite", "setup/_package_data/primaite_config.yaml")) + + with open(pkg_config_path, "r") as file: + # load from config + config_dict = yaml.safe_load(file) + + PRIMAITE_CONFIG["developer_mode"] = config_dict["developer_mode"] + + yield + + PRIMAITE_CONFIG["developer_mode"] = current_config["developer_mode"] # restore config to prevent being yelled at + update_primaite_application_config(config=PRIMAITE_CONFIG) + + +def test_dev_mode_enable_disable(): + """Test dev mode enable and disable.""" + # check defaults + assert PRIMAITE_CONFIG["developer_mode"]["enabled"] is False # not enabled by default + + result = cli(["dev-mode", "show"]) + assert "Production" in result.output # should print that it is in Production mode by default + + result = cli(["dev-mode", "enable"]) + + assert "Development" in result.output # should print that it is in Development mode + + assert PRIMAITE_CONFIG["developer_mode"]["enabled"] # config should reflect that dev mode is enabled + + result = cli(["dev-mode", "show"]) + assert "Development" in result.output # should print that it is in Development mode + + result = cli(["dev-mode", "disable"]) + + assert "Production" in result.output # should print that it is in Production mode + + assert PRIMAITE_CONFIG["developer_mode"]["enabled"] is False # config should reflect that dev mode is disabled + + result = cli(["dev-mode", "show"]) + assert "Production" in result.output # should print that it is in Production mode + + +def test_dev_mode_config_sys_log_level(): + """Check that the system log level can be changed via CLI.""" + # check defaults + assert PRIMAITE_CONFIG["developer_mode"]["sys_log_level"] == "DEBUG" # DEBUG by default + + result = cli(["dev-mode", "config", "-level", "WARNING"]) + + assert "sys_log_level=WARNING" in result.output # should print correct value + + # config should reflect that log level is WARNING + assert PRIMAITE_CONFIG["developer_mode"]["sys_log_level"] == "WARNING" + + result = cli(["dev-mode", "config", "--sys-log-level", "INFO"]) + + assert "sys_log_level=INFO" in result.output # should print correct value + + # config should reflect that log level is WARNING + assert PRIMAITE_CONFIG["developer_mode"]["sys_log_level"] == "INFO" + + +def test_dev_mode_config_sys_logs_enable_disable(): + """Test that the system logs output can be enabled or disabled.""" + # check defaults + assert PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] is False # False by default + + result = cli(["dev-mode", "config", "--output-sys-logs"]) + assert "output_sys_logs=True" in result.output # should print correct value + + # config should reflect that output_sys_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] + + result = cli(["dev-mode", "config", "--no-sys-logs"]) + assert "output_sys_logs=False" in result.output # should print correct value + + # config should reflect that output_sys_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] is False + + result = cli(["dev-mode", "config", "-sys"]) + assert "output_sys_logs=True" in result.output # should print correct value + + # config should reflect that output_sys_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] + + result = cli(["dev-mode", "config", "-nsys"]) + assert "output_sys_logs=False" in result.output # should print correct value + + # config should reflect that output_sys_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_sys_logs"] is False + + +def test_dev_mode_config_pcap_logs_enable_disable(): + """Test that the pcap logs output can be enabled or disabled.""" + # check defaults + assert PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] is False # False by default + + result = cli(["dev-mode", "config", "--output-pcap-logs"]) + assert "output_pcap_logs=True" in result.output # should print correct value + + # config should reflect that output_pcap_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] + + result = cli(["dev-mode", "config", "--no-pcap-logs"]) + assert "output_pcap_logs=False" in result.output # should print correct value + + # config should reflect that output_pcap_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] is False + + result = cli(["dev-mode", "config", "-pcap"]) + assert "output_pcap_logs=True" in result.output # should print correct value + + # config should reflect that output_pcap_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] + + result = cli(["dev-mode", "config", "-npcap"]) + assert "output_pcap_logs=False" in result.output # should print correct value + + # config should reflect that output_pcap_logs is True + assert PRIMAITE_CONFIG["developer_mode"]["output_pcap_logs"] is False + + +def test_dev_mode_config_output_to_terminal_enable_disable(): + """Test that the output to terminal can be enabled or disabled.""" + # check defaults + assert PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] is False # False by default + + result = cli(["dev-mode", "config", "--output-to-terminal"]) + assert "output_to_terminal=True" in result.output # should print correct value + + # config should reflect that output_to_terminal is True + assert PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] + + result = cli(["dev-mode", "config", "--no-terminal"]) + assert "output_to_terminal=False" in result.output # should print correct value + + # config should reflect that output_to_terminal is True + assert PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] is False + + result = cli(["dev-mode", "config", "-t"]) + assert "output_to_terminal=True" in result.output # should print correct value + + # config should reflect that output_to_terminal is True + assert PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] + + result = cli(["dev-mode", "config", "-nt"]) + assert "output_to_terminal=False" in result.output # should print correct value + + # config should reflect that output_to_terminal is True + assert PRIMAITE_CONFIG["developer_mode"]["output_to_terminal"] is False diff --git a/tests/integration_tests/component_creation/__init__.py b/tests/integration_tests/component_creation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/component_creation/test_action_integration.py b/tests/integration_tests/component_creation/test_action_integration.py new file mode 100644 index 00000000..e7c9fcc6 --- /dev/null +++ b/tests/integration_tests/component_creation/test_action_integration.py @@ -0,0 +1,53 @@ +from primaite.simulator.core import RequestType +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.services.database.database_service import DatabaseService + + +def test_passing_actions_down(monkeypatch) -> None: + """Check that an action is passed down correctly to the child component.""" + + sim = Simulation() + + pc1 = Computer(hostname="PC-1", ip_address="10.10.1.1", subnet_mask="255.255.255.0") + pc1.start_up_duration = 0 + pc1.power_on() + pc2 = Computer(hostname="PC-2", ip_address="10.10.1.2", subnet_mask="255.255.255.0") + srv = Server(hostname="WEBSERVER", ip_address="10.10.1.100", subnet_mask="255.255.255.0") + s1 = Switch(hostname="switch1") + + for n in [pc1, pc2, srv, s1]: + sim.network.add_node(n) + + database_service = DatabaseService(file_system=srv.file_system) + srv.install_service(database_service) + + downloads_folder = pc1.file_system.create_folder("downloads") + pc1.file_system.create_file("bermuda_triangle.png", folder_name="downloads") + + sim.network.connect(pc1.network_interface[1], s1.network_interface[1]) + sim.network.connect(pc2.network_interface[1], s1.network_interface[2]) + sim.network.connect(s1.network_interface[3], srv.network_interface[1]) + + # call this method to make sure no errors occur. + sim._request_manager.get_request_types_recursively() + + # patch the action to do something which we can check the result of. + action_invoked = False + + def succeed(): + nonlocal action_invoked + action_invoked = True + + monkeypatch.setitem( + downloads_folder._request_manager.request_types, "repair", RequestType(func=lambda request, context: succeed()) + ) + + assert not action_invoked + + # call the patched method + sim.apply_request(["network", "node", pc1.hostname, "file_system", "folder", "downloads", "repair"]) + + assert action_invoked diff --git a/tests/integration_tests/component_creation/test_permission_system.py b/tests/integration_tests/component_creation/test_permission_system.py new file mode 100644 index 00000000..bcadebb4 --- /dev/null +++ b/tests/integration_tests/component_creation/test_permission_system.py @@ -0,0 +1,192 @@ +from enum import Enum +from typing import Dict, List, Literal + +import pytest + +from primaite.simulator.core import AllowAllValidator, RequestManager, RequestType, SimComponent +from primaite.simulator.domain.controller import AccountGroup, GroupMembershipValidator + + +@pytest.mark.skip(reason="Action validation is not currently a required feature.") +def test_group_action_validation() -> None: + """ + Check that actions are denied when an unauthorised request is made. + + This test checks the integration between SimComponent and the permissions validation system. First, we create a + basic node and folder class. We configure the node so that only admins can create a folder. Then, we try to create + a folder as both an admin user and a non-admin user. + """ + + class Folder(SimComponent): + name: str + + def describe_state(self) -> Dict: + return super().describe_state() + + class Node(SimComponent): + name: str + folders: List[Folder] = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._request_manager = RequestManager() + + self._request_manager.add_request( + "create_folder", + RequestType( + func=lambda request, context: self.create_folder(request[0]), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def create_folder(self, folder_name: str) -> None: + new_folder = Folder(uuid="0000-0000-0001", name=folder_name) + self.folders.append(new_folder) + + def remove_folder(self, folder: Folder) -> None: + self.folders = [x for x in self.folders if x is not folder] + + # check that the folder is created when a local admin tried to do it + permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_ADMIN"]}} + my_node = Node(uuid="0000-0000-1234", name="pc") + my_node.apply_request(["create_folder", "memes"], context=permitted_context) + assert len(my_node.folders) == 1 + assert my_node.folders[0].name == "memes" + + # check that the number of folders is still 1 even after attempting to create a second one without permissions + invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_USER", "DOMAIN_USER"]}} + my_node.apply_request(["create_folder", "memes2"], context=invalid_context) + assert len(my_node.folders) == 1 + assert my_node.folders[0].name == "memes" + + +@pytest.mark.skip(reason="Action validation is not currently a required feature.") +def test_hierarchical_action_with_validation() -> None: + """ + Check that validation works with sub-objects. + + This test creates a parent object (Node) and a child object (Application) which both accept actions. The node allows + action passthrough to applications. The purpose of this test is to check that after an action is passed through to + a child object, that the permission system still works as intended. + """ + + class Application(SimComponent): + name: str + state: Literal["on", "off", "disabled"] = "off" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.request_manager = RequestManager() + + self.request_manager.add_request( + "turn_on", + RequestType( + func=lambda request, context: self.turn_on(), + validator=AllowAllValidator(), + ), + ) + self.request_manager.add_request( + "turn_off", + RequestType( + func=lambda request, context: self.turn_off(), + validator=AllowAllValidator(), + ), + ) + self.request_manager.add_request( + "disable", + RequestType( + func=lambda request, context: self.disable(), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), + ), + ) + self.request_manager.add_request( + "enable", + RequestType( + func=lambda request, context: self.enable(), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def disable(self) -> None: + self.state = "disabled" + + def enable(self) -> None: + if self.state == "disabled": + self.state = "off" + + def turn_on(self) -> None: + if self.state == "off": + self.state = "on" + + def turn_off(self) -> None: + if self.state == "on": + self.state = "off" + + class Node(SimComponent): + name: str + state: Literal["on", "off"] = "on" + apps: List[Application] = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.request_manager = RequestManager() + + self.request_manager.add_request( + "apps", + RequestType( + func=lambda request, context: self.send_action_to_app(request.pop(0), request, context), + validator=AllowAllValidator(), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def install_app(self, app_name: str) -> None: + new_app = Application(name=app_name) + self.apps.append(new_app) + + def send_action_to_app(self, app_name: str, options: List[str], context: Dict): + for app in self.apps: + if app_name == app.name: + app.apply_request(options, context) + break + else: + msg = f"Node has no app with name {app_name}" + raise LookupError(msg) + + my_node = Node(name="pc") + my_node.install_app("Chrome") + my_node.install_app("Firefox") + + non_admin_context = { + "request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_USER", "DOMAIN_USER"]} + } + + admin_context = { + "request_source": { + "agent": "BLUE", + "account": "User1", + "groups": ["LOCAL_ADMIN", "DOMAIN_ADMIN", "LOCAL_USER", "DOMAIN_USER"], + } + } + + # check that a non-admin can't disable this app + my_node.apply_request(["apps", "Chrome", "disable"], non_admin_context) + assert my_node.apps[0].name == "Chrome" # if failure occurs on this line, the test itself is broken + assert my_node.apps[0].state == "off" + + # check that a non-admin can turn this app on + my_node.apply_request(["apps", "Firefox", "turn_on"], non_admin_context) + assert my_node.apps[1].name == "Firefox" # if failure occurs on this line, the test itself is broken + assert my_node.apps[1].state == "on" + + # check that an admin can disable this app + my_node.apply_request(["apps", "Chrome", "disable"], admin_context) + assert my_node.apps[0].state == "disabled" diff --git a/tests/integration_tests/configuration_file_parsing/__init__.py b/tests/integration_tests/configuration_file_parsing/__init__.py new file mode 100644 index 00000000..be21c036 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/__init__.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Union + +import yaml + +from primaite.game.game import PrimaiteGame +from tests import TEST_ASSETS_ROOT + +BASIC_CONFIG = TEST_ASSETS_ROOT / "configs/basic_switched_network.yaml" + +DMZ_NETWORK = TEST_ASSETS_ROOT / "configs/dmz_network.yaml" + +BASIC_FIREWALL = TEST_ASSETS_ROOT / "configs/basic_firewall.yaml" + + +def load_config(config_path: Union[str, Path]) -> PrimaiteGame: + """Returns a PrimaiteGame object which loads the contents of a given yaml path.""" + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) + + return PrimaiteGame.from_config(cfg) diff --git a/tests/integration_tests/configuration_file_parsing/nodes/__init__.py b/tests/integration_tests/configuration_file_parsing/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/configuration_file_parsing/nodes/network/__init__.py b/tests/integration_tests/configuration_file_parsing/nodes/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/configuration_file_parsing/nodes/network/test_firewall_config.py b/tests/integration_tests/configuration_file_parsing/nodes/network/test_firewall_config.py new file mode 100644 index 00000000..fc6e05ec --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/nodes/network/test_firewall_config.py @@ -0,0 +1,135 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.firewall import Firewall +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from tests.integration_tests.configuration_file_parsing import BASIC_FIREWALL, DMZ_NETWORK, load_config + + +@pytest.fixture(scope="function") +def dmz_config() -> Network: + game = load_config(DMZ_NETWORK) + return game.simulation.network + + +@pytest.fixture(scope="function") +def basic_firewall_config() -> Network: + game = load_config(BASIC_FIREWALL) + return game.simulation.network + + +def test_firewall_is_in_configuration(dmz_config): + """Test that the firewall exists in the configuration file.""" + network: Network = dmz_config + + firewall: Firewall = network.get_node_by_hostname("firewall") + + assert firewall + assert firewall.operating_state == NodeOperatingState.ON + + +def test_firewall_routes_are_correctly_added(dmz_config): + """Test that the firewall routes have been correctly added to and configured in the network.""" + network: Network = dmz_config + + firewall: Firewall = network.get_node_by_hostname("firewall") + client_1: Computer = network.get_node_by_hostname("client_1") + dmz_server: Server = network.get_node_by_hostname("dmz_server") + external_computer: Computer = network.get_node_by_hostname("external_computer") + external_server: Server = network.get_node_by_hostname("external_server") + + # there should be a route to client_1 + assert firewall.route_table.find_best_route(client_1.network_interface[1].ip_address) + assert dmz_server.ping(client_1.network_interface[1].ip_address) + assert external_computer.ping(client_1.network_interface[1].ip_address) + assert external_server.ping(client_1.network_interface[1].ip_address) + + # client_1 should be able to ping other nodes + assert client_1.ping(dmz_server.network_interface[1].ip_address) + assert client_1.ping(external_computer.network_interface[1].ip_address) + assert client_1.ping(external_server.network_interface[1].ip_address) + + +def test_firewall_acl_rules_correctly_added(dmz_config): + """ + Test that makes sure that the firewall ACLs have been configured onto the firewall + node via configuration file. + """ + firewall: Firewall = dmz_config.get_node_by_hostname("firewall") + + # ICMP and ARP should be allowed internal_inbound + assert firewall.internal_inbound_acl.num_rules == 2 + assert firewall.internal_inbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.internal_inbound_acl.acl[22].src_port == Port.ARP + assert firewall.internal_inbound_acl.acl[22].dst_port == Port.ARP + assert firewall.internal_inbound_acl.acl[23].action == ACLAction.PERMIT + assert firewall.internal_inbound_acl.acl[23].protocol == IPProtocol.ICMP + assert firewall.internal_inbound_acl.implicit_action == ACLAction.DENY + + # ICMP and ARP should be allowed internal_outbound + assert firewall.internal_outbound_acl.num_rules == 2 + assert firewall.internal_outbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.internal_outbound_acl.acl[22].src_port == Port.ARP + assert firewall.internal_outbound_acl.acl[22].dst_port == Port.ARP + assert firewall.internal_outbound_acl.acl[23].action == ACLAction.PERMIT + assert firewall.internal_outbound_acl.acl[23].protocol == IPProtocol.ICMP + assert firewall.internal_outbound_acl.implicit_action == ACLAction.DENY + + # ICMP and ARP should be allowed dmz_inbound + assert firewall.dmz_inbound_acl.num_rules == 2 + assert firewall.dmz_inbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.dmz_inbound_acl.acl[22].src_port == Port.ARP + assert firewall.dmz_inbound_acl.acl[22].dst_port == Port.ARP + assert firewall.dmz_inbound_acl.acl[23].action == ACLAction.PERMIT + assert firewall.dmz_inbound_acl.acl[23].protocol == IPProtocol.ICMP + assert firewall.dmz_inbound_acl.implicit_action == ACLAction.DENY + + # ICMP and ARP should be allowed dmz_outbound + assert firewall.dmz_outbound_acl.num_rules == 2 + assert firewall.dmz_outbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.dmz_outbound_acl.acl[22].src_port == Port.ARP + assert firewall.dmz_outbound_acl.acl[22].dst_port == Port.ARP + assert firewall.dmz_outbound_acl.acl[23].action == ACLAction.PERMIT + assert firewall.dmz_outbound_acl.acl[23].protocol == IPProtocol.ICMP + assert firewall.dmz_outbound_acl.implicit_action == ACLAction.DENY + + # ICMP and ARP should be allowed external_inbound + assert firewall.external_inbound_acl.num_rules == 1 + assert firewall.external_inbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.external_inbound_acl.acl[22].src_port == Port.ARP + assert firewall.external_inbound_acl.acl[22].dst_port == Port.ARP + # external_inbound should have implicit action PERMIT + # ICMP does not have a provided ACL Rule but implicit action should allow anything + assert firewall.external_inbound_acl.implicit_action == ACLAction.PERMIT + + # ICMP and ARP should be allowed external_outbound + assert firewall.external_outbound_acl.num_rules == 1 + assert firewall.external_outbound_acl.acl[22].action == ACLAction.PERMIT + assert firewall.external_outbound_acl.acl[22].src_port == Port.ARP + assert firewall.external_outbound_acl.acl[22].dst_port == Port.ARP + # external_outbound should have implicit action PERMIT + # ICMP does not have a provided ACL Rule but implicit action should allow anything + assert firewall.external_outbound_acl.implicit_action == ACLAction.PERMIT + + +def test_firewall_with_no_dmz_port(basic_firewall_config): + """ + Test to check that: + - the DMZ port can be ignored i.e. is optional. + - the external_outbound_acl and external_inbound_acl are optional + """ + network: Network = basic_firewall_config + + firewall: Firewall = network.get_node_by_hostname("firewall") + + assert firewall.dmz_port.ip_address == IPv4Address("127.0.0.1") + + assert firewall.external_outbound_acl.num_rules == 0 + assert firewall.external_inbound_acl.num_rules == 0 diff --git a/tests/integration_tests/configuration_file_parsing/nodes/network/test_router_config.py b/tests/integration_tests/configuration_file_parsing/nodes/network/test_router_config.py new file mode 100644 index 00000000..4382cc30 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/nodes/network/test_router_config.py @@ -0,0 +1,69 @@ +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from tests.integration_tests.configuration_file_parsing import DMZ_NETWORK, load_config + + +@pytest.fixture(scope="function") +def dmz_config() -> Network: + game = load_config(DMZ_NETWORK) + return game.simulation.network + + +def test_router_is_in_configuration(dmz_config): + """Test that the router exists in the configuration file.""" + network: Network = dmz_config + + router_1: Router = network.get_node_by_hostname("router_1") + + assert router_1 + assert router_1.operating_state == NodeOperatingState.ON + + +def test_router_routes_are_correctly_added(dmz_config): + """Test that makes sure that router routes have been added from the configuration file.""" + network: Network = dmz_config + + router_1: Router = network.get_node_by_hostname("router_1") + client_1: Computer = network.get_node_by_hostname("client_1") + dmz_server: Server = network.get_node_by_hostname("dmz_server") + external_computer: Computer = network.get_node_by_hostname("external_computer") + external_server: Server = network.get_node_by_hostname("external_server") + + # there should be a route to dmz_server + assert router_1.route_table.find_best_route(dmz_server.network_interface[1].ip_address) + assert client_1.ping(dmz_server.network_interface[1].ip_address) + assert external_computer.ping(dmz_server.network_interface[1].ip_address) + assert external_server.ping(dmz_server.network_interface[1].ip_address) + + # there should be a route to external_computer + assert router_1.route_table.find_best_route(external_computer.network_interface[1].ip_address) + assert client_1.ping(external_computer.network_interface[1].ip_address) + assert dmz_server.ping(external_computer.network_interface[1].ip_address) + assert external_server.ping(external_computer.network_interface[1].ip_address) + + # there should be a route to external_server + assert router_1.route_table.find_best_route(external_server.network_interface[1].ip_address) + assert client_1.ping(external_server.network_interface[1].ip_address) + assert dmz_server.ping(external_server.network_interface[1].ip_address) + assert external_computer.ping(external_server.network_interface[1].ip_address) + + +def test_router_acl_rules_correctly_added(dmz_config): + """Test that makes sure that the router ACLs have been configured onto the router node via configuration file.""" + router_1: Router = dmz_config.get_node_by_hostname("router_1") + + # ICMP and ARP should be allowed + assert router_1.acl.num_rules == 2 + assert router_1.acl.acl[22].action == ACLAction.PERMIT + assert router_1.acl.acl[22].src_port == Port.ARP + assert router_1.acl.acl[22].dst_port == Port.ARP + assert router_1.acl.acl[23].action == ACLAction.PERMIT + assert router_1.acl.acl[23].protocol == IPProtocol.ICMP + assert router_1.acl.implicit_action == ACLAction.DENY diff --git a/tests/integration_tests/configuration_file_parsing/nodes/test_node_config.py b/tests/integration_tests/configuration_file_parsing/nodes/test_node_config.py new file mode 100644 index 00000000..b71c0c9c --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/nodes/test_node_config.py @@ -0,0 +1,48 @@ +from primaite.config.load import data_manipulation_config_path +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from tests.integration_tests.configuration_file_parsing import BASIC_CONFIG, DMZ_NETWORK, load_config + + +def test_example_config(): + """Test that the example config can be parsed properly.""" + game = load_config(data_manipulation_config_path()) + network: Network = game.simulation.network + + assert len(network.nodes) == 10 # 10 nodes in example network + assert len(network.router_nodes) == 1 # 1 router in network + assert len(network.switch_nodes) == 2 # 2 switches in network + assert len(network.server_nodes) == 5 # 5 servers in network + + +def test_dmz_config(): + """Test that the DMZ network config can be parsed properly.""" + game = load_config(DMZ_NETWORK) + + network: Network = game.simulation.network + + assert len(network.nodes) == 9 # 9 nodes in network + assert len(network.router_nodes) == 1 # 1 router in network + assert len(network.firewall_nodes) == 1 # 1 firewall in network + assert len(network.switch_nodes) == 3 # 3 switches in network + assert len(network.server_nodes) == 2 # 2 servers in network + + +def test_basic_config(): + """Test that the basic_switched_network config can be parsed properly.""" + game = load_config(BASIC_CONFIG) + network: Network = game.simulation.network + assert len(network.nodes) == 4 # 4 nodes in network + + client_1: Computer = network.get_node_by_hostname("client_1") + assert client_1.operating_state == NodeOperatingState.ON + client_2: Computer = network.get_node_by_hostname("client_2") + assert client_2.operating_state == NodeOperatingState.ON + + # client 3 should not be online + client_3: Computer = network.get_node_by_hostname("client_3") + assert client_3.operating_state == NodeOperatingState.OFF + + for link in network.links: + assert network.links[link].bandwidth == 200 diff --git a/tests/integration_tests/configuration_file_parsing/software_installation_and_configuration.py b/tests/integration_tests/configuration_file_parsing/software_installation_and_configuration.py new file mode 100644 index 00000000..a5fcb372 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/software_installation_and_configuration.py @@ -0,0 +1,221 @@ +from ipaddress import IPv4Address +from pathlib import Path +from typing import Union + +import yaml + +from primaite.config.load import data_manipulation_config_path +from primaite.game.agent.interface import ProxyAgent +from primaite.game.agent.scripted_agents.data_manipulation_bot import DataManipulationAgent +from primaite.game.agent.scripted_agents.probabilistic_agent import ProbabilisticAgent +from primaite.game.game import APPLICATION_TYPES_MAPPING, PrimaiteGame, SERVICE_TYPES_MAPPING +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.applications.red_applications.dos_bot import DoSBot +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.web_server.web_server import WebServer +from tests import TEST_ASSETS_ROOT + +BASIC_CONFIG = TEST_ASSETS_ROOT / "configs/basic_switched_network.yaml" + + +def load_config(config_path: Union[str, Path]) -> PrimaiteGame: + """Returns a PrimaiteGame object which loads the contents of a given yaml path.""" + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) + + return PrimaiteGame.from_config(cfg) + + +def test_example_config(): + """Test that the example config can be parsed properly.""" + game = load_config(data_manipulation_config_path()) + + assert len(game.agents) == 4 # red, blue and 2 green agents + + # green agent 1 + assert "client_2_green_user" in game.agents + assert isinstance(game.agents["client_2_green_user"], ProbabilisticAgent) + + # green agent 2 + assert "client_1_green_user" in game.agents + assert isinstance(game.agents["client_1_green_user"], ProbabilisticAgent) + + # red agent + assert "data_manipulation_attacker" in game.agents + assert isinstance(game.agents["data_manipulation_attacker"], DataManipulationAgent) + + # blue agent + assert "defender" in game.agents + assert isinstance(game.agents["defender"], ProxyAgent) + + network: Network = game.simulation.network + + assert len(network.nodes) == 10 # 10 nodes in example network + assert len(network.router_nodes) == 1 # 1 router in network + assert len(network.switch_nodes) == 2 # 2 switches in network + assert len(network.server_nodes) == 5 # 5 servers in network + + +def test_node_software_install(): + """Test that software can be installed on a node.""" + game = load_config(BASIC_CONFIG) + + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + client_2: Computer = game.simulation.network.get_node_by_hostname("client_2") + + system_software = {DNSClient, FTPClient, NTPClient, WebBrowser} + + # check that system software is installed on client 1 + for software in system_software: + assert client_1.software_manager.software.get(software.__name__) is not None + + # check that system software is installed on client 2 + for software in system_software: + assert client_2.software_manager.software.get(software.__name__) is not None + + # check that applications have been installed on client 1 + for applications in APPLICATION_TYPES_MAPPING: + assert client_1.software_manager.software.get(applications) is not None + + # check that services have been installed on client 1 + for service in SERVICE_TYPES_MAPPING: + assert client_1.software_manager.software.get(service) is not None + + +def test_web_browser_install(): + """Test that the web browser can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + web_browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + + assert web_browser.target_url == "http://arcd.com/users/" + + +def test_database_client_install(): + """Test that the Database Client service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + database_client: DatabaseClient = client_1.software_manager.software.get("DatabaseClient") + + assert database_client.server_ip_address == IPv4Address("192.168.1.10") + assert database_client.server_password == "arcd" + + +def test_data_manipulation_bot_install(): + """Test that the data manipulation bot can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + data_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + + assert data_manipulation_bot.server_ip_address == IPv4Address("192.168.1.21") + assert data_manipulation_bot.payload == "DELETE" + assert data_manipulation_bot.data_manipulation_p_of_success == 0.8 + assert data_manipulation_bot.port_scan_p_of_success == 0.8 + assert data_manipulation_bot.server_password == "arcd" + + +def test_dos_bot_install(): + """Test that the denial of service bot can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + dos_bot: DoSBot = client_1.software_manager.software.get("DoSBot") + + assert dos_bot.target_ip_address == IPv4Address("192.168.10.21") + assert dos_bot.payload == "SPOOF DATA" + assert dos_bot.port_scan_p_of_success == 0.8 + assert dos_bot.dos_intensity == 1.0 # default + assert dos_bot.max_sessions == 1000 # default + assert dos_bot.repeat is False # default + + +def test_dns_client_install(): + """Test that the DNS Client service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + dns_client: DNSClient = client_1.software_manager.software.get("DNSClient") + + assert dns_client.dns_server == IPv4Address("192.168.1.10") + + +def test_dns_server_install(): + """Test that the DNS Client service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + dns_server: DNSServer = client_1.software_manager.software.get("DNSServer") + + assert dns_server.dns_lookup("arcd.com") == IPv4Address("192.168.1.10") + + +def test_database_service_install(): + """Test that the Database Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + database_service: DatabaseService = client_1.software_manager.software.get("DatabaseService") + + assert database_service.backup_server_ip == IPv4Address("192.168.1.10") + + +def test_web_server_install(): + """Test that the Web Server Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + web_server_service: WebServer = client_1.software_manager.software.get("WebServer") + + # config should have also installed database client - web server service should be able to retrieve this + assert web_server_service.software_manager.software.get("DatabaseClient") is not None + + +def test_ftp_client_install(): + """Test that the FTP Client Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + ftp_client_service: FTPClient = client_1.software_manager.software.get("FTPClient") + assert ftp_client_service is not None + + +def test_ftp_server_install(): + """Test that the FTP Server Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + ftp_server_service: FTPServer = client_1.software_manager.software.get("FTPServer") + assert ftp_server_service is not None + assert ftp_server_service.server_password == "arcd" + + +def test_ntp_client_install(): + """Test that the NTP Client Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + ntp_client_service: NTPClient = client_1.software_manager.software.get("NTPClient") + assert ntp_client_service is not None + assert ntp_client_service.ntp_server == IPv4Address("192.168.1.10") + + +def test_ntp_server_install(): + """Test that the NTP Server Service can be configured via config.""" + game = load_config(BASIC_CONFIG) + client_1: Computer = game.simulation.network.get_node_by_hostname("client_1") + + ntp_server_service: NTPServer = client_1.software_manager.software.get("NTPServer") + assert ntp_server_service is not None diff --git a/tests/integration_tests/configuration_file_parsing/test_episode_scheduler.py b/tests/integration_tests/configuration_file_parsing/test_episode_scheduler.py new file mode 100644 index 00000000..c6fd1a2f --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/test_episode_scheduler.py @@ -0,0 +1,69 @@ +import pytest +import yaml + +from primaite.session.environment import PrimaiteGymEnv +from primaite.session.ray_envs import PrimaiteRayEnv, PrimaiteRayMARLEnv +from tests.conftest import TEST_ASSETS_ROOT + +folder_path = TEST_ASSETS_ROOT / "configs" / "scenario_with_placeholders" +single_yaml_config = TEST_ASSETS_ROOT / "configs" / "test_primaite_session.yaml" +with open(single_yaml_config, "r") as f: + config_dict = yaml.safe_load(f) + + +@pytest.mark.parametrize("env_type", [PrimaiteGymEnv, PrimaiteRayEnv, PrimaiteRayMARLEnv]) +def test_creating_env_with_folder(env_type): + """Check that the environment can be created with a folder path.""" + + def check_taking_steps(e): + if isinstance(e, PrimaiteRayMARLEnv): + for i in range(9): + e.step({k: i for k in e.game.rl_agents}) + else: + for i in range(9): + e.step(i) + + env = env_type(env_config=folder_path) + assert env is not None + for _ in range(3): # do it multiple times to ensure it loops back to the beginning + assert len(env.game.agents) == 1 + assert "defender" in env.game.agents + check_taking_steps(env) + + env.reset() + assert len(env.game.agents) == 2 + assert "defender" in env.game.agents + assert "red_A" in env.game.agents + check_taking_steps(env) + + env.reset() + assert len(env.game.agents) == 3 + assert all([a in env.game.agents for a in ["defender", "green_A", "red_A"]]) + check_taking_steps(env) + + env.reset() + assert len(env.game.agents) == 3 + assert all([a in env.game.agents for a in ["defender", "green_B", "red_B"]]) + check_taking_steps(env) + + env.reset() + + +@pytest.mark.parametrize( + "env_data, env_type", + [ + (single_yaml_config, PrimaiteGymEnv), + (single_yaml_config, PrimaiteRayEnv), + (single_yaml_config, PrimaiteRayMARLEnv), + (config_dict, PrimaiteGymEnv), + (config_dict, PrimaiteRayEnv), + (config_dict, PrimaiteRayMARLEnv), + ], +) +def test_creating_env_with_static_config(env_data, env_type): + """Check that the environment can be created with a single yaml file.""" + env = env_type(env_config=single_yaml_config) + assert env is not None + agents_before = len(env.game.agents) + env.reset() + assert len(env.game.agents) == agents_before diff --git a/tests/integration_tests/configuration_file_parsing/test_game_options_config.py b/tests/integration_tests/configuration_file_parsing/test_game_options_config.py new file mode 100644 index 00000000..adbbf2b5 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/test_game_options_config.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Union + +import yaml + +from primaite.config.load import data_manipulation_config_path +from primaite.game.game import PrimaiteGame +from tests import TEST_ASSETS_ROOT + +BASIC_CONFIG = TEST_ASSETS_ROOT / "configs/basic_switched_network.yaml" + + +def load_config(config_path: Union[str, Path]) -> PrimaiteGame: + """Returns a PrimaiteGame object which loads the contents of a given yaml path.""" + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) + + return PrimaiteGame.from_config(cfg) + + +def test_thresholds(): + """Test that the game options can be parsed correctly.""" + game = load_config(data_manipulation_config_path()) + + assert game.options.thresholds is not None diff --git a/tests/integration_tests/configuration_file_parsing/test_io_settings.py b/tests/integration_tests/configuration_file_parsing/test_io_settings.py new file mode 100644 index 00000000..21f56e97 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/test_io_settings.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Union + +import yaml + +from primaite.config.load import data_manipulation_config_path +from primaite.game.game import PrimaiteGame +from primaite.session.environment import PrimaiteGymEnv +from primaite.simulator import LogLevel +from tests import TEST_ASSETS_ROOT + +BASIC_CONFIG = TEST_ASSETS_ROOT / "configs/basic_switched_network.yaml" + + +def load_config(config_path: Union[str, Path]) -> PrimaiteGame: + """Returns a PrimaiteGame object which loads the contents of a given yaml path.""" + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) + + return PrimaiteGame.from_config(cfg) + + +def test_io_settings(): + """Test that the io_settings are loaded correctly.""" + with open(BASIC_CONFIG, "r") as f: + cfg = yaml.safe_load(f) + env = PrimaiteGymEnv(env_config=cfg) + + assert env.io.settings is not None + + assert env.io.settings.sys_log_level is LogLevel.WARNING + assert env.io.settings.save_pcap_logs + assert env.io.settings.save_sys_logs + assert env.io.settings.save_step_metadata is False + + assert env.io.settings.write_sys_log_to_terminal is False # false by default diff --git a/tests/integration_tests/configuration_file_parsing/test_no_nodes_links_agents_config.py b/tests/integration_tests/configuration_file_parsing/test_no_nodes_links_agents_config.py new file mode 100644 index 00000000..5c9b0cb9 --- /dev/null +++ b/tests/integration_tests/configuration_file_parsing/test_no_nodes_links_agents_config.py @@ -0,0 +1,19 @@ +import yaml + +from primaite.game.game import PrimaiteGame +from tests import TEST_ASSETS_ROOT + +CONFIG_FILE = TEST_ASSETS_ROOT / "configs" / "no_nodes_links_agents_network.yaml" + + +def test_no_nodes_links_agents_config(): + """Tests PrimaiteGame can be created from config file where there are no nodes, links, agents in the config file.""" + with open(CONFIG_FILE, "r") as f: + cfg = yaml.safe_load(f) + + game = PrimaiteGame.from_config(cfg) + + network = game.simulation.network + + assert len(network.nodes) == 0 + assert len(network.links) == 0 diff --git a/tests/integration_tests/game_layer/observations/__init__.py b/tests/integration_tests/game_layer/observations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/game_layer/observations/test_acl_observations.py b/tests/integration_tests/game_layer/observations/test_acl_observations.py new file mode 100644 index 00000000..5aa2ec2a --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_acl_observations.py @@ -0,0 +1,68 @@ +import pytest + +from primaite.game.agent.observations.acl_observation import ACLObservation +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer + + +@pytest.fixture(scope="function") +def simulation(example_network) -> Simulation: + sim = Simulation() + + # set simulation network as example network + sim.network = example_network + + return sim + + +def test_acl_observations(simulation): + """Test the ACL rule observations.""" + router: Router = simulation.network.get_node_by_hostname("router_1") + client_1: Computer = simulation.network.get_node_by_hostname("client_1") + server: Computer = simulation.network.get_node_by_hostname("server_1") + + # quick set up of ntp + client_1.software_manager.install(NTPClient) + ntp_client: NTPClient = client_1.software_manager.software.get("NTPClient") + ntp_client.configure(server.network_interface.get(1).ip_address) + server.software_manager.install(NTPServer) + + # add router acl rule + router.acl.add_rule(action=ACLAction.PERMIT, dst_port=Port.NTP, src_port=Port.NTP, position=1) + + acl_obs = ACLObservation( + where=["network", "nodes", router.hostname, "acl", "acl"], + ip_list=[], + port_list=["NTP", "HTTP", "POSTGRES_SERVER"], + protocol_list=["TCP", "UDP", "ICMP"], + num_rules=10, + wildcard_list=[], + ) + + observation_space = acl_obs.observe(simulation.describe_state()) + assert observation_space.get(1) is not None + rule_obs = observation_space.get(1) # this is the ACL Rule added to allow NTP + assert rule_obs.get("position") == 0 # rule was put at position 1 (0 because counting from 1 instead of 1) + assert rule_obs.get("permission") == 1 # permit = 1 deny = 2 + assert rule_obs.get("source_ip_id") == 1 # applies to all source nodes + assert rule_obs.get("dest_ip_id") == 1 # applies to all destination nodes + assert rule_obs.get("source_port_id") == 2 # NTP port is mapped to value 2 (1 = ALL, so 1+1 = 2 quik mafs) + assert rule_obs.get("dest_port_id") == 2 # NTP port is mapped to value 2 + assert rule_obs.get("protocol_id") == 1 # 1 = No Protocol + + router.acl.remove_rule(1) + + observation_space = acl_obs.observe(simulation.describe_state()) + assert observation_space.get(1) is not None + rule_obs = observation_space.get(1) # this is the ACL Rule added to allow NTP + assert rule_obs.get("position") == 0 + assert rule_obs.get("permission") == 0 + assert rule_obs.get("source_ip_id") == 0 + assert rule_obs.get("dest_ip_id") == 0 + assert rule_obs.get("source_port_id") == 0 + assert rule_obs.get("dest_port_id") == 0 + assert rule_obs.get("protocol_id") == 0 diff --git a/tests/integration_tests/game_layer/observations/test_file_system_observations.py b/tests/integration_tests/game_layer/observations/test_file_system_observations.py new file mode 100644 index 00000000..cb83ac5e --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_file_system_observations.py @@ -0,0 +1,77 @@ +import pytest +from gymnasium import spaces + +from primaite.game.agent.observations.file_system_observations import FileObservation, FolderObservation +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.sim_container import Simulation + + +@pytest.fixture(scope="function") +def simulation(example_network) -> Simulation: + sim = Simulation() + + # set simulation network as example network + sim.network = example_network + + return sim + + +def test_file_observation(simulation): + """Test the file observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + # create a file on the pc + file = pc.file_system.create_file(file_name="dog.png") + + dog_file_obs = FileObservation( + where=["network", "nodes", pc.hostname, "file_system", "folders", "root", "files", "dog.png"], + include_num_access=False, + ) + + assert dog_file_obs.space["health_status"] == spaces.Discrete(6) + + observation_state = dog_file_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 1 # good initial + + file.corrupt() + observation_state = dog_file_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 1 # scan file so this changes + + file.scan() + file.apply_timestep(0) # apply time step + observation_state = dog_file_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 3 # corrupted + + +def test_folder_observation(simulation): + """Test the folder observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + # create a file and folder on the pc + folder = pc.file_system.create_folder("test_folder") + file = pc.file_system.create_file(file_name="dog.png", folder_name="test_folder") + + root_folder_obs = FolderObservation( + where=["network", "nodes", pc.hostname, "file_system", "folders", "test_folder"], + include_num_access=False, + num_files=1, + files=[], + ) + + assert root_folder_obs.space["health_status"] == spaces.Discrete(6) + + observation_state = root_folder_obs.observe(simulation.describe_state()) + assert observation_state.get("FILES") is not None + assert observation_state.get("health_status") == 1 + + file.corrupt() # corrupt just the file + observation_state = root_folder_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 1 # scan folder to change this + + folder.scan() + for i in range(folder.scan_duration + 1): + folder.apply_timestep(i) # apply as many timesteps as needed for a scan + + observation_state = root_folder_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 3 # file is corrupt therefore folder is corrupted too + + +# TODO: Add tests to check num access is correct. diff --git a/tests/integration_tests/game_layer/observations/test_firewall_observation.py b/tests/integration_tests/game_layer/observations/test_firewall_observation.py new file mode 100644 index 00000000..959e30f6 --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_firewall_observation.py @@ -0,0 +1,128 @@ +from primaite.game.agent.observations.firewall_observation import FirewallObservation +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.firewall import Firewall +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def check_default_rules(acl_obs): + assert len(acl_obs) == 7 + assert all(acl_obs[i]["position"] == i - 1 for i in range(1, 8)) + assert all(acl_obs[i]["permission"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["source_ip_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["source_wildcard_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["source_port_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["dest_ip_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["dest_wildcard_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["dest_port_id"] == 0 for i in range(1, 8)) + assert all(acl_obs[i]["protocol_id"] == 0 for i in range(1, 8)) + + +def test_firewall_observation(): + """Test adding/removing acl rules and enabling/disabling ports.""" + net = Network() + firewall = Firewall(hostname="firewall", operating_state=NodeOperatingState.ON) + firewall_observation = FirewallObservation( + where=[], + num_rules=7, + ip_list=["10.0.0.1", "10.0.0.2"], + wildcard_list=["0.0.0.255", "0.0.0.1"], + port_list=["HTTP", "DNS"], + protocol_list=["TCP"], + ) + + observation = firewall_observation.observe(firewall.describe_state()) + assert "ACL" in observation + assert "PORTS" in observation + assert "INTERNAL" in observation["ACL"] + assert "EXTERNAL" in observation["ACL"] + assert "DMZ" in observation["ACL"] + assert "INBOUND" in observation["ACL"]["INTERNAL"] + assert "OUTBOUND" in observation["ACL"]["INTERNAL"] + assert "INBOUND" in observation["ACL"]["EXTERNAL"] + assert "OUTBOUND" in observation["ACL"]["EXTERNAL"] + assert "INBOUND" in observation["ACL"]["DMZ"] + assert "OUTBOUND" in observation["ACL"]["DMZ"] + all_acls = ( + observation["ACL"]["INTERNAL"]["INBOUND"], + observation["ACL"]["INTERNAL"]["OUTBOUND"], + observation["ACL"]["EXTERNAL"]["INBOUND"], + observation["ACL"]["EXTERNAL"]["OUTBOUND"], + observation["ACL"]["DMZ"]["INBOUND"], + observation["ACL"]["DMZ"]["OUTBOUND"], + ) + for acl_obs in all_acls: + check_default_rules(acl_obs) + + # add a rule to the internal inbound and check that the observation is correct + firewall.internal_inbound_acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip_address="10.0.0.1", + src_wildcard_mask="0.0.0.1", + dst_ip_address="10.0.0.2", + dst_wildcard_mask="0.0.0.1", + src_port=Port.HTTP, + dst_port=Port.HTTP, + position=5, + ) + + observation = firewall_observation.observe(firewall.describe_state()) + observed_rule = observation["ACL"]["INTERNAL"]["INBOUND"][5] + assert observed_rule["position"] == 4 + assert observed_rule["permission"] == 2 + assert observed_rule["source_ip_id"] == 2 + assert observed_rule["source_wildcard_id"] == 3 + assert observed_rule["source_port_id"] == 2 + assert observed_rule["dest_ip_id"] == 3 + assert observed_rule["dest_wildcard_id"] == 3 + assert observed_rule["dest_port_id"] == 2 + assert observed_rule["protocol_id"] == 2 + + # check that none of the other acls have changed + all_acls = ( + observation["ACL"]["INTERNAL"]["OUTBOUND"], + observation["ACL"]["EXTERNAL"]["INBOUND"], + observation["ACL"]["EXTERNAL"]["OUTBOUND"], + observation["ACL"]["DMZ"]["INBOUND"], + observation["ACL"]["DMZ"]["OUTBOUND"], + ) + for acl_obs in all_acls: + check_default_rules(acl_obs) + + # remove the rule and check that the observation is correct + firewall.internal_inbound_acl.remove_rule(5) + observation = firewall_observation.observe(firewall.describe_state()) + all_acls = ( + observation["ACL"]["INTERNAL"]["INBOUND"], + observation["ACL"]["INTERNAL"]["OUTBOUND"], + observation["ACL"]["EXTERNAL"]["INBOUND"], + observation["ACL"]["EXTERNAL"]["OUTBOUND"], + observation["ACL"]["DMZ"]["INBOUND"], + observation["ACL"]["DMZ"]["OUTBOUND"], + ) + for acl_obs in all_acls: + check_default_rules(acl_obs) + + # check that there are three ports in the observation + assert len(observation["PORTS"]) == 3 + + # check that the ports are all disabled + assert all(observation["PORTS"][i]["operating_status"] == 2 for i in range(1, 4)) + + # connect a switch to the firewall and check that only the correct port is updated + switch = Switch(hostname="switch", num_ports=1, operating_state=NodeOperatingState.ON) + link = net.connect(firewall.network_interface[1], switch.network_interface[1]) + assert firewall.network_interface[1].enabled + observation = firewall_observation.observe(firewall.describe_state()) + assert observation["PORTS"][1]["operating_status"] == 1 + assert all(observation["PORTS"][i]["operating_status"] == 2 for i in range(2, 4)) + + # disable the port and check that the operating status is updated + firewall.network_interface[1].disable() + assert not firewall.network_interface[1].enabled + observation = firewall_observation.observe(firewall.describe_state()) + assert all(observation["PORTS"][i]["operating_status"] == 2 for i in range(1, 4)) diff --git a/tests/integration_tests/game_layer/observations/test_link_observations.py b/tests/integration_tests/game_layer/observations/test_link_observations.py new file mode 100644 index 00000000..dce7b23d --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_link_observations.py @@ -0,0 +1,94 @@ +import pytest +from gymnasium import spaces + +from primaite.game.agent.observations.link_observation import LinkObservation +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import Link, Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.sim_container import Simulation + + +@pytest.fixture(scope="function") +def simulation() -> Simulation: + sim = Simulation() + + network = Network() + + # Create Computer + computer = Computer( + hostname="computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + + # Create Server + server = Server( + hostname="server", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server.power_on() + + # Connect Computer and Server + network.connect(computer.network_interface[1], server.network_interface[1]) + + # Should be linked + assert next(iter(network.links.values())).is_up + + assert computer.ping(server.network_interface.get(1).ip_address) + + # set simulation network as example network + sim.network = network + + return sim + + +def test_link_observation(): + """Check the shape and contents of the link observation.""" + net = Network() + sim = Simulation(network=net) + switch = Switch(hostname="switch", num_ports=5, operating_state=NodeOperatingState.ON) + computer_1 = Computer( + hostname="computer_1", ip_address="10.0.0.1", subnet_mask="255.255.255.0", start_up_duration=0 + ) + computer_2 = Computer( + hostname="computer_2", ip_address="10.0.0.2", subnet_mask="255.255.255.0", start_up_duration=0 + ) + computer_1.power_on() + computer_2.power_on() + link_1 = net.connect(switch.network_interface[1], computer_1.network_interface[1]) + link_2 = net.connect(switch.network_interface[2], computer_2.network_interface[1]) + assert link_1 is not None + assert link_2 is not None + + link_1_observation = LinkObservation(where=["network", "links", "switch:eth-1<->computer_1:eth-1"]) + link_2_observation = LinkObservation(where=["network", "links", "switch:eth-2<->computer_2:eth-1"]) + + state = sim.describe_state() + link_1_obs = link_1_observation.observe(state) + link_2_obs = link_2_observation.observe(state) + assert "PROTOCOLS" in link_1_obs + assert "PROTOCOLS" in link_2_obs + assert "ALL" in link_1_obs["PROTOCOLS"] + assert "ALL" in link_2_obs["PROTOCOLS"] + assert link_1_observation.space["PROTOCOLS"]["ALL"] == spaces.Discrete(11) + assert link_2_observation.space["PROTOCOLS"]["ALL"] == spaces.Discrete(11) + assert link_1_obs["PROTOCOLS"]["ALL"] == 0 + assert link_2_obs["PROTOCOLS"]["ALL"] == 0 + + # Test that the link observation is updated when a packet is sent + computer_1.ping("10.0.0.2") + computer_2.ping("10.0.0.1") + state = sim.describe_state() + link_1_obs = link_1_observation.observe(state) + link_2_obs = link_2_observation.observe(state) + assert link_1_obs["PROTOCOLS"]["ALL"] > 0 + assert link_2_obs["PROTOCOLS"]["ALL"] > 0 diff --git a/tests/integration_tests/game_layer/observations/test_nic_observations.py b/tests/integration_tests/game_layer/observations/test_nic_observations.py new file mode 100644 index 00000000..66b7df55 --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_nic_observations.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import Union + +import pytest +import yaml +from gymnasium import spaces + +from primaite.game.agent.observations.nic_observations import NICObservation +from primaite.game.game import PrimaiteGame +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.nmne import CAPTURE_NMNE +from primaite.simulator.sim_container import Simulation +from tests import TEST_ASSETS_ROOT + +BASIC_CONFIG = TEST_ASSETS_ROOT / "configs/basic_switched_network.yaml" + + +def load_config(config_path: Union[str, Path]) -> PrimaiteGame: + """Returns a PrimaiteGame object which loads the contents of a given yaml path.""" + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) + + return PrimaiteGame.from_config(cfg) + + +@pytest.fixture(scope="function") +def simulation(example_network) -> Simulation: + sim = Simulation() + + # set simulation network as example network + sim.network = example_network + + return sim + + +def test_nic(simulation): + """Test the NIC observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + + nic: NIC = pc.network_interface[1] + + nic_obs = NICObservation(where=["network", "nodes", pc.hostname, "NICs", 1], include_nmne=True) + + assert nic_obs.space["nic_status"] == spaces.Discrete(3) + assert nic_obs.space["NMNE"]["inbound"] == spaces.Discrete(4) + assert nic_obs.space["NMNE"]["outbound"] == spaces.Discrete(4) + + observation_state = nic_obs.observe(simulation.describe_state()) + assert observation_state.get("nic_status") == 1 # enabled + assert observation_state.get("NMNE") is not None + assert observation_state["NMNE"].get("inbound") == 0 + assert observation_state["NMNE"].get("outbound") == 0 + + nic.disable() + observation_state = nic_obs.observe(simulation.describe_state()) + assert observation_state.get("nic_status") == 2 # disabled + + +def test_nic_categories(simulation): + """Test the NIC observation nmne count categories.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + + nic_obs = NICObservation(where=["network", "nodes", pc.hostname, "NICs", 1], include_nmne=True) + + assert nic_obs.high_nmne_threshold == 10 # default + assert nic_obs.med_nmne_threshold == 5 # default + assert nic_obs.low_nmne_threshold == 0 # default + + +@pytest.mark.skip(reason="Feature not implemented yet") +def test_config_nic_categories(simulation): + pc: Computer = simulation.network.get_node_by_hostname("client_1") + nic_obs = NICObservation( + where=["network", "nodes", pc.hostname, "NICs", 1], + low_nmne_threshold=3, + med_nmne_threshold=6, + high_nmne_threshold=9, + include_nmne=True, + ) + + assert nic_obs.high_nmne_threshold == 9 + assert nic_obs.med_nmne_threshold == 6 + assert nic_obs.low_nmne_threshold == 3 + + with pytest.raises(Exception): + # should throw an error + NICObservation( + where=["network", "nodes", pc.hostname, "NICs", 1], + low_nmne_threshold=9, + med_nmne_threshold=6, + high_nmne_threshold=9, + include_nmne=True, + ) + + with pytest.raises(Exception): + # should throw an error + NICObservation( + where=["network", "nodes", pc.hostname, "NICs", 1], + low_nmne_threshold=3, + med_nmne_threshold=9, + high_nmne_threshold=9, + include_nmne=True, + ) diff --git a/tests/integration_tests/game_layer/observations/test_node_observations.py b/tests/integration_tests/game_layer/observations/test_node_observations.py new file mode 100644 index 00000000..458cf0ab --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_node_observations.py @@ -0,0 +1,59 @@ +import copy +from uuid import uuid4 + +import pytest +from gymnasium import spaces + +from primaite.game.agent.observations.host_observations import HostObservation +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.sim_container import Simulation + + +@pytest.fixture(scope="function") +def simulation(example_network) -> Simulation: + sim = Simulation() + + # set simulation network as example network + sim.network = example_network + + return sim + + +def test_host_observation(simulation): + """Test a Host observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + + host_obs = HostObservation( + where=["network", "nodes", pc.hostname], + num_applications=0, + num_files=1, + num_folders=1, + num_nics=2, + num_services=1, + include_num_access=False, + include_nmne=False, + services=[], + applications=[], + folders=[], + network_interfaces=[], + ) + + assert host_obs.space["operating_status"] == spaces.Discrete(5) + + observation_state = host_obs.observe(simulation.describe_state()) + assert observation_state.get("operating_status") == 1 # computer is on + + assert observation_state.get("SERVICES") is not None + assert observation_state.get("FOLDERS") is not None + assert observation_state.get("NICS") is not None + + # turn off computer + pc.power_off() + observation_state = host_obs.observe(simulation.describe_state()) + assert observation_state.get("operating_status") == 4 # shutting down + + for i in range(pc.shut_down_duration + 1): + pc.apply_timestep(i) + + observation_state = host_obs.observe(simulation.describe_state()) + assert observation_state.get("operating_status") == 2 diff --git a/tests/integration_tests/game_layer/observations/test_router_observation.py b/tests/integration_tests/game_layer/observations/test_router_observation.py new file mode 100644 index 00000000..55471676 --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_router_observation.py @@ -0,0 +1,108 @@ +from pprint import pprint + +from primaite.game.agent.observations.acl_observation import ACLObservation +from primaite.game.agent.observations.nic_observations import PortObservation +from primaite.game.agent.observations.router_observation import RouterObservation +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.sim_container import Simulation + + +def test_router_observation(): + """Test adding/removing acl rules and enabling/disabling ports.""" + net = Network() + router = Router(hostname="router", num_ports=5, operating_state=NodeOperatingState.ON) + + ports = [PortObservation(where=["NICs", i]) for i in range(1, 6)] + acl = ACLObservation( + where=["acl", "acl"], + num_rules=7, + ip_list=["10.0.0.1", "10.0.0.2"], + wildcard_list=["0.0.0.255", "0.0.0.1"], + port_list=["HTTP", "DNS"], + protocol_list=["TCP"], + ) + router_observation = RouterObservation(where=[], ports=ports, num_ports=8, acl=acl) + + # Observe the state using the RouterObservation instance + observed_output = router_observation.observe(router.describe_state()) + + # Check that the right number of ports and acls are in the router observation + assert len(observed_output["PORTS"]) == 8 + assert len(observed_output["ACL"]) == 7 + + # Add an ACL rule to the router + router.acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip_address="10.0.0.1", + src_wildcard_mask="0.0.0.1", + dst_ip_address="10.0.0.2", + dst_wildcard_mask="0.0.0.1", + src_port=Port.HTTP, + dst_port=Port.HTTP, + position=5, + ) + # Observe the state using the RouterObservation instance + observed_output = router_observation.observe(router.describe_state()) + observed_rule = observed_output["ACL"][5] + assert observed_rule["position"] == 4 + assert observed_rule["permission"] == 2 + assert observed_rule["source_ip_id"] == 2 + assert observed_rule["source_wildcard_id"] == 3 + assert observed_rule["source_port_id"] == 2 + assert observed_rule["dest_ip_id"] == 3 + assert observed_rule["dest_wildcard_id"] == 3 + assert observed_rule["dest_port_id"] == 2 + assert observed_rule["protocol_id"] == 2 + + # Add an ACL rule with ALL/NONE values and check that the observation is correct + router.acl.add_rule( + action=ACLAction.PERMIT, + protocol=None, + src_ip_address=None, + src_wildcard_mask=None, + dst_ip_address=None, + dst_wildcard_mask=None, + src_port=None, + dst_port=None, + position=2, + ) + observed_output = router_observation.observe(router.describe_state()) + observed_rule = observed_output["ACL"][2] + assert observed_rule["position"] == 1 + assert observed_rule["permission"] == 1 + assert observed_rule["source_ip_id"] == 1 + assert observed_rule["source_wildcard_id"] == 1 + assert observed_rule["source_port_id"] == 1 + assert observed_rule["dest_ip_id"] == 1 + assert observed_rule["dest_wildcard_id"] == 1 + assert observed_rule["dest_port_id"] == 1 + assert observed_rule["protocol_id"] == 1 + + # Check that the router ports are all disabled + assert all(observed_output["PORTS"][i]["operating_status"] == 2 for i in range(1, 6)) + + # connect a switch to the router and check that only the correct port is updated + switch = Switch(hostname="switch", num_ports=1, operating_state=NodeOperatingState.ON) + link = net.connect(router.network_interface[1], switch.network_interface[1]) + assert router.network_interface[1].enabled + observed_output = router_observation.observe(router.describe_state()) + assert observed_output["PORTS"][1]["operating_status"] == 1 + assert all(observed_output["PORTS"][i]["operating_status"] == 2 for i in range(2, 6)) + + # disable the port and check that the operating status is updated + router.network_interface[1].disable() + assert not router.network_interface[1].enabled + observed_output = router_observation.observe(router.describe_state()) + assert all(observed_output["PORTS"][i]["operating_status"] == 2 for i in range(1, 6)) + + # Check that ports that are out of range are shown as unused + observed_output = router_observation.observe(router.describe_state()) + assert observed_output["PORTS"][6]["operating_status"] == 0 + assert observed_output["PORTS"][7]["operating_status"] == 0 + assert observed_output["PORTS"][8]["operating_status"] == 0 diff --git a/tests/integration_tests/game_layer/observations/test_software_observations.py b/tests/integration_tests/game_layer/observations/test_software_observations.py new file mode 100644 index 00000000..4ae0701e --- /dev/null +++ b/tests/integration_tests/game_layer/observations/test_software_observations.py @@ -0,0 +1,70 @@ +import pytest +from gymnasium import spaces + +from primaite.game.agent.observations.software_observation import ApplicationObservation, ServiceObservation +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.ntp.ntp_server import NTPServer + + +@pytest.fixture(scope="function") +def simulation(example_network) -> Simulation: + sim = Simulation() + + # set simulation network as example network + sim.network = example_network + + return sim + + +def test_service_observation(simulation): + """Test the service observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + # install software on the computer + pc.software_manager.install(NTPServer) + + ntp_server = pc.software_manager.software.get("NTPServer") + assert ntp_server + + service_obs = ServiceObservation(where=["network", "nodes", pc.hostname, "services", "NTPServer"]) + + assert service_obs.space["operating_status"] == spaces.Discrete(7) + assert service_obs.space["health_status"] == spaces.Discrete(5) + + observation_state = service_obs.observe(simulation.describe_state()) + + assert observation_state.get("health_status") == 0 + assert observation_state.get("operating_status") == 1 # running + + ntp_server.restart() + observation_state = service_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 0 + assert observation_state.get("operating_status") == 6 # resetting + + +def test_application_observation(simulation): + """Test the application observation.""" + pc: Computer = simulation.network.get_node_by_hostname("client_1") + # install software on the computer + pc.software_manager.install(DatabaseClient) + + web_browser: WebBrowser = pc.software_manager.software.get("WebBrowser") + assert web_browser + + app_obs = ApplicationObservation(where=["network", "nodes", pc.hostname, "applications", "WebBrowser"]) + + web_browser.close() + observation_state = app_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 0 + assert observation_state.get("operating_status") == 2 # stopped + assert observation_state.get("num_executions") == 0 + + web_browser.run() + web_browser.scan() # scan to update health status + web_browser.get_webpage("test") + observation_state = app_obs.observe(simulation.describe_state()) + assert observation_state.get("health_status") == 1 + assert observation_state.get("operating_status") == 1 # running + assert observation_state.get("num_executions") == 1 diff --git a/tests/integration_tests/game_layer/test_actions.py b/tests/integration_tests/game_layer/test_actions.py new file mode 100644 index 00000000..43987c38 --- /dev/null +++ b/tests/integration_tests/game_layer/test_actions.py @@ -0,0 +1,682 @@ +# Plan for creating integration tests for the actions: +# I need to test that the requests coming out of the actions have the intended effect on the simulation. +# I can do this by creating a simulation, and then running the action on the simulation, and then checking +# the state of the simulation. + +# Steps for creating the integration tests: +# 1. Create a fixture which creates a simulation. +# 2. Create a fixture which creates a game, including a simple agent with some actions. +# 3. Get the agent to perform an action of my choosing. +# 4. Check that the simulation has changed in the way that I expect. +# 5. Repeat for all actions. + +from ipaddress import IPv4Address +from typing import Tuple + +import pytest +import yaml + +from primaite.game.agent.interface import ProxyAgent +from primaite.game.game import PrimaiteGame +from primaite.session.environment import PrimaiteGymEnv +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.software import SoftwareHealthState +from tests import TEST_ASSETS_ROOT + +FIREWALL_ACTIONS_NETWORK = TEST_ASSETS_ROOT / "configs/firewall_actions_network.yaml" + + +def test_do_nothing_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the DoNothingAction can form a request and that it is accepted by the simulation.""" + game, agent = game_and_agent + + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + + +def test_node_service_scan_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """ + Test that the NodeServiceScanAction can form a request and that it is accepted by the simulation. + + The health status of applications is not always updated in the state dict, rather the agent needs to perform a scan. + Therefore, we set a service to be compromised, check the state is still good, then perform a scan, and check + that the state changes to the true value. + """ + game, agent = game_and_agent + + # 1: Check that the service starts off in a good state, and that visible state is hidden until first scan + svc = game.simulation.network.get_node_by_hostname("server_1").software_manager.software.get("DNSServer") + assert svc.health_state_actual == SoftwareHealthState.GOOD + assert svc.health_state_visible == SoftwareHealthState.UNUSED + + # 2: Scan and check that the visible state is now correct + action = ("NODE_SERVICE_SCAN", {"node_id": 1, "service_id": 0}) + agent.store_action(action) + game.step() + assert svc.health_state_actual == SoftwareHealthState.GOOD + assert svc.health_state_visible == SoftwareHealthState.GOOD + + # 3: Corrupt the service and check that the visible state is still good + svc.health_state_actual = SoftwareHealthState.COMPROMISED + assert svc.health_state_visible == SoftwareHealthState.GOOD + + # 4: Scan and check that the visible state is now correct + action = ("NODE_SERVICE_SCAN", {"node_id": 1, "service_id": 0}) + agent.store_action(action) + game.step() + assert svc.health_state_actual == SoftwareHealthState.COMPROMISED + assert svc.health_state_visible == SoftwareHealthState.COMPROMISED + + +def test_node_service_fix_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """ + Test that the NodeServiceFixAction can form a request and that it is accepted by the simulation. + + When you initiate a patch action, the software health state turns to FIXING, then after a few steps, it goes + to GOOD. + """ + game, agent = game_and_agent + + # 1: Corrupt the service + svc = game.simulation.network.get_node_by_hostname("server_1").software_manager.software.get("DNSServer") + svc.health_state_actual = SoftwareHealthState.COMPROMISED + + # 2: Apply a patch action + action = ("NODE_SERVICE_FIX", {"node_id": 1, "service_id": 0}) + agent.store_action(action) + game.step() + + # 3: Check that the service is now in the FIXING state + assert svc.health_state_actual == SoftwareHealthState.FIXING + + # 4: perform a few do-nothing steps and check that the service is now in the good state + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + assert svc.health_state_actual == SoftwareHealthState.GOOD + + +def test_router_acl_addrule_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """ + Test that the RouterACLAddRuleAction can form a request and that it is accepted by the simulation. + + The ACL starts off with 4 rules, and we add a rule, and check that the ACL now has 5 rules. + """ + game, agent = game_and_agent + + # 1: Check that traffic is normal and acl starts off with 4 rules. + client_1 = game.simulation.network.get_node_by_hostname("client_1") + server_1 = game.simulation.network.get_node_by_hostname("server_1") + server_2 = game.simulation.network.get_node_by_hostname("server_2") + router = game.simulation.network.get_node_by_hostname("router") + assert router.acl.num_rules == 4 + assert client_1.ping("10.0.2.3") # client_1 can ping server_2 + assert server_2.ping("10.0.1.2") # server_2 can ping client_1 + + # 2: Add a rule to block client 1 from reaching server 2 on router + action = ( + "ROUTER_ACL_ADDRULE", + { + "target_router_nodename": "router", + "position": 4, # 4th rule + "permission": 2, # DENY + "source_ip_id": 3, # 10.0.1.2 (client_1) + "dest_ip_id": 6, # 10.0.2.3 (server_2) + "dest_port_id": 1, # ALL + "source_port_id": 1, # ALL + "protocol_id": 1, # ALL + "source_wildcard_id": 0, + "dest_wildcard_id": 0, + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the ACL now has 5 rules, and that client 1 cannot ping server 2 + assert router.acl.num_rules == 5 + assert not client_1.ping("10.0.2.3") # Cannot ping server_2 + assert client_1.ping("10.0.2.2") # Can ping server_1 + assert not server_2.ping( + "10.0.1.2" + ) # Server 2 can't ping client_1 (although rule is one-way, the ping response is blocked) + + # 4: Add a rule to block server_1 from reaching server_2 on router (this should not affect comms as they are on same subnet) + action = ( + "ROUTER_ACL_ADDRULE", + { + "target_router_nodename": "router", + "position": 5, # 5th rule + "permission": 2, # DENY + "source_ip_id": 5, # 10.0.2.2 (server_1) + "dest_ip_id": 6, # 10.0.2.3 (server_2) + "dest_port_id": 1, # ALL + "source_port_id": 1, # ALL + "protocol_id": 1, # ALL + "source_wildcard_id": 0, + "dest_wildcard_id": 0, + }, + ) + agent.store_action(action) + game.step() + + # 5: Check that the ACL now has 6 rules, but that server_1 can still ping server_2 + assert router.acl.num_rules == 6 + assert server_1.ping("10.0.2.3") # Can ping server_2 + + +def test_router_acl_removerule_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the RouterACLRemoveRuleAction can form a request and that it is accepted by the simulation.""" + game, agent = game_and_agent + + # 1: Check that http traffic is going across the network nicely. + client_1 = game.simulation.network.get_node_by_hostname("client_1") + server_1 = game.simulation.network.get_node_by_hostname("server_1") + router = game.simulation.network.get_node_by_hostname("router") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.run() + browser.target_url = "http://www.example.com" + assert browser.get_webpage() # check that the browser can access example.com before we block it + + # 2: Remove rule that allows HTTP traffic across the network + action = ( + "ROUTER_ACL_REMOVERULE", + { + "target_router_nodename": "router", + "position": 3, # 4th rule + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the ACL now has 3 rules, and that client 1 cannot access example.com + assert router.acl.num_rules == 3 + assert not browser.get_webpage() + client_1.software_manager.software.get("DNSClient").dns_cache.clear() + assert client_1.ping("10.0.2.2") # pinging still works because ICMP is allowed + assert client_1.ping("10.0.2.3") + + +def test_host_nic_disable_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the HostNICDisableAction can form a request and that it is accepted by the simulation.""" + game, agent = game_and_agent + + # 1: Check that client_1 can access the network + client_1 = game.simulation.network.get_node_by_hostname("client_1") + server_1 = game.simulation.network.get_node_by_hostname("server_1") + server_2 = game.simulation.network.get_node_by_hostname("server_2") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.run() + browser.target_url = "http://www.example.com" + assert browser.get_webpage() # check that the browser can access example.com before we block it + + # 2: Disable the NIC on client_1 + action = ( + "HOST_NIC_DISABLE", + { + "node_id": 0, # client_1 + "nic_id": 0, # the only nic (eth-1) + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the NIC is disabled, and that client 1 cannot access example.com + assert client_1.network_interface[1].enabled == False + assert not browser.get_webpage() + assert not client_1.ping("10.0.2.2") + assert not client_1.ping("10.0.2.3") + + # 4: check that servers can still communicate + assert server_1.ping("10.0.2.3") + + +def test_host_nic_enable_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the HostNICEnableAction can form a request and that it is accepted by the simulation.""" + + game, agent = game_and_agent + + # 1: Disable client_1 nic + client_1 = game.simulation.network.get_node_by_hostname("client_1") + client_1.network_interface[1].disable() + assert not client_1.ping("10.0.2.2") + + # 2: Use action to enable nic + action = ( + "HOST_NIC_ENABLE", + { + "node_id": 0, # client_1 + "nic_id": 0, # the only nic (eth-1) + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the NIC is enabled, and that client 1 can ping again + assert client_1.network_interface[1].enabled == True + assert client_1.ping("10.0.2.3") + + +def test_node_file_scan_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that a when a file is scanned, it's visible health status gets set to the actual health status.""" + + game, agent = game_and_agent + + # 1: assert file is healthy + client_1 = game.simulation.network.get_node_by_hostname("client_1") + file = client_1.file_system.get_file("downloads", "cat.png") + assert file.health_status == FileSystemItemHealthStatus.GOOD + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + # 2: perform a scan and make sure nothing has changed + action = ( + "NODE_FILE_SCAN", + { + "node_id": 0, # client_1, + "folder_id": 0, # downloads, + "file_id": 0, # cat.png + }, + ) + agent.store_action(action) + game.step() + + assert file.health_status == FileSystemItemHealthStatus.GOOD + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + # 3: Set the file to corrupted, and check that only actual updates, not visible. + file.health_status = FileSystemItemHealthStatus.CORRUPT + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + # 4: Perform a scan and check that it updates + agent.store_action(action) + game.step() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_node_file_delete_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that a file can be deleted by the agent.""" + game, agent = game_and_agent + + # 1: assert the file is there + client_1 = game.simulation.network.get_node_by_hostname("client_1") + file = client_1.file_system.get_file("downloads", "cat.png") + assert file is not None + assert not file.deleted + + # 2: delete the file + action = ( + "NODE_FILE_DELETE", + { + "node_id": 0, # client_1 + "folder_id": 0, # downloads + "file_id": 0, # cat.png + }, + ) + agent.store_action(action) + game.step() + + # 3. Check that the file is not there any more + assert not client_1.file_system.get_file("downloads", "cat.png") + # 3.1 (but with the reference to the original file, we can check that deleted flag is True ) + assert file.deleted + + +def test_node_file_create(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that a file is created.""" + game, agent = game_and_agent + + client_1 = game.simulation.network.get_node_by_hostname("client_1") # + + action = ( + "NODE_FILE_CREATE", + { + "node_id": 0, + "folder_name": "test", + "file_name": "file.txt", + }, + ) + agent.store_action(action) + game.step() + + assert client_1.file_system.get_file(folder_name="test", file_name="file.txt") + + +def test_node_file_access(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the file access increments.""" + game, agent = game_and_agent + + client_1 = game.simulation.network.get_node_by_hostname("client_1") # + + action = ( + "NODE_FILE_CREATE", + { + "node_id": 0, + "folder_name": "test", + "file_name": "file.txt", + }, + ) + agent.store_action(action) + game.step() + + assert client_1.file_system.get_file(folder_name="test", file_name="file.txt").num_access == 0 + + action = ( + "NODE_FILE_ACCESS", + { + "node_id": 0, + "folder_name": "test", + "file_name": "file.txt", + }, + ) + agent.store_action(action) + game.step() + + assert client_1.file_system.get_file(folder_name="test", file_name="file.txt").num_access == 1 + + +def test_node_folder_create(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that a folder is created.""" + game, agent = game_and_agent + + client_1 = game.simulation.network.get_node_by_hostname("client_1") # + + action = ( + "NODE_FOLDER_CREATE", + { + "node_id": 0, + "folder_name": "test", + }, + ) + agent.store_action(action) + game.step() + + assert client_1.file_system.get_folder(folder_name="test") + + +def test_network_router_port_disable_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NetworkPortDisableAction can form a request and that it is accepted by the simulation.""" + game, agent = game_and_agent + + # 1: Check that client_1 can access the network + client_1 = game.simulation.network.get_node_by_hostname("client_1") + server_1 = game.simulation.network.get_node_by_hostname("server_1") + router = game.simulation.network.get_node_by_hostname("router") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.run() + browser.target_url = "http://www.example.com" + assert browser.get_webpage() # check that the browser can access example.com before we block it + + # 2: Disable the NIC on client_1 + action = ( + "NETWORK_PORT_DISABLE", + { + "target_nodename": "router", # router + "port_id": 1, # port 1 + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the NIC is disabled, and that client 1 cannot access example.com + assert router.network_interface[1].enabled == False + assert not browser.get_webpage() + assert not client_1.ping("10.0.2.2") + assert not client_1.ping("10.0.2.3") + + # 4: check that servers can still communicate + assert server_1.ping("10.0.2.3") + + +def test_network_router_port_enable_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NetworkPortEnableAction can form a request and that it is accepted by the simulation.""" + + game, agent = game_and_agent + + # 1: Disable router port 1 + router = game.simulation.network.get_node_by_hostname("router") + client_1 = game.simulation.network.get_node_by_hostname("client_1") + router.network_interface[1].disable() + assert not client_1.ping("10.0.2.2") + + # 2: Use action to enable port + action = ( + "NETWORK_PORT_ENABLE", + { + "target_nodename": "router", # router + "port_id": 1, # port 1 + }, + ) + agent.store_action(action) + game.step() + + # 3: Check that the Port is enabled, and that client 1 can ping again + assert router.network_interface[1].enabled == True + assert client_1.ping("10.0.2.3") + + +def test_node_application_scan_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NodeApplicationScanAction updates the application status as expected.""" + game, agent = game_and_agent + + # 1: Check that http traffic is going across the network nicely. + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.run() + browser.target_url = "http://www.example.com" + assert browser.get_webpage() # check that the browser can access example.com + + assert browser.health_state_actual == SoftwareHealthState.GOOD + assert browser.health_state_visible == SoftwareHealthState.UNUSED + + # 2: Scan and check that the visible state is now correct + action = ("NODE_APPLICATION_SCAN", {"node_id": 0, "application_id": 0}) + agent.store_action(action) + game.step() + assert browser.health_state_actual == SoftwareHealthState.GOOD + assert browser.health_state_visible == SoftwareHealthState.GOOD + + # 3: Corrupt the service and check that the visible state is still good + browser.health_state_actual = SoftwareHealthState.COMPROMISED + assert browser.health_state_visible == SoftwareHealthState.GOOD + + # 4: Scan and check that the visible state is now correct + action = ("NODE_APPLICATION_SCAN", {"node_id": 0, "application_id": 0}) + agent.store_action(action) + game.step() + assert browser.health_state_actual == SoftwareHealthState.COMPROMISED + assert browser.health_state_visible == SoftwareHealthState.COMPROMISED + + +def test_node_application_fix_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NodeApplicationFixAction can form a request and that it is accepted by the simulation. + + When you initiate a fix action, the software health state turns to FIXING, then after a few steps, it goes + to GOOD.""" + game, agent = game_and_agent + + # 1: Check that http traffic is going across the network nicely. + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.health_state_actual = SoftwareHealthState.COMPROMISED + + # 2: Apply a fix action + action = ("NODE_APPLICATION_FIX", {"node_id": 0, "application_id": 0}) + agent.store_action(action) + game.step() + + # 3: Check that the application is now in the FIXING state + assert browser.health_state_actual == SoftwareHealthState.FIXING + + # 4: perform a few do-nothing steps and check that the application is now in the good state + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + assert browser.health_state_actual == SoftwareHealthState.GOOD + + +def test_node_application_close_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NodeApplicationCloseAction can form a request and that it is accepted by the simulation. + + When you initiate a close action, the Application Operating State changes for CLOSED.""" + game, agent = game_and_agent + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + browser: WebBrowser = client_1.software_manager.software.get("WebBrowser") + browser.run() + assert browser.operating_state == ApplicationOperatingState.RUNNING + + # 2: Apply a close action + action = ("NODE_APPLICATION_CLOSE", {"node_id": 0, "application_id": 0}) + agent.store_action(action) + game.step() + + assert browser.operating_state == ApplicationOperatingState.CLOSED + + +def test_node_application_install_and_uninstall_integration(game_and_agent: Tuple[PrimaiteGame, ProxyAgent]): + """Test that the NodeApplicationInstallAction and NodeApplicationRemoveAction can form a request and that + it is accepted by the simulation. + + When you initiate a install action, the Application will be installed and configured on the node. + The remove action will uninstall the application from the node.""" + game, agent = game_and_agent + + client_1 = game.simulation.network.get_node_by_hostname("client_1") + + assert client_1.software_manager.software.get("DoSBot") is None + + action = ("NODE_APPLICATION_INSTALL", {"node_id": 0, "application_name": "DoSBot", "ip_address": "192.168.1.14"}) + agent.store_action(action) + game.step() + + assert client_1.software_manager.software.get("DoSBot") is not None + + action = ("NODE_APPLICATION_REMOVE", {"node_id": 0, "application_name": "DoSBot"}) + agent.store_action(action) + game.step() + + assert client_1.software_manager.software.get("DoSBot") is None + + +def test_firewall_acl_add_remove_rule_integration(): + """ + Test that FirewallACLAddRuleAction and FirewallACLRemoveRuleAction can form a request and that it is accepted by the simulation. + + Check that all the details of the ACL rules are correctly added to each ACL list of the Firewall. + Check that rules are removed as expected. + """ + with open(FIREWALL_ACTIONS_NETWORK, "r") as f: + cfg = yaml.safe_load(f) + + env = PrimaiteGymEnv(env_config=cfg) + + # 1: Check that traffic is normal and acl starts off with 4 rules. + firewall = env.game.simulation.network.get_node_by_hostname("firewall") + assert firewall.internal_inbound_acl.num_rules == 2 + assert firewall.internal_outbound_acl.num_rules == 2 + assert firewall.dmz_inbound_acl.num_rules == 2 + assert firewall.dmz_outbound_acl.num_rules == 2 + assert firewall.external_inbound_acl.num_rules == 1 + assert firewall.external_outbound_acl.num_rules == 1 + + env.step(1) # Add ACL rule to Internal Inbound + assert firewall.internal_inbound_acl.num_rules == 3 + assert firewall.internal_inbound_acl.acl[1].action.name == "PERMIT" + assert firewall.internal_inbound_acl.acl[1].src_ip_address == IPv4Address("192.168.0.10") + assert firewall.internal_inbound_acl.acl[1].dst_ip_address is None + assert firewall.internal_inbound_acl.acl[1].dst_port is None + assert firewall.internal_inbound_acl.acl[1].src_port is None + assert firewall.internal_inbound_acl.acl[1].protocol is None + + env.step(2) # Remove ACL rule from Internal Inbound + assert firewall.internal_inbound_acl.num_rules == 2 + + env.step(3) # Add ACL rule to Internal Outbound + assert firewall.internal_outbound_acl.num_rules == 3 + assert firewall.internal_outbound_acl.acl[1].action.name == "DENY" + assert firewall.internal_outbound_acl.acl[1].src_ip_address == IPv4Address("192.168.0.10") + assert firewall.internal_outbound_acl.acl[1].dst_ip_address is None + assert firewall.internal_outbound_acl.acl[1].dst_port == Port.DNS + assert firewall.internal_outbound_acl.acl[1].src_port == Port.ARP + assert firewall.internal_outbound_acl.acl[1].protocol == IPProtocol.ICMP + + env.step(4) # Remove ACL rule from Internal Outbound + assert firewall.internal_outbound_acl.num_rules == 2 + + env.step(5) # Add ACL rule to DMZ Inbound + assert firewall.dmz_inbound_acl.num_rules == 3 + assert firewall.dmz_inbound_acl.acl[1].action.name == "DENY" + assert firewall.dmz_inbound_acl.acl[1].src_ip_address == IPv4Address("192.168.10.10") + assert firewall.dmz_inbound_acl.acl[1].dst_ip_address == IPv4Address("192.168.0.10") + assert firewall.dmz_inbound_acl.acl[1].dst_port == Port.HTTP + assert firewall.dmz_inbound_acl.acl[1].src_port == Port.HTTP + assert firewall.dmz_inbound_acl.acl[1].protocol == IPProtocol.UDP + + env.step(6) # Remove ACL rule from DMZ Inbound + assert firewall.dmz_inbound_acl.num_rules == 2 + + env.step(7) # Add ACL rule to DMZ Outbound + assert firewall.dmz_outbound_acl.num_rules == 3 + assert firewall.dmz_outbound_acl.acl[2].action.name == "DENY" + assert firewall.dmz_outbound_acl.acl[2].src_ip_address == IPv4Address("192.168.10.10") + assert firewall.dmz_outbound_acl.acl[2].dst_ip_address == IPv4Address("192.168.0.10") + assert firewall.dmz_outbound_acl.acl[2].dst_port == Port.HTTP + assert firewall.dmz_outbound_acl.acl[2].src_port == Port.HTTP + assert firewall.dmz_outbound_acl.acl[2].protocol == IPProtocol.TCP + + env.step(8) # Remove ACL rule from DMZ Outbound + assert firewall.dmz_outbound_acl.num_rules == 2 + + env.step(9) # Add ACL rule to External Inbound + assert firewall.external_inbound_acl.num_rules == 2 + assert firewall.external_inbound_acl.acl[10].action.name == "DENY" + assert firewall.external_inbound_acl.acl[10].src_ip_address == IPv4Address("192.168.20.10") + assert firewall.external_inbound_acl.acl[10].dst_ip_address == IPv4Address("192.168.10.10") + assert firewall.external_inbound_acl.acl[10].dst_port == Port.POSTGRES_SERVER + assert firewall.external_inbound_acl.acl[10].src_port == Port.POSTGRES_SERVER + assert firewall.external_inbound_acl.acl[10].protocol == IPProtocol.ICMP + + env.step(10) # Remove ACL rule from External Inbound + assert firewall.external_inbound_acl.num_rules == 1 + + env.step(11) # Add ACL rule to External Outbound + assert firewall.external_outbound_acl.num_rules == 2 + assert firewall.external_outbound_acl.acl[1].action.name == "DENY" + assert firewall.external_outbound_acl.acl[1].src_ip_address == IPv4Address("192.168.20.10") + assert firewall.external_outbound_acl.acl[1].dst_ip_address == IPv4Address("192.168.0.10") + assert firewall.external_outbound_acl.acl[1].dst_port is None + assert firewall.external_outbound_acl.acl[1].src_port is None + assert firewall.external_outbound_acl.acl[1].protocol is None + + env.step(12) # Remove ACL rule from External Outbound + assert firewall.external_outbound_acl.num_rules == 1 + + +def test_firewall_port_disable_enable_integration(): + """ + Test that NetworkPortEnableAction and NetworkPortDisableAction can form a request and that it is accepted by the simulation. + """ + with open(FIREWALL_ACTIONS_NETWORK, "r") as f: + cfg = yaml.safe_load(f) + + env = PrimaiteGymEnv(env_config=cfg) + firewall = env.game.simulation.network.get_node_by_hostname("firewall") + + assert firewall.dmz_port.enabled == True + + env.step(13) # Disable Firewall DMZ Port + assert firewall.dmz_port.enabled == False + + env.step(14) # Enable Firewall DMZ Port + assert firewall.dmz_port.enabled == True diff --git a/tests/integration_tests/game_layer/test_observations.py b/tests/integration_tests/game_layer/test_observations.py new file mode 100644 index 00000000..ed07e030 --- /dev/null +++ b/tests/integration_tests/game_layer/test_observations.py @@ -0,0 +1,26 @@ +from gymnasium import spaces + +from primaite.game.agent.observations.file_system_observations import FileObservation +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.sim_container import Simulation + + +def test_file_observation(): + sim = Simulation() + pc = Computer(hostname="beep", ip_address="123.123.123.123", subnet_mask="255.255.255.0") + sim.network.add_node(pc) + f = pc.file_system.create_file(file_name="dog.png") + + state = sim.describe_state() + + dog_file_obs = FileObservation( + where=["network", "nodes", pc.hostname, "file_system", "folders", "root", "files", "dog.png"], + include_num_access=False, + ) + assert dog_file_obs.observe(state) == {"health_status": 1} + assert dog_file_obs.space == spaces.Dict({"health_status": spaces.Discrete(6)}) + + +# TODO: +# def test_file_num_access(): +# ... diff --git a/tests/integration_tests/game_layer/test_rewards.py b/tests/integration_tests/game_layer/test_rewards.py new file mode 100644 index 00000000..dff536de --- /dev/null +++ b/tests/integration_tests/game_layer/test_rewards.py @@ -0,0 +1,120 @@ +import yaml + +from primaite.game.agent.interface import AgentHistoryItem +from primaite.game.agent.rewards import GreenAdminDatabaseUnreachablePenalty, WebpageUnavailablePenalty +from primaite.game.game import PrimaiteGame +from primaite.session.environment import PrimaiteGymEnv +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.services.database.database_service import DatabaseService +from tests import TEST_ASSETS_ROOT +from tests.conftest import ControlledAgent + + +def test_WebpageUnavailablePenalty(game_and_agent): + """Test that we get the right reward for failing to fetch a website.""" + game, agent = game_and_agent + agent: ControlledAgent + comp = WebpageUnavailablePenalty(node_hostname="client_1") + + agent.reward_function.register_component(comp, 0.7) + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + + # client 1 has not attempted to fetch webpage yet! + assert agent.reward_function.current_reward == 0.0 + + client_1 = game.simulation.network.get_node_by_hostname("client_1") + browser = client_1.software_manager.software.get("WebBrowser") + browser.run() + browser.target_url = "http://www.example.com" + assert browser.get_webpage() + action = ("DONOTHING", {}) + agent.store_action(action) + game.step() + assert agent.reward_function.current_reward == 0.7 + + router: Router = game.simulation.network.get_node_by_hostname("router") + router.acl.add_rule(action=ACLAction.DENY, protocol=IPProtocol.TCP, src_port=Port.HTTP, dst_port=Port.HTTP) + assert not browser.get_webpage() + agent.store_action(action) + game.step() + assert agent.reward_function.current_reward == -0.7 + + +def test_uc2_rewards(game_and_agent): + """Test that the reward component correctly applies a penalty when the selected client cannot reach the database.""" + game, agent = game_and_agent + agent: ControlledAgent + + server_1: Server = game.simulation.network.get_node_by_hostname("server_1") + server_1.software_manager.install(DatabaseService) + db_service = server_1.software_manager.software.get("DatabaseService") + db_service.start() + + client_1 = game.simulation.network.get_node_by_hostname("client_1") + client_1.software_manager.install(DatabaseClient) + db_client: DatabaseClient = client_1.software_manager.software.get("DatabaseClient") + db_client.configure(server_ip_address=server_1.network_interface[1].ip_address) + db_client.run() + + router: Router = game.simulation.network.get_node_by_hostname("router") + router.acl.add_rule(ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=2) + + comp = GreenAdminDatabaseUnreachablePenalty("client_1") + + response = db_client.apply_request( + [ + "execute", + ] + ) + state = game.get_sim_state() + reward_value = comp.calculate( + state, + last_action_response=AgentHistoryItem( + timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response + ), + ) + assert reward_value == 1.0 + + router.acl.remove_rule(position=2) + + db_client.apply_request( + [ + "execute", + ] + ) + state = game.get_sim_state() + reward_value = comp.calculate( + state, + last_action_response=AgentHistoryItem( + timestep=0, action="NODE_APPLICATION_EXECUTE", parameters={}, request=["execute"], response=response + ), + ) + assert reward_value == -1.0 + + +def test_shared_reward(): + CFG_PATH = TEST_ASSETS_ROOT / "configs/shared_rewards.yaml" + with open(CFG_PATH, "r") as f: + cfg = yaml.safe_load(f) + + env = PrimaiteGymEnv(env_config=cfg) + + env.reset() + + order = env.game._reward_calculation_order + assert order.index("defender") > order.index("client_1_green_user") + assert order.index("defender") > order.index("client_2_green_user") + + for step in range(256): + act = env.action_space.sample() + env.step(act) + g1_reward = env.game.agents["client_1_green_user"].reward_function.current_reward + g2_reward = env.game.agents["client_2_green_user"].reward_function.current_reward + blue_reward = env.game.agents["defender"].reward_function.current_reward + assert blue_reward == g1_reward + g2_reward diff --git a/tests/integration_tests/network/__init__.py b/tests/integration_tests/network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/network/test_broadcast.py b/tests/integration_tests/network/test_broadcast.py new file mode 100644 index 00000000..6b6deb93 --- /dev/null +++ b/tests/integration_tests/network/test_broadcast.py @@ -0,0 +1,176 @@ +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.services.service import Service + + +class BroadcastService(Service): + """A service for sending broadcast and unicast messages over a network.""" + + def __init__(self, **kwargs): + # Set default service properties for broadcasting + kwargs["name"] = "BroadcastService" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the service + pass + + def unicast(self, ip_address: IPv4Address): + # Send a unicast payload to a specific IP address + super().send( + payload="unicast", + dest_ip_address=ip_address, + dest_port=Port.HTTP, + ) + + def broadcast(self, ip_network: IPv4Network): + # Send a broadcast payload to an entire IP network + super().send(payload="broadcast", dest_ip_address=ip_network, dest_port=Port.HTTP, ip_protocol=self.protocol) + + +class BroadcastClient(Application): + """A client application to receive broadcast and unicast messages.""" + + payloads_received: List = [] + + def __init__(self, **kwargs): + # Set default client properties + kwargs["name"] = "BroadcastClient" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the application + pass + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + # Append received payloads to the list and print a message + self.payloads_received.append(payload) + print(f"Payload: {payload} received on node {self.sys_log.hostname}") + + +@pytest.fixture(scope="function") +def broadcast_network() -> Network: + network = Network() + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_1.power_on() + client_1.software_manager.install(BroadcastClient) + application_1 = client_1.software_manager.software["BroadcastClient"] + application_1.run() + + client_2 = Computer( + hostname="client_2", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_2.power_on() + client_2.software_manager.install(BroadcastClient) + application_2 = client_2.software_manager.software["BroadcastClient"] + application_2.run() + + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.1", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + + server_1.software_manager.install(BroadcastService) + service: BroadcastService = server_1.software_manager.software["BroadcastService"] + service.start() + + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + + network.connect(endpoint_a=client_1.network_interface[1], endpoint_b=switch_1.network_interface[1]) + network.connect(endpoint_a=client_2.network_interface[1], endpoint_b=switch_1.network_interface[2]) + network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_1.network_interface[3]) + + return network + + +@pytest.fixture(scope="function") +def broadcast_service_and_clients(broadcast_network) -> Tuple[BroadcastService, BroadcastClient, BroadcastClient]: + client_1: BroadcastClient = broadcast_network.get_node_by_hostname("client_1").software_manager.software[ + "BroadcastClient" + ] + client_2: BroadcastClient = broadcast_network.get_node_by_hostname("client_2").software_manager.software[ + "BroadcastClient" + ] + service: BroadcastService = broadcast_network.get_node_by_hostname("server_1").software_manager.software[ + "BroadcastService" + ] + + return service, client_1, client_2 + + +def test_broadcast_correct_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.1.0/24")) + + assert client_1.payloads_received == ["broadcast"] + assert client_2.payloads_received == ["broadcast"] + + +def test_broadcast_incorrect_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.2.0/24")) + + assert not client_1.payloads_received + assert not client_2.payloads_received + + +def test_unicast_correct_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.1.2")) + + assert client_1.payloads_received == ["unicast"] + assert not client_2.payloads_received + + +def test_unicast_incorrect_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.2.2")) + + assert not client_1.payloads_received + assert not client_2.payloads_received diff --git a/tests/integration_tests/network/test_capture_nmne.py b/tests/integration_tests/network/test_capture_nmne.py new file mode 100644 index 00000000..fccd580d --- /dev/null +++ b/tests/integration_tests/network/test_capture_nmne.py @@ -0,0 +1,276 @@ +from primaite.game.agent.observations.nic_observations import NICObservation +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.nmne import set_nmne_config +from primaite.simulator.sim_container import Simulation +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection + + +def test_capture_nmne(uc2_network): + """ + Conducts a test to verify that Malicious Network Events (MNEs) are correctly captured. + + This test involves a web server querying a database server and checks if the MNEs are captured + based on predefined keywords in the network configuration. Specifically, it checks the capture + of the "DELETE" SQL command as a malicious network event. + """ + web_server: Server = uc2_network.get_node_by_hostname("web_server") # noqa + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] # noqa + db_client_connection: DatabaseClientConnection = db_client.get_new_connection() + + db_server: Server = uc2_network.get_node_by_hostname("database_server") # noqa + + web_server_nic = web_server.network_interface[1] + db_server_nic = db_server.network_interface[1] + + # Set the NMNE configuration to capture DELETE/ENCRYPT queries as MNEs + nmne_config = { + "capture_nmne": True, # Enable the capture of MNEs + "nmne_capture_keywords": [ + "DELETE", + "ENCRYPT", + ], # Specify "DELETE/ENCRYPT" SQL command as a keyword for MNE detection + } + + # Apply the NMNE configuration settings + set_nmne_config(nmne_config) + + # Assert that initially, there are no captured MNEs on both web and database servers + assert web_server_nic.nmne == {} + assert db_server_nic.nmne == {} + + # Perform a "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that it does not trigger an MNE capture. + assert web_server_nic.nmne == {} + assert db_server_nic.nmne == {} + + # Perform a "DELETE" query + db_client_connection.query(sql="DELETE") + + # Check that the web server's outbound interface and the database server's inbound interface register the MNE + assert web_server_nic.nmne == {"direction": {"outbound": {"keywords": {"*": 1}}}} + assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 1}}}} + + # Perform another "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that no additional MNEs are captured + assert web_server_nic.nmne == {"direction": {"outbound": {"keywords": {"*": 1}}}} + assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 1}}}} + + # Perform another "DELETE" query + db_client_connection.query(sql="DELETE") + + # Check that the web server and database server interfaces register an additional MNE + assert web_server_nic.nmne == {"direction": {"outbound": {"keywords": {"*": 2}}}} + assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 2}}}} + + # Perform an "ENCRYPT" query + db_client_connection.query(sql="ENCRYPT") + + # Check that the web server and database server interfaces register an additional MNE + assert web_server_nic.nmne == {"direction": {"outbound": {"keywords": {"*": 3}}}} + assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 3}}}} + + # Perform another "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that no additional MNEs are captured + assert web_server_nic.nmne == {"direction": {"outbound": {"keywords": {"*": 3}}}} + assert db_server_nic.nmne == {"direction": {"inbound": {"keywords": {"*": 3}}}} + + +def test_describe_state_nmne(uc2_network): + """ + Conducts a test to verify that Malicious Network Events (MNEs) are correctly represented in the nic state. + + This test involves a web server querying a database server and checks if the MNEs are captured + based on predefined keywords in the network configuration. Specifically, it checks the capture + of the "DELETE" / "ENCRYPT" SQL commands as a malicious network event. It also checks that running describe_state + only shows MNEs since the last time describe_state was called. + """ + web_server: Server = uc2_network.get_node_by_hostname("web_server") # noqa + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] # noqa + db_client_connection: DatabaseClientConnection = db_client.get_new_connection() + + db_server: Server = uc2_network.get_node_by_hostname("database_server") # noqa + + web_server_nic = web_server.network_interface[1] + db_server_nic = db_server.network_interface[1] + + # Set the NMNE configuration to capture DELETE/ENCRYPT queries as MNEs + nmne_config = { + "capture_nmne": True, # Enable the capture of MNEs + "nmne_capture_keywords": [ + "DELETE", + "ENCRYPT", + ], # "DELETE" & "ENCRYPT" SQL commands as a keywords for MNE detection + } + + # Apply the NMNE configuration settings + set_nmne_config(nmne_config) + + # Assert that initially, there are no captured MNEs on both web and database servers + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {} + assert db_server_nic_state["nmne"] == {} + + # Perform a "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that it does not trigger an MNE capture. + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {} + assert db_server_nic_state["nmne"] == {} + + # Perform a "DELETE" query + db_client_connection.query(sql="DELETE") + + # Check that the web server's outbound interface and the database server's inbound interface register the MNE + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 1}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 1}}}} + + # Perform another "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that no additional MNEs are captured + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 1}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 1}}}} + + # Perform another "DELETE" query + db_client_connection.query(sql="DELETE") + + # Check that the web server and database server interfaces register an additional MNE + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 2}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 2}}}} + + # Perform a "ENCRYPT" query + db_client_connection.query(sql="ENCRYPT") + + # Check that the web server's outbound interface and the database server's inbound interface register the MNE + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 3}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 3}}}} + + # Perform another "SELECT" query + db_client_connection.query(sql="SELECT") + + # Check that no additional MNEs are captured + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 3}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 3}}}} + + # Perform another "ENCRYPT" + db_client_connection.query(sql="ENCRYPT") + + # Check that the web server and database server interfaces register an additional MNE + web_server_nic_state = web_server_nic.describe_state() + db_server_nic_state = db_server_nic.describe_state() + uc2_network.apply_timestep(timestep=0) + assert web_server_nic_state["nmne"] == {"direction": {"outbound": {"keywords": {"*": 4}}}} + assert db_server_nic_state["nmne"] == {"direction": {"inbound": {"keywords": {"*": 4}}}} + + +def test_capture_nmne_observations(uc2_network): + """ + Tests the NICObservation class's functionality within a simulated network environment. + + This test ensures the observation space, as defined by instances of NICObservation, accurately reflects the + number of MNEs detected based on network activities over multiple iterations. + + The test employs a series of "DELETE" and "ENCRYPT" SQL operations, considered as MNEs, to validate the dynamic update + and accuracy of the observation space related to network interface conditions. It confirms that the + observed NIC states match expected MNE activity levels. + """ + # Initialise a new Simulation instance and assign the test network to it. + sim = Simulation() + sim.network = uc2_network + + web_server: Server = uc2_network.get_node_by_hostname("web_server") + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] + db_client_connection: DatabaseClientConnection = db_client.get_new_connection() + + # Set the NMNE configuration to capture DELETE/ENCRYPT queries as MNEs + nmne_config = { + "capture_nmne": True, # Enable the capture of MNEs + "nmne_capture_keywords": [ + "DELETE", + "ENCRYPT", + ], # Specify "DELETE" & "ENCRYPT" SQL commands as a keywords for MNE detection + } + + # Apply the NMNE configuration settings + set_nmne_config(nmne_config) + + # Define observations for the NICs of the database and web servers + db_server_nic_obs = NICObservation(where=["network", "nodes", "database_server", "NICs", 1], include_nmne=True) + web_server_nic_obs = NICObservation(where=["network", "nodes", "web_server", "NICs", 1], include_nmne=True) + + # Iterate through a set of test cases to simulate multiple DELETE queries + for i in range(0, 20): + # Perform a "DELETE" query each iteration + for j in range(i): + db_client_connection.query(sql="DELETE") + + # Observe the current state of NMNEs from the NICs of both the database and web servers + state = sim.describe_state() + db_nic_obs = db_server_nic_obs.observe(state)["NMNE"] + web_nic_obs = web_server_nic_obs.observe(state)["NMNE"] + + # Define expected NMNE values based on the iteration count + if i > 10: + expected_nmne = 3 # High level of detected MNEs after 10 iterations + elif i > 5: + expected_nmne = 2 # Moderate level after more than 5 iterations + elif i > 0: + expected_nmne = 1 # Low level detected after just starting + else: + expected_nmne = 0 # No MNEs detected + + # Assert that the observed NMNEs match the expected values for both NICs + assert web_nic_obs["outbound"] == expected_nmne + assert db_nic_obs["inbound"] == expected_nmne + uc2_network.apply_timestep(timestep=0) + + for i in range(0, 20): + # Perform a "ENCRYPT" query each iteration + for j in range(i): + db_client_connection.query(sql="ENCRYPT") + + # Observe the current state of NMNEs from the NICs of both the database and web servers + state = sim.describe_state() + db_nic_obs = db_server_nic_obs.observe(state)["NMNE"] + web_nic_obs = web_server_nic_obs.observe(state)["NMNE"] + + # Define expected NMNE values based on the iteration count + if i > 10: + expected_nmne = 3 # High level of detected MNEs after 10 iterations + elif i > 5: + expected_nmne = 2 # Moderate level after more than 5 iterations + elif i > 0: + expected_nmne = 1 # Low level detected after just starting + else: + expected_nmne = 0 # No MNEs detected + + # Assert that the observed NMNEs match the expected values for both NICs + assert web_nic_obs["outbound"] == expected_nmne + assert db_nic_obs["inbound"] == expected_nmne + uc2_network.apply_timestep(timestep=0) diff --git a/tests/integration_tests/network/test_firewall.py b/tests/integration_tests/network/test_firewall.py new file mode 100644 index 00000000..846699f0 --- /dev/null +++ b/tests/integration_tests/network/test_firewall.py @@ -0,0 +1,280 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.firewall import Firewall +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer + + +@pytest.fixture(scope="function") +def dmz_external_internal_network() -> Network: + """ + Fixture for setting up a simulated network with a firewall, external node, internal node, and DMZ node. This + configuration is designed to test firewall rules and their impact on traffic between these network segments. + + -------------- -------------- -------------- + | external |---------| firewall |---------| internal | + -------------- -------------- -------------- + | + | + --------- + | DMZ | + --------- + + The network is set up as follows: + - An external node simulates an entity outside the organization's network. + - An internal node represents a device within the organization's LAN. + - A DMZ (Demilitarized Zone) node acts as a server or service exposed to external traffic. + - A firewall node controls traffic between these nodes based on ACL (Access Control List) rules. + + The firewall is configured to allow ICMP and ARP traffic across all interfaces to ensure basic connectivity + for the tests. Specific tests will modify ACL rules to test various traffic filtering scenarios. + + :return: A `Network` instance with the described nodes and configurations. + """ + network = Network() + + firewall_node: Firewall = Firewall(hostname="firewall_1", start_up_duration=0) + firewall_node.power_on() + # configure firewall ports + firewall_node.configure_external_port( + ip_address=IPv4Address("192.168.10.1"), subnet_mask=IPv4Address("255.255.255.0") + ) + firewall_node.configure_dmz_port(ip_address=IPv4Address("192.168.1.1"), subnet_mask=IPv4Address("255.255.255.0")) + firewall_node.configure_internal_port( + ip_address=IPv4Address("192.168.0.1"), subnet_mask=IPv4Address("255.255.255.0") + ) + + # Allow ICMP + firewall_node.internal_inbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + firewall_node.internal_outbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + firewall_node.external_inbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + firewall_node.external_outbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + firewall_node.dmz_inbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + firewall_node.dmz_outbound_acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Allow ARP + firewall_node.internal_inbound_acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22 + ) + firewall_node.internal_outbound_acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22 + ) + firewall_node.external_inbound_acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22 + ) + firewall_node.external_outbound_acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22 + ) + firewall_node.dmz_inbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + firewall_node.dmz_outbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + + # external node + external_node = Computer( + hostname="external_node", + ip_address="192.168.10.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + start_up_duration=0, + ) + external_node.power_on() + external_node.software_manager.install(NTPServer) + ntp_service: NTPServer = external_node.software_manager.software["NTPServer"] + ntp_service.start() + # connect external node to firewall node + network.connect(endpoint_b=external_node.network_interface[1], endpoint_a=firewall_node.external_port) + + # internal node + internal_node = Computer( + hostname="internal_node", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + internal_node.power_on() + internal_node.software_manager.install(NTPClient) + internal_ntp_client: NTPClient = internal_node.software_manager.software["NTPClient"] + internal_ntp_client.configure(external_node.network_interface[1].ip_address) + internal_ntp_client.start() + # connect external node to firewall node + network.connect(endpoint_b=internal_node.network_interface[1], endpoint_a=firewall_node.internal_port) + + # dmz node + dmz_node = Computer( + hostname="dmz_node", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + dmz_node.power_on() + dmz_ntp_client: NTPClient = dmz_node.software_manager.software["NTPClient"] + dmz_ntp_client.configure(external_node.network_interface[1].ip_address) + dmz_ntp_client.start() + # connect external node to firewall node + network.connect(endpoint_b=dmz_node.network_interface[1], endpoint_a=firewall_node.dmz_port) + + return network + + +def test_firewall_can_ping_nodes(dmz_external_internal_network): + """ + Tests the firewall's ability to ping the external, internal, and DMZ nodes in the network. + + Verifies that the firewall has connectivity to all nodes within the network by performing a ping operation. + Successful pings indicate proper network setup and basic ICMP traffic passage through the firewall. + """ + firewall = dmz_external_internal_network.get_node_by_hostname("firewall_1") + + # ping from the firewall + assert firewall.ping("192.168.0.2") # firewall to internal + assert firewall.ping("192.168.1.2") # firewall to dmz + assert firewall.ping("192.168.10.2") # firewall to external + + +def test_nodes_can_ping_default_gateway(dmz_external_internal_network): + """ + Checks if the external, internal, and DMZ nodes can ping their respective default gateways. + + This test confirms that each node is correctly configured with a route to its default gateway and that the + firewall permits ICMP traffic for these basic connectivity checks. + """ + external_node = dmz_external_internal_network.get_node_by_hostname("external_node") + internal_node = dmz_external_internal_network.get_node_by_hostname("internal_node") + dmz_node = dmz_external_internal_network.get_node_by_hostname("dmz_node") + + assert internal_node.ping(internal_node.default_gateway) # default gateway internal + assert dmz_node.ping(dmz_node.default_gateway) # default gateway dmz + assert external_node.ping(external_node.default_gateway) # default gateway external + + +def test_nodes_can_ping_default_gateway_on_another_subnet(dmz_external_internal_network): + """ + Verifies that nodes can ping default gateways located in a different subnet, facilitated by the firewall. + + This test assesses the routing and firewall ACL configurations that allow ICMP traffic between different + network segments, ensuring that nodes can reach default gateways outside their local subnet. + """ + external_node = dmz_external_internal_network.get_node_by_hostname("external_node") + internal_node = dmz_external_internal_network.get_node_by_hostname("internal_node") + dmz_node = dmz_external_internal_network.get_node_by_hostname("dmz_node") + + assert internal_node.ping(external_node.default_gateway) # internal node to external default gateway + assert internal_node.ping(dmz_node.default_gateway) # internal node to dmz default gateway + + assert dmz_node.ping(internal_node.default_gateway) # dmz node to internal default gateway + assert dmz_node.ping(external_node.default_gateway) # dmz node to external default gateway + + assert external_node.ping(external_node.default_gateway) # external node to internal default gateway + assert external_node.ping(dmz_node.default_gateway) # external node to dmz default gateway + + +def test_nodes_can_ping_each_other(dmz_external_internal_network): + """ + Evaluates the ability of each node (external, internal, DMZ) to ping the other nodes within the network. + + This comprehensive connectivity test checks if the firewall's current ACL configuration allows for inter-node + communication via ICMP pings, highlighting the effectiveness of the firewall rules in place. + """ + external_node = dmz_external_internal_network.get_node_by_hostname("external_node") + internal_node = dmz_external_internal_network.get_node_by_hostname("internal_node") + dmz_node = dmz_external_internal_network.get_node_by_hostname("dmz_node") + + # test that nodes can ping each other + assert internal_node.ping(external_node.network_interface[1].ip_address) + assert internal_node.ping(dmz_node.network_interface[1].ip_address) + + assert external_node.ping(internal_node.network_interface[1].ip_address) + assert external_node.ping(dmz_node.network_interface[1].ip_address) + + assert dmz_node.ping(internal_node.network_interface[1].ip_address) + assert dmz_node.ping(external_node.network_interface[1].ip_address) + + +def test_service_blocked(dmz_external_internal_network): + """ + Tests the firewall's default blocking stance on NTP service requests from internal and DMZ nodes. + + Initially, without specific ACL rules to allow NTP traffic, this test confirms that NTP clients on both the + internal and DMZ nodes are unable to update their time, demonstrating the firewall's effective blocking of + unspecified services. + """ + firewall = dmz_external_internal_network.get_node_by_hostname("firewall_1") + internal_node = dmz_external_internal_network.get_node_by_hostname("internal_node") + dmz_node = dmz_external_internal_network.get_node_by_hostname("dmz_node") + internal_ntp_client: NTPClient = internal_node.software_manager.software["NTPClient"] + dmz_ntp_client: NTPClient = dmz_node.software_manager.software["NTPClient"] + + assert not internal_ntp_client.time + + internal_ntp_client.request_time() + + assert not internal_ntp_client.time + + assert not dmz_ntp_client.time + + dmz_ntp_client.request_time() + + assert not dmz_ntp_client.time + + firewall.show_rules() + + +def test_service_allowed_with_rule(dmz_external_internal_network): + """ + Tests that NTP service requests are allowed through the firewall based on ACL rules. + + This test verifies the functionality of the firewall in a network scenario where both an internal node and + a node in the DMZ attempt to access NTP services. Initially, no NTP traffic is allowed. The test then + configures ACL rules on the firewall to permit NTP traffic and checks if the NTP clients on the internal + node and DMZ node can successfully request and receive time updates. + + Procedure: + 1. Assert that the internal node's NTP client initially has no time information due to ACL restrictions. + 2. Add ACL rules to the firewall to permit outbound and inbound NTP traffic from the internal network. + 3. Trigger an NTP time request from the internal node and assert that it successfully receives time + information. + 4. Assert that the DMZ node's NTP client initially has no time information. + 5. Add ACL rules to the firewall to permit outbound and inbound NTP traffic from the DMZ. + 6. Trigger an NTP time request from the DMZ node and assert that it successfully receives time information. + + Asserts: + - The internal node's NTP client has no time information before ACL rules are applied. + - The internal node's NTP client successfully receives time information after the appropriate ACL rules + are applied. + - The DMZ node's NTP client has no time information before ACL rules are applied for the DMZ. + - The DMZ node's NTP client successfully receives time information after the appropriate ACL rules for + the DMZ are applied. + """ + firewall = dmz_external_internal_network.get_node_by_hostname("firewall_1") + internal_node = dmz_external_internal_network.get_node_by_hostname("internal_node") + dmz_node = dmz_external_internal_network.get_node_by_hostname("dmz_node") + internal_ntp_client: NTPClient = internal_node.software_manager.software["NTPClient"] + dmz_ntp_client: NTPClient = dmz_node.software_manager.software["NTPClient"] + + assert not internal_ntp_client.time + + firewall.internal_outbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=1) + firewall.internal_inbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=1) + + internal_ntp_client.request_time() + + assert internal_ntp_client.time + + assert not dmz_ntp_client.time + + firewall.dmz_outbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=1) + firewall.dmz_inbound_acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=1) + + dmz_ntp_client.request_time() + + assert dmz_ntp_client.time + + firewall.show_rules() diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py new file mode 100644 index 00000000..eb30a245 --- /dev/null +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -0,0 +1,62 @@ +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.host_node import NIC +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.switch import Switch + + +def test_node_to_node_ping(): + """Tests two Computers are able to ping each other.""" + network = Network() + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_1.power_on() + + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + + switch_1 = Switch(hostname="switch_1", start_up_duration=0) + switch_1.power_on() + + network.connect(endpoint_a=client_1.network_interface[1], endpoint_b=switch_1.network_interface[1]) + network.connect(endpoint_a=server_1.network_interface[1], endpoint_b=switch_1.network_interface[2]) + + assert client_1.ping("192.168.1.11") + + +def test_multi_nic(): + """Tests that Computers with multiple NICs can ping each other and the data go across the correct links.""" + network = Network() + + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + node_b.connect_nic(NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0")) + + node_c = Computer(hostname="node_c", ip_address="10.0.0.13", subnet_mask="255.0.0.0", start_up_duration=0) + node_c.power_on() + + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + network.connect(node_b.network_interface[2], node_c.network_interface[1]) + + assert node_a.ping(node_b.network_interface[1].ip_address) + + assert node_c.ping(node_b.network_interface[2].ip_address) + + assert not node_a.ping(node_b.network_interface[2].ip_address) + + assert not node_a.ping(node_c.network_interface[1].ip_address) diff --git a/tests/integration_tests/network/test_multi_lan_internet_example_network.py b/tests/integration_tests/network/test_multi_lan_internet_example_network.py new file mode 100644 index 00000000..f6d702d8 --- /dev/null +++ b/tests/integration_tests/network/test_multi_lan_internet_example_network.py @@ -0,0 +1,199 @@ +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.networks import multi_lan_internet_network_example +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from src.primaite.simulator.system.applications.web_browser import WebBrowser + + +def test_all_with_configured_dns_server_ip_can_resolve_url(): + network = multi_lan_internet_network_example() + + for node in network.nodes.values(): + dns_client: DNSClient = node.software_manager.software.get("DNSClient") + + if not dns_client: + continue + + if dns_client.dns_server: + assert dns_client.check_domain_exists("sometech.ai") + + +def test_external_pcs_can_access_sometech_website(): + network = multi_lan_internet_network_example() + + pc_1_browser: WebBrowser = network.get_node_by_hostname("pc_1").software_manager.software["WebBrowser"] + pc_2_browser: WebBrowser = network.get_node_by_hostname("pc_2").software_manager.software["WebBrowser"] + + assert pc_1_browser.get_webpage() + assert pc_2_browser.get_webpage() + + +def test_external_pcs_cannot_access_sometech_db(): + network = multi_lan_internet_network_example() + + pc_1_db_client: DatabaseClient = network.get_node_by_hostname("pc_1").software_manager.software["DatabaseClient"] + pc_2_db_client: DatabaseClient = network.get_node_by_hostname("pc_2").software_manager.software["DatabaseClient"] + + assert not pc_1_db_client.get_new_connection() + assert not pc_2_db_client.get_new_connection() + + +def test_external_pcs_cannot_access_ftp_on_sometech_storage_server(): + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + pc_1_ftp_client: FTPClient = network.get_node_by_hostname("pc_1").software_manager.software["FTPClient"] + pc_2_ftp_client: FTPClient = network.get_node_by_hostname("pc_2").software_manager.software["FTPClient"] + + assert not pc_1_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + assert not pc_2_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + +def test_sometech_webserver_can_access_sometech_db_server(): + network = multi_lan_internet_network_example() + + web_db_client: DatabaseClient = network.get_node_by_hostname("some_tech_web_srv").software_manager.software[ + "DatabaseClient" + ] + + assert web_db_client.get_new_connection() + + +def test_sometech_webserver_cannot_access_ftp_on_sometech_storage_server(): + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + web_server: Server = network.get_node_by_hostname("some_tech_web_srv") + web_server.software_manager.install(FTPClient) + web_ftp_client: FTPClient = web_server.software_manager.software["FTPClient"] + + assert not web_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + +def test_sometech_dev_pcs_can_access_sometech_website(): + network = multi_lan_internet_network_example() + + some_tech_snr_dev_pc: Computer = network.get_node_by_hostname("some_tech_snr_dev_pc") + + snr_dev_browser: WebBrowser = some_tech_snr_dev_pc.software_manager.software["WebBrowser"] + + assert snr_dev_browser.get_webpage() + + some_tech_jnr_dev_pc: Computer = network.get_node_by_hostname("some_tech_jnr_dev_pc") + + jnr_dev_browser: WebBrowser = some_tech_jnr_dev_pc.software_manager.software["WebBrowser"] + + assert jnr_dev_browser.get_webpage() + + +def test_sometech_dev_pcs_can_connect_to_sometech_db_server(): + network = multi_lan_internet_network_example() + + some_tech_snr_dev_pc: Computer = network.get_node_by_hostname("some_tech_snr_dev_pc") + snr_dev_db_client: DatabaseClient = some_tech_snr_dev_pc.software_manager.software["DatabaseClient"] + + assert snr_dev_db_client.get_new_connection() + + some_tech_jnr_dev_pc: Computer = network.get_node_by_hostname("some_tech_jnr_dev_pc") + jnr_dev_db_client: DatabaseClient = some_tech_jnr_dev_pc.software_manager.software["DatabaseClient"] + + assert jnr_dev_db_client.get_new_connection() + + +def test_sometech_snr_dev_can_access_ftp_on_sometech_storage_server(): + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_snr_dev_pc: Computer = network.get_node_by_hostname("some_tech_snr_dev_pc") + snr_dev_ftp_client: FTPClient = some_tech_snr_dev_pc.software_manager.software["FTPClient"] + + assert snr_dev_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + +def test_sometech_jnr_dev_cannot_access_ftp_on_sometech_storage_server(): + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_jnr_dev_pc: Computer = network.get_node_by_hostname("some_tech_jnr_dev_pc") + jnr_dev_ftp_client: FTPClient = some_tech_jnr_dev_pc.software_manager.software["FTPClient"] + + assert not jnr_dev_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) + + +def test_sometech_hr_pc_can_access_sometech_website(): + network = multi_lan_internet_network_example() + + some_tech_hr_pc: Computer = network.get_node_by_hostname("some_tech_hr_1") + + hr_browser: WebBrowser = some_tech_hr_pc.software_manager.software["WebBrowser"] + + assert hr_browser.get_webpage() + + +def test_sometech_hr_pc_cannot_access_sometech_db(): + network = multi_lan_internet_network_example() + + some_tech_hr_pc: Computer = network.get_node_by_hostname("some_tech_hr_1") + + hr_db_client: DatabaseClient = some_tech_hr_pc.software_manager.software["DatabaseClient"] + + assert not hr_db_client.get_new_connection() + + +def test_sometech_hr_pc_cannot_access_ftp_on_sometech_storage_server(): + network = multi_lan_internet_network_example() + + some_tech_storage_srv = network.get_node_by_hostname("some_tech_storage_srv") + some_tech_storage_srv.file_system.create_file(file_name="test.png") + + some_tech_hr_pc: Computer = network.get_node_by_hostname("some_tech_hr_1") + hr_ftp_client: FTPClient = some_tech_hr_pc.software_manager.software["FTPClient"] + + assert not hr_ftp_client.request_file( + dest_ip_address=some_tech_storage_srv.network_interface[1].ip_address, + src_folder_name="root", + src_file_name="test.png", + dest_folder_name="root", + dest_file_name="test.png", + ) diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py new file mode 100644 index 00000000..5cf36bce --- /dev/null +++ b/tests/integration_tests/network/test_network_creation.py @@ -0,0 +1,103 @@ +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.host_node import NIC +from primaite.simulator.network.hardware.nodes.host.server import Server + + +def test_network(example_network): + network: Network = example_network + client_1: Computer = network.get_node_by_hostname("client_1") + client_2: Computer = network.get_node_by_hostname("client_2") + server_1: Server = network.get_node_by_hostname("server_1") + server_2: Server = network.get_node_by_hostname("server_2") + + assert client_1.ping(client_2.network_interface[1].ip_address) + assert client_2.ping(client_1.network_interface[1].ip_address) + + assert server_1.ping(server_2.network_interface[1].ip_address) + assert server_2.ping(server_1.network_interface[1].ip_address) + + assert client_1.ping(server_1.network_interface[1].ip_address) + assert client_2.ping(server_1.network_interface[1].ip_address) + assert client_1.ping(server_2.network_interface[1].ip_address) + assert client_2.ping(server_2.network_interface[1].ip_address) + + +def test_adding_removing_nodes(): + """Check that we can create and add a node to a network.""" + net = Network() + n1 = Computer(hostname="computer", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net + + +def test_readding_node(): + """Check that warning is raised when readding a node.""" + net = Network() + n1 = Computer(hostname="computer", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + net.add_node(n1) + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + +def test_removing_nonexistent_node(): + """Check that warning is raised when trying to remove a node that is not in the network.""" + net = Network() + n1 = Computer(hostname="computer1", ip_address="192.168.1.1", subnet_mask="255.255.255.0", start_up_duration=0) + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net + + +def test_connecting_nodes(): + """Check that two nodes on the network can be connected.""" + net = Network() + n1 = Computer(hostname="computer1", ip_address="192.168.1.1", subnet_mask="255.255.255.0", start_up_duration=0) + n2 = Computer(hostname="computer2", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + + net.add_node(n1) + net.add_node(n2) + + net.connect(n1.network_interface[1], n2.network_interface[1]) + + assert len(net.links) == 1 + link = list(net.links.values())[0] + assert link in net + assert link.parent is net + + +def test_connecting_node_to_itself_fails(): + net = Network() + node = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node.power_on() + node.connect_nic(NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0")) + + net.add_node(node) + + net.connect(node.network_interface[1], node.network_interface[2]) + + assert node in net + assert node.network_interface[1]._connected_link is None + assert node.network_interface[2]._connected_link is None + assert len(net.links) == 0 + + +def test_disconnecting_nodes(): + net = Network() + + n1 = Computer(hostname="computer1", ip_address="192.168.1.1", subnet_mask="255.255.255.0", start_up_duration=0) + n2 = Computer(hostname="computer2", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + + net.connect(n1.network_interface[1], n2.network_interface[1]) + assert len(net.links) == 1 + + link = list(net.links.values())[0] + net.remove_link(link) + assert link not in net + assert len(net.links) == 0 diff --git a/tests/integration_tests/network/test_nic_link_connection.py b/tests/integration_tests/network/test_nic_link_connection.py new file mode 100644 index 00000000..f13248a2 --- /dev/null +++ b/tests/integration_tests/network/test_nic_link_connection.py @@ -0,0 +1,11 @@ +import pytest + +from primaite.simulator.network.hardware.base import Link +from primaite.simulator.network.hardware.nodes.host.host_node import NIC + + +def test_link_fails_with_same_nic(): + """Tests Link creation fails with endpoint_a and endpoint_b are the same NIC.""" + with pytest.raises(ValueError): + nic_a = NIC(ip_address="192.168.1.2", subnet_mask="255.255.255.0") + Link(endpoint_a=nic_a, endpoint_b=nic_a) diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py new file mode 100644 index 00000000..267b9b53 --- /dev/null +++ b/tests/integration_tests/network/test_routing.py @@ -0,0 +1,217 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer + + +@pytest.fixture(scope="function") +def pc_a_pc_b_router_1() -> Tuple[Computer, Computer, Router]: + network = Network() + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + pc_b.power_on() + + router_1 = Router(hostname="router_1", start_up_duration=0) + router_1.power_on() + + router_1.configure_port(1, "192.168.0.1", "255.255.255.0") + router_1.configure_port(2, "192.168.1.1", "255.255.255.0") + + network.connect(endpoint_a=pc_a.network_interface[1], endpoint_b=router_1.network_interface[1]) + network.connect(endpoint_a=pc_b.network_interface[1], endpoint_b=router_1.network_interface[2]) + router_1.enable_port(1) + router_1.enable_port(2) + + return pc_a, pc_b, router_1 + + +@pytest.fixture(scope="function") +def multi_hop_network() -> Network: + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = Router(hostname="router_1", start_up_duration=0) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_port(2, "192.168.0.1", "255.255.255.0") + network.connect(pc_a.network_interface[1], router_1.network_interface[2]) + router_1.enable_port(2) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = Router(hostname="router_2", start_up_duration=0) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_port(2, "192.168.2.1", "255.255.255.0") + network.connect(pc_b.network_interface[1], router_2.network_interface[2]) + router_2.enable_port(2) + + # Configure Router 2 ACLs + + # Configure the connection between Router 1 port 1 and Router 2 port 1 + router_2.configure_port(1, "192.168.1.2", "255.255.255.252") + router_1.configure_port(1, "192.168.1.1", "255.255.255.252") + network.connect(router_1.network_interface[1], router_2.network_interface[1]) + router_1.enable_port(1) + router_2.enable_port(1) + return network + + +def test_ping_default_gateway(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping(pc_a.default_gateway) + + +def test_ping_other_router_port(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping(pc_b.default_gateway) + + +def test_host_on_other_subnet(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping(pc_b.network_interface[1].ip_address) + + +def test_no_route_no_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + assert not pc_a.ping(pc_b.network_interface[1].ip_address) + + +def test_with_routes_can_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + assert pc_a.ping(pc_b.network_interface[1].ip_address) + + +def test_with_default_routes_can_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.set_default_route_next_hop_ip_address("192.168.1.2") + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.set_default_route_next_hop_ip_address("192.168.1.1") + + assert pc_a.ping(pc_b.network_interface[1].ip_address) + + +def test_ping_router_port_multi_hop(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + router_2 = multi_hop_network.get_node_by_hostname("router_2") + + router_2.route_table.add_route( + address="192.168.0.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + assert pc_a.ping(router_2.network_interface[1].ip_address) + + +def test_routing_services(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + pc_a.software_manager.install(NTPClient) + ntp_client = pc_a.software_manager.software["NTPClient"] + ntp_client.start() + + pc_b.software_manager.install(NTPServer) + pc_b.software_manager.software["NTPServer"].start() + + ntp_client.configure(ntp_server_ip_address=pc_b.network_interface[1].ip_address) + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + + assert ntp_client.time is None + ntp_client.request_time() + assert ntp_client.time is None + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + ntp_client.request_time() + assert ntp_client.time is not None diff --git a/tests/integration_tests/network/test_switched_network.py b/tests/integration_tests/network/test_switched_network.py new file mode 100644 index 00000000..98f36df6 --- /dev/null +++ b/tests/integration_tests/network/test_switched_network.py @@ -0,0 +1,5 @@ +def test_switched_network(client_switch_server): + """Tests a node can ping another node via the switch.""" + computer, switch, server = client_switch_server + + assert computer.ping(server.network_interface[1].ip_address) diff --git a/tests/integration_tests/network/test_wireless_router.py b/tests/integration_tests/network/test_wireless_router.py new file mode 100644 index 00000000..d739bd0b --- /dev/null +++ b/tests/integration_tests/network/test_wireless_router.py @@ -0,0 +1,113 @@ +import pytest +import yaml + +from primaite.game.game import PrimaiteGame +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.network.router import ACLAction +from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from tests import TEST_ASSETS_ROOT + + +@pytest.fixture(scope="function") +def wireless_wan_network(): + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = WirelessRouter(hostname="router_1", start_up_duration=0, airspace=network.airspace) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_router_interface("192.168.0.1", "255.255.255.0") + network.connect(pc_a.network_interface[1], router_1.network_interface[2]) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = WirelessRouter(hostname="router_2", start_up_duration=0, airspace=network.airspace) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_router_interface("192.168.2.1", "255.255.255.0") + network.connect(pc_b.network_interface[1], router_2.network_interface[2]) + + # Configure Router 2 ACLs + + # Configure the wireless connection between Router 1 port 1 and Router 2 port 1 + router_1.configure_wireless_access_point("192.168.1.1", "255.255.255.0") + router_2.configure_wireless_access_point("192.168.1.2", "255.255.255.0") + + network.airspace.show() + + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + return pc_a, pc_b, router_1, router_2 + + +@pytest.fixture(scope="function") +def wireless_wan_network_from_config_yaml(): + config_path = TEST_ASSETS_ROOT / "configs" / "wireless_wan_network_config.yaml" + + with open(config_path, "r") as f: + config_dict = yaml.safe_load(f) + network = PrimaiteGame.from_config(cfg=config_dict).simulation.network + + network.airspace.show() + + return network + + +def test_cross_wireless_wan_connectivity(wireless_wan_network): + pc_a, pc_b, router_1, router_2 = wireless_wan_network + # Ensure that PCs can ping across routers before any frequency change + assert pc_a.ping(pc_a.default_gateway), "PC A should ping its default gateway successfully." + assert pc_b.ping(pc_b.default_gateway), "PC B should ping its default gateway successfully." + + assert pc_a.ping(pc_b.network_interface[1].ip_address), "PC A should ping PC B across routers successfully." + assert pc_b.ping(pc_a.network_interface[1].ip_address), "PC B should ping PC A across routers successfully." + + +def test_cross_wireless_wan_connectivity_from_yaml(wireless_wan_network_from_config_yaml): + pc_a = wireless_wan_network_from_config_yaml.get_node_by_hostname("pc_a") + pc_b = wireless_wan_network_from_config_yaml.get_node_by_hostname("pc_b") + + assert pc_a.ping(pc_a.default_gateway), "PC A should ping its default gateway successfully." + assert pc_b.ping(pc_b.default_gateway), "PC B should ping its default gateway successfully." + + assert pc_a.ping(pc_b.network_interface[1].ip_address), "PC A should ping PC B across routers successfully." + assert pc_b.ping(pc_a.network_interface[1].ip_address), "PC B should ping PC A across routers successfully." diff --git a/tests/integration_tests/system/__init__.py b/tests/integration_tests/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py b/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py new file mode 100644 index 00000000..69d14b46 --- /dev/null +++ b/tests/integration_tests/system/red_applications/test_data_manipulation_bot_and_server.py @@ -0,0 +1,157 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import ( + DataManipulationAttackStage, + DataManipulationBot, +) +from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.software import SoftwareHealthState + + +@pytest.fixture(scope="function") +def data_manipulation_bot_and_db_server(client_server) -> Tuple[DataManipulationBot, Computer, DatabaseService, Server]: + computer, server = client_server + + # install db client on computer + computer.software_manager.install(DatabaseClient) + db_client: DatabaseClient = computer.software_manager.software.get("DatabaseClient") + db_client.run() + + # Install DoSBot on computer + computer.software_manager.install(DataManipulationBot) + + data_manipulation_bot: DataManipulationBot = computer.software_manager.software.get("DataManipulationBot") + data_manipulation_bot.configure( + server_ip_address=IPv4Address(server.network_interface[1].ip_address), payload="DELETE" + ) + + # Install DB Server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + return data_manipulation_bot, computer, db_server_service, server + + +@pytest.fixture(scope="function") +def data_manipulation_db_server_green_client(example_network) -> Network: + network: Network = example_network + + router_1: Router = example_network.get_node_by_hostname("router_1") + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + client_1: Computer = network.get_node_by_hostname("client_1") + client_2: Computer = network.get_node_by_hostname("client_2") + server: Server = network.get_node_by_hostname("server_1") + + # install db client on client 1 + client_1.software_manager.install(DatabaseClient) + db_client: DatabaseClient = client_1.software_manager.software.get("DatabaseClient") + db_client.run() + + # install Data Manipulation bot on client 1 + client_1.software_manager.install(DataManipulationBot) + + data_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + data_manipulation_bot.configure( + server_ip_address=IPv4Address(server.network_interface[1].ip_address), payload="DELETE" + ) + + # install db server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + # Install DB client (green) on client 2 + client_2.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address(server.network_interface[1].ip_address)) + database_client.run() + + return network + + +def test_repeating_data_manipulation_attack(data_manipulation_bot_and_db_server): + """Test a repeating data manipulation attack.""" + data_manipulation_bot, computer, db_server_service, server = data_manipulation_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + data_manipulation_bot.port_scan_p_of_success = 1 + data_manipulation_bot.data_manipulation_p_of_success = 1 + data_manipulation_bot.repeat = True + data_manipulation_bot.attack() + + assert data_manipulation_bot.attack_stage == DataManipulationAttackStage.NOT_STARTED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert data_manipulation_bot.attack_stage == DataManipulationAttackStage.NOT_STARTED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + + +def test_non_repeating_data_manipulation_attack(data_manipulation_bot_and_db_server): + """Test a non repeating data manipulation attack.""" + data_manipulation_bot, computer, db_server_service, server = data_manipulation_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + data_manipulation_bot.port_scan_p_of_success = 1 + data_manipulation_bot.data_manipulation_p_of_success = 1 + data_manipulation_bot.repeat = False + data_manipulation_bot.attack() + + assert data_manipulation_bot.attack_stage == DataManipulationAttackStage.SUCCEEDED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert data_manipulation_bot.attack_stage == DataManipulationAttackStage.SUCCEEDED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + + +def test_data_manipulation_disrupts_green_agent_connection(data_manipulation_db_server_green_client): + """Test to see that the data manipulation bot affects a green agent query.""" + network: Network = data_manipulation_db_server_green_client + + client_1: Computer = network.get_node_by_hostname("client_1") + data_manipulation_bot: DataManipulationBot = client_1.software_manager.software.get("DataManipulationBot") + + client_2: Computer = network.get_node_by_hostname("client_2") + green_db_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + + server: Server = network.get_node_by_hostname("server_1") + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + + green_db_connection: DatabaseClientConnection = green_db_client.get_new_connection() + + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.GOOD + assert green_db_connection.query("SELECT") + assert green_db_client.last_query_response.get("status_code") == 200 + + data_manipulation_bot.port_scan_p_of_success = 1 + data_manipulation_bot.data_manipulation_p_of_success = 1 + data_manipulation_bot.repeat = False + data_manipulation_bot.attack() + + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + assert green_db_connection.query("SELECT") is False + assert green_db_client.last_query_response.get("status_code") != 200 diff --git a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py new file mode 100644 index 00000000..8ed10da6 --- /dev/null +++ b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py @@ -0,0 +1,184 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.software import SoftwareHealthState + + +@pytest.fixture(scope="function") +def dos_bot_and_db_server(client_server) -> Tuple[DoSBot, Computer, DatabaseService, Server]: + computer, server = client_server + + # Install DoSBot on computer + computer.software_manager.install(DoSBot) + + dos_bot: DoSBot = computer.software_manager.software.get("DoSBot") + dos_bot.configure( + target_ip_address=IPv4Address(server.network_interface[1].ip_address), + target_port=Port.POSTGRES_SERVER, + ) + + # Install DB Server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + return dos_bot, computer, db_server_service, server + + +@pytest.fixture(scope="function") +def dos_bot_db_server_green_client(example_network) -> Network: + network: Network = example_network + + router_1: Router = example_network.get_node_by_hostname("router_1") + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + client_1: Computer = network.get_node_by_hostname("client_1") + client_2: Computer = network.get_node_by_hostname("client_2") + server: Server = network.get_node_by_hostname("server_1") + + # install DoS bot on client 1 + client_1.software_manager.install(DoSBot) + + dos_bot: DoSBot = client_1.software_manager.software.get("DoSBot") + dos_bot.configure( + target_ip_address=IPv4Address(server.network_interface[1].ip_address), + target_port=Port.POSTGRES_SERVER, + ) + + # install db server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + # Install DB client (green) on client 2 + client_2.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address("192.168.0.1")) + database_client.run() + + return network + + +@pytest.mark.xfail(reason="Tests fail due to recent changes in how DB connections are handled for example layout.") +def test_repeating_dos_attack(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = True + dos_bot.run() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + db_server_service.clear_connections() + db_server_service.set_health_state(SoftwareHealthState.GOOD) + assert len(db_server_service.connections) == 0 + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + +@pytest.mark.xfail(reason="Tests fail due to recent changes in how DB connections are handled for example layout.") +def test_non_repeating_dos_attack(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = False + dos_bot.run() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + db_server_service.clear_connections() + db_server_service.set_health_state(SoftwareHealthState.GOOD) + assert len(db_server_service.connections) == 0 + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert len(dos_bot.connections) == 0 + assert len(db_server_service.connections) == 0 + assert len(dos_bot.connections) == 0 + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + +@pytest.mark.xfail(reason="Tests fail due to recent changes in how DB connections are handled for example layout.") +def test_dos_bot_database_service_connection(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + dos_bot.operating_state = ApplicationOperatingState.RUNNING + dos_bot.attack_stage = DoSAttackStage.PORT_SCAN + dos_bot._perform_dos() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + +@pytest.mark.xfail(reason="Tests fail due to recent changes in how DB connections are handled for example layout.") +def test_dos_blocks_green_agent_connection(dos_bot_db_server_green_client): + network: Network = dos_bot_db_server_green_client + + client_1: Computer = network.get_node_by_hostname("client_1") + dos_bot: DoSBot = client_1.software_manager.software.get("DoSBot") + + client_2: Computer = network.get_node_by_hostname("client_2") + green_db_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + + server: Server = network.get_node_by_hostname("server_1") + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = False + dos_bot.run() + + # DoS bot fills up connection of db server service + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(green_db_client.connections) == 0 + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + # db server service is overwhelmed + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + # green agent tries to connect but fails because service is overwhelmed + assert green_db_client.connect() is False + assert len(green_db_client.connections) == 0 diff --git a/tests/integration_tests/system/red_applications/test_ransomware_script.py b/tests/integration_tests/system/red_applications/test_ransomware_script.py new file mode 100644 index 00000000..9a04610b --- /dev/null +++ b/tests/integration_tests/system/red_applications/test_ransomware_script.py @@ -0,0 +1,164 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection +from primaite.simulator.system.applications.red_applications.ransomware_script import ( + RansomwareAttackStage, + RansomwareScript, +) +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.software import SoftwareHealthState + + +@pytest.fixture(scope="function") +def ransomware_script_and_db_server(client_server) -> Tuple[RansomwareScript, Computer, DatabaseService, Server]: + computer, server = client_server + + # install db client on computer + computer.software_manager.install(DatabaseClient) + db_client: DatabaseClient = computer.software_manager.software.get("DatabaseClient") + db_client.run() + + # Install DoSBot on computer + computer.software_manager.install(RansomwareScript) + + ransomware_script_application: RansomwareScript = computer.software_manager.software.get("RansomwareScript") + ransomware_script_application.configure( + server_ip_address=IPv4Address(server.network_interface[1].ip_address), payload="ENCRYPT" + ) + + # Install DB Server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + return ransomware_script_application, computer, db_server_service, server + + +@pytest.fixture(scope="function") +def ransomware_script_db_server_green_client(example_network) -> Network: + network: Network = example_network + + router_1: Router = example_network.get_node_by_hostname("router_1") + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + client_1: Computer = network.get_node_by_hostname("client_1") + client_2: Computer = network.get_node_by_hostname("client_2") + server: Server = network.get_node_by_hostname("server_1") + + # install db client on client 1 + client_1.software_manager.install(DatabaseClient) + db_client: DatabaseClient = client_1.software_manager.software.get("DatabaseClient") + db_client.run() + + # install Ransomware Script bot on client 1 + client_1.software_manager.install(RansomwareScript) + + ransomware_script_application: RansomwareScript = client_1.software_manager.software.get("RansomwareScript") + ransomware_script_application.configure( + server_ip_address=IPv4Address(server.network_interface[1].ip_address), payload="ENCRYPT" + ) + + # install db server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + # Install DB client (green) on client 2 + client_2.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address(server.network_interface[1].ip_address)) + database_client.run() + + return network + + +def test_repeating_ransomware_script_attack(ransomware_script_and_db_server): + """Test a repeating data manipulation attack.""" + RansomwareScript, computer, db_server_service, server = ransomware_script_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + assert computer.file_system.num_file_creations == 0 + + RansomwareScript.target_scan_p_of_success = 1 + RansomwareScript.c2_beacon_p_of_success = 1 + RansomwareScript.ransomware_encrypt_p_of_success = 1 + RansomwareScript.repeat = True + RansomwareScript.attack() + + assert RansomwareScript.attack_stage == RansomwareAttackStage.NOT_STARTED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + assert computer.file_system.num_file_creations == 1 + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert RansomwareScript.attack_stage == RansomwareAttackStage.NOT_STARTED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.COMPROMISED + + +def test_repeating_ransomware_script_attack(ransomware_script_and_db_server): + """Test a repeating ransowmare script attack.""" + RansomwareScript, computer, db_server_service, server = ransomware_script_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + RansomwareScript.target_scan_p_of_success = 1 + RansomwareScript.c2_beacon_p_of_success = 1 + RansomwareScript.ransomware_encrypt_p_of_success = 1 + RansomwareScript.repeat = False + RansomwareScript.attack() + + assert RansomwareScript.attack_stage == RansomwareAttackStage.SUCCEEDED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.CORRUPT + assert computer.file_system.num_file_creations == 1 + + computer.apply_timestep(timestep=1) + computer.pre_timestep(timestep=1) + server.apply_timestep(timestep=1) + server.pre_timestep(timestep=1) + + assert RansomwareScript.attack_stage == RansomwareAttackStage.SUCCEEDED + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.CORRUPT + assert computer.file_system.num_file_creations == 0 + + +def test_ransomware_disrupts_green_agent_connection(ransomware_script_db_server_green_client): + """Test to see show that the database service still operate""" + network: Network = ransomware_script_db_server_green_client + + client_1: Computer = network.get_node_by_hostname("client_1") + ransomware_script_application: RansomwareScript = client_1.software_manager.software.get("RansomwareScript") + + client_2: Computer = network.get_node_by_hostname("client_2") + green_db_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + green_db_client_connection: DatabaseClientConnection = green_db_client.get_new_connection() + + server: Server = network.get_node_by_hostname("server_1") + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.GOOD + assert green_db_client_connection.query("SELECT") + assert green_db_client.last_query_response.get("status_code") == 200 + + ransomware_script_application.target_scan_p_of_success = 1 + ransomware_script_application.ransomware_encrypt_p_of_success = 1 + ransomware_script_application.c2_beacon_p_of_success = 1 + ransomware_script_application.repeat = False + ransomware_script_application.attack() + + assert db_server_service.db_file.health_status is FileSystemItemHealthStatus.CORRUPT + assert green_db_client_connection.query("SELECT") is True + assert green_db_client.last_query_response.get("status_code") == 200 diff --git a/tests/integration_tests/system/test_application_on_node.py b/tests/integration_tests/system/test_application_on_node.py new file mode 100644 index 00000000..6a493955 --- /dev/null +++ b/tests/integration_tests/system/test_application_on_node.py @@ -0,0 +1,108 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.system.applications.application import Application, ApplicationOperatingState + + +@pytest.fixture(scope="function") +def populated_node(application_class) -> Tuple[Application, Computer]: + computer: Computer = Computer( + hostname="test_computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + shut_down_duration=0, + ) + computer.power_on() + computer.software_manager.install(application_class) + + app = computer.software_manager.software.get("TestApplication") + app.run() + + return app, computer + + +def test_application_on_offline_node(application_class): + """Test to check that the application cannot be interacted with when node it is on is off.""" + computer: Computer = Computer( + hostname="test_computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + shut_down_duration=0, + ) + computer.software_manager.install(application_class) + + app: Application = computer.software_manager.software.get("TestApplication") + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + app.run() + assert app.operating_state is ApplicationOperatingState.CLOSED + + +def test_server_turns_off_application(populated_node): + """Check that the application is turned off when the server is turned off""" + app, computer = populated_node + + assert computer.operating_state is NodeOperatingState.ON + assert app.operating_state is ApplicationOperatingState.RUNNING + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + +def test_application_cannot_be_turned_on_when_computer_is_off(populated_node): + """Check that the application cannot be started when the computer is off.""" + app, computer = populated_node + + assert computer.operating_state is NodeOperatingState.ON + assert app.operating_state is ApplicationOperatingState.RUNNING + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + app.run() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + +def test_computer_runs_applications(populated_node): + """Check that turning on the computer will turn on applications.""" + app, computer = populated_node + + assert computer.operating_state is NodeOperatingState.ON + assert app.operating_state is ApplicationOperatingState.RUNNING + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + computer.power_on() + + assert computer.operating_state is NodeOperatingState.ON + assert app.operating_state is ApplicationOperatingState.RUNNING + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert app.operating_state is ApplicationOperatingState.CLOSED + + computer.power_on() + + assert computer.operating_state is NodeOperatingState.ON + assert app.operating_state is ApplicationOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_database_on_node.py b/tests/integration_tests/system/test_database_on_node.py new file mode 100644 index 00000000..ea7d7aae --- /dev/null +++ b/tests/integration_tests/system/test_database_on_node.py @@ -0,0 +1,364 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.switch import Switch +from primaite.simulator.system.applications.database_client import DatabaseClient, DatabaseClientConnection +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def peer_to_peer() -> Tuple[Computer, Computer]: + network = Network() + node_a = Computer(hostname="node_a", ip_address="192.168.0.10", subnet_mask="255.255.255.0", start_up_duration=0) + node_a.power_on() + node_a.software_manager.get_open_ports() + + node_b = Computer(hostname="node_b", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0) + node_b.power_on() + network.connect(node_a.network_interface[1], node_b.network_interface[1]) + + assert node_a.ping("192.168.0.11") + + node_a.software_manager.install(DatabaseClient) + node_a.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.11")) + node_a.software_manager.software["DatabaseClient"].run() + + node_b.software_manager.install(DatabaseService) + database_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + database_service.start() + return node_a, node_b + + +@pytest.fixture(scope="function") +def peer_to_peer_secure_db(peer_to_peer) -> Tuple[Computer, Computer]: + node_a, node_b = peer_to_peer + + database_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + database_service.stop() + database_service.password = "12345" + database_service.start() + return node_a, node_b + + +def test_database_client_server_connection(peer_to_peer): + node_a, node_b = peer_to_peer + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] + + db_client.connect() + + assert len(db_client.client_connections) == 1 + assert len(db_service.connections) == 1 + + db_client.disconnect() + assert len(db_client.client_connections) == 0 + assert len(db_service.connections) == 0 + + +def test_database_client_server_correct_password(peer_to_peer_secure_db): + node_a, node_b = peer_to_peer_secure_db + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] + + db_client.configure(server_ip_address=IPv4Address("192.168.0.11"), server_password="12345") + db_client.connect() + assert len(db_client.client_connections) == 1 + assert len(db_service.connections) == 1 + + +def test_database_client_server_incorrect_password(peer_to_peer_secure_db): + node_a, node_b = peer_to_peer_secure_db + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] + + # should fail + db_client.connect() + assert len(db_client.connections) == 0 + assert len(db_service.connections) == 0 + + db_client.configure(server_ip_address=IPv4Address("192.168.0.11"), server_password="wrongpass") + db_client.connect() + assert len(db_client.connections) == 0 + assert len(db_service.connections) == 0 + + +def test_database_client_native_connection_query(uc2_network): + """Tests DB query across the network returns HTTP status 200 and date.""" + web_server: Server = uc2_network.get_node_by_hostname("web_server") + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] + db_client.connect() + assert db_client.query(sql="SELECT") + assert db_client.query(sql="INSERT") + + +def test_database_client_connection_query(uc2_network): + """Tests DB query across the network returns HTTP status 200 and date.""" + web_server: Server = uc2_network.get_node_by_hostname("web_server") + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] + + db_connection: DatabaseClientConnection = db_client.get_new_connection() + + assert db_connection.query(sql="SELECT") + assert db_connection.query(sql="INSERT") + + +def test_create_database_backup(uc2_network): + """Run the backup_database method and check if the FTP server has the relevant file.""" + db_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] + + # back up should be created + assert db_service.backup_database() is True + + backup_server: Server = uc2_network.get_node_by_hostname("backup_server") + ftp_server: FTPServer = backup_server.software_manager.software["FTPServer"] + + # backup file should exist in the backup server + assert ftp_server.file_system.get_file(folder_name=db_service.uuid, file_name="database.db") is not None + + +def test_restore_backup(uc2_network): + """Run the restore_backup method and check if the backup is properly restored.""" + db_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] + + # create a back up + assert db_service.backup_database() is True + + # delete database locally + db_service.file_system.delete_file(folder_name="database", file_name="database.db") + + assert db_service.file_system.get_file(folder_name="database", file_name="database.db") is None + + # back up should be restored + assert db_service.restore_backup() is True + + assert db_service.file_system.get_file(folder_name="database", file_name="database.db") is not None + + +def test_restore_backup_without_updating_scan(uc2_network): + """Same test as restore backup but the file is previously seen as corrupted.""" + db_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] + + # create a back up + assert db_service.backup_database() is True + + db_service.db_file.corrupt() # corrupt the db + assert db_service.db_file.health_status == FileSystemItemHealthStatus.CORRUPT # db file is actually corrupt + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.GOOD # not scanned yet + + db_service.db_file.scan() # scan the db file + + # db file is corrupt since last scan + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + # back up should be restored + assert db_service.restore_backup() is True + + assert db_service.db_file.health_status == FileSystemItemHealthStatus.GOOD # db file is actually good + # db file is corrupt since last scan + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + db_service.db_file.scan() # scan the db file + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.GOOD # now looks good + + +def test_restore_backup_after_deleting_file_without_updating_scan(uc2_network): + """Same test as restore backup but the file is previously seen as corrupted.""" + db_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] + + assert db_service.backup_database() is True + + db_service.db_file.corrupt() # corrupt the db + assert db_service.db_file.health_status == FileSystemItemHealthStatus.CORRUPT # db file is actually corrupt + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.GOOD # not scanned yet + + db_service.db_file.scan() # scan the db file + + # db file is corrupt since last scan + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + # delete database locally + db_service.file_system.delete_file(folder_name="database", file_name="database.db") + + # db file is gone, reduced to atoms + assert db_service.db_file is None + + # back up should be restored + assert db_service.restore_backup() is True + + assert db_service.db_file.health_status == FileSystemItemHealthStatus.GOOD # db file is actually good + # db file is corrupt since last scan + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + db_service.db_file.scan() # scan the db file + assert db_service.db_file.visible_health_status == FileSystemItemHealthStatus.GOOD # now looks good + + +def test_database_client_cannot_query_offline_database_server(uc2_network): + """Tests DB query across the network returns HTTP status 404 when db server is offline.""" + db_server: Server = uc2_network.get_node_by_hostname("database_server") + db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + + assert db_server.operating_state is NodeOperatingState.ON + assert db_service.operating_state is ServiceOperatingState.RUNNING + + web_server: Server = uc2_network.get_node_by_hostname("web_server") + db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") + db_client.connect() + assert len(db_client.client_connections) + + # Establish a new connection to the DatabaseService + db_connection: DatabaseClientConnection = db_client.get_new_connection() + + assert db_connection.query("SELECT") is True + assert db_connection.query("INSERT") is True + db_server.power_off() + + for i in range(db_server.shut_down_duration + 1): + uc2_network.apply_timestep(timestep=i) + + assert db_server.operating_state is NodeOperatingState.OFF + assert db_service.operating_state is ServiceOperatingState.STOPPED + + assert db_connection.query("SELECT") is False + assert db_connection.query("INSERT") is False + + +def test_database_client_uninstall_terminates_connections(peer_to_peer): + node_a, node_b = peer_to_peer + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + + db_connection: DatabaseClientConnection = db_client.get_new_connection() + + # Check that all connection counters are correct and that the client connection can query the database + assert len(db_service.connections) == 1 + + assert len(db_client.client_connections) == 1 + + assert db_connection.is_active + + assert db_connection.query("SELECT") + + # Perform the DatabaseClient uninstall + node_a.software_manager.uninstall("DatabaseClient") + + # Check that all connection counters are updated accordingly and client connection can no longer query the database + assert len(db_service.connections) == 0 + + assert len(db_client.client_connections) == 0 + + assert not db_connection.query("SELECT") + + assert not db_connection.is_active + + +def test_database_service_can_terminate_connection(peer_to_peer): + node_a, node_b = peer_to_peer + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + + db_connection: DatabaseClientConnection = db_client.get_new_connection() + + # Check that all connection counters are correct and that the client connection can query the database + assert len(db_service.connections) == 1 + + assert len(db_client.client_connections) == 1 + + assert db_connection.is_active + + assert db_connection.query("SELECT") + + # Perform the server-led connection termination + connection_id = next(iter(db_service.connections.keys())) + db_service.terminate_connection(connection_id) + + # Check that all connection counters are updated accordingly and client connection can no longer query the database + assert len(db_service.connections) == 0 + + assert len(db_client.client_connections) == 0 + + assert not db_connection.query("SELECT") + + assert not db_connection.is_active + + +def test_client_connection_terminate_does_not_terminate_another_clients_connection(): + network = Network() + + db_server = Server( + hostname="db_client", ip_address="192.168.0.11", subnet_mask="255.255.255.0", start_up_duration=0 + ) + db_server.power_on() + + db_server.software_manager.install(DatabaseService) + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] # noqa + db_service.start() + + client_a = Computer( + hostname="client_a", ip_address="192.168.0.12", subnet_mask="255.255.255.0", start_up_duration=0 + ) + client_a.power_on() + + client_a.software_manager.install(DatabaseClient) + client_a.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.11")) + client_a.software_manager.software["DatabaseClient"].run() + + client_b = Computer( + hostname="client_b", ip_address="192.168.0.13", subnet_mask="255.255.255.0", start_up_duration=0 + ) + client_b.power_on() + + client_b.software_manager.install(DatabaseClient) + client_b.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.11")) + client_b.software_manager.software["DatabaseClient"].run() + + switch = Switch(hostname="switch", start_up_duration=0, num_ports=3) + switch.power_on() + + network.connect(endpoint_a=switch.network_interface[1], endpoint_b=db_server.network_interface[1]) + network.connect(endpoint_a=switch.network_interface[2], endpoint_b=client_a.network_interface[1]) + network.connect(endpoint_a=switch.network_interface[3], endpoint_b=client_b.network_interface[1]) + + db_client_a: DatabaseClient = client_a.software_manager.software["DatabaseClient"] # noqa + db_connection_a = db_client_a.get_new_connection() + + assert db_connection_a.query("SELECT") + assert len(db_service.connections) == 1 + + db_client_b: DatabaseClient = client_b.software_manager.software["DatabaseClient"] # noqa + db_connection_b = db_client_b.get_new_connection() + + assert db_connection_b.query("SELECT") + assert len(db_service.connections) == 2 + + db_connection_a.disconnect() + + assert db_connection_b.query("SELECT") + assert len(db_service.connections) == 1 + + +def test_database_server_install_ftp_client(): + server = Server(hostname="db_server", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + server.software_manager.install(DatabaseService) + assert server.software_manager.software.get("FTPClient") diff --git a/tests/integration_tests/system/test_dns_client_server.py b/tests/integration_tests/system/test_dns_client_server.py new file mode 100644 index 00000000..e6275459 --- /dev/null +++ b/tests/integration_tests/system/test_dns_client_server.py @@ -0,0 +1,84 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def dns_client_and_dns_server(client_server) -> Tuple[DNSClient, Computer, DNSServer, Server]: + computer, server = client_server + + # Install DNS Client on computer + computer.software_manager.install(DNSClient) + dns_client: DNSClient = computer.software_manager.software.get("DNSClient") + dns_client.start() + # set server as DNS Server + dns_client.dns_server = IPv4Address(server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address) + + # Install DNS Server on server + server.software_manager.install(DNSServer) + dns_server: DNSServer = server.software_manager.software.get("DNSServer") + dns_server.start() + # register arcd.com as a domain + dns_server.dns_register( + domain_name="arcd.com", + domain_ip_address=IPv4Address(server.network_interface[1].ip_address), + ) + + return dns_client, computer, dns_server, server + + +def test_dns_client_server(dns_client_and_dns_server): + dns_client, computer, dns_server, server = dns_client_and_dns_server + + assert dns_client.operating_state == ServiceOperatingState.RUNNING + assert dns_server.operating_state == ServiceOperatingState.RUNNING + + dns_server.show() + + # fake domain should not be added to dns cache + assert not dns_client.check_domain_exists(target_domain="fake-domain.com") + assert dns_client.dns_cache.get("fake-domain.com", None) is None + + # arcd.com is registered in dns server and should be saved to cache + assert dns_client.check_domain_exists(target_domain="arcd.com") + assert dns_client.dns_cache.get("arcd.com", None) is not None + + assert len(dns_client.dns_cache) == 1 + + +def test_dns_client_requests_offline_dns_server(dns_client_and_dns_server): + dns_client, computer, dns_server, server = dns_client_and_dns_server + + assert dns_client.operating_state == ServiceOperatingState.RUNNING + assert dns_server.operating_state == ServiceOperatingState.RUNNING + + dns_server.show() + + # arcd.com is registered in dns server + assert dns_client.check_domain_exists(target_domain="arcd.com") + assert dns_client.dns_cache.get("arcd.com", None) is not None + + assert len(dns_client.dns_cache) == 1 + dns_client.dns_cache = {} + + server.power_off() + + for i in range(server.shut_down_duration + 1): + server.apply_timestep(timestep=i) + + assert server.operating_state == NodeOperatingState.OFF + assert dns_server.operating_state == ServiceOperatingState.STOPPED + + # this time it should not cache because dns server is not online + assert dns_client.check_domain_exists(target_domain="arcd.com") is False + assert dns_client.dns_cache.get("arcd.com", None) is None + + assert len(dns_client.dns_cache) == 0 diff --git a/tests/integration_tests/system/test_ftp_client_server.py b/tests/integration_tests/system/test_ftp_client_server.py new file mode 100644 index 00000000..6b46e302 --- /dev/null +++ b/tests/integration_tests/system/test_ftp_client_server.py @@ -0,0 +1,106 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def ftp_client_and_ftp_server(client_server) -> Tuple[FTPClient, Computer, FTPServer, Server]: + computer, server = client_server + + # Install FTP Client service on computer + computer.software_manager.install(FTPClient) + ftp_client: FTPClient = computer.software_manager.software.get("FTPClient") + ftp_client.start() + + # Install FTP Server service on server + server.software_manager.install(FTPServer) + ftp_server: FTPServer = server.software_manager.software.get("FTPServer") + ftp_server.start() + + return ftp_client, computer, ftp_server, server + + +def test_ftp_client_store_file_in_server(ftp_client_and_ftp_server): + """ + Test checks to see if the client is able to store files in the backup server. + """ + ftp_client, computer, ftp_server, server = ftp_client_and_ftp_server + + assert ftp_client.operating_state == ServiceOperatingState.RUNNING + assert ftp_server.operating_state == ServiceOperatingState.RUNNING + + # create file on ftp client + ftp_client.file_system.create_file(file_name="test_file.txt") + + assert ftp_client.send_file( + src_folder_name="root", + src_file_name="test_file.txt", + dest_folder_name="client_1_backup", + dest_file_name="test_file.txt", + dest_ip_address=server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address, + ) + + assert ftp_server.file_system.get_file(folder_name="client_1_backup", file_name="test_file.txt") + + +def test_ftp_client_retrieve_file_from_server(ftp_client_and_ftp_server): + """ + Test checks to see if the client is able to retrieve files from the backup server. + """ + ftp_client, computer, ftp_server, server = ftp_client_and_ftp_server + + assert ftp_client.operating_state == ServiceOperatingState.RUNNING + assert ftp_server.operating_state == ServiceOperatingState.RUNNING + + # create file on ftp server + ftp_server.file_system.create_file(file_name="test_file.txt", folder_name="file_share") + + assert ftp_client.request_file( + src_folder_name="file_share", + src_file_name="test_file.txt", + dest_folder_name="downloads", + dest_file_name="test_file.txt", + dest_ip_address=server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address, + ) + + # client should have retrieved the file + assert ftp_client.file_system.get_file(folder_name="downloads", file_name="test_file.txt") + + +def test_ftp_client_tries_to_connect_to_offline_server(ftp_client_and_ftp_server): + """Test checks to make sure that the client can't do anything when the server is offline.""" + ftp_client, computer, ftp_server, server = ftp_client_and_ftp_server + + assert ftp_client.operating_state == ServiceOperatingState.RUNNING + assert ftp_server.operating_state == ServiceOperatingState.RUNNING + + # create file on ftp server + ftp_server.file_system.create_file(file_name="test_file.txt", folder_name="file_share") + + server.power_off() + + for i in range(server.shut_down_duration + 1): + server.apply_timestep(timestep=i) + + assert ftp_client.operating_state == ServiceOperatingState.RUNNING + assert ftp_server.operating_state == ServiceOperatingState.STOPPED + + assert ( + ftp_client.request_file( + src_folder_name="file_share", + src_file_name="test_file.txt", + dest_folder_name="downloads", + dest_file_name="test_file.txt", + dest_ip_address=server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address, + ) + is False + ) + + # client should have retrieved the file + assert ftp_client.file_system.get_file(folder_name="downloads", file_name="test_file.txt") is None diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py new file mode 100644 index 00000000..7e52377b --- /dev/null +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -0,0 +1,84 @@ +from ipaddress import IPv4Address +from time import sleep +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.service import ServiceOperatingState + +# Create simple network for testing +# Define one node to be an NTP server and another node to be a NTP Client. + + +@pytest.fixture(scope="function") +def create_ntp_network(client_server) -> Tuple[NTPClient, Computer, NTPServer, Server]: + """ + +------------+ +------------+ + | ntp | | ntp | + | client_1 +------------+ server_1 | + | | | | + +------------+ +------------+ + + """ + client, server = client_server + + server.power_on() + server.software_manager.install(NTPServer) + ntp_server: NTPServer = server.software_manager.software.get("NTPServer") + ntp_server.start() + + client.power_on() + client.software_manager.install(NTPClient) + ntp_client: NTPClient = client.software_manager.software.get("NTPClient") + ntp_client.start() + + return ntp_client, client, ntp_server, server + + +def test_ntp_client_server(create_ntp_network): + ntp_client, client, ntp_server, server = create_ntp_network + + ntp_server: NTPServer = server.software_manager.software["NTPServer"] + ntp_client: NTPClient = client.software_manager.software["NTPClient"] + + assert ntp_server.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.1.3")) + + assert not ntp_client.time + ntp_client.request_time() + assert ntp_client.time + first_time = ntp_client.time + sleep(0.1) + ntp_client.apply_timestep(1) # Check time advances + second_time = ntp_client.time + assert first_time < second_time + + +# Test ntp client behaviour when ntp server is unavailable. +def test_ntp_server_failure(create_ntp_network): + ntp_client, client, ntp_server, server = create_ntp_network + + ntp_server: NTPServer = server.software_manager.software["NTPServer"] + ntp_client: NTPClient = client.software_manager.software["NTPClient"] + + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.1.3")) + + # Turn off ntp server. + ntp_server.stop() + assert ntp_server.operating_state == ServiceOperatingState.STOPPED + # And request a time update. + ntp_client.request_time() + assert ntp_client.time is None + + # Restart ntp server. + ntp_server.start() + assert ntp_server.operating_state == ServiceOperatingState.RUNNING + ntp_client.request_time() + assert ntp_client.time is not None diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py new file mode 100644 index 00000000..44d045c2 --- /dev/null +++ b/tests/integration_tests/system/test_service_on_node.py @@ -0,0 +1,120 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.system.services.service import Service, ServiceOperatingState + + +@pytest.fixture(scope="function") +def populated_node( + service_class, +) -> Tuple[Server, Service]: + server = Server( + hostname="server", + ip_address="192.168.0.1", + subnet_mask="255.255.255.0", + start_up_duration=0, + shut_down_duration=0, + ) + server.power_on() + server.software_manager.install(service_class) + + service = server.software_manager.software.get("TestService") + service.start() + + return server, service + + +def test_service_on_offline_node(service_class): + """Test to check that the service cannot be interacted with when node it is on is off.""" + computer: Computer = Computer( + hostname="test_computer", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + shut_down_duration=0, + ) + computer.power_on() + computer.software_manager.install(service_class) + + service: Service = computer.software_manager.software.get("TestService") + + computer.power_off() + + assert computer.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + service.start() + assert service.operating_state is ServiceOperatingState.STOPPED + + service.resume() + assert service.operating_state is ServiceOperatingState.STOPPED + + service.restart() + assert service.operating_state is ServiceOperatingState.STOPPED + + service.pause() + assert service.operating_state is ServiceOperatingState.STOPPED + + +def test_server_turns_off_service(populated_node): + """Check that the service is turned off when the server is turned off""" + server, service = populated_node + + assert server.operating_state is NodeOperatingState.ON + assert service.operating_state is ServiceOperatingState.RUNNING + + server.power_off() + + assert server.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + +def test_service_cannot_be_turned_on_when_server_is_off(populated_node): + """Check that the service cannot be started when the server is off.""" + server, service = populated_node + + assert server.operating_state is NodeOperatingState.ON + assert service.operating_state is ServiceOperatingState.RUNNING + + server.power_off() + + assert server.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + service.start() + + assert server.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + +def test_server_turns_on_service(populated_node): + """Check that turning on the server turns on service.""" + server, service = populated_node + + assert server.operating_state is NodeOperatingState.ON + assert service.operating_state is ServiceOperatingState.RUNNING + + server.power_off() + + assert server.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + server.power_on() + + assert server.operating_state is NodeOperatingState.ON + assert service.operating_state is ServiceOperatingState.RUNNING + + server.power_off() + + assert server.operating_state is NodeOperatingState.OFF + assert service.operating_state is ServiceOperatingState.STOPPED + + server.power_on() + + assert server.operating_state is NodeOperatingState.ON + assert service.operating_state is ServiceOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_web_client_server.py b/tests/integration_tests/system/test_web_client_server.py new file mode 100644 index 00000000..5e3ff544 --- /dev/null +++ b/tests/integration_tests/system/test_web_client_server.py @@ -0,0 +1,121 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.protocols.http import HttpStatusCode +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.web_server.web_server import WebServer + + +@pytest.fixture(scope="function") +def web_client_and_web_server(client_server) -> Tuple[WebBrowser, Computer, WebServer, Server]: + computer, server = client_server + + # Install Web Browser on computer + computer.software_manager.install(WebBrowser) + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + web_browser.run() + + # Install DNS Client service on computer + computer.software_manager.install(DNSClient) + dns_client: DNSClient = computer.software_manager.software.get("DNSClient") + # set dns server + dns_client.dns_server = server.network_interfaces[next(iter(server.network_interfaces))].ip_address + + # Install Web Server service on server + server.software_manager.install(WebServer) + web_server_service: WebServer = server.software_manager.software.get("WebServer") + web_server_service.start() + + # Install DNS Server service on server + server.software_manager.install(DNSServer) + dns_server: DNSServer = server.software_manager.software.get("DNSServer") + # register arcd.com to DNS + dns_server.dns_register( + domain_name="arcd.com", + domain_ip_address=server.network_interfaces[next(iter(server.network_interfaces))].ip_address, + ) + + return web_browser, computer, web_server_service, server + + +def test_web_page_get_users_page_request_with_domain_name(web_client_and_web_server): + """Test to see if the client can handle requests with domain names""" + web_browser_app, computer, web_server_service, server = web_client_and_web_server + + web_server_ip = server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address + web_browser_app.target_url = f"http://arcd.com/" + assert web_browser_app.operating_state == ApplicationOperatingState.RUNNING + + assert web_browser_app.get_webpage() is True + + # latest response should have status code 200 + assert web_browser_app.latest_response is not None + assert web_browser_app.latest_response.status_code == HttpStatusCode.OK + + +def test_web_page_get_users_page_request_with_ip_address(web_client_and_web_server): + """Test to see if the client can handle requests that use ip_address.""" + web_browser_app, computer, web_server_service, server = web_client_and_web_server + + web_server_ip = server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address + web_browser_app.target_url = f"http://{web_server_ip}/" + assert web_browser_app.operating_state == ApplicationOperatingState.RUNNING + + assert web_browser_app.get_webpage() is True + + # latest response should have status code 200 + assert web_browser_app.latest_response is not None + assert web_browser_app.latest_response.status_code == HttpStatusCode.OK + + +def test_web_page_request_from_shut_down_server(web_client_and_web_server): + """Test to see that the web server does not respond when the server is off.""" + web_browser_app, computer, web_server_service, server = web_client_and_web_server + + web_server_ip = server.network_interfaces.get(next(iter(server.network_interfaces))).ip_address + web_browser_app.target_url = f"http://arcd.com/" + assert web_browser_app.operating_state == ApplicationOperatingState.RUNNING + + assert web_browser_app.get_webpage() is True + + # latest response should have status code 200 + assert web_browser_app.latest_response is not None + assert web_browser_app.latest_response.status_code == HttpStatusCode.OK + + server.power_off() + + server.power_off() + + for i in range(server.shut_down_duration + 1): + server.apply_timestep(timestep=i) + + # node should be off + assert server.operating_state is NodeOperatingState.OFF + + assert web_browser_app.get_webpage() is False + assert web_browser_app.latest_response.status_code == HttpStatusCode.NOT_FOUND + + +def test_web_page_request_from_closed_web_browser(web_client_and_web_server): + web_browser_app, computer, web_server_service, server = web_client_and_web_server + + assert web_browser_app.operating_state == ApplicationOperatingState.RUNNING + web_browser_app.target_url = f"http://arcd.com/" + assert web_browser_app.get_webpage() is True + + # latest response should have status code 200 + assert web_browser_app.latest_response.status_code == HttpStatusCode.OK + + web_browser_app.close() + + # node should be off + assert web_browser_app.operating_state is ApplicationOperatingState.CLOSED + + assert web_browser_app.get_webpage() is False diff --git a/tests/integration_tests/system/test_web_client_server_and_database.py b/tests/integration_tests/system/test_web_client_server_and_database.py new file mode 100644 index 00000000..0d1bb584 --- /dev/null +++ b/tests/integration_tests/system/test_web_client_server_and_database.py @@ -0,0 +1,149 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import Link +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.web_server.web_server import WebServer + + +@pytest.fixture(scope="function") +def web_client_web_server_database(example_network) -> Tuple[Network, Computer, Server, Server]: + # add rules to network router + router_1: Router = example_network.get_node_by_hostname("router_1") + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + # Allow DNS requests + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.DNS, dst_port=Port.DNS, position=1) + + # Allow FTP requests + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.FTP, dst_port=Port.FTP, position=2) + + # Open port 80 for web server + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + + # Create Computer + computer: Computer = example_network.get_node_by_hostname("client_1") + + # Create Web Server + web_server: Server = example_network.get_node_by_hostname("server_1") + + # Create Database Server + db_server = example_network.get_node_by_hostname("server_2") + + # Get the NICs + computer_nic = computer.network_interfaces[next(iter(computer.network_interfaces))] + server_nic = web_server.network_interfaces[next(iter(web_server.network_interfaces))] + db_server_nic = db_server.network_interfaces[next(iter(db_server.network_interfaces))] + + # Connect Computer and Server + link_computer_server = Link(endpoint_a=computer_nic, endpoint_b=server_nic, bandwidth=100) + # Should be linked + assert link_computer_server.is_up + + # Connect database server and web server + link_server_db = Link(endpoint_a=server_nic, endpoint_b=db_server_nic, bandwidth=100) + # Should be linked + assert link_computer_server.is_up + assert link_server_db.is_up + + # Install DatabaseService on db server + db_server.software_manager.install(DatabaseService) + db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + db_service.start() + + # Install Web Browser on computer + computer.software_manager.install(WebBrowser) + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + web_browser.target_url = "http://arcd.com/users/" + web_browser.run() + + # Install DNS Client service on computer + computer.software_manager.install(DNSClient) + dns_client: DNSClient = computer.software_manager.software.get("DNSClient") + # set dns server + dns_client.dns_server = web_server.network_interfaces[next(iter(web_server.network_interfaces))].ip_address + + # Install Web Server service on web server + web_server.software_manager.install(WebServer) + web_server_service: WebServer = web_server.software_manager.software.get("WebServer") + web_server_service.start() + + # Install DNS Server service on web server + web_server.software_manager.install(DNSServer) + dns_server: DNSServer = web_server.software_manager.software.get("DNSServer") + # register arcd.com to DNS + dns_server.dns_register( + domain_name="arcd.com", + domain_ip_address=web_server.network_interfaces[next(iter(web_server.network_interfaces))].ip_address, + ) + + # Install DatabaseClient service on web server + web_server.software_manager.install(DatabaseClient) + db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") + db_client.server_ip_address = IPv4Address(db_server_nic.ip_address) # set IP address of Database Server + db_client.run() + assert dns_client.check_domain_exists("arcd.com") + assert db_client.connect() + + return example_network, computer, web_server, db_server + + +def test_web_client_requests_users(web_client_web_server_database): + _, computer, _, _ = web_client_web_server_database + + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + + assert web_browser.get_webpage() + + +class TestWebBrowserHistory: + def test_populating_history(self, web_client_web_server_database): + network, computer, _, _ = web_client_web_server_database + + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + assert web_browser.history == [] + web_browser.get_webpage() + assert len(web_browser.history) == 1 + web_browser.get_webpage() + assert len(web_browser.history) == 2 + assert web_browser.history[-1].status == WebBrowser.BrowserHistoryItem._HistoryItemStatus.LOADED + assert web_browser.history[-1].response_code == 200 + + router = network.get_node_by_hostname("router_1") + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) + assert not web_browser.get_webpage() + assert len(web_browser.history) == 3 + # with current NIC behaviour, even if you block communication, you won't get SERVER_UNREACHABLE because + # application.send always returns true, even if communication fails. we should change what is returned from NICs + assert web_browser.history[-1].status == WebBrowser.BrowserHistoryItem._HistoryItemStatus.LOADED + assert web_browser.history[-1].response_code == 404 + + def test_history_in_state(self, web_client_web_server_database): + network, computer, _, _ = web_client_web_server_database + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + + state = computer.describe_state() + assert "history" in state["applications"]["WebBrowser"] + assert len(state["applications"]["WebBrowser"]["history"]) == 0 + + web_browser.get_webpage() + router = network.get_node_by_hostname("router_1") + router.acl.add_rule(action=ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=0) + web_browser.get_webpage() + + state = computer.describe_state() + assert state["applications"]["WebBrowser"]["history"][0]["outcome"] == 200 + assert state["applications"]["WebBrowser"]["history"][1]["outcome"] == 404 diff --git a/tests/integration_tests/test_simulation/__init__.py b/tests/integration_tests/test_simulation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/test_simulation/test_request_response.py b/tests/integration_tests/test_simulation/test_request_response.py new file mode 100644 index 00000000..a18f0336 --- /dev/null +++ b/tests/integration_tests/test_simulation/test_request_response.py @@ -0,0 +1,186 @@ +# some test cases: +# 0. test that sending a request to a valid target results in a success +# 1. test that sending a request to a component that doesn't exist results in a failure +# 2. test that sending a request to a software on a turned-off component results in a failure +# 3. test every implemented action under several different situation, some of which should lead to a success and some to a failure. + +import pytest + +from primaite.interface.request import RequestResponse +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.transport_layer import Port +from tests.conftest import TestApplication, TestService + + +def test_successful_node_file_system_creation_request(example_network): + """Tests that the file system requests.""" + client_1 = example_network.get_node_by_hostname("client_1") + assert client_1.file_system.get_file(folder_name="root", file_name="test.txt") is None + + response = example_network.apply_request(["node", "client_1", "file_system", "create", "file", "", "test.txt"]) + + assert response + assert client_1.file_system.get_file(folder_name="root", file_name="test.txt") + + assert client_1.file_system.get_folder(folder_name="test_folder") is None + + response = example_network.apply_request(["node", "client_1", "file_system", "create", "folder", "test_folder"]) + + assert response + assert client_1.file_system.get_folder(folder_name="test_folder") + + assert client_1.file_system.get_file(folder_name="root", file_name="test.txt").num_access == 0 + + response = example_network.apply_request(["node", "client_1", "file_system", "access", "root", "test.txt"]) + + assert response + assert client_1.file_system.get_file(folder_name="root", file_name="test.txt").num_access == 1 + + +def test_successful_application_requests(example_network): + net = example_network + + client_1 = net.get_node_by_hostname("client_1") + client_1.software_manager.install(TestApplication) + client_1.software_manager.software.get("TestApplication").run() + + resp_1 = net.apply_request(["node", "client_1", "application", "TestApplication", "scan"]) + assert resp_1 == RequestResponse(status="success", data={}) + resp_2 = net.apply_request(["node", "client_1", "application", "TestApplication", "fix"]) + assert resp_2 == RequestResponse(status="success", data={}) + resp_3 = net.apply_request(["node", "client_1", "application", "TestApplication", "compromise"]) + assert resp_3 == RequestResponse(status="success", data={}) + + +def test_successful_service_requests(example_network): + net = example_network + server_1 = net.get_node_by_hostname("server_1") + server_1.software_manager.install(TestService) + + # Careful: the order here is important, for example we cannot run "stop" unless we run "start" first + for verb in [ + "disable", + "enable", + "start", + "stop", + "start", + "restart", + "pause", + "resume", + "compromise", + "scan", + "fix", + ]: + resp_1 = net.apply_request(["node", "server_1", "service", "TestService", verb]) + assert resp_1 == RequestResponse(status="success", data={}) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + server_1.apply_timestep(timestep=1) + # lazily apply timestep 7 times to make absolutely sure any time-based things like restart have a chance to finish + + +def test_non_existent_requests(example_network): + net = example_network + resp_1 = net.apply_request(["fake"]) + assert resp_1.status == "unreachable" + resp_2 = net.apply_request(["network", "node", "client_39", "application", "WebBrowser", "execute"]) + assert resp_2.status == "unreachable" + + +@pytest.mark.parametrize( + "node_request", + [ + ["node", "client_1", "file_system", "folder", "root", "scan"], + ["node", "client_1", "os", "scan"], + ["node", "client_1", "service", "DNSClient", "stop"], + ["node", "client_1", "application", "WebBrowser", "scan"], + ["node", "client_1", "network_interface", 1, "disable"], + ], +) +def test_request_fails_if_node_off(example_network, node_request): + """Test that requests succeed when the node is on, and fail if the node is off.""" + net = example_network + client_1: HostNode = net.get_node_by_hostname("client_1") + client_1.shut_down_duration = 0 + + assert client_1.operating_state == NodeOperatingState.ON + resp_1 = net.apply_request(node_request) + assert resp_1.status == "success" + + client_1.power_off() + assert client_1.operating_state == NodeOperatingState.OFF + resp_2 = net.apply_request(node_request) + assert resp_2.status == "failure" + + +class TestDataManipulationGreenRequests: + def test_node_off(self, uc2_network): + """Test that green requests succeed when the node is on and fail if the node is off.""" + net: Network = uc2_network + + client_1_browser_execute = net.apply_request(["node", "client_1", "application", "WebBrowser", "execute"]) + client_1_db_client_execute = net.apply_request(["node", "client_1", "application", "DatabaseClient", "execute"]) + client_2_browser_execute = net.apply_request(["node", "client_2", "application", "WebBrowser", "execute"]) + client_2_db_client_execute = net.apply_request(["node", "client_2", "application", "DatabaseClient", "execute"]) + assert client_1_browser_execute.status == "success" + assert client_1_db_client_execute.status == "success" + assert client_2_browser_execute.status == "success" + assert client_2_db_client_execute.status == "success" + + client_1 = net.get_node_by_hostname("client_1") + client_2 = net.get_node_by_hostname("client_2") + + client_1.shut_down_duration = 0 + client_1.power_off() + client_2.shut_down_duration = 0 + client_2.power_off() + + client_1_browser_execute_off = net.apply_request(["node", "client_1", "application", "WebBrowser", "execute"]) + client_1_db_client_execute_off = net.apply_request( + ["node", "client_1", "application", "DatabaseClient", "execute"] + ) + client_2_browser_execute_off = net.apply_request(["node", "client_2", "application", "WebBrowser", "execute"]) + client_2_db_client_execute_off = net.apply_request( + ["node", "client_2", "application", "DatabaseClient", "execute"] + ) + assert client_1_browser_execute_off.status == "failure" + assert client_1_db_client_execute_off.status == "failure" + assert client_2_browser_execute_off.status == "failure" + assert client_2_db_client_execute_off.status == "failure" + + def test_acl_block(self, uc2_network): + """Test that green requests succeed when not blocked by ACLs but fail when blocked.""" + net: Network = uc2_network + + router: Router = net.get_node_by_hostname("router_1") + client_1: HostNode = net.get_node_by_hostname("client_1") + client_2: HostNode = net.get_node_by_hostname("client_2") + + client_1_browser_execute = net.apply_request(["node", "client_1", "application", "WebBrowser", "execute"]) + client_2_browser_execute = net.apply_request(["node", "client_2", "application", "WebBrowser", "execute"]) + assert client_1_browser_execute.status == "success" + assert client_2_browser_execute.status == "success" + + router.acl.add_rule(ACLAction.DENY, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + client_1_browser_execute = net.apply_request(["node", "client_1", "application", "WebBrowser", "execute"]) + client_2_browser_execute = net.apply_request(["node", "client_2", "application", "WebBrowser", "execute"]) + assert client_1_browser_execute.status == "failure" + assert client_2_browser_execute.status == "failure" + + client_1_db_client_execute = net.apply_request(["node", "client_1", "application", "DatabaseClient", "execute"]) + client_2_db_client_execute = net.apply_request(["node", "client_2", "application", "DatabaseClient", "execute"]) + assert client_1_db_client_execute.status == "success" + assert client_2_db_client_execute.status == "success" + + router.acl.add_rule(ACLAction.DENY, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER) + client_1_db_client_execute = net.apply_request(["node", "client_1", "application", "DatabaseClient", "execute"]) + client_2_db_client_execute = net.apply_request(["node", "client_2", "application", "DatabaseClient", "execute"]) + assert client_1_db_client_execute.status == "failure" + assert client_2_db_client_execute.status == "failure" diff --git a/tests/mock_and_patch/get_session_path_mock.py b/tests/mock_and_patch/get_session_path_mock.py index 16c4a274..06fe5893 100644 --- a/tests/mock_and_patch/get_session_path_mock.py +++ b/tests/mock_and_patch/get_session_path_mock.py @@ -9,7 +9,7 @@ from primaite import getLogger _LOGGER = getLogger(__name__) -def get_temp_session_path(session_timestamp: datetime) -> Path: +def temp_user_sessions_path() -> Path: """ Get a temp directory session path the test session will output to. diff --git a/tests/test_acl.py b/tests/test_acl.py deleted file mode 100644 index d8357cf6..00000000 --- a/tests/test_acl.py +++ /dev/null @@ -1,166 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Used to tes the ACL functions.""" - -from primaite.acl.access_control_list import AccessControlList -from primaite.acl.acl_rule import ACLRule -from primaite.common.enums import RulePermissionType - - -def test_acl_address_match_1(): - """Test that matching IP addresses produce True.""" - acl = AccessControlList(RulePermissionType.DENY, 10) - - rule = ACLRule(RulePermissionType.ALLOW, "192.168.1.1", "192.168.1.2", "TCP", "80") - - assert acl.check_address_match(rule, "192.168.1.1", "192.168.1.2") == True - - -def test_acl_address_match_2(): - """Test that mismatching IP addresses produce False.""" - acl = AccessControlList(RulePermissionType.DENY, 10) - - rule = ACLRule(RulePermissionType.ALLOW, "192.168.1.1", "192.168.1.2", "TCP", "80") - - assert acl.check_address_match(rule, "192.168.1.1", "192.168.1.3") == False - - -def test_acl_address_match_3(): - """Test the ANY condition for source IP addresses produce True.""" - acl = AccessControlList(RulePermissionType.DENY, 10) - - rule = ACLRule(RulePermissionType.ALLOW, "ANY", "192.168.1.2", "TCP", "80") - - assert acl.check_address_match(rule, "192.168.1.1", "192.168.1.2") == True - - -def test_acl_address_match_4(): - """Test the ANY condition for dest IP addresses produce True.""" - acl = AccessControlList(RulePermissionType.DENY, 10) - - rule = ACLRule(RulePermissionType.ALLOW, "192.168.1.1", "ANY", "TCP", "80") - - assert acl.check_address_match(rule, "192.168.1.1", "192.168.1.2") == True - - -def test_check_acl_block_affirmative(): - """Test the block function (affirmative).""" - # Create the Access Control List - acl = AccessControlList(RulePermissionType.DENY, 10) - - # Create a rule - acl_rule_permission = RulePermissionType.ALLOW - acl_rule_source = "192.168.1.1" - acl_rule_destination = "192.168.1.2" - acl_rule_protocol = "TCP" - acl_rule_port = "80" - acl_position_in_list = "0" - - acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_position_in_list, - ) - assert acl.is_blocked("192.168.1.1", "192.168.1.2", "TCP", "80") == False - - -def test_check_acl_block_negative(): - """Test the block function (negative).""" - # Create the Access Control List - acl = AccessControlList(RulePermissionType.DENY, 10) - - # Create a rule - acl_rule_permission = RulePermissionType.DENY - acl_rule_source = "192.168.1.1" - acl_rule_destination = "192.168.1.2" - acl_rule_protocol = "TCP" - acl_rule_port = "80" - acl_position_in_list = "0" - - acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_position_in_list, - ) - - assert acl.is_blocked("192.168.1.1", "192.168.1.2", "TCP", "80") == True - - -def test_rule_hash(): - """Test the rule hash.""" - # Create the Access Control List - acl = AccessControlList(RulePermissionType.DENY, 10) - - rule = ACLRule(RulePermissionType.DENY, "192.168.1.1", "192.168.1.2", "TCP", "80") - hash_value_local = hash(rule) - - hash_value_remote = acl.get_dictionary_hash(RulePermissionType.DENY, "192.168.1.1", "192.168.1.2", "TCP", "80") - - assert hash_value_local == hash_value_remote - - -def test_delete_rule(): - """Adds 3 rules and deletes 1 rule and checks its deletion.""" - # Create the Access Control List - acl = AccessControlList(RulePermissionType.ALLOW, 10) - - # Create a first rule - acl_rule_permission = RulePermissionType.DENY - acl_rule_source = "192.168.1.1" - acl_rule_destination = "192.168.1.2" - acl_rule_protocol = "TCP" - acl_rule_port = "80" - acl_position_in_list = "0" - - acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_position_in_list, - ) - - # Create a second rule - acl_rule_permission = RulePermissionType.DENY - acl_rule_source = "20" - acl_rule_destination = "30" - acl_rule_protocol = "FTP" - acl_rule_port = "21" - acl_position_in_list = "2" - - acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_position_in_list, - ) - - # Create a third rule - acl_rule_permission = RulePermissionType.ALLOW - acl_rule_source = "192.168.1.3" - acl_rule_destination = "192.168.1.1" - acl_rule_protocol = "UDP" - acl_rule_port = "60" - acl_position_in_list = "4" - - acl.add_rule( - acl_rule_permission, - acl_rule_source, - acl_rule_destination, - acl_rule_protocol, - acl_rule_port, - acl_position_in_list, - ) - # Remove the second ACL rule added from the list - acl.remove_rule(RulePermissionType.DENY, "20", "30", "FTP", "21") - - assert len(acl.acl) == 10 - assert acl.acl[2] is None diff --git a/tests/test_active_node.py b/tests/test_active_node.py deleted file mode 100644 index 44d38313..00000000 --- a/tests/test_active_node.py +++ /dev/null @@ -1,122 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Used to test Active Node functions.""" -import pytest - -from primaite.common.enums import FileSystemState, HardwareState, SoftwareState -from primaite.nodes.active_node import ActiveNode - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, SoftwareState.GOOD), - (HardwareState.ON, SoftwareState.OVERWHELMED), - ], -) -def test_os_state_change(operating_state, expected_state): - """ - Test that a node cannot change its Software State. - - When its hardware state is OFF. - """ - active_node = ActiveNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - SoftwareState.GOOD, - "GOOD", - 1, - ) - - active_node.software_state = SoftwareState.OVERWHELMED - - assert active_node.software_state == expected_state - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, SoftwareState.GOOD), - (HardwareState.ON, SoftwareState.OVERWHELMED), - ], -) -def test_os_state_change_if_not_compromised(operating_state, expected_state): - """ - Test that a node cannot change its Software State. - - If not compromised) when its hardware state is OFF. - """ - active_node = ActiveNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - SoftwareState.GOOD, - "GOOD", - 1, - ) - - active_node.set_software_state_if_not_compromised(SoftwareState.OVERWHELMED) - - assert active_node.software_state == expected_state - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, FileSystemState.GOOD), - (HardwareState.ON, FileSystemState.CORRUPT), - ], -) -def test_file_system_change(operating_state, expected_state): - """Test that a node cannot change its file system state when its hardware state is ON.""" - active_node = ActiveNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - "COMPROMISED", - FileSystemState.GOOD, - 1, - ) - - active_node.set_file_system_state(FileSystemState.CORRUPT) - - assert active_node.file_system_state_actual == expected_state - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, FileSystemState.GOOD), - (HardwareState.ON, FileSystemState.CORRUPT), - ], -) -def test_file_system_change_if_not_compromised(operating_state, expected_state): - """ - Test that a node cannot change its file system state. - - If not compromised) when its hardware state is OFF. - """ - active_node = ActiveNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - "GOOD", - FileSystemState.GOOD, - 1, - ) - - active_node.set_file_system_state_if_not_compromised(FileSystemState.CORRUPT) - - assert active_node.file_system_state_actual == expected_state diff --git a/tests/test_observation_space.py b/tests/test_observation_space.py deleted file mode 100644 index ff3528e1..00000000 --- a/tests/test_observation_space.py +++ /dev/null @@ -1,377 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Test env creation and behaviour with different observation spaces.""" - -import numpy as np -import pytest - -from primaite.environment.observations import NodeLinkTable, NodeStatuses, ObservationsHandler -from tests import TEST_CONFIG_ROOT - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_without_obs.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown.yaml", - ] - ], - indirect=True, -) -def test_default_obs_space(temp_primaite_session): - """Create environment with no obs space defined in config and check that the default obs space was created.""" - with temp_primaite_session as session: - session.env.update_environent_obs() - - components = session.env.obs_handler.registered_obs_components - - assert len(components) == 1 - assert isinstance(components[0], NodeLinkTable) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_without_obs.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown.yaml", - ] - ], - indirect=True, -) -def test_registering_components(temp_primaite_session): - """Test regitering and deregistering a component.""" - with temp_primaite_session as session: - env = session.env - handler = ObservationsHandler() - component = NodeStatuses(env) - handler.register(component) - assert component in handler.registered_obs_components - handler.deregister(component) - assert component not in handler.registered_obs_components - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_NODE_LINK_TABLE.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown.yaml", - ] - ], - indirect=True, -) -class TestNodeLinkTable: - """Test the NodeLinkTable observation component (in isolation).""" - - def test_obs_shape(self, temp_primaite_session): - """Try creating env with box observation space.""" - with temp_primaite_session as session: - env = session.env - env.update_environent_obs() - - # we have three nodes and two links, with two service - # therefore the box observation space will have: - # * 5 rows (3 nodes + 2 links) - # * 6 columns (four fixed and two for the services) - assert env.env_obs.shape == (5, 6) - - def test_value(self, temp_primaite_session): - """ - Test that the observation is generated correctly. - - The laydown has: - * 3 nodes (2 service nodes and 1 active node) - * 2 services - * 2 links - - Both nodes have both services, and all states are GOOD, therefore the expected observation value is: - - * Node 1: - * 1 (id) - * 1 (good hardware state) - * 3 (compromised OS state) - * 1 (good file system state) - * 1 (good TCP state) - * 1 (good UDP state) - * Node 2: - * 2 (id) - * 1 (good hardware state) - * 1 (good OS state) - * 1 (good file system state) - * 1 (good TCP state) - * 4 (overwhelmed UDP state) - * Node 3 (active node): - * 3 (id) - * 1 (good hardware state) - * 1 (good OS state) - * 1 (good file system state) - * 0 (doesn't have service1) - * 0 (doesn't have service2) - * Link 1: - * 4 (id) - * 0 (n/a hardware state) - * 0 (n/a OS state) - * 0 (n/a file system state) - * 999 (999 traffic for service1) - * 0 (no traffic for service2) - * Link 2: - * 5 (id) - * 0 (good hardware state) - * 0 (good OS state) - * 0 (good file system state) - * 999 (999 traffic service1) - * 0 (no traffic for service2) - """ - with temp_primaite_session as session: - env = session.env - # act = np.asarray([0,]) - obs, reward, done, info = env.step(0) # apply the 'do nothing' action - - assert np.array_equal( - obs, - [ - [1, 1, 3, 1, 1, 1], - [2, 1, 1, 1, 1, 4], - [3, 1, 1, 1, 0, 0], - [4, 0, 0, 0, 999, 0], - [5, 0, 0, 0, 999, 0], - ], - ) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_NODE_STATUSES.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown.yaml", - ] - ], - indirect=True, -) -class TestNodeStatuses: - """Test the NodeStatuses observation component (in isolation).""" - - def test_obs_shape(self, temp_primaite_session): - """Try creating env with NodeStatuses as the only component.""" - with temp_primaite_session as session: - env = session.env - assert env.env_obs.shape == (15,) - - def test_values(self, temp_primaite_session): - """ - Test that the hardware and software states are encoded correctly. - - The laydown has: - * one node with a compromised operating system state - * one node with two services, and the second service is overwhelmed. - * all other states are good or null - Therefore, the expected state is: - * node 1: - * hardware = good (1) - * OS = compromised (3) - * file system = good (1) - * service 1 = good (1) - * service 2 = good (1) - * node 2: - * hardware = good (1) - * OS = good (1) - * file system = good (1) - * service 1 = good (1) - * service 2 = overwhelmed (4) - * node 3 (switch): - * hardware = good (1) - * OS = good (1) - * file system = good (1) - * service 1 = n/a (0) - * service 2 = n/a (0) - """ - with temp_primaite_session as session: - env = session.env - obs, _, _, _ = env.step(0) # apply the 'do nothing' action - print(obs) - assert np.array_equal(obs, [1, 3, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 0, 0]) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_LINK_TRAFFIC_LEVELS.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown.yaml", - ] - ], - indirect=True, -) -class TestLinkTrafficLevels: - """Test the LinkTrafficLevels observation component (in isolation).""" - - def test_obs_shape(self, temp_primaite_session): - """Try creating env with MultiDiscrete observation space.""" - with temp_primaite_session as session: - env = session.env - env.update_environent_obs() - - # we have two links and two services, so the shape should be 2 * 2 - assert env.env_obs.shape == (2 * 2,) - - def test_values(self, temp_primaite_session): - """ - Test that traffic values are encoded correctly. - - The laydown has: - * two services - * three nodes - * two links - * an IER trying to send 999 bits of data over both links the whole time (via the first service) - * link bandwidth of 1000, therefore the utilisation is 99.9% - """ - with temp_primaite_session as session: - env = session.env - obs, reward, done, info = env.step(0) - obs, reward, done, info = env.step(0) - - # the observation space has combine_service_traffic set to False, so the space has this format: - # [link1_service1, link1_service2, link2_service1, link2_service2] - # we send 999 bits of data via link1 and link2 on service 1. - # therefore the first and third elements should be 6 and all others 0 - # (`7` corresponds to 100% utiilsation and `6` corresponds to 87.5%-100%) - assert np.array_equal(obs, [6, 0, 6, 0]) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "obs_tests/main_config_ACCESS_CONTROL_LIST.yaml", - TEST_CONFIG_ROOT / "obs_tests/laydown_ACL.yaml", - ] - ], - indirect=True, -) -class TestAccessControlList: - """Test the AccessControlList observation component (in isolation).""" - - def test_obs_shape(self, temp_primaite_session): - """Try creating env with MultiDiscrete observation space. - - The laydown has 3 ACL Rules - that is the maximum_acl_rules it can have. - Each ACL Rule in the observation space has 6 different elements: - - 6 * 3 = 18 - """ - with temp_primaite_session as session: - env = session.env - env.update_environent_obs() - - assert env.env_obs.shape == (18,) - - def test_values(self, temp_primaite_session): - """Test that traffic values are encoded correctly. - - The laydown has: - * one ACL IMPLICIT DENY rule - - Therefore, the ACL is full of NAs aka zeros and just 6 non-zero elements representing DENY ANY ANY ANY at - Position 2. - """ - with temp_primaite_session as session: - env = session.env - obs, reward, done, info = env.step(0) - obs, reward, done, info = env.step(0) - - assert np.array_equal(obs, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2]) - - def test_observation_space_with_implicit_rule(self, temp_primaite_session): - """ - Test observation space is what is expected when an agent adds ACLs during an episode. - - At the start of the episode, there is a single implicit DENY rule - In the observation space IMPLICIT DENY: 1,1,1,1,1,0 - 0 shows the rule is the start (when episode began no other rules were created) so this is correct. - - On Step 2, there is an ACL rule added at Position 0: 2,2,3,2,3,0 - - On Step 4, there is a second ACL rule added at POSITION 1: 2,4,2,3,3,1 - - The final observation space should be this: - [2, 2, 3, 2, 3, 0, 2, 4, 2, 3, 3, 1, 1, 1, 1, 1, 1, 2] - - The ACL Rule from Step 2 is added first and has a HIGHER position than the ACL rule from Step 4 - but both come before the IMPLICIT DENY which will ALWAYS be at the end of the ACL List. - """ - # TODO: Refactor this at some point to build a custom ACL Hardcoded - # Agent and then patch the AgentIdentifier Enum class so that it - # has ACL_AGENT. This then allows us to set the agent identified in - # the main config and is a bit cleaner. - - with temp_primaite_session as session: - env = session.env - training_config = env.training_config - for episode in range(0, training_config.num_train_episodes): - for step in range(0, training_config.num_train_steps): - # Do nothing action - action = 0 - if step == 2: - # Action to add the first ACL rule - action = 43 - elif step == 4: - # Action to add the second ACL rule - action = 96 - - # Run the simulation step on the live environment - obs, reward, done, info = env.step(action) - - # Break if done is True - if done: - break - obs = env.env_obs - - assert np.array_equal(obs, [2, 2, 3, 2, 3, 0, 2, 4, 2, 3, 3, 1, 1, 1, 1, 1, 1, 2]) - - def test_observation_space_with_different_positions(self, temp_primaite_session): - """ - Test observation space is what is expected when an agent adds ACLs during an episode. - - At the start of the episode, there is a single implicit DENY rule - In the observation space IMPLICIT DENY: 1,1,1,1,1,0 - 0 shows the rule is the start (when episode began no other rules were created) so this is correct. - - On Step 2, there is an ACL rule added at Position 1: 2,2,3,2,3,1 - - On Step 4 there is a second ACL rule added at Position 0: 2,4,2,3,3,0 - - The final observation space should be this: - [2 , 4, 2, 3, 3, 0, 2, 2, 3, 2, 3, 1, 1, 1, 1, 1, 1, 2] - - The ACL Rule from Step 2 is added before and has a LOWER position than the ACL rule from Step 4 - but both come before the IMPLICIT DENY which will ALWAYS be at the end of the ACL List. - """ - # TODO: Refactor this at some point to build a custom ACL Hardcoded - # Agent and then patch the AgentIdentifier Enum class so that it - # has ACL_AGENT. This then allows us to set the agent identified in - # the main config and is a bit cleaner. - - with temp_primaite_session as session: - env = session.env - training_config = env.training_config - for episode in range(0, training_config.num_train_episodes): - for step in range(0, training_config.num_train_steps): - # Do nothing action - action = 0 - if step == 2: - # Action to add the first ACL rule - action = 44 - elif step == 4: - # Action to add the second ACL rule - action = 95 - # Run the simulation step on the live environment - obs, reward, done, info = env.step(action) - - # Break if done is True - if done: - break - obs = env.env_obs - - assert np.array_equal(obs, [2, 4, 2, 3, 3, 0, 2, 2, 3, 2, 3, 1, 1, 1, 1, 1, 1, 2]) diff --git a/tests/test_primaite_session.py b/tests/test_primaite_session.py deleted file mode 100644 index b76a2ecf..00000000 --- a/tests/test_primaite_session.py +++ /dev/null @@ -1,77 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import os - -import pytest - -from primaite import getLogger -from primaite.config.lay_down_config import dos_very_basic_config_path -from tests import TEST_CONFIG_ROOT - -_LOGGER = getLogger(__name__) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [TEST_CONFIG_ROOT / "session_test/training_config_main_rllib.yaml", dos_very_basic_config_path()], - [TEST_CONFIG_ROOT / "session_test/training_config_main_sb3.yaml", dos_very_basic_config_path()], - ], - indirect=True, -) -def test_primaite_session(temp_primaite_session): - """ - Tests the PrimaiteSession class and all of its outputs. - - This test runs for both a Stable Baselines3 agent, and a Ray RLlib agent. - """ - with temp_primaite_session as session: - session_path = session.session_path - assert session_path.exists() - session.learn() - # Learning outputs are saved in session.learning_path - session.evaluate() - # Evaluation outputs are saved in session.evaluation_path - - # If you need to inspect any session outputs, it must be done inside - # the context manager - - # Check that the metadata json file exists - assert (session_path / "session_metadata.json").exists() - - # Check that the network png file exists - assert (session_path / f"network_{session.timestamp_str}.png").exists() - - # Check that the saved agent exists - assert session._agent_session._saved_agent_path.exists() - - # Check that both the transactions and av reward csv files exist - for file in session.learning_path.iterdir(): - if file.suffix == ".csv": - assert "all_transactions" in file.name or "average_reward_per_episode" in file.name - - # Check that both the transactions and av reward csv files exist - for file in session.evaluation_path.iterdir(): - if file.suffix == ".csv": - assert "all_transactions" in file.name or "average_reward_per_episode" in file.name - - # Check that the average reward per episode plots exist - assert (session.learning_path / f"average_reward_per_episode_{session.timestamp_str}.png").exists() - assert (session.evaluation_path / f"average_reward_per_episode_{session.timestamp_str}.png").exists() - - # Check that the metadata has captured the correct number of learning and eval episodes and steps - assert len(session.learn_av_reward_per_episode_dict().keys()) == 10 - assert len(session.learn_all_transactions_dict().keys()) == 10 * 256 - - assert len(session.eval_av_reward_per_episode_dict().keys()) == 3 - assert len(session.eval_all_transactions_dict().keys()) == 3 * 256 - - _LOGGER.debug("Inspecting files in temp session path...") - for dir_path, dir_names, file_names in os.walk(session_path): - for file in file_names: - path = os.path.join(dir_path, file) - file_str = path.split(str(session_path))[-1] - _LOGGER.debug(f" {file_str}") - - # Now that we've exited the context manager, the session.session_path - # directory and its contents are deleted - assert not session_path.exists() diff --git a/tests/test_red_random_agent_behaviour.py b/tests/test_red_random_agent_behaviour.py deleted file mode 100644 index e99f4adb..00000000 --- a/tests/test_red_random_agent_behaviour.py +++ /dev/null @@ -1,39 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import pytest - -from primaite.config.lay_down_config import data_manipulation_config_path -from primaite.nodes.node_state_instruction_red import NodeStateInstructionRed -from tests import TEST_CONFIG_ROOT - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "test_random_red_main_config.yaml", - data_manipulation_config_path(), - ] - ], - indirect=True, -) -def test_random_red_agent_behaviour(temp_primaite_session): - """Test that red agent POL is randomised each episode.""" - list_of_node_instructions = [] - - with temp_primaite_session as session: - session.evaluate() - list_of_node_instructions.append(session.env.red_node_pol) - - session.evaluate() - list_of_node_instructions.append(session.env.red_node_pol) - - # compare instructions to make sure that red instructions are truly random - for index, instruction in enumerate(list_of_node_instructions): - for key in list_of_node_instructions[index].keys(): - instruction: NodeStateInstructionRed = list_of_node_instructions[index][key] - print(f"run {index}") - print(f"{key} start step: {instruction.get_start_step()}") - print(f"{key} end step: {instruction.get_end_step()}") - print(f"{key} target node id: {instruction.get_target_node_id()}") - print("") - assert list_of_node_instructions[0].__ne__(list_of_node_instructions[1]) diff --git a/tests/test_resetting_node.py b/tests/test_resetting_node.py deleted file mode 100644 index d4e27c17..00000000 --- a/tests/test_resetting_node.py +++ /dev/null @@ -1,86 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Used to test Active Node functions.""" -import pytest - -from primaite.common.enums import FileSystemState, HardwareState, NodeType, Priority, SoftwareState -from primaite.common.service import Service -from primaite.config.training_config import TrainingConfig -from primaite.nodes.active_node import ActiveNode -from primaite.nodes.service_node import ServiceNode - - -@pytest.mark.parametrize( - "starting_operating_state, expected_operating_state", - [(HardwareState.RESETTING, HardwareState.ON)], -) -def test_node_resets_correctly(starting_operating_state, expected_operating_state): - """Tests that a node resets correctly.""" - active_node = ActiveNode( - node_id="0", - name="node", - node_type=NodeType.COMPUTER, - priority=Priority.P1, - hardware_state=starting_operating_state, - ip_address="192.168.0.1", - software_state=SoftwareState.COMPROMISED, - file_system_state=FileSystemState.CORRUPT, - config_values=TrainingConfig(), - ) - - for x in range(5): - active_node.update_resetting_status() - - assert active_node.software_state == SoftwareState.GOOD - assert active_node.file_system_state_actual == FileSystemState.GOOD - assert active_node.hardware_state == expected_operating_state - - -@pytest.mark.parametrize( - "operating_state, expected_operating_state", - [(HardwareState.BOOTING, HardwareState.ON)], -) -def test_node_boots_correctly(operating_state, expected_operating_state): - """Tests that a node boots correctly.""" - service_node = ServiceNode( - node_id=0, - name="node", - node_type="COMPUTER", - priority="1", - hardware_state=operating_state, - ip_address="192.168.0.1", - software_state=SoftwareState.GOOD, - file_system_state="GOOD", - config_values=1, - ) - service_attributes = Service(name="node", port="80", software_state=SoftwareState.COMPROMISED) - service_node.add_service(service_attributes) - - for x in range(5): - service_node.update_booting_status() - - assert service_attributes.software_state == SoftwareState.GOOD - assert service_node.hardware_state == expected_operating_state - - -@pytest.mark.parametrize( - "operating_state, expected_operating_state", - [(HardwareState.SHUTTING_DOWN, HardwareState.OFF)], -) -def test_node_shutdown_correctly(operating_state, expected_operating_state): - """Tests that a node shutdown correctly.""" - active_node = ActiveNode( - node_id=0, - name="node", - node_type="COMPUTER", - priority="1", - hardware_state=operating_state, - ip_address="192.168.0.1", - software_state=SoftwareState.GOOD, - file_system_state="GOOD", - config_values=1, - ) - - for x in range(5): - active_node.update_shutdown_status() - - assert active_node.hardware_state == expected_operating_state diff --git a/tests/test_reward.py b/tests/test_reward.py deleted file mode 100644 index 2ac66af1..00000000 --- a/tests/test_reward.py +++ /dev/null @@ -1,53 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import pytest - -from primaite import getLogger -from tests import TEST_CONFIG_ROOT - -_LOGGER = getLogger(__name__) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "one_node_states_on_off_main_config.yaml", - TEST_CONFIG_ROOT / "one_node_states_on_off_lay_down_config.yaml", - ] - ], - indirect=True, -) -def test_rewards_are_being_penalised_at_each_step_function( - temp_primaite_session, -): - """ - Test that hardware state is penalised at each step. - - When the initial state is OFF compared to reference state which is ON. - - The config 'one_node_states_on_off_lay_down_config.yaml' has 15 steps: - On different steps, the laydown config has Pattern of Life (PoLs) which change a state of the node's attribute. - For example, turning the nodes' file system state to CORRUPT from its original state GOOD. - As a result these are the following rewards are activated: - File System State: corrupt_should_be_good = -10 * 2 (on Steps 1 & 2) - Hardware State: off_should_be_on = -10 * 2 (on Steps 4 & 5) - Service State: compromised_should_be_good = -20 * 2 (on Steps 7 & 8) - Software State: compromised_should_be_good = -20 * 2 (on Steps 10 & 11) - - The Pattern of Life (PoLs) last for 2 steps, so the agent is penalised twice. - - Note: This test run inherits from conftest.py where the PrimAITE environment is ran and the blue agent is hard-coded - to do NOTHING on every step. - We use Pattern of Lifes (PoLs) to change the nodes states and display that the agent is being penalised on all steps - where the live network node differs from the network reference node. - - Total Reward: -10 + -10 + -10 + -10 + -20 + -20 + -20 + -20 = -120 - Step Count: 15 - - For the 4 steps where this occurs the average reward is: - Average Reward: -8 (-120 / 15) - """ - with temp_primaite_session as session: - session.evaluate() - ev_rewards = session.eval_av_reward_per_episode_dict() - assert ev_rewards[1] == -8.0 diff --git a/tests/test_seeding_and_deterministic_session.py b/tests/test_seeding_and_deterministic_session.py deleted file mode 100644 index 9500c4a3..00000000 --- a/tests/test_seeding_and_deterministic_session.py +++ /dev/null @@ -1,65 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import pytest as pytest - -from primaite.config.lay_down_config import dos_very_basic_config_path -from tests import TEST_CONFIG_ROOT - - -@pytest.mark.parametrize( - "temp_primaite_session", - [[TEST_CONFIG_ROOT / "ppo_seeded_training_config.yaml", dos_very_basic_config_path()]], - indirect=True, -) -def test_seeded_learning(temp_primaite_session): - """ - Test running seeded learning produces the same output when ran twice. - - .. note:: - - If this is failing, the hard-coded expected_mean_reward_per_episode - from a pre-trained agent will probably need to be updated. If the - env changes and those changed how this agent is trained, chances are - the mean rewards are going to be different. - - Run the test, but print out the session.learn_av_reward_per_episode() - before comparing it. Then copy the printed dict and replace the - expected_mean_reward_per_episode with those values. The test should - now work. If not, then you've got a bug :). - """ - expected_mean_reward_per_episode = { - 1: -20.7421875, - 2: -19.82421875, - 3: -17.01171875, - 4: -19.08203125, - 5: -21.93359375, - 6: -20.21484375, - 7: -15.546875, - 8: -12.08984375, - 9: -17.59765625, - 10: -14.6875, - } - - with temp_primaite_session as session: - assert ( - session._training_config.seed == 67890 - ), "Expected output is based upon a agent that was trained with seed 67890" - session.learn() - actual_mean_reward_per_episode = session.learn_av_reward_per_episode_dict() - print(actual_mean_reward_per_episode, "THISt") - - assert actual_mean_reward_per_episode == expected_mean_reward_per_episode - - -@pytest.mark.parametrize( - "temp_primaite_session", - [[TEST_CONFIG_ROOT / "ppo_seeded_training_config.yaml", dos_very_basic_config_path()]], - indirect=True, -) -def test_deterministic_evaluation(temp_primaite_session): - """Test running deterministic evaluation gives same av eward per episode.""" - with temp_primaite_session as session: - # do stuff - session.learn() - session.evaluate() - eval_mean_reward = session.eval_av_reward_per_episode_dict() - assert len(set(eval_mean_reward.values())) == 1 diff --git a/tests/test_service_node.py b/tests/test_service_node.py deleted file mode 100644 index 906bcf55..00000000 --- a/tests/test_service_node.py +++ /dev/null @@ -1,71 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Used to test Service Node functions.""" -import pytest - -from primaite.common.enums import HardwareState, SoftwareState -from primaite.common.service import Service -from primaite.nodes.service_node import ServiceNode - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, SoftwareState.GOOD), - (HardwareState.ON, SoftwareState.OVERWHELMED), - ], -) -def test_service_state_change(operating_state, expected_state): - """ - Test that a node cannot change the state of a running service. - - When its hardware state is OFF. - """ - service_node = ServiceNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - "COMPROMISED", - "RESTORING", - 1, - ) - service = Service("TCP", 80, SoftwareState.GOOD) - service_node.add_service(service) - - service_node.set_service_state("TCP", SoftwareState.OVERWHELMED) - - assert service_node.get_service_state("TCP") == expected_state - - -@pytest.mark.parametrize( - "operating_state, expected_state", - [ - (HardwareState.OFF, SoftwareState.GOOD), - (HardwareState.ON, SoftwareState.OVERWHELMED), - ], -) -def test_service_state_change_if_not_comprised(operating_state, expected_state): - """ - Test that a node cannot change the state of a running service. - - If not compromised when its hardware state is ON. - """ - service_node = ServiceNode( - 0, - "node", - "COMPUTER", - "1", - operating_state, - "192.168.0.1", - "GOOD", - "RESTORING", - 1, - ) - service = Service("TCP", 80, SoftwareState.GOOD) - service_node.add_service(service) - - service_node.set_service_state_if_not_compromised("TCP", SoftwareState.OVERWHELMED) - - assert service_node.get_service_state("TCP") == expected_state diff --git a/tests/test_session_loading.py b/tests/test_session_loading.py deleted file mode 100644 index f9990f76..00000000 --- a/tests/test_session_loading.py +++ /dev/null @@ -1,187 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import os.path -import shutil -import tempfile -from pathlib import Path -from typing import Union -from uuid import uuid4 - -from typer.testing import CliRunner - -from primaite import getLogger -from primaite.agents.sb3 import SB3Agent -from primaite.cli import app -from primaite.common.enums import AgentFramework, AgentIdentifier -from primaite.main import run -from primaite.primaite_session import PrimaiteSession -from primaite.utils.session_output_reader import av_rewards_dict -from tests import TEST_ASSETS_ROOT - -_LOGGER = getLogger(__name__) - -runner = CliRunner() - -sb3_expected_avg_reward_per_episode = { - 10: 0.0, - 11: -0.0011074218750000008, - 12: -0.0010000000000000007, - 13: -0.0016601562500000013, - 14: -0.001400390625000001, - 15: -0.0009863281250000007, - 16: -0.0011855468750000008, - 17: -0.0009511718750000007, - 18: -0.0008789062500000007, - 19: -0.0012226562500000009, - 20: -0.0010292968750000007, -} - -sb3_expected_eval_rewards = -0.0018515625000000014 - - -def copy_session_asset(asset_path: Union[str, Path]) -> str: - """Copies the asset into a temporary test folder.""" - if asset_path is None: - raise Exception("No path provided") - - if isinstance(asset_path, Path): - asset_path = str(os.path.normpath(asset_path)) - - copy_path = str(Path(tempfile.gettempdir()) / "primaite" / str(uuid4())) - - # copy the asset into a temp path - try: - shutil.copytree(asset_path, copy_path) - except Exception as e: - msg = f"Unable to copy directory: {asset_path}" - _LOGGER.error(msg, e) - print(msg, e) - - _LOGGER.debug(f"Copied test asset to: {copy_path}") - - # return the copied assets path - return copy_path - - -def test_load_sb3_session(): - """Test that loading an SB3 agent works.""" - test_path = copy_session_asset(TEST_ASSETS_ROOT / "example_sb3_agent_session") - - loaded_agent = SB3Agent(session_path=test_path) - - # loaded agent should have the same UUID as the previous agent - assert loaded_agent.uuid == "301874d3-2e14-43c2-ba7f-e2b03ad05dde" - assert loaded_agent._training_config.agent_framework == AgentFramework.SB3.name - assert loaded_agent._training_config.agent_identifier == AgentIdentifier.PPO.name - assert loaded_agent._training_config.deterministic - assert loaded_agent._training_config.seed == 12345 - assert str(loaded_agent.session_path) == str(test_path) - - # run another learn session - loaded_agent.learn() - - learn_mean_rewards = av_rewards_dict( - loaded_agent.learning_path / f"average_reward_per_episode_{loaded_agent.timestamp_str}.csv" - ) - - # run is seeded so should have the expected learn value - assert learn_mean_rewards == sb3_expected_avg_reward_per_episode - - # run an evaluation - loaded_agent.evaluate() - - # load the evaluation average reward csv file - eval_mean_reward = av_rewards_dict( - loaded_agent.evaluation_path / f"average_reward_per_episode_{loaded_agent.timestamp_str}.csv" - ) - - # the agent config ran the evaluation in deterministic mode, so should have the same reward value - assert len(set(eval_mean_reward.values())) == 1 - - # the evaluation should be the same as a previous run - assert next(iter(set(eval_mean_reward.values()))) == sb3_expected_eval_rewards - - # delete the test directory - shutil.rmtree(test_path) - - -def test_load_primaite_session(): - """Test that loading a Primaite session works.""" - test_path = copy_session_asset(TEST_ASSETS_ROOT / "example_sb3_agent_session") - - # create loaded session - session = PrimaiteSession(session_path=test_path) - - # run setup on session - session.setup() - - # make sure that the session was loaded correctly - assert session._agent_session.uuid == "301874d3-2e14-43c2-ba7f-e2b03ad05dde" - assert session._agent_session._training_config.agent_framework == AgentFramework.SB3.name - assert session._agent_session._training_config.agent_identifier == AgentIdentifier.PPO.name - assert session._agent_session._training_config.deterministic - assert session._agent_session._training_config.seed == 12345 - assert str(session._agent_session.session_path) == str(test_path) - - # run another learn session - session.learn() - - learn_mean_rewards = av_rewards_dict( - session.learning_path / f"average_reward_per_episode_{session.timestamp_str}.csv" - ) - - # run is seeded so should have the expected learn value - assert learn_mean_rewards == sb3_expected_avg_reward_per_episode - - # run an evaluation - session.evaluate() - - # load the evaluation average reward csv file - eval_mean_reward = av_rewards_dict( - session.evaluation_path / f"average_reward_per_episode_{session.timestamp_str}.csv" - ) - - # the agent config ran the evaluation in deterministic mode, so should have the same reward value - assert len(set(eval_mean_reward.values())) == 1 - - # the evaluation should be the same as a previous run - assert next(iter(set(eval_mean_reward.values()))) == sb3_expected_eval_rewards - - # delete the test directory - shutil.rmtree(test_path) - - -def test_run_loading(): - """Test loading session via main.run.""" - test_path = copy_session_asset(TEST_ASSETS_ROOT / "example_sb3_agent_session") - - # create loaded session - run(session_path=test_path) - - learn_mean_rewards = av_rewards_dict( - next(Path(test_path).rglob("**/learning/average_reward_per_episode_*.csv"), None) - ) - - # run is seeded so should have the expected learn value - assert learn_mean_rewards == sb3_expected_avg_reward_per_episode - - # delete the test directory - shutil.rmtree(test_path) - - -def test_cli(): - """Test loading session via CLI.""" - test_path = copy_session_asset(TEST_ASSETS_ROOT / "example_sb3_agent_session") - result = runner.invoke(app, ["session", "--load", test_path]) - - # cli should work - assert result.exit_code == 0 - - learn_mean_rewards = av_rewards_dict( - next(Path(test_path).rglob("**/learning/average_reward_per_episode_*.csv"), None) - ) - - # run is seeded so should have the expected learn value - assert learn_mean_rewards == sb3_expected_avg_reward_per_episode - - # delete the test directory - shutil.rmtree(test_path) diff --git a/tests/test_single_action_space.py b/tests/test_single_action_space.py deleted file mode 100644 index 5d300232..00000000 --- a/tests/test_single_action_space.py +++ /dev/null @@ -1,129 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import time - -import pytest - -from primaite.acl.acl_rule import ACLRule -from primaite.common.enums import HardwareState -from primaite.environment.primaite_env import Primaite -from tests import TEST_CONFIG_ROOT - - -def run_generic_set_actions(env: Primaite): - """Run against a generic agent with specified blue agent actions.""" - # Reset the environment at the start of the episode - # env.reset() - training_config = env.training_config - for episode in range(0, training_config.num_train_episodes): - for step in range(0, training_config.num_train_steps): - # Send the observation space to the agent to get an action - # TEMP - random action for now - # action = env.blue_agent_action(obs) - action = 0 - # print("Episode:", episode, "\nStep:", step) - if step == 5: - # [1, 1, 2, 1, 1, 1, 1(position)] - # Creates an ACL rule - # Allows traffic from server_1 to node_1 on port FTP - action = 56 - elif step == 7: - # [1, 1, 2, 0] Node Action - # Sets Node 1 Hardware State to OFF - # Does not resolve any service - action = 128 - # Run the simulation step on the live environment - obs, reward, done, info = env.step(action) - - # Break if done is True - if done: - break - - # Introduce a delay between steps - time.sleep(training_config.time_delay / 1000) - - # Reset the environment at the end of the episode - # env.reset() - - # env.close() - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "single_action_space_main_config.yaml", - TEST_CONFIG_ROOT / "single_action_space_lay_down_config.yaml", - ] - ], - indirect=True, -) -def test_single_action_space_is_valid(temp_primaite_session): - """Test single action space is valid.""" - # TODO: Refactor this at some point to build a custom ACL Hardcoded - # Agent and then patch the AgentIdentifier Enum class so that it - # has ACL_AGENT. This then allows us to set the agent identified in - # the main config and is a bit cleaner. - with temp_primaite_session as session: - env = session.env - - run_generic_set_actions(env) - # Retrieve the action space dictionary values from environment - env_action_space_dict = env.action_dict.values() - # Flags to check the conditions of the action space - contains_acl_actions = False - contains_node_actions = False - both_action_spaces = False - # Loop through each element of the list (which is every value from the dictionary) - for dict_item in env_action_space_dict: - # Node action detected - if len(dict_item) == 4: - contains_node_actions = True - # Link action detected - elif len(dict_item) == 7: - contains_acl_actions = True - # If both are there then the ANY action type is working - if contains_node_actions and contains_acl_actions: - both_action_spaces = True - # Check condition should be True - assert both_action_spaces - - -@pytest.mark.parametrize( - "temp_primaite_session", - [ - [ - TEST_CONFIG_ROOT / "single_action_space_fixed_blue_actions_main_config.yaml", - TEST_CONFIG_ROOT / "single_action_space_lay_down_config.yaml", - ] - ], - indirect=True, -) -def test_agent_is_executing_actions_from_both_spaces(temp_primaite_session): - """Test to ensure the blue agent is carrying out both kinds of operations (NODE & ACL).""" - # TODO: Refactor this at some point to build a custom ACL Hardcoded - # Agent and then patch the AgentIdentifier Enum class so that it - # has ACL_AGENT. This then allows us to set the agent identified in - # the main config and is a bit cleaner. - with temp_primaite_session as session: - env = session.env - # Run environment with specified fixed blue agent actions only - run_generic_set_actions(env) - # Retrieve hardware state of computer_1 node in laydown config - # Agent turned this off in Step 5 - computer_node_hardware_state = env.nodes["1"].hardware_state - # Retrieve the Access Control List object stored by the environment at the end of the episode - access_control_list = env.acl - # Use the Access Control List object acl object attribute to get dictionary - # Use dictionary.values() to get total list of all items in the dictionary - acl_rules_list = access_control_list.acl - # Length of this list tells you how many items are in the dictionary - # This number is the frequency of Access Control Rules in the environment - # In the scenario, we specified that the agent should create only 1 acl rule - # This 1 rule added to the implicit deny means there should be 2 rules in total. - rules_count = 0 - for rule in acl_rules_list: - if isinstance(rule, ACLRule): - rules_count += 1 - # Therefore these statements below MUST be true - assert computer_node_hardware_state == HardwareState.OFF - assert rules_count == 2 diff --git a/tests/test_train_eval_episode_steps.py b/tests/test_train_eval_episode_steps.py deleted file mode 100644 index 1b53fe9d..00000000 --- a/tests/test_train_eval_episode_steps.py +++ /dev/null @@ -1,43 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import pytest - -from primaite import getLogger -from primaite.config.lay_down_config import dos_very_basic_config_path -from tests import TEST_CONFIG_ROOT - -_LOGGER = getLogger(__name__) - - -@pytest.mark.parametrize( - "temp_primaite_session", - [[TEST_CONFIG_ROOT / "train_episode_step.yaml", dos_very_basic_config_path()]], - indirect=True, -) -def test_eval_steps_differ_from_training(temp_primaite_session): - """Uses PrimaiteSession class to compare number of episodes used for training and evaluation. - - Train_episode_step.yaml main config: - num_train_steps = 25 - num_train_episodes = 3 - num_eval_steps = 17 - num_eval_episodes = 1 - """ - expected_learning_metadata = {"total_episodes": 3, "total_time_steps": 75} - expected_evaluation_metadata = {"total_episodes": 1, "total_time_steps": 17} - - with temp_primaite_session as session: - # Run learning and check episode and step counts - session.learn() - assert session.env.actual_episode_count == expected_learning_metadata["total_episodes"] - assert session.env.total_step_count == expected_learning_metadata["total_time_steps"] - - # Run evaluation and check episode and step counts - session.evaluate() - assert session.env.actual_episode_count == expected_evaluation_metadata["total_episodes"] - assert session.env.total_step_count == expected_evaluation_metadata["total_time_steps"] - - # Load the session_metadata.json file and check that the both the - # learning and evaluation match what is expected above - metadata = session.metadata_file_as_dict() - assert metadata["learning"] == expected_learning_metadata - assert metadata["evaluation"] == expected_evaluation_metadata diff --git a/tests/test_training_config.py b/tests/test_training_config.py deleted file mode 100644 index 58f9c797..00000000 --- a/tests/test_training_config.py +++ /dev/null @@ -1,36 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -import yaml - -from primaite.config import training_config -from tests import TEST_CONFIG_ROOT - - -def test_legacy_lay_down_config_yaml_conversion(): - """Tests the conversion of legacy lay down config files.""" - legacy_path = TEST_CONFIG_ROOT / "legacy_conversion" / "legacy_training_config.yaml" - new_path = TEST_CONFIG_ROOT / "legacy_conversion" / "new_training_config.yaml" - - with open(legacy_path, "r") as file: - legacy_dict = yaml.safe_load(file) - - with open(new_path, "r") as file: - new_dict = yaml.safe_load(file) - - converted_dict = training_config.convert_legacy_training_config_dict(legacy_dict) - - for key, value in new_dict.items(): - assert converted_dict[key] == value - - -def test_create_config_values_main_from_file(): - """Tests creating an instance of TrainingConfig from file.""" - new_path = TEST_CONFIG_ROOT / "legacy_conversion" / "new_training_config.yaml" - - training_config.load(new_path) - - -def test_create_config_values_main_from_legacy_file(): - """Tests creating an instance of TrainingConfig from legacy file.""" - new_path = TEST_CONFIG_ROOT / "legacy_conversion" / "legacy_training_config.yaml" - - training_config.load(new_path, legacy_file=True) diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/__init__.py b/tests/unit_tests/_primaite/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_game/__init__.py b/tests/unit_tests/_primaite/_game/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_game/_agent/__init__.py b/tests/unit_tests/_primaite/_game/_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_game/_agent/test_actions.py b/tests/unit_tests/_primaite/_game/_agent/test_actions.py new file mode 100644 index 00000000..b41e22c9 --- /dev/null +++ b/tests/unit_tests/_primaite/_game/_agent/test_actions.py @@ -0,0 +1,90 @@ +from unittest.mock import Mock + +import pytest + +from primaite.game.agent.actions import ( + ActionManager, + DoNothingAction, + NodeServiceDisableAction, + NodeServiceEnableAction, + NodeServicePauseAction, + NodeServiceRestartAction, + NodeServiceResumeAction, + NodeServiceScanAction, + NodeServiceStartAction, + NodeServiceStopAction, +) + + +def test_do_nothing_action_form_request(): + """Test that the DoNothingAction can form a request and that it is correct.""" + manager = Mock() + + action = DoNothingAction(manager=manager) + + request = action.form_request() + + assert request == ["do_nothing"] + + +@pytest.mark.parametrize( + "action_class, action_verb", + [ + (NodeServiceScanAction, "scan"), + (NodeServiceStopAction, "stop"), + (NodeServiceStartAction, "start"), + (NodeServicePauseAction, "pause"), + (NodeServiceResumeAction, "resume"), + (NodeServiceRestartAction, "restart"), + (NodeServiceDisableAction, "disable"), + (NodeServiceEnableAction, "enable"), + ], +) # flake8: noqa +@pytest.mark.parametrize( + "node_name, service_name, expect_to_do_nothing", + [ + ("pc_1", "chrome", False), + (None, "chrome", True), + ("pc_1", None, True), + (None, None, True), + ], +) # flake8: noqa +def test_service_action_form_request(node_name, service_name, expect_to_do_nothing, action_class, action_verb): + """Test that the ServiceScanAction can form a request and that it is correct.""" + manager: ActionManager = Mock() + manager.get_node_name_by_idx.return_value = node_name + manager.get_service_name_by_idx.return_value = service_name + + action = action_class(manager=manager, num_nodes=1, num_services=1) + + request = action.form_request(node_id=0, service_id=0) + + if expect_to_do_nothing: + assert request == ["do_nothing"] + else: + assert request == ["network", "node", node_name, "service", service_name, action_verb] + + +@pytest.mark.parametrize( + "node_name, service_name, expect_to_do_nothing", + [ + ("pc_1", "chrome", False), + (None, "chrome", True), + ("pc_1", None, True), + (None, None, True), + ], +) # flake8: noqa +def test_service_scan_form_request(node_name, service_name, expect_to_do_nothing): + """Test that the ServiceScanAction can form a request and that it is correct.""" + manager: ActionManager = Mock() + manager.get_node_name_by_idx.return_value = node_name + manager.get_service_name_by_idx.return_value = service_name + + action = NodeServiceScanAction(manager=manager, num_nodes=1, num_services=1) + + request = action.form_request(node_id=0, service_id=0) + + if expect_to_do_nothing: + assert request == ["do_nothing"] + else: + assert request == ["network", "node", node_name, "service", service_name, "scan"] diff --git a/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py b/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py new file mode 100644 index 00000000..7eacb30d --- /dev/null +++ b/tests/unit_tests/_primaite/_game/_agent/test_probabilistic_agent.py @@ -0,0 +1,84 @@ +from primaite.game.agent.actions import ActionManager +from primaite.game.agent.observations.observation_manager import NestedObservation, ObservationManager +from primaite.game.agent.rewards import RewardFunction +from primaite.game.agent.scripted_agents.probabilistic_agent import ProbabilisticAgent + + +def test_probabilistic_agent(): + """ + Check that the probabilistic agent selects actions with approximately the right probabilities. + + Using a binomial probability calculator (https://www.wolframalpha.com/input/?i=binomial+distribution+calculator), + we can generate some lower and upper bounds of how many times we expect the agent to take each action. These values + were chosen to guarantee a less than 1 in a million chance of the test failing due to unlucky random number + generation. + """ + N_TRIALS = 10_000 + P_DO_NOTHING = 0.1 + P_NODE_APPLICATION_EXECUTE = 0.3 + P_NODE_FILE_DELETE = 0.6 + MIN_DO_NOTHING = 850 + MAX_DO_NOTHING = 1150 + MIN_NODE_APPLICATION_EXECUTE = 2800 + MAX_NODE_APPLICATION_EXECUTE = 3200 + MIN_NODE_FILE_DELETE = 5750 + MAX_NODE_FILE_DELETE = 6250 + + action_space = ActionManager( + actions=[ + {"type": "DONOTHING"}, + {"type": "NODE_APPLICATION_EXECUTE"}, + {"type": "NODE_FILE_DELETE"}, + ], + nodes=[ + { + "node_name": "client_1", + "applications": [{"application_name": "WebBrowser"}], + "folders": [{"folder_name": "downloads", "files": [{"file_name": "cat.png"}]}], + }, + ], + max_folders_per_node=2, + max_files_per_folder=2, + max_services_per_node=2, + max_applications_per_node=2, + max_nics_per_node=2, + max_acl_rules=10, + protocols=["TCP", "UDP", "ICMP"], + ports=["HTTP", "DNS", "ARP"], + act_map={ + 0: {"action": "DONOTHING", "options": {}}, + 1: {"action": "NODE_APPLICATION_EXECUTE", "options": {"node_id": 0, "application_id": 0}}, + 2: {"action": "NODE_FILE_DELETE", "options": {"node_id": 0, "folder_id": 0, "file_id": 0}}, + }, + ) + observation_space = ObservationManager(NestedObservation(components={})) + reward_function = RewardFunction() + + pa = ProbabilisticAgent( + agent_name="test_agent", + action_space=action_space, + observation_space=observation_space, + reward_function=reward_function, + settings={ + "action_probabilities": {0: P_DO_NOTHING, 1: P_NODE_APPLICATION_EXECUTE, 2: P_NODE_FILE_DELETE}, + "random_seed": 120, + }, + ) + + do_nothing_count = 0 + node_application_execute_count = 0 + node_file_delete_count = 0 + for _ in range(N_TRIALS): + a = pa.get_action(0) + if a == ("DONOTHING", {}): + do_nothing_count += 1 + elif a == ("NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0}): + node_application_execute_count += 1 + elif a == ("NODE_FILE_DELETE", {"node_id": 0, "folder_id": 0, "file_id": 0}): + node_file_delete_count += 1 + else: + raise AssertionError("Probabilistic agent produced an unexpected action.") + + assert MIN_DO_NOTHING < do_nothing_count < MAX_DO_NOTHING + assert MIN_NODE_APPLICATION_EXECUTE < node_application_execute_count < MAX_NODE_APPLICATION_EXECUTE + assert MIN_NODE_FILE_DELETE < node_file_delete_count < MAX_NODE_FILE_DELETE diff --git a/tests/unit_tests/_primaite/_interface/__init__.py b/tests/unit_tests/_primaite/_interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_interface/test_request.py b/tests/unit_tests/_primaite/_interface/test_request.py new file mode 100644 index 00000000..5c65b572 --- /dev/null +++ b/tests/unit_tests/_primaite/_interface/test_request.py @@ -0,0 +1,32 @@ +import pytest +from pydantic import ValidationError + +from primaite.interface.request import RequestResponse + + +def test_creating_response_object(): + """Test that we can create a response object with given parameters.""" + r1 = RequestResponse(status="success", data={"test_data": 1, "other_data": 2}) + r2 = RequestResponse(status="unreachable") + r3 = RequestResponse(data={"test_data": "is_good"}) + r4 = RequestResponse() + assert isinstance(r1, RequestResponse) + assert isinstance(r2, RequestResponse) + assert isinstance(r3, RequestResponse) + assert isinstance(r4, RequestResponse) + + +def test_creating_response_from_boolean(): + """Test that we can build a response with a single boolean.""" + r1 = RequestResponse.from_bool(True) + assert r1.status == "success" + + r2 = RequestResponse.from_bool(False) + assert r2.status == "failure" + + with pytest.raises(ValidationError): + r3 = RequestResponse.from_bool(1) + with pytest.raises(ValidationError): + r4 = RequestResponse.from_bool("good") + with pytest.raises(ValidationError): + r5 = RequestResponse.from_bool({"data": True}) diff --git a/tests/unit_tests/_primaite/_session/__init__.py b/tests/unit_tests/_primaite/_session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_session/test_episode_schedule.py b/tests/unit_tests/_primaite/_session/test_episode_schedule.py new file mode 100644 index 00000000..25a68cbb --- /dev/null +++ b/tests/unit_tests/_primaite/_session/test_episode_schedule.py @@ -0,0 +1,50 @@ +import pytest +import yaml + +from primaite.session.episode_schedule import ConstantEpisodeScheduler, EpisodeListScheduler + + +def test_episode_list_scheduler(): + # Initialize an instance of EpisodeListScheduler + + # Define a schedule and episode data for testing + schedule = {0: ["episode1"], 1: ["episode2"]} + episode_data = {"episode1": "data1: 1", "episode2": "data2: 2"} + base_scenario = """agents: []""" + + scheduler = EpisodeListScheduler(schedule=schedule, episode_data=episode_data, base_scenario=base_scenario) + # Test when episode number is within the schedule + result = scheduler(0) + assert isinstance(result, dict) + assert yaml.safe_load("data1: 1\nagents: []") == result + + # Test next episode + result = scheduler(1) + assert isinstance(result, dict) + assert yaml.safe_load("data2: 2\nagents: []") == result + + # Test when episode number exceeds the schedule + result = scheduler(2) + assert isinstance(result, dict) + assert yaml.safe_load("data1: 1\nagents: []") == result + assert scheduler._exceeded_episode_list + + # Test when episode number is a sequence + scheduler.schedule = {0: ["episode1", "episode2"]} + result = scheduler(0) + assert isinstance(result, dict) + assert yaml.safe_load("data1: 1\ndata2: 2\nagents: []") == result + + +def test_constant_episode_scheduler(): + # Initialize an instance of ConstantEpisodeScheduler + config = {"key": "value"} + scheduler = ConstantEpisodeScheduler(config=config) + + result = scheduler(0) + assert isinstance(result, dict) + assert {"key": "value"} == result + + result = scheduler(1) + assert isinstance(result, dict) + assert {"key": "value"} == result diff --git a/tests/unit_tests/_primaite/_simulator/__init__.py b/tests/unit_tests/_primaite/_simulator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_domain/__init__.py b/tests/unit_tests/_primaite/_simulator/_domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py new file mode 100644 index 00000000..786fe851 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -0,0 +1,119 @@ +"""Test the account module of the simulator.""" +import pytest + +from primaite.simulator.domain.account import Account, AccountType + + +@pytest.fixture(scope="function") +def account() -> Account: + acct = Account(username="Jake", password="totally_hashed_password", account_type=AccountType.USER) + return acct + + +def test_original_state(account): + """Test the original state - see if it resets properly""" + state = account.describe_state() + assert state["num_logons"] is 0 + assert state["num_logoffs"] is 0 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is True + + account.log_on() + account.log_off() + account.disable() + + state = account.describe_state() + assert state["num_logons"] is 1 + assert state["num_logoffs"] is 1 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is False + + +def test_enable(account): + """Should enable the account.""" + account.enabled = False + account.enable() + assert account.enabled is True + + +def test_disable(account): + """Should disable the account.""" + account.enabled = True + account.disable() + assert account.enabled is False + + +def test_log_on_increments(account): + """Should increase the log on value by 1.""" + account.num_logons = 0 + account.log_on() + assert account.num_logons is 1 + + +def test_log_off_increments(account): + """Should increase the log on value by 1.""" + account.num_logoffs = 0 + account.log_off() + assert account.num_logoffs is 1 + + +def test_account_serialise(account): + """Test that an account can be serialised. If pydantic throws error then this test fails.""" + serialised = account.model_dump_json() + print(serialised) + + +def test_account_deserialise(account): + """Test that an account can be deserialised. The test fails if pydantic throws an error.""" + acct_json = ( + '{"uuid":"dfb2bcaa-d3a1-48fd-af3f-c943354622b4","num_logons":0,"num_logoffs":0,"num_group_changes":0,' + '"username":"Jake","password":"totally_hashed_password","account_type":2,"status":2,"request_manager":null}' + ) + assert Account.model_validate_json(acct_json) + + +def test_describe_state(account): + state = account.describe_state() + assert state["num_logons"] is 0 + assert state["num_logoffs"] is 0 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is True + + account.log_on() + state = account.describe_state() + assert state["num_logons"] is 1 + assert state["num_logoffs"] is 0 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is True + + account.log_off() + state = account.describe_state() + assert state["num_logons"] is 1 + assert state["num_logoffs"] is 1 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is True + + account.disable() + state = account.describe_state() + assert state["num_logons"] is 1 + assert state["num_logoffs"] is 1 + assert state["num_group_changes"] is 0 + assert state["username"] is "Jake" + assert state["password"] is "totally_hashed_password" + assert state["account_type"] is AccountType.USER.value + assert state["enabled"] is False diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py b/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/__init__.py b/tests/unit_tests/_primaite/_simulator/_file_system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file.py new file mode 100644 index 00000000..72a5889e --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file.py @@ -0,0 +1,85 @@ +import warnings + +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.file_type import FileType + + +def test_create_file_no_extension(file_system): + """Tests that creating a file without an extension sets the file type to FileType.UNKNOWN.""" + file = file_system.create_file(file_name="test_file") + assert len(file_system.folders) is 1 + assert file_system.get_folder("root").get_file("test_file") == file + assert file_system.get_folder("root").get_file("test_file").file_type == FileType.UNKNOWN + assert file_system.get_folder("root").get_file("test_file").size == 0 + + +def test_file_scan(file_system): + """Test the ability to update visible status.""" + file: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + assert file.health_status == FileSystemItemHealthStatus.GOOD + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + file.corrupt() + + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + file.scan() + + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_file_reveal_to_red_scan(file_system): + """Test the ability to reveal files to red.""" + file = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + assert file.revealed_to_red is False + + file.reveal_to_red() + + assert file.revealed_to_red is True + + +@pytest.mark.skip(reason="NODE_FILE_CHECKHASH not implemented") +def test_simulated_file_check_hash(file_system): + file: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + file.check_hash() + assert file.health_status == FileSystemItemHealthStatus.GOOD + # change simulated file size + file.sim_size = 0 + file.check_hash() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_file_corrupt_repair_restore(file_system): + """Test the ability to corrupt and repair files.""" + file: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + file.corrupt() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + file.repair() + assert file.health_status == FileSystemItemHealthStatus.GOOD + + file.corrupt() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + file.restore() + assert file.health_status == FileSystemItemHealthStatus.GOOD + + +def test_file_warning_triggered(file_system): + file: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + file.check_hash() + # Check warning issued + assert len(w) == 1 + assert "not implemented" in str(w[-1].message) diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_actions.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_actions.py new file mode 100644 index 00000000..efa49134 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_actions.py @@ -0,0 +1,108 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder + + +@pytest.fixture(scope="function") +def populated_file_system(file_system) -> Tuple[FileSystem, Folder, File]: + """Create a file system with a folder and file.""" + folder = file_system.create_folder(folder_name="test_folder") + file = file_system.create_file(folder_name="test_folder", file_name="test_file.txt") + + return file_system, folder, file + + +def test_file_scan_request(populated_file_system): + """Test that an agent can request a file scan.""" + fs, folder, file = populated_file_system + + file.corrupt() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + fs.apply_request(request=["file", file.name, "scan"]) + + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +@pytest.mark.skip(reason="NODE_FILE_CHECKHASH not implemented") +def test_file_checkhash_request(populated_file_system): + """Test that an agent can request a file hash check.""" + fs, folder, file = populated_file_system + + fs.apply_request(request=["file", file.name, "checkhash"]) + + assert file.health_status == FileSystemItemHealthStatus.GOOD + file.sim_size = 0 + + fs.apply_request(request=["file", file.name, "checkhash"]) + + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_file_repair_request(populated_file_system): + """Test that an agent can request a file repair.""" + fs, folder, file = populated_file_system + + file.corrupt() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + fs.apply_request(request=["file", file.name, "repair"]) + assert file.health_status == FileSystemItemHealthStatus.GOOD + + +def test_file_restore_request(populated_file_system): + """Test that an agent can request that a file can be restored.""" + fs, folder, file = populated_file_system + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid) is not None + + fs.apply_request(request=["delete", "file", folder.name, file.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid, include_deleted=True).deleted is True + + fs.apply_request(request=["restore", "file", folder.name, file.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + assert fs.get_file(folder_name=folder.name, file_name=file.name).deleted is False + + fs.apply_request(request=["file", file.name, "corrupt"]) + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.CORRUPT + + fs.apply_request(request=["restore", "file", folder.name, file.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.GOOD + + +def test_file_corrupt_request(populated_file_system): + """Test that an agent can request a file corruption.""" + fs, folder, file = populated_file_system + fs.apply_request(request=["file", file.name, "corrupt"]) + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_deleted_file_cannot_be_interacted_with(populated_file_system): + """Test that actions cannot affect deleted files.""" + fs, folder, file = populated_file_system + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + + fs.apply_request(request=["file", file.name, "corrupt"]) + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.CORRUPT + assert ( + fs.get_file(folder_name=folder.name, file_name=file.name).visible_health_status + == FileSystemItemHealthStatus.GOOD + ) + + fs.apply_request(request=["delete", "file", folder.name, file.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + + fs.apply_request(request=["file", file.name, "repair"]) + fs.apply_request(request=["file", file.name, "scan"]) + + file = folder.deleted_files.get(file.uuid) + + assert file.health_status is not FileSystemItemHealthStatus.GOOD + assert file.visible_health_status is not FileSystemItemHealthStatus.CORRUPT diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py new file mode 100644 index 00000000..0cb7dce7 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -0,0 +1,251 @@ +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.file_type import FileType +from primaite.simulator.file_system.folder import Folder + + +def test_create_folder_and_file(file_system): + """Test creating a folder and a file.""" + assert len(file_system.folders) == 1 + file_system.create_folder(folder_name="test_folder") + + assert len(file_system.folders) is 2 + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + assert len(file_system.get_folder("test_folder").files) == 1 + + assert file_system.num_file_creations == 1 + + assert file_system.get_folder("test_folder").get_file("test_file.txt") + + file_system.apply_timestep(0) + file_system.pre_timestep(0) + + # num file creations should reset + assert file_system.num_file_creations == 0 + + file_system.show(full=True) + + +def test_create_file_no_folder(file_system): + """Tests that creating a file without a folder creates a folder and sets that as the file's parent.""" + file = file_system.create_file(file_name="test_file.txt", size=10) + assert len(file_system.folders) is 1 + assert file_system.num_file_creations == 1 + assert file_system.get_folder("root").get_file("test_file.txt") == file + assert file_system.get_folder("root").get_file("test_file.txt").file_type == FileType.TXT + assert file_system.get_folder("root").get_file("test_file.txt").size == 10 + + file_system.apply_timestep(0) + file_system.pre_timestep(0) + + # num file creations should reset + assert file_system.num_file_creations == 0 + + file_system.show(full=True) + + +def test_delete_file(file_system): + """Tests that a file can be deleted.""" + file = file_system.create_file(file_name="test_file.txt") + assert len(file_system.folders) == 1 + assert len(file_system.get_folder("root").files) == 1 + + file_system.delete_file(folder_name="root", file_name="test_file.txt") + assert file.num_access == 1 + assert file_system.num_file_deletions == 1 + assert len(file_system.folders) == 1 + assert len(file_system.get_folder("root").files) == 0 + assert len(file_system.get_folder("root").deleted_files) == 1 + + file_system.apply_timestep(0) + file_system.pre_timestep(0) + + # num file deletions should reset + assert file_system.num_file_deletions == 0 + + file_system.show(full=True) + + +def test_delete_non_existent_file(file_system): + """Tests deleting a non existent file.""" + file_system.create_file(file_name="test_file.txt") + # folder should be created + assert len(file_system.folders) == 1 + # should only have 1 file in the file system + assert len(file_system.get_folder("root").files) == 1 + + # deleting should not change how many files are in folder + file_system.delete_file(folder_name="root", file_name="does_not_exist!") + assert file_system.num_file_deletions == 0 + + # should still only be one folder + assert len(file_system.folders) == 1 + # The folder should still have 1 file + assert len(file_system.get_folder("root").files) == 1 + + file_system.show(full=True) + + +def test_delete_folder(file_system): + file_system.create_folder(folder_name="test_folder") + assert len(file_system.folders) == 2 + + file_system.delete_folder(folder_name="test_folder") + assert len(file_system.folders) == 1 + + assert len(file_system.deleted_folders) == 1 + + file_system.show(full=True) + + +def test_create_duplicate_folder(file_system): + """Test that creating a duplicate folder throws exception.""" + assert len(file_system.folders) == 1 + file_system.create_folder(folder_name="test_folder") + + assert len(file_system.folders) is 2 + with pytest.raises(Exception): + file_system.create_folder(folder_name="test_folder") + + assert len(file_system.folders) is 2 + + file_system.show(full=True) + + +def test_create_duplicate_file(file_system): + """Test that creating a duplicate file throws exception.""" + assert len(file_system.folders) == 1 + file_system.create_folder(folder_name="test_folder") + + assert len(file_system.folders) is 2 + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + assert file_system.num_file_creations == 1 + + assert len(file_system.get_folder("test_folder").files) == 1 + + with pytest.raises(Exception): + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + assert len(file_system.get_folder("test_folder").files) == 1 + assert file_system.num_file_creations == 1 + + file_system.show(full=True) + + +def test_deleting_a_non_existent_folder(file_system): + file_system.create_folder(folder_name="test_folder") + assert len(file_system.folders) == 2 + + file_system.delete_folder(folder_name="does not exist!") + assert len(file_system.folders) == 2 + + file_system.show(full=True) + + +def test_deleting_root_folder_fails(file_system): + assert len(file_system.folders) == 1 + + file_system.delete_folder(folder_name="root") + assert len(file_system.folders) == 1 + + file_system.show(full=True) + + +def test_move_file(file_system): + """Tests the file move function.""" + file_system.create_folder(folder_name="src_folder") + file_system.create_folder(folder_name="dst_folder") + + file = file_system.create_file(file_name="test_file.txt", size=10, folder_name="src_folder") + original_uuid = file.uuid + + assert len(file_system.get_folder("src_folder").files) == 1 + assert len(file_system.get_folder("dst_folder").files) == 0 + assert file_system.num_file_deletions == 0 + assert file_system.num_file_creations == 1 + + file_system.move_file(src_folder_name="src_folder", src_file_name="test_file.txt", dst_folder_name="dst_folder") + assert file_system.num_file_deletions == 1 + assert file_system.num_file_creations == 2 + assert file.num_access == 1 + + assert len(file_system.get_folder("src_folder").files) == 0 + assert len(file_system.get_folder("dst_folder").files) == 1 + assert file_system.get_file("dst_folder", "test_file.txt").uuid == original_uuid + + file_system.apply_timestep(0) + file_system.pre_timestep(0) + + # num file creations and deletions should reset + assert file_system.num_file_creations == 0 + assert file_system.num_file_deletions == 0 + + file_system.show(full=True) + + +def test_copy_file(file_system): + """Tests the file copy function.""" + file_system.create_folder(folder_name="src_folder") + file_system.create_folder(folder_name="dst_folder") + + file = file_system.create_file(file_name="test_file.txt", size=10, folder_name="src_folder") + assert file_system.num_file_creations == 1 + original_uuid = file.uuid + + assert len(file_system.get_folder("src_folder").files) == 1 + assert len(file_system.get_folder("dst_folder").files) == 0 + + file_system.copy_file(src_folder_name="src_folder", src_file_name="test_file.txt", dst_folder_name="dst_folder") + assert file_system.num_file_creations == 2 + assert file.num_access == 1 + + assert len(file_system.get_folder("src_folder").files) == 1 + assert len(file_system.get_folder("dst_folder").files) == 1 + assert file_system.get_file("dst_folder", "test_file.txt").uuid != original_uuid + + file_system.apply_timestep(0) + file_system.pre_timestep(0) + + # num file creations should reset + assert file_system.num_file_creations == 0 + + file_system.show(full=True) + + +def test_get_file(file_system): + """Test that files can be retrieved.""" + folder: Folder = file_system.create_folder(folder_name="test_folder") + file1: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + file2: File = file_system.create_file(file_name="test_file2.txt", folder_name="test_folder") + + file_system.delete_file("test_folder", "test_file2.txt") + # file 2 was accessed before being deleted + assert file2.num_access == 1 + + assert file_system.get_file_by_id(file_uuid=file1.uuid, folder_uuid=folder.uuid) is not None + assert file_system.get_file_by_id(file_uuid=file2.uuid, folder_uuid=folder.uuid) is None + assert file_system.get_file_by_id(file_uuid=file2.uuid, folder_uuid=folder.uuid, include_deleted=True) is not None + assert file_system.get_file_by_id(file_uuid=file2.uuid, include_deleted=True) is not None + + assert file2.num_access == 1 # cannot access deleted file + + file_system.delete_folder(folder_name="test_folder") + assert file_system.get_file_by_id(file_uuid=file2.uuid, include_deleted=True) is not None + + file_system.show(full=True) + + +@pytest.mark.skip(reason="Skipping until we tackle serialisation") +def test_serialisation(file_system): + """Test to check that the object serialisation works correctly.""" + file_system.create_file(file_name="test_file.txt") + + serialised_file_sys = file_system.model_dump_json() + deserialised_file_sys = FileSystem.model_validate_json(serialised_file_sys) + + assert file_system.model_dump_json() == deserialised_file_sys.model_dump_json() + + file_system.show(full=True) diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_actions.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_actions.py new file mode 100644 index 00000000..62af93c4 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_actions.py @@ -0,0 +1,40 @@ +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.folder import Folder + + +@pytest.fixture(scope="function") +def populated_file_system(file_system) -> Tuple[FileSystem, Folder, File]: + """Create a file system with a folder and file.""" + folder = file_system.create_folder(folder_name="test_folder") + file = file_system.create_file(folder_name="test_folder", file_name="test_file.txt") + + return file_system, folder, file + + +def test_file_delete_request(populated_file_system): + """Test that an agent can request a file deletion.""" + fs, folder, file = populated_file_system + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + + fs.apply_request(request=["delete", "file", folder.name, file.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + + fs.show(full=True) + + +def test_folder_delete_request(populated_file_system): + """Test that an agent can request a folder deletion.""" + fs, folder, file = populated_file_system + assert folder.get_file_by_id(file_uuid=file.uuid) is not None + assert fs.get_folder_by_id(folder_uuid=folder.uuid) is not None + + fs.apply_request(request=["delete", "folder", folder.name]) + assert fs.get_folder_by_id(folder_uuid=folder.uuid) is None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid) is None + + fs.show(full=True) diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_folder.py new file mode 100644 index 00000000..1d9f2d9c --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_folder.py @@ -0,0 +1,134 @@ +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder + + +@pytest.mark.skip(reason="Implementation for quarantine not needed yet") +def test_folder_quarantine_state(file_system): + """Tests the changing of folder quarantine status.""" + folder = file_system.get_folder("root") + + assert folder.quarantine_status() is False + + folder.quarantine() + assert folder.quarantine_status() is True + + folder.unquarantine() + assert folder.quarantine_status() is False + + +def test_folder_get_file(file_system): + """Test that files can be retrieved from the folder.""" + folder: Folder = file_system.create_folder(folder_name="test_folder") + file1: File = file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + file2: File = file_system.create_file(file_name="test_file2.txt", folder_name="test_folder") + + folder.remove_file(file2) + + assert folder.get_file_by_id(file_uuid=file1.uuid) is not None + assert folder.get_file_by_id(file_uuid=file2.uuid) is None + + assert folder.get_file_by_id(file_uuid=file2.uuid, include_deleted=True) is not None + + +def test_folder_scan(file_system): + """Test the ability to update visible status.""" + folder: Folder = file_system.create_folder(folder_name="test_folder") + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + file_system.create_file(file_name="test_file2.txt", folder_name="test_folder") + + file1: File = folder.get_file_by_id(file_uuid=list(folder.files)[1]) + file2: File = folder.get_file_by_id(file_uuid=list(folder.files)[0]) + + assert folder.health_status == FileSystemItemHealthStatus.GOOD + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file1.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file2.visible_health_status == FileSystemItemHealthStatus.GOOD + + folder.corrupt() + + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file1.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file2.visible_health_status == FileSystemItemHealthStatus.GOOD + + folder.scan() + + folder.apply_timestep(timestep=0) + + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file1.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file2.visible_health_status == FileSystemItemHealthStatus.GOOD + + folder.apply_timestep(timestep=1) + folder.apply_timestep(timestep=2) + + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file1.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file2.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_folder_reveal_to_red_scan(file_system): + """Test the ability to reveal files to red.""" + folder: Folder = file_system.create_folder(folder_name="test_folder") + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + file_system.create_file(file_name="test_file2.txt", folder_name="test_folder") + + file1: File = folder.get_file_by_id(file_uuid=list(folder.files)[1]) + file2: File = folder.get_file_by_id(file_uuid=list(folder.files)[0]) + + assert folder.revealed_to_red is False + assert file1.revealed_to_red is False + assert file2.revealed_to_red is False + + folder.reveal_to_red() + + folder.apply_timestep(timestep=0) + + assert folder.revealed_to_red is False + assert file1.revealed_to_red is False + assert file2.revealed_to_red is False + + folder.apply_timestep(timestep=1) + folder.apply_timestep(timestep=2) + + assert folder.revealed_to_red is True + assert file1.revealed_to_red is True + assert file2.revealed_to_red is True + + +def test_folder_corrupt_repair(file_system): + """Test the ability to corrupt and repair folders.""" + folder: Folder = file_system.create_folder(folder_name="test_folder") + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + folder.corrupt() + + file = folder.get_file(file_name="test_file.txt") + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + + folder.repair() + + file = folder.get_file(file_name="test_file.txt") + assert folder.health_status == FileSystemItemHealthStatus.GOOD + assert file.health_status == FileSystemItemHealthStatus.GOOD + + +@pytest.mark.skip(reason="NODE_FILE_CHECKHASH not implemented") +def test_simulated_folder_check_hash(file_system): + folder: Folder = file_system.create_folder(folder_name="test_folder") + file_system.create_file(file_name="test_file.txt", folder_name="test_folder") + + folder.check_hash() + assert folder.health_status == FileSystemItemHealthStatus.GOOD + + # change simulated file size + file = folder.get_file(file_name="test_file.txt") + file.sim_size = 0 + folder.check_hash() + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_folder_actions.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_folder_actions.py new file mode 100644 index 00000000..11043844 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_folder_actions.py @@ -0,0 +1,179 @@ +import warnings +from typing import Tuple + +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder + + +@pytest.fixture(scope="function") +def populated_file_system(file_system) -> Tuple[FileSystem, Folder, File]: + """Create a file system with a folder and file.""" + folder = file_system.create_folder(folder_name="test_folder") + file = file_system.create_file(folder_name="test_folder", file_name="test_file.txt") + + return file_system, folder, file + + +def test_folder_scan_request(populated_file_system): + """Test that an agent can request a folder scan.""" + fs, folder, file = populated_file_system + fs.create_file(file_name="test_file2.txt", folder_name="test_folder") + + file1: File = folder.get_file_by_id(file_uuid=list(folder.files)[1]) + file2: File = folder.get_file_by_id(file_uuid=list(folder.files)[0]) + + folder.corrupt() + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file1.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file2.visible_health_status == FileSystemItemHealthStatus.GOOD + + fs.apply_request(request=["folder", folder.name, "scan"]) + + folder.apply_timestep(timestep=0) + + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file1.visible_health_status == FileSystemItemHealthStatus.GOOD + assert file2.visible_health_status == FileSystemItemHealthStatus.GOOD + + folder.apply_timestep(timestep=1) + folder.apply_timestep(timestep=2) + + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file1.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file2.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +@pytest.mark.skip(reason="NODE_FOLDER_CHECKHASH not implemented") +def test_folder_checkhash_request(populated_file_system): + """Test that an agent can request a folder hash check.""" + fs, folder, file = populated_file_system + + fs.apply_request(request=["folder", folder.name, "checkhash"]) + + assert folder.health_status == FileSystemItemHealthStatus.GOOD + file.sim_size = 0 + + fs.apply_request(request=["folder", folder.name, "checkhash"]) + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_folder_warning_triggered(populated_file_system): + fs, folder, _ = populated_file_system + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + fs.apply_request(request=["folder", folder.name, "checkhash"]) + # Check warning issued + assert len(w) == 1 + assert "not implemented" in str(w[-1].message) + + +def test_folder_repair_request(populated_file_system): + """Test that an agent can request a folder repair.""" + fs, folder, file = populated_file_system + + folder.corrupt() + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + + fs.apply_request(request=["folder", folder.name, "repair"]) + assert file.health_status == FileSystemItemHealthStatus.GOOD + assert folder.health_status == FileSystemItemHealthStatus.GOOD + + +def test_folder_restore_request(populated_file_system): + """Test that an agent can request that a folder can be restored.""" + fs, folder, file = populated_file_system + assert fs.get_folder_by_id(folder_uuid=folder.uuid) is not None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid) is not None + + # delete folder + fs.apply_request(request=["delete", "folder", folder.name]) + assert fs.get_folder(folder_name=folder.name) is None + assert fs.get_folder_by_id(folder_uuid=folder.uuid, include_deleted=True).deleted is True + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid, include_deleted=True).deleted is True + + # restore folder + fs.apply_request(request=["restore", "folder", folder.name]) + fs.apply_timestep(timestep=0) + assert fs.get_folder(folder_name=folder.name) is not None + assert ( + fs.get_folder_by_id(folder_uuid=folder.uuid, include_deleted=True).health_status + == FileSystemItemHealthStatus.RESTORING + ) + assert fs.get_folder_by_id(folder_uuid=folder.uuid, include_deleted=True).deleted is False + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid, include_deleted=True).deleted is True + + fs.apply_timestep(timestep=1) + fs.apply_timestep(timestep=2) + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + assert ( + fs.get_file(folder_name=folder.name, file_name=file.name).health_status + is not FileSystemItemHealthStatus.RESTORING + ) + assert fs.get_file(folder_name=folder.name, file_name=file.name).deleted is False + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid, include_deleted=True).deleted is False + + # corrupt folder + fs.apply_request(request=["folder", folder.name, "corrupt"]) + assert fs.get_folder(folder_name=folder.name).health_status == FileSystemItemHealthStatus.CORRUPT + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.CORRUPT + + # restore folder + fs.apply_request(request=["restore", "folder", folder.name]) + fs.apply_timestep(timestep=0) + assert fs.get_folder(folder_name=folder.name).health_status == FileSystemItemHealthStatus.RESTORING + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.CORRUPT + + fs.apply_timestep(timestep=1) + fs.apply_timestep(timestep=2) + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + assert ( + fs.get_file(folder_name=folder.name, file_name=file.name).health_status + is not FileSystemItemHealthStatus.RESTORING + ) + assert fs.get_file(folder_name=folder.name, file_name=file.name).deleted is False + + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + assert fs.get_file_by_id(folder_uuid=folder.uuid, file_uuid=file.uuid, include_deleted=True).deleted is False + + +def test_folder_corrupt_request(populated_file_system): + """Test that an agent can request a folder corruption.""" + fs, folder, file = populated_file_system + fs.apply_request(request=["folder", folder.name, "corrupt"]) + assert file.health_status == FileSystemItemHealthStatus.CORRUPT + assert folder.health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_deleted_folder_and_its_files_cannot_be_interacted_with(populated_file_system): + """Test that actions cannot affect deleted folder and its child files.""" + fs, folder, file = populated_file_system + assert fs.get_file(folder_name=folder.name, file_name=file.name) is not None + + fs.apply_request(request=["file", file.name, "corrupt"]) + assert fs.get_file(folder_name=folder.name, file_name=file.name).health_status == FileSystemItemHealthStatus.CORRUPT + + fs.apply_request(request=["delete", "folder", folder.name]) + assert fs.get_file(folder_name=folder.name, file_name=file.name) is None + + fs.apply_request(request=["file", file.name, "repair"]) + + deleted_folder = fs.deleted_folders.get(folder.uuid) + deleted_file = deleted_folder.deleted_files.get(file.uuid) + + assert deleted_file.health_status is not FileSystemItemHealthStatus.GOOD diff --git a/tests/unit_tests/_primaite/_simulator/_network/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py new file mode 100644 index 00000000..8b1aa9be --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py @@ -0,0 +1,293 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.hardware.base import generate_mac_address +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.protocols.icmp import ICMPPacket +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader, UDPHeader + + +@pytest.fixture(scope="function") +def router_with_acl_rules(): + """ + Provides a router instance with predefined ACL rules for testing. + + :Setup: + 1. Creates a Router object named "Router". + 2. Adds a PERMIT rule for TCP traffic from 192.168.1.1:HTTPS to 192.168.1.2:HTTP. + 3. Adds a DENY rule for TCP traffic from 192.168.1.3:8080 to 192.168.1.4:80. + + :return: A configured Router object with ACL rules. + """ + router = Router("Router") + acl = router.acl + # Add rules here as needed + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.1", + src_port=Port.HTTPS, + dst_ip_address="192.168.1.2", + dst_port=Port.HTTP, + position=1, + ) + acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.3", + src_port=Port(8080), + dst_ip_address="192.168.1.4", + dst_port=Port(80), + position=2, + ) + return router + + +@pytest.fixture(scope="function") +def router_with_wildcard_acl(): + """ + Provides a router instance with ACL rules that include wildcard masking for testing. + + :Setup: + 1. Creates a Router object named "Router". + 2. Adds a PERMIT rule for TCP traffic from 192.168.1.1:8080 to 10.1.1.2:80. + 3. Adds a DENY rule with a wildcard mask for TCP traffic from the 192.168.1.0/24 network to 10.1.1.3:443. + 4. Adds a PERMIT rule for any traffic to the 10.2.0.0/16 network. + + :return: A Router object with configured ACL rules, including rules with wildcard masking. + """ + router = Router("Router") + acl = router.acl + # Rule to permit traffic from a specific source IP and port to a specific destination IP and port + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.1", + src_port=Port(8080), + dst_ip_address="10.1.1.2", + dst_port=Port(80), + position=1, + ) + # Rule to deny traffic from an IP range to a specific destination IP and port + acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip_address="192.168.1.0", + src_wildcard_mask="0.0.0.255", + dst_ip_address="10.1.1.3", + dst_port=Port(443), + position=2, + ) + # Rule to permit any traffic to a range of destination IPs + acl.add_rule( + action=ACLAction.PERMIT, + protocol=None, + src_ip_address=None, + dst_ip_address="10.2.0.0", + dst_wildcard_mask="0.0.255.255", + position=3, + ) + return router + + +def test_add_rule(router_with_acl_rules): + """ + Tests that an ACL rule is added correctly to the router's ACL. + + Asserts: + - The action of the added rule is PERMIT. + - The protocol of the added rule is TCP. + - The source IP address matches "192.168.1.1". + - The source port is HTTPS. + - The destination IP address matches "192.168.1.2". + - The destination port is HTTP. + """ + acl = router_with_acl_rules.acl + + assert acl.acl[1].action == ACLAction.PERMIT + assert acl.acl[1].protocol == IPProtocol.TCP + assert acl.acl[1].src_ip_address == IPv4Address("192.168.1.1") + assert acl.acl[1].src_port == Port.HTTPS + assert acl.acl[1].dst_ip_address == IPv4Address("192.168.1.2") + assert acl.acl[1].dst_port == Port.HTTP + + +def test_remove_rule(router_with_acl_rules): + """ + Tests the removal of an ACL rule from the router's ACL. + + Asserts that accessing the removed rule index in the ACL returns None. + """ + acl = router_with_acl_rules.acl + acl.remove_rule(1) + assert acl.acl[1] is None + + +def test_traffic_permitted_by_specific_rule(router_with_acl_rules): + """ + Verifies that traffic matching a specific ACL rule is correctly permitted. + + Asserts traffic that matches a permit rule is allowed through the ACL. + """ + acl = router_with_acl_rules.acl + permitted_frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.1", dst_ip_address="192.168.1.2", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port.HTTPS, dst_port=Port.HTTP), + ) + is_permitted, _ = acl.is_permitted(permitted_frame) + assert is_permitted + + +def test_traffic_denied_by_specific_rule(router_with_acl_rules): + """ + Verifies that traffic matching a specific ACL rule is correctly denied. + + Asserts traffic that matches a deny rule is blocked by the ACL. + """ + + acl = router_with_acl_rules.acl + not_permitted_frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.3", dst_ip_address="192.168.1.4", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port(8080), dst_port=Port(80)), + ) + is_permitted, _ = acl.is_permitted(not_permitted_frame) + assert not is_permitted + + +def test_default_rule(router_with_acl_rules): + """ + Tests the default deny rule of the ACL. + + This test verifies that traffic which does not match any explicit permit rule in the ACL + is correctly denied, as per the common "default deny" security stance that ACLs implement. + + Asserts the frame does not match any of the predefined ACL rules and is therefore not permitted by the ACL, + illustrating the default deny behavior when no explicit permit rule is matched. + """ + acl = router_with_acl_rules.acl + not_permitted_frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.5", dst_ip_address="192.168.1.12", protocol=IPProtocol.UDP), + udp=UDPHeader(src_port=Port.HTTPS, dst_port=Port.HTTP), + ) + is_permitted, rule = acl.is_permitted(not_permitted_frame) + assert not is_permitted + + +def test_direct_ip_match_with_acl(router_with_wildcard_acl): + """ + Tests ACL functionality for a direct IP address match. + + Asserts direct IP address match traffic is permitted by the ACL rule. + """ + acl = router_with_wildcard_acl.acl + frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.1", dst_ip_address="10.1.1.2", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port(8080), dst_port=Port(80)), + ) + assert acl.is_permitted(frame)[0], "Direct IP match should be permitted." + + +def test_ip_range_match_denied_with_acl(router_with_wildcard_acl): + """ + Tests ACL functionality for denying traffic from an IP range using wildcard masking. + + Asserts traffic from the specified IP range is correctly denied by the ACL rule. + """ + acl = router_with_wildcard_acl.acl + frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.100", dst_ip_address="10.1.1.3", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port(8080), dst_port=Port(443)), + ) + assert not acl.is_permitted(frame)[0], "IP range match with wildcard mask should be denied." + + +def test_traffic_permitted_to_destination_range_with_acl(router_with_wildcard_acl): + """ + Tests ACL functionality for permitting traffic to a destination IP range using wildcard masking. + + Asserts traffic to the specified destination IP range is correctly permitted by the ACL rule. + """ + acl = router_with_wildcard_acl.acl + frame = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.50", dst_ip_address="10.2.200.200", protocol=IPProtocol.UDP), + udp=UDPHeader(src_port=Port(1433), dst_port=Port(1433)), + ) + assert acl.is_permitted(frame)[0], "Traffic to destination IP range should be permitted." + + +def test_ip_traffic_from_specific_subnet(): + """ + Tests that the ACL permits or denies IP traffic from specific subnets, mimicking a Cisco ACL rule for IP traffic. + + This test verifies the ACL's ability to permit all IP traffic from a specific subnet (192.168.1.0/24) while denying + traffic from other subnets. The test mimics a Cisco ACL rule that allows IP traffic from a specified range using + wildcard masking. + + The test frames are constructed with varying protocols (TCP, UDP, ICMP) and source IP addresses, to demonstrate the + rule's general applicability to all IP protocols and its enforcement based on source IP address range. + + Asserts + - Traffic from within the 192.168.1.0/24 subnet is permitted. + - Traffic from outside the 192.168.1.0/24 subnet is denied. + """ + + router = Router("Router") + acl = router.acl + # Add rules here as needed + acl.add_rule( + action=ACLAction.PERMIT, + src_ip_address="192.168.1.0", + src_wildcard_mask="0.0.0.255", + position=1, + ) + + permitted_frame_1 = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.50", dst_ip_address="10.2.200.200", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER), + ) + + assert acl.is_permitted(permitted_frame_1)[0] + + permitted_frame_2 = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.10", dst_ip_address="85.199.214.101", protocol=IPProtocol.UDP), + udp=UDPHeader(src_port=Port.NTP, dst_port=Port.NTP), + ) + + assert acl.is_permitted(permitted_frame_2)[0] + + permitted_frame_3 = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.1.200", dst_ip_address="192.168.1.1", protocol=IPProtocol.ICMP), + icmp=ICMPPacket(identifier=1), + ) + + assert acl.is_permitted(permitted_frame_3)[0] + + not_permitted_frame_1 = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.0.50", dst_ip_address="10.2.200.200", protocol=IPProtocol.TCP), + tcp=TCPHeader(src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER), + ) + + assert not acl.is_permitted(not_permitted_frame_1)[0] + + not_permitted_frame_2 = Frame( + ethernet=EthernetHeader(src_mac_addr=generate_mac_address(), dst_mac_addr=generate_mac_address()), + ip=IPPacket(src_ip_address="192.168.2.10", dst_ip_address="85.199.214.101", protocol=IPProtocol.UDP), + udp=UDPHeader(src_port=Port.NTP, dst_port=Port.NTP), + ) + + assert not acl.is_permitted(not_permitted_frame_2)[0] + + acl.show() diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py new file mode 100644 index 00000000..be74a721 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py @@ -0,0 +1,111 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def test_wireless_router_from_config(): + cfg = { + "ref": "router_1", + "type": "router", + "hostname": "router_1", + "num_ports": 6, + "ports": { + 1: { + "ip_address": "192.168.1.1", + "subnet_mask": "255.255.255.0", + }, + 2: { + "ip_address": "192.168.2.1", + "subnet_mask": "255.255.255.0", + }, + }, + "acl": { + 0: { + "action": "PERMIT", + "src_port": "POSTGRES_SERVER", + "dst_port": "POSTGRES_SERVER", + }, + 1: { + "action": "PERMIT", + "protocol": "ICMP", + }, + 2: { + "action": "PERMIT", + "src_ip": "100.100.100.1", + "dst_ip": "100.100.101.1", + }, + 3: { + "action": "PERMIT", + "src_ip": "100.100.102.0", + "dst_ip": "100.100.103.0", + "src_wildcard_mask": "0.0.0.255", + "dst_wildcard_mask": "0.0.0.255", + }, + 20: { + "action": "DENY", + }, + }, + } + + rt = Router.from_config(cfg=cfg) + + assert rt.num_ports == 6 + + assert rt.network_interface[1].ip_address == IPv4Address("192.168.1.1") + assert rt.network_interface[1].subnet_mask == IPv4Address("255.255.255.0") + + assert rt.network_interface[2].ip_address == IPv4Address("192.168.2.1") + assert rt.network_interface[2].subnet_mask == IPv4Address("255.255.255.0") + + assert not rt.network_interface[3].enabled + assert not rt.network_interface[4].enabled + assert not rt.network_interface[5].enabled + assert not rt.network_interface[6].enabled + + r0 = rt.acl.acl[0] + assert r0.action == ACLAction.PERMIT + assert r0.src_port == r0.dst_port == Port.POSTGRES_SERVER + assert r0.src_ip_address == r0.dst_ip_address == r0.dst_wildcard_mask == r0.src_wildcard_mask == r0.protocol == None + + r1 = rt.acl.acl[1] + assert r1.action == ACLAction.PERMIT + assert r1.protocol == IPProtocol.ICMP + assert ( + r1.src_ip_address + == r1.dst_ip_address + == r1.dst_wildcard_mask + == r1.src_wildcard_mask + == r1.src_port + == r1.dst_port + == None + ) + + r2 = rt.acl.acl[2] + assert r2.action == ACLAction.PERMIT + assert r2.src_ip_address == IPv4Address("100.100.100.1") + assert r2.dst_ip_address == IPv4Address("100.100.101.1") + assert r2.src_wildcard_mask == r2.dst_wildcard_mask == None + assert r2.src_port == r2.dst_port == r2.protocol == None + + r3 = rt.acl.acl[3] + assert r3.action == ACLAction.PERMIT + assert r3.src_ip_address == IPv4Address("100.100.102.0") + assert r3.dst_ip_address == IPv4Address("100.100.103.0") + assert r3.src_wildcard_mask == IPv4Address("0.0.0.255") + assert r3.dst_wildcard_mask == IPv4Address("0.0.0.255") + assert r3.src_port == r3.dst_port == r3.protocol == None + + r20 = rt.acl.acl[20] + assert r20.action == ACLAction.DENY + assert ( + r20.src_ip_address + == r20.dst_ip_address + == r20.src_wildcard_mask + == r20.dst_wildcard_mask + == r20.src_port + == r20.dst_port + == r20.protocol + == None + ) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_switch.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_switch.py new file mode 100644 index 00000000..a65f591e --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_switch.py @@ -0,0 +1,18 @@ +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.network.switch import Switch + + +@pytest.fixture(scope="function") +def switch() -> Switch: + switch: Switch = Switch(hostname="switch_1", num_ports=8, start_up_duration=0) + switch.power_on() + switch.show() + return switch + + +def test_describe_state(switch): + state = switch.describe_state() + assert len(state.get("ports")) is 8 + assert state.get("num_ports") is 8 diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py new file mode 100644 index 00000000..d0738c64 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py @@ -0,0 +1,56 @@ +import re +from ipaddress import IPv4Address + +import pytest +from pydantic import ValidationError + +from primaite.simulator.network.hardware.base import generate_mac_address +from primaite.simulator.network.hardware.nodes.host.host_node import NIC + + +def test_mac_address_generation(): + """Tests random mac address generation.""" + mac_address = generate_mac_address() + assert re.match("[0-9a-f]{2}([-:]?)[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", mac_address) + + +def test_mac_address_with_oui(): + """Tests random mac address generation with oui.""" + oui = "aa:bb:cc" + mac_address = generate_mac_address(oui=oui) + assert re.match("[0-9a-f]{2}([-:]?)[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", mac_address) + assert mac_address[:8] == oui + + +def test_invalid_oui_mac_address(): + """Tests random mac address generation fails with invalid oui.""" + invalid_oui = "aa-bb-cc" + with pytest.raises(ValueError): + generate_mac_address(oui=invalid_oui) + + +def test_nic_ip_address_type_conversion(): + """Tests NIC IP and gateway address is converted to IPv4Address is originally a string.""" + network_interface = NIC( + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + ) + assert isinstance(network_interface.ip_address, IPv4Address) + + +def test_nic_deserialize(): + """Tests NIC serialization and deserialization.""" + network_interface = NIC( + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + ) + + nic_json = network_interface.model_dump_json() + deserialized_nic = NIC.model_validate_json(nic_json) + assert nic_json == deserialized_nic.model_dump_json() + + +def test_nic_ip_address_as_network_address_fails(): + """Tests NIC creation fails if ip address and subnet mask are a network address.""" + with pytest.raises(ValidationError): + NIC(ip_address="192.168.0.0", subnet_mask="255.255.255.0") diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py new file mode 100644 index 00000000..a1b8a6c1 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py @@ -0,0 +1,156 @@ +import pytest + +from primaite.simulator.file_system.file import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder +from primaite.simulator.network.hardware.base import Node, NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.system.software import SoftwareHealthState + + +@pytest.fixture +def node() -> Node: + return Computer(hostname="test", ip_address="192.168.1.2", subnet_mask="255.255.255.0") + + +def test_node_startup(node): + assert node.operating_state == NodeOperatingState.OFF + node.apply_request(["startup"]) + assert node.operating_state == NodeOperatingState.BOOTING + + idx = 0 + while node.operating_state == NodeOperatingState.BOOTING: + node.apply_timestep(timestep=idx) + idx += 1 + + assert node.operating_state == NodeOperatingState.ON + + +def test_node_shutdown(node): + assert node.operating_state == NodeOperatingState.OFF + node.apply_request(["startup"]) + idx = 0 + while node.operating_state == NodeOperatingState.BOOTING: + node.apply_timestep(timestep=idx) + idx += 1 + + assert node.operating_state == NodeOperatingState.ON + + node.apply_request(["shutdown"]) + + idx = 0 + while node.operating_state == NodeOperatingState.SHUTTING_DOWN: + node.apply_timestep(timestep=idx) + idx += 1 + + assert node.operating_state == NodeOperatingState.OFF + + +def test_node_os_scan(node, service, application): + """Test OS Scanning.""" + node.operating_state = NodeOperatingState.ON + + # add process to node + # TODO implement processes + + # add services to node + service.set_health_state(SoftwareHealthState.COMPROMISED) + node.install_service(service=service) + assert service.health_state_visible == SoftwareHealthState.UNUSED + + # add application to node + application.set_health_state(SoftwareHealthState.COMPROMISED) + node.install_application(application=application) + assert application.health_state_visible == SoftwareHealthState.UNUSED + + # add folder and file to node + folder: Folder = node.file_system.create_folder(folder_name="test_folder") + folder.corrupt() + assert folder.visible_health_status == FileSystemItemHealthStatus.GOOD + + file: File = node.file_system.create_file(folder_name="test_folder", file_name="file.txt") + file2: File = node.file_system.create_file(folder_name="test_folder", file_name="file2.txt") + file.corrupt() + file2.corrupt() + assert file.visible_health_status == FileSystemItemHealthStatus.GOOD + + # run os scan + node.apply_request(["os", "scan"]) + + # apply time steps + for i in range(10): + node.apply_timestep(timestep=i) + + # should update the state of all items + # TODO assert process.health_state_visible == SoftwareHealthState.COMPROMISED + assert service.health_state_visible == SoftwareHealthState.COMPROMISED + assert application.health_state_visible == SoftwareHealthState.COMPROMISED + assert folder.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file.visible_health_status == FileSystemItemHealthStatus.CORRUPT + assert file2.visible_health_status == FileSystemItemHealthStatus.CORRUPT + + +def test_node_red_scan(node, service, application): + """Test revealing to red""" + node.operating_state = NodeOperatingState.ON + + # add process to node + # TODO implement processes + + # add services to node + node.install_service(service=service) + assert service.revealed_to_red is False + + # add application to node + application.set_health_state(SoftwareHealthState.COMPROMISED) + node.install_application(application=application) + assert application.revealed_to_red is False + + # add folder and file to node + folder: Folder = node.file_system.create_folder(folder_name="test_folder") + assert folder.revealed_to_red is False + + file: File = node.file_system.create_file(folder_name="test_folder", file_name="file.txt") + file2: File = node.file_system.create_file(folder_name="test_folder", file_name="file2.txt") + assert file.revealed_to_red is False + assert file2.revealed_to_red is False + + # run os scan + node.apply_request(["scan"]) + + # apply time steps + for i in range(10): + node.apply_timestep(timestep=i) + + # should update the state of all items + # TODO assert process.revealed_to_red is True + assert service.revealed_to_red is True + assert application.revealed_to_red is True + assert folder.revealed_to_red is True + assert file.revealed_to_red is True + assert file2.revealed_to_red is True + + +def test_reset_node(node): + """Test that a node can be reset.""" + node.operating_state = NodeOperatingState.ON + + node.apply_request(["reset"]) + assert node.operating_state == NodeOperatingState.SHUTTING_DOWN + + """ + 3 steps to shut down + 2 steps to set up the turning of it back on + 3 steps to turn back on + + 3 + 2 + 3 = 8 + kwik mafs + """ + + for i in range(8): + node.apply_timestep(timestep=i) + + if i == 3: + assert node.operating_state == NodeOperatingState.BOOTING + + assert node.operating_state == NodeOperatingState.ON diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py new file mode 100644 index 00000000..1fbbd1c1 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py @@ -0,0 +1,91 @@ +import pytest + +from primaite.simulator.network.protocols.icmp import ICMPPacket +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol, Precedence +from primaite.simulator.network.transmission.primaite_layer import AgentSource, DataStatus +from primaite.simulator.network.transmission.transport_layer import Port, TCPFlags, TCPHeader, UDPHeader + + +def test_frame_minimal_instantiation(): + """Tests that the minimum frame (TCP SYN) using default values.""" + frame = Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20"), + tcp=TCPHeader( + src_port=8080, + dst_port=80, + ), + ) + + # Check network layer default values + assert frame.ip.protocol == IPProtocol.TCP + assert frame.ip.ttl == 64 + assert frame.ip.precedence == Precedence.ROUTINE + + # Check transport layer default values + assert frame.tcp.flags == [TCPFlags.SYN] + + # Check primaite custom header default values + assert frame.primaite.agent_source == AgentSource.GREEN + assert frame.primaite.data_status == DataStatus.GOOD + + # Check that model can be dumped down to json and returned as size in Bytes + assert frame.size + + +def test_frame_creation_fails_tcp_without_header(): + """Tests Frame creation fails if the IPProtocol is TCP but there is no TCPHeader.""" + with pytest.raises(ValueError): + Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.TCP), + ) + + +def test_frame_creation_fails_udp_without_header(): + """Tests Frame creation fails if the IPProtocol is UDP but there is no UDPHeader.""" + with pytest.raises(ValueError): + Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.UDP), + ) + + +def test_frame_creation_fails_tcp_with_udp_header(): + """Tests Frame creation fails if the IPProtocol is TCP but there is a UDPHeader.""" + with pytest.raises(ValueError): + Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.TCP), + udp=UDPHeader(src_port=8080, dst_port=80), + ) + + +def test_frame_creation_fails_udp_with_tcp_header(): + """Tests Frame creation fails if the IPProtocol is UDP but there is a TCPHeader.""" + with pytest.raises(ValueError): + Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.UDP), + udp=TCPHeader(src_port=8080, dst_port=80), + ) + + +def test_icmp_frame_creation(): + """Tests Frame creation for ICMP.""" + frame = Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.ICMP), + icmp=ICMPPacket(), + ) + assert frame + + +def test_icmp_frame_creation_fails_without_icmp_header(): + """Tests Frame creation for ICMP.""" + with pytest.raises(ValueError): + Frame( + ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.ICMP), + ) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py new file mode 100644 index 00000000..0ea98107 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py @@ -0,0 +1,24 @@ +import pytest + +from primaite.simulator.network.protocols.icmp import ICMPPacket, ICMPType + + +def test_icmp_minimal_header_creation(): + """Checks the minimal ICMPPacket (ping 1 request) creation using default values.""" + ping = ICMPPacket() + + assert ping.icmp_type == ICMPType.ECHO_REQUEST + assert ping.icmp_code == 0 + assert ping.identifier + assert ping.sequence == 0 + + +def test_valid_icmp_type_code_pairing(): + """Tests ICMPPacket creation with valid type and code pairing.""" + assert ICMPPacket(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=6) + + +def test_invalid_icmp_type_code_pairing(): + """Tests ICMPPacket creation fails with invalid type and code pairing.""" + with pytest.raises(ValueError): + assert ICMPPacket(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=16) diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py new file mode 100644 index 00000000..f0e386b8 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -0,0 +1,98 @@ +import json + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import Link, Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer + + +def filter_keys_nested_item(data, keys): + stack = [(data, {})] + while stack: + current, filtered = stack.pop() + if isinstance(current, dict): + for k, v in current.items(): + if k in keys: + filtered[k] = filter_keys_nested_item(v, keys) + elif isinstance(v, (dict, list)): + stack.append((v, {})) + elif isinstance(current, list): + for item in current: + stack.append((item, {})) + return filtered + + +@pytest.fixture(scope="function") +def network(example_network) -> Network: + assert len(example_network.router_nodes) is 1 + assert len(example_network.switch_nodes) is 2 + assert len(example_network.computer_nodes) is 2 + assert len(example_network.server_nodes) is 2 + + example_network.show() + + return example_network + + +def test_describe_state(network): + """Test that describe state works.""" + state = network.describe_state() + + assert len(state["nodes"]) is 7 + assert len(state["links"]) is 6 + + +def test_creating_container(): + """Check that we can create a network container""" + net = Network() + assert net.nodes == {} + assert net.links == {} + net.show() + + +def test_apply_timestep_to_nodes(network): + """Calling apply_timestep on the network should apply to the nodes within it.""" + client_1: Computer = network.get_node_by_hostname("client_1") + assert client_1.operating_state is NodeOperatingState.ON + + client_1.power_off() + assert client_1.operating_state is NodeOperatingState.SHUTTING_DOWN + + for i in range(client_1.shut_down_duration + 1): + network.apply_timestep(timestep=i) + + assert client_1.operating_state is NodeOperatingState.OFF + + network.apply_timestep(client_1.shut_down_duration + 2) + assert client_1.operating_state is NodeOperatingState.OFF + + +def test_removing_node_that_does_not_exist(network): + """Node that does not exist on network should not affect existing nodes.""" + assert len(network.nodes) is 7 + + network.remove_node(Computer(hostname="new_node", ip_address="192.168.1.2", subnet_mask="255.255.255.0")) + assert len(network.nodes) is 7 + + +def test_remove_node(network): + """Remove node should remove the correct node.""" + assert len(network.nodes) is 7 + + client_1: Computer = network.get_node_by_hostname("client_1") + network.remove_node(client_1) + + assert network.get_node_by_hostname("client_1") is None + assert len(network.nodes) is 6 + + +def test_remove_link(network): + """Remove link should remove the correct link.""" + assert len(network.links) is 6 + link: Link = network.links.get(next(iter(network.links))) + + network.remove_link(link) + assert len(network.links) is 5 + assert network.links.get(link.uuid) is None diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_utils.py b/tests/unit_tests/_primaite/_simulator/_network/test_utils.py new file mode 100644 index 00000000..a0c1da45 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/test_utils.py @@ -0,0 +1,11 @@ +from primaite.simulator.network.utils import convert_bytes_to_megabits, convert_megabits_to_bytes + + +def test_convert_bytes_to_megabits(): + assert round(convert_bytes_to_megabits(B=131072), 5) == float(1) + assert round(convert_bytes_to_megabits(B=69420), 5) == float(0.52963) + + +def test_convert_megabits_to_bytes(): + assert round(convert_megabits_to_bytes(Mbits=1), 5) == float(131072) + assert round(convert_megabits_to_bytes(Mbits=float(0.52963)), 5) == float(69419.66336) diff --git a/tests/unit_tests/_primaite/_simulator/_system/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py new file mode 100644 index 00000000..1937363a --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py @@ -0,0 +1,82 @@ +import pytest + +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.networks import arcd_uc2_network +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import ( + DataManipulationAttackStage, + DataManipulationBot, +) + + +@pytest.fixture(scope="function") +def dm_client() -> Node: + network = arcd_uc2_network() + return network.get_node_by_hostname("client_1") + + +@pytest.fixture +def dm_bot(dm_client) -> DataManipulationBot: + return dm_client.software_manager.software.get("DataManipulationBot") + + +def test_create_dm_bot(dm_client): + data_manipulation_bot: DataManipulationBot = dm_client.software_manager.software.get("DataManipulationBot") + + assert data_manipulation_bot.name == "DataManipulationBot" + assert data_manipulation_bot.port == Port.NONE + assert data_manipulation_bot.protocol == IPProtocol.NONE + assert data_manipulation_bot.payload == "DELETE" + + +def test_dm_bot_logon(dm_bot): + dm_bot.attack_stage = DataManipulationAttackStage.NOT_STARTED + + dm_bot._logon() + + assert dm_bot.attack_stage == DataManipulationAttackStage.LOGON + + +def test_dm_bot_perform_port_scan_no_success(dm_bot): + dm_bot.attack_stage = DataManipulationAttackStage.LOGON + + dm_bot._perform_port_scan(p_of_success=0.0) + + assert dm_bot.attack_stage == DataManipulationAttackStage.LOGON + + +def test_dm_bot_perform_port_scan_success(dm_bot): + dm_bot.attack_stage = DataManipulationAttackStage.LOGON + + dm_bot._perform_port_scan(p_of_success=1.0) + + assert dm_bot.attack_stage == DataManipulationAttackStage.PORT_SCAN + + +def test_dm_bot_perform_data_manipulation_no_success(dm_bot): + dm_bot.attack_stage = DataManipulationAttackStage.PORT_SCAN + + dm_bot._perform_data_manipulation(p_of_success=0.0) + + assert dm_bot.attack_stage == DataManipulationAttackStage.PORT_SCAN + + +def test_dm_bot_perform_data_manipulation_success(dm_bot): + dm_bot.attack_stage = DataManipulationAttackStage.PORT_SCAN + dm_bot.operating_state = ApplicationOperatingState.RUNNING + + dm_bot._perform_data_manipulation(p_of_success=1.0) + + assert dm_bot.attack_stage in (DataManipulationAttackStage.SUCCEEDED, DataManipulationAttackStage.FAILED) + assert len(dm_bot._host_db_client.client_connections) + + +def test_dm_bot_fails_without_db_client(dm_client): + dm_client.software_manager.uninstall("DatabaseClient") + dm_bot = dm_client.software_manager.software.get("DataManipulationBot") + assert dm_bot._host_db_client is None + dm_bot.attack_stage = DataManipulationAttackStage.PORT_SCAN + dm_bot._perform_data_manipulation(p_of_success=1.0) + assert dm_bot.attack_stage is DataManipulationAttackStage.FAILED diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py new file mode 100644 index 00000000..4bfd28d0 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py @@ -0,0 +1,57 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot + + +@pytest.fixture(scope="function") +def dos_bot() -> DoSBot: + computer = Computer( + hostname="compromised_pc", ip_address="192.168.0.1", subnet_mask="255.255.255.0", start_up_duration=0 + ) + computer.power_on() + computer.software_manager.install(DoSBot) + + dos_bot: DoSBot = computer.software_manager.software.get("DoSBot") + dos_bot.configure(target_ip_address=IPv4Address("192.168.0.1")) + return dos_bot + + +def test_dos_bot_creation(dos_bot): + """Test that the DoS bot is installed on a node.""" + assert dos_bot is not None + + +def test_dos_bot_cannot_run_when_node_offline(dos_bot): + dos_bot_node: Computer = dos_bot.parent + assert dos_bot_node.operating_state is NodeOperatingState.ON + + dos_bot_node.power_off() + + for i in range(dos_bot_node.shut_down_duration + 1): + dos_bot_node.apply_timestep(timestep=i) + + assert dos_bot_node.operating_state is NodeOperatingState.OFF + + dos_bot._application_loop() + + # assert not run + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + + +def test_dos_bot_not_configured(dos_bot): + dos_bot.target_ip_address = None + + dos_bot.operating_state = ApplicationOperatingState.RUNNING + dos_bot._application_loop() + + +def test_dos_bot_perform_port_scan(dos_bot): + dos_bot._perform_port_scan(p_of_success=1) + + assert dos_bot.attack_stage is DoSAttackStage.PORT_SCAN diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py new file mode 100644 index 00000000..90c3f303 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py @@ -0,0 +1,50 @@ +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.software import SoftwareHealthState + + +def test_scan(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.scan() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.GOOD + + +def test_run_application(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_close_application(application): + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + application.close() + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_application_describe_states(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + assert SoftwareHealthState.UNUSED.value == application.describe_state().get("health_state_actual") + + application.run() + assert SoftwareHealthState.GOOD.value == application.describe_state().get("health_state_actual") + + application.set_health_state(SoftwareHealthState.COMPROMISED) + assert SoftwareHealthState.COMPROMISED.value == application.describe_state().get("health_state_actual") + + application.fix() + assert SoftwareHealthState.FIXING.value == application.describe_state().get("health_state_actual") diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py new file mode 100644 index 00000000..13b11589 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py @@ -0,0 +1,126 @@ +from ipaddress import IPv4Address +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 +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.services.database.database_service import DatabaseService + + +@pytest.fixture(scope="function") +def database_client_on_computer() -> Tuple[DatabaseClient, Computer]: + network = Network() + + db_server = Server(hostname="db_server", ip_address="192.168.0.1", subnet_mask="255.255.255.0", start_up_duration=0) + db_server.power_on() + db_server.software_manager.install(DatabaseService) + db_server.software_manager.software["DatabaseService"].start() + + db_client = Computer( + hostname="db_client", ip_address="192.168.0.2", subnet_mask="255.255.255.0", start_up_duration=0 + ) + db_client.power_on() + db_client.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = db_client.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address("192.168.0.1")) + database_client.run() + + network.connect(db_server.network_interface[1], db_client.network_interface[1]) + + return database_client, db_client + + +def test_creation(database_client_on_computer): + database_client, computer = database_client_on_computer + database_client.describe_state() + + +def test_connect_when_client_is_closed(database_client_on_computer): + """Database client should not connect when it is not running.""" + database_client, computer = database_client_on_computer + + database_client.close() + assert database_client.operating_state is ApplicationOperatingState.CLOSED + + assert database_client.connect() is False + + +def test_connect_to_database_fails_on_reattempt(database_client_on_computer): + """Database client should return False when the attempt to connect fails.""" + database_client, computer = database_client_on_computer + + database_client.connected = False + + database_connection = database_client._connect( + server_ip_address=IPv4Address("192.168.0.1"), connection_request_id="", is_reattempt=True + ) + assert database_connection is None + + +def test_disconnect_when_client_is_closed(database_client_on_computer): + """Database client disconnect should not do anything when it is not running.""" + database_client, computer = database_client_on_computer + + database_client.connect() + assert database_client.server_ip_address is not None + + database_client.close() + assert database_client.operating_state is ApplicationOperatingState.CLOSED + + database_client.disconnect() + + assert database_client.connected is True + assert database_client.server_ip_address is not None + + +def test_disconnect(database_client_on_computer): + """Database client should remove the connection.""" + database_client, computer = database_client_on_computer + + assert database_client.connected is False + + database_client.connect() + + assert database_client.connected is True + + database_client.disconnect() + + assert database_client.connected is False + + +def test_query_when_client_is_closed(database_client_on_computer): + """Database client should return False when it is not running.""" + database_client, computer = database_client_on_computer + database_client.close() + assert database_client.operating_state is ApplicationOperatingState.CLOSED + + assert database_client.query(sql="test") is False + + +def test_query_fail_to_connect(database_client_on_computer): + """Database client query should return False if the connect attempt fails.""" + database_client, computer = database_client_on_computer + + def return_false(**kwargs): + return False + + database_client.connect = return_false + database_client.connected = False + + assert database_client.query(sql="test") is False + + +def test_client_receives_response_when_closed(database_client_on_computer): + """Database client receive should return False when it is closed.""" + database_client, computer = database_client_on_computer + + database_client.close() + assert database_client.operating_state is ApplicationOperatingState.CLOSED + + database_client.receive(payload={}, session_id="") diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py new file mode 100644 index 00000000..1300c33a --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py @@ -0,0 +1,66 @@ +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.protocols.http import HttpResponsePacket, HttpStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.web_browser import WebBrowser + + +@pytest.fixture(scope="function") +def web_browser() -> WebBrowser: + computer = Computer( + hostname="web_client", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + # Web Browser should be pre-installed in computer + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + web_browser.run() + assert web_browser.operating_state is ApplicationOperatingState.RUNNING + return web_browser + + +def test_create_web_client(): + computer = Computer( + hostname="web_client", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + computer.power_on() + # Web Browser should be pre-installed in computer + web_browser: WebBrowser = computer.software_manager.software.get("WebBrowser") + assert web_browser.name is "WebBrowser" + assert web_browser.port is Port.HTTP + assert web_browser.protocol is IPProtocol.TCP + + +def test_receive_invalid_payload(web_browser): + assert web_browser.receive(payload={}) is False + + +def test_receive_payload(web_browser): + payload = HttpResponsePacket(status_code=HttpStatusCode.OK) + assert web_browser.latest_response is None + + web_browser.receive(payload=payload) + + assert web_browser.latest_response is not None + + +def test_invalid_target_url(web_browser): + # none value target url + web_browser.target_url = None + assert web_browser.get_webpage() is False + + +def test_non_existent_target_url(web_browser): + web_browser.target_url = "http://192.168.255.255" + assert web_browser.get_webpage() is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py new file mode 100644 index 00000000..0df6cf27 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py @@ -0,0 +1,18 @@ +import pytest + +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.system.services.database.database_service import DatabaseService + + +@pytest.fixture(scope="function") +def database_server() -> Node: + node = Computer(hostname="db_node", ip_address="192.168.1.2", subnet_mask="255.255.255.0", start_up_duration=0) + node.power_on() + node.software_manager.install(DatabaseService) + node.software_manager.software.get("DatabaseService").start() + return node + + +def test_creation(database_server): + database_server.software_manager.show() diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_client.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_client.py new file mode 100644 index 00000000..bc11d278 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_client.py @@ -0,0 +1,103 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.protocols.dns import DNSPacket, DNSReply, DNSRequest +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def dns_client() -> Computer: + node = Computer( + hostname="dns_client", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), + ) + return node + + +def test_create_dns_client(dns_client): + assert dns_client is not None + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + assert dns_client_service.name is "DNSClient" + assert dns_client_service.port is Port.DNS + assert dns_client_service.protocol is IPProtocol.TCP + + +def test_dns_client_add_domain_to_cache_when_not_running(dns_client): + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + assert dns_client.operating_state is NodeOperatingState.OFF + assert dns_client_service.operating_state is ServiceOperatingState.STOPPED + + assert ( + dns_client_service.add_domain_to_cache(domain_name="test.com", ip_address=IPv4Address("192.168.1.100")) is False + ) + + assert dns_client_service.dns_cache.get("test.com") is None + + +def test_dns_client_check_domain_exists_when_not_running(dns_client): + dns_client.operating_state = NodeOperatingState.ON + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + dns_client_service.start() + + assert dns_client.operating_state is NodeOperatingState.ON + assert dns_client_service.operating_state is ServiceOperatingState.RUNNING + + assert ( + dns_client_service.add_domain_to_cache(domain_name="test.com", ip_address=IPv4Address("192.168.1.100")) + is not False + ) + + assert dns_client_service.check_domain_exists("test.com") is True + + dns_client.power_off() + + for i in range(dns_client.shut_down_duration + 1): + dns_client.apply_timestep(timestep=i) + + assert dns_client.operating_state is NodeOperatingState.OFF + assert dns_client_service.operating_state is ServiceOperatingState.STOPPED + + assert dns_client_service.check_domain_exists("test.com") is False + + +def test_dns_client_check_domain_in_cache(dns_client): + """Test to make sure that the check_domain_in_cache returns the correct values.""" + dns_client.operating_state = NodeOperatingState.ON + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + dns_client_service.start() + + # add a domain to the dns client cache + dns_client_service.add_domain_to_cache("real-domain.com", IPv4Address("192.168.1.12")) + + assert dns_client_service.check_domain_exists("fake-domain.com") is False + assert dns_client_service.check_domain_exists("real-domain.com") is True + + +def test_dns_client_receive(dns_client): + """Test to make sure the DNS Client knows how to deal with request responses.""" + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + + dns_client_service.receive( + payload=DNSPacket( + dns_request=DNSRequest(domain_name_request="real-domain.com"), + dns_reply=DNSReply(domain_name_ip_address=IPv4Address("192.168.1.12")), + ) + ) + + # domain name should be saved to cache + assert dns_client_service.dns_cache["real-domain.com"] == IPv4Address("192.168.1.12") + + +def test_dns_client_receive_non_dns_payload(dns_client): + dns_client_service: DNSClient = dns_client.software_manager.software.get("DNSClient") + + assert dns_client_service.receive(payload=None) is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_server.py new file mode 100644 index 00000000..469c52f3 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns_server.py @@ -0,0 +1,69 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.dns.dns_server import DNSServer + + +@pytest.fixture(scope="function") +def dns_server() -> Node: + node = Server( + hostname="dns_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + node.power_on() + node.software_manager.install(software_class=DNSServer) + return node + + +def test_create_dns_server(dns_server): + assert dns_server is not None + dns_server_service: DNSServer = dns_server.software_manager.software.get("DNSServer") + assert dns_server_service.name is "DNSServer" + assert dns_server_service.port is Port.DNS + assert dns_server_service.protocol is IPProtocol.TCP + + +def test_dns_server_domain_name_registration(dns_server): + """Test to check if the domain name registration works.""" + dns_server_service: DNSServer = dns_server.software_manager.software.get("DNSServer") + + # register the web server in the domain controller + dns_server_service.dns_register(domain_name="real-domain.com", domain_ip_address=IPv4Address("192.168.1.12")) + + # return none for an unknown domain + assert dns_server_service.dns_lookup("fake-domain.com") is None + assert dns_server_service.dns_lookup("real-domain.com") is not None + + +def test_dns_server_receive(dns_server): + """Test to make sure that the DNS Server correctly responds to a DNS Client request.""" + dns_server_service: DNSServer = dns_server.software_manager.software.get("DNSServer") + + # register the web server in the domain controller + dns_server_service.dns_register(domain_name="real-domain.com", domain_ip_address=IPv4Address("192.168.1.12")) + + client = Computer(hostname="client", ip_address="192.168.1.11", subnet_mask="255.255.255.0", start_up_duration=0) + client.power_on() + client.dns_server = IPv4Address("192.168.1.10") + network = Network() + network.connect(dns_server.network_interface[1], client.network_interface[1]) + dns_client: DNSClient = client.software_manager.software["DNSClient"] # noqa + dns_client.check_domain_exists("fake-domain.com") + + assert dns_client.check_domain_exists("fake-domain.com") is False + + assert dns_client.check_domain_exists("real-domain.com") is False + + dns_server_service.show() diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py new file mode 100644 index 00000000..69b2f296 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py @@ -0,0 +1,125 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.computer import Computer +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def ftp_client() -> Node: + node = Computer( + hostname="ftp_client", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + node.power_on() + return node + + +def test_create_ftp_client(ftp_client): + assert ftp_client is not None + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + assert ftp_client_service.name is "FTPClient" + assert ftp_client_service.port is Port.FTP + assert ftp_client_service.protocol is IPProtocol.TCP + + +def test_ftp_client_store_file(ftp_client): + """Test to make sure the FTP Client knows how to deal with request responses.""" + assert ftp_client.file_system.get_file(folder_name="downloads", file_name="file.txt") is None + + response: FTPPacket = FTPPacket( + ftp_command=FTPCommand.STOR, + ftp_command_args={ + "dest_folder_name": "downloads", + "dest_file_name": "file.txt", + "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, + }, + packet_payload_size=24, + status_code=FTPStatusCode.OK, + ) + + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + ftp_client_service.receive(response) + + assert ftp_client.file_system.get_file(folder_name="downloads", file_name="file.txt") + + +def test_ftp_should_not_process_commands_if_service_not_running(ftp_client): + """Method _process_ftp_command should return false if service is not running.""" + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.PORT, + ftp_command_args=Port.FTP, + status_code=FTPStatusCode.OK, + ) + + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + ftp_client_service.stop() + assert ftp_client_service.operating_state is ServiceOperatingState.STOPPED + assert ftp_client_service._process_ftp_command(payload=payload).status_code is FTPStatusCode.ERROR + + +def test_ftp_tries_to_senf_file__that_does_not_exist(ftp_client): + """Method send_file should return false if no file to send.""" + assert ftp_client.file_system.get_file(folder_name="root", file_name="test.txt") is None + + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + assert ftp_client_service.operating_state is ServiceOperatingState.RUNNING + assert ( + ftp_client_service.send_file( + dest_ip_address=IPv4Address("192.168.1.1"), + src_folder_name="root", + src_file_name="test.txt", + dest_folder_name="root", + dest_file_name="text.txt", + ) + is False + ) + + +def test_offline_ftp_client_receives_request(ftp_client): + """Receive should return false if the node the ftp client is installed on is offline.""" + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + ftp_client.power_off() + + for i in range(ftp_client.shut_down_duration + 1): + ftp_client.apply_timestep(timestep=i) + + assert ftp_client.operating_state is NodeOperatingState.OFF + assert ftp_client_service.operating_state is ServiceOperatingState.STOPPED + + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.PORT, + ftp_command_args=Port.FTP, + status_code=FTPStatusCode.OK, + ) + + assert ftp_client_service.receive(payload=payload) is False + + +def test_receive_should_fail_if_payload_is_not_ftp(ftp_client): + """Receive should return false if the node the ftp client is installed on is not an FTPPacket.""" + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + assert ftp_client_service.receive(payload=None) is False + + +def test_receive_should_ignore_payload_with_none_status_code(ftp_client): + """Receive should ignore payload with no set status code to prevent infinite send/receive loops.""" + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.PORT, + ftp_command_args=Port.FTP, + status_code=None, + ) + ftp_client_service: FTPClient = ftp_client.software_manager.software.get("FTPClient") + assert ftp_client_service.receive(payload=payload) is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py new file mode 100644 index 00000000..f4e635d6 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py @@ -0,0 +1,92 @@ +import pytest + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.service import ServiceOperatingState + + +@pytest.fixture(scope="function") +def ftp_server() -> Node: + node = Server( + hostname="ftp_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + node.power_on() + node.software_manager.install(software_class=FTPServer) + return node + + +def test_create_ftp_server(ftp_server): + assert ftp_server is not None + ftp_server_service: FTPServer = ftp_server.software_manager.software.get("FTPServer") + assert ftp_server_service.name is "FTPServer" + assert ftp_server_service.port is Port.FTP + assert ftp_server_service.protocol is IPProtocol.TCP + + +def test_ftp_server_store_file(ftp_server): + """Test to make sure the FTP Server knows how to deal with request responses.""" + assert ftp_server.file_system.get_file(folder_name="downloads", file_name="file.txt") is None + + response: FTPPacket = FTPPacket( + ftp_command=FTPCommand.STOR, + ftp_command_args={ + "dest_folder_name": "downloads", + "dest_file_name": "file.txt", + "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, + }, + packet_payload_size=24, + ) + + ftp_server_service: FTPServer = ftp_server.software_manager.software.get("FTPServer") + ftp_server_service.receive(response) + + assert ftp_server.file_system.get_file(folder_name="downloads", file_name="file.txt") + + +def test_ftp_server_should_send_error_if_port_arg_is_invalid(ftp_server): + """Should fail if the port command receives an invalid port.""" + payload: FTPPacket = FTPPacket( + ftp_command=FTPCommand.PORT, + ftp_command_args=None, + packet_payload_size=24, + ) + + ftp_server_service: FTPServer = ftp_server.software_manager.software.get("FTPServer") + assert ftp_server_service._process_ftp_command(payload=payload).status_code is FTPStatusCode.ERROR + + +def test_ftp_server_receives_non_ftp_packet(ftp_server): + """Receive should return false if the service receives a non ftp packet.""" + response: FTPPacket = None + + ftp_server_service: FTPServer = ftp_server.software_manager.software.get("FTPServer") + assert ftp_server_service.receive(response) is False + + +def test_offline_ftp_server_receives_request(ftp_server): + """Receive should return false if the service is stopped.""" + response: FTPPacket = FTPPacket( + ftp_command=FTPCommand.STOR, + ftp_command_args={ + "dest_folder_name": "downloads", + "dest_file_name": "file.txt", + "file_size": 24, + }, + packet_payload_size=24, + ) + + ftp_server_service: FTPServer = ftp_server.software_manager.software.get("FTPServer") + ftp_server_service.stop() + assert ftp_server_service.operating_state is ServiceOperatingState.STOPPED + assert ftp_server_service.receive(response) is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_service_actions.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_service_actions.py new file mode 100644 index 00000000..edc111e3 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_service_actions.py @@ -0,0 +1,93 @@ +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.software import SoftwareHealthState + + +def test_service_scan(service): + """Test that an agent can request a service scan.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_visible == SoftwareHealthState.UNUSED + + service.apply_request(["scan"]) + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_visible == SoftwareHealthState.GOOD + + +def test_service_stop(service): + """Test that an agent can request to stop a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["stop"]) + assert service.operating_state == ServiceOperatingState.STOPPED + + +def test_service_start(service): + """Test that an agent can request to start a service.""" + assert service.operating_state == ServiceOperatingState.STOPPED + service.apply_request(["start"]) + assert service.operating_state == ServiceOperatingState.RUNNING + + +def test_service_pause(service): + """Test that an agent can request to pause a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["pause"]) + assert service.operating_state == ServiceOperatingState.PAUSED + + +def test_service_resume(service): + """Test that an agent can request to resume a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["pause"]) + assert service.operating_state == ServiceOperatingState.PAUSED + + service.apply_request(["resume"]) + assert service.operating_state == ServiceOperatingState.RUNNING + + +def test_service_restart(service): + """Test that an agent can request to restart a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["restart"]) + assert service.operating_state == ServiceOperatingState.RESTARTING + + +def test_service_disable(service): + """Test that an agent can request to disable a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["disable"]) + assert service.operating_state == ServiceOperatingState.DISABLED + + +def test_service_enable(service): + """Test that an agent can request to enable a service.""" + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + + service.apply_request(["disable"]) + assert service.operating_state == ServiceOperatingState.DISABLED + + service.apply_request(["enable"]) + assert service.operating_state == ServiceOperatingState.STOPPED + + +def test_service_fix(service): + """Test that a service can be fixed and that it takes two timesteps to complete.""" + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.apply_request(["fix"]) + assert service.health_state_actual == SoftwareHealthState.FIXING + service.apply_timestep(1) + assert service.health_state_actual == SoftwareHealthState.FIXING + service.apply_timestep(2) + assert service.health_state_actual == SoftwareHealthState.GOOD diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py new file mode 100644 index 00000000..765922fd --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -0,0 +1,195 @@ +from uuid import uuid4 + +import pytest + +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.software import SoftwareHealthState + + +def test_scan(service): + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_visible == SoftwareHealthState.UNUSED + + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_visible == SoftwareHealthState.UNUSED + + service.scan() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_visible == SoftwareHealthState.GOOD + + +def test_start_service(service): + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + service.start() + + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_stop_service(service): + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.stop() + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_pause_and_resume_service(service): + assert service.operating_state == ServiceOperatingState.STOPPED + service.resume() + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + service.pause() + assert service.operating_state == ServiceOperatingState.PAUSED + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.resume() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_restart(service): + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + service.restart() + # Service is STOPPED. Restart will only work if the service was PAUSED or RUNNING + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + service.start() + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + service.restart() + # Service is RUNNING. Restart should work + assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.GOOD + + timestep = 0 + while service.operating_state == ServiceOperatingState.RESTARTING: + service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.GOOD + timestep += 1 + + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_restart_compromised(service): + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + # compromise the service + service.set_health_state(SoftwareHealthState.COMPROMISED) + + service.restart() + assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + """ + Service should be compromised even after reset. + + Only way to remove compromised status is via FIXING. + """ + + timestep = 0 + while service.operating_state == ServiceOperatingState.RESTARTING: + service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + timestep += 1 + + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_compromised_service_remains_compromised(service): + """ + Tests that a compromised service stays compromised. + + The only way that the service can be uncompromised is by running patch. + """ + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.set_health_state(SoftwareHealthState.COMPROMISED) + + service.stop() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.start() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.disable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.enable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.pause() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.resume() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_service_fixing(service): + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.set_health_state(SoftwareHealthState.COMPROMISED) + + service.fix() + assert service.health_state_actual == SoftwareHealthState.FIXING + + for i in range(service.fixing_duration + 1): + service.apply_timestep(i) + + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_enable_disable(service): + service.disable() + assert service.operating_state == ServiceOperatingState.DISABLED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + service.enable() + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + +def test_overwhelm_service(service): + service.max_sessions = 2 + service.start() + + uuid = str(uuid4()) + assert service.add_connection(connection_id=uuid) # should be true + assert service.health_state_actual == SoftwareHealthState.GOOD + + assert not service.add_connection(connection_id=uuid) # fails because connection already exists + assert service.health_state_actual == SoftwareHealthState.GOOD + + assert service.add_connection(connection_id=str(uuid4())) # succeed + assert service.health_state_actual == SoftwareHealthState.GOOD + + assert not service.add_connection(connection_id=str(uuid4())) # fail because at capacity + assert service.health_state_actual is SoftwareHealthState.OVERWHELMED + + +@pytest.mark.xfail(reason="Fails as it's now too simple. Needs to be be refactored so that uses a service on a node.") +def test_create_and_terminate_connections(service): + service.start() + uuid = str(uuid4()) + + assert service.add_connection(connection_id=uuid) # should be true + assert len(service.connections) == 1 + assert service.health_state_actual is SoftwareHealthState.GOOD + + assert service.terminate_connection(connection_id=uuid) # should be true + assert len(service.connections) == 0 + assert service.health_state_actual is SoftwareHealthState.GOOD diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py new file mode 100644 index 00000000..6fac0bcf --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -0,0 +1,54 @@ +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.host.server import Server +from primaite.simulator.network.protocols.http import ( + HttpRequestMethod, + HttpRequestPacket, + HttpResponsePacket, + HttpStatusCode, +) +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.web_server.web_server import WebServer + + +@pytest.fixture(scope="function") +def web_server() -> Server: + node = Server( + hostname="web_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + node.power_on() + node.software_manager.install(WebServer) + node.software_manager.software.get("WebServer").start() + return node + + +def test_create_web_server(web_server): + assert web_server is not None + web_server_service: WebServer = web_server.software_manager.software.get("WebServer") + assert web_server_service.name is "WebServer" + assert web_server_service.port is Port.HTTP + assert web_server_service.protocol is IPProtocol.TCP + + +def test_handling_get_request_not_found_path(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url="http://domain.com/fake-path") + + web_server_service: WebServer = web_server.software_manager.software.get("WebServer") + + response: HttpResponsePacket = web_server_service._handle_get_request(payload=payload) + assert response.status_code == HttpStatusCode.NOT_FOUND + + +def test_handling_get_request_home_page(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url="http://domain.com/") + + web_server_service: WebServer = web_server.software_manager.software.get("WebServer") + + response: HttpResponsePacket = web_server_service._handle_get_request(payload=payload) + assert response.status_code == HttpStatusCode.OK diff --git a/tests/unit_tests/_primaite/_simulator/_system/core/test_sys_log.py b/tests/unit_tests/_primaite/_simulator/_system/core/test_sys_log.py new file mode 100644 index 00000000..1009adc3 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/core/test_sys_log.py @@ -0,0 +1,136 @@ +from uuid import uuid4 + +import pytest + +from primaite import PRIMAITE_CONFIG +from primaite.simulator import LogLevel, SIM_OUTPUT +from primaite.simulator.system.core.sys_log import SysLog + + +@pytest.fixture(autouse=True) +def override_dev_mode_temporarily(): + """Temporarily turn off dev mode for this test.""" + primaite_dev_mode = PRIMAITE_CONFIG["developer_mode"]["enabled"] + PRIMAITE_CONFIG["developer_mode"]["enabled"] = False + yield # run tests + PRIMAITE_CONFIG["developer_mode"]["enabled"] = primaite_dev_mode + + +@pytest.fixture(scope="function") +def syslog() -> SysLog: + return SysLog(hostname="test") + + +def test_debug_sys_log_level(syslog, capsys): + """Test that the debug log level logs debug syslogs and above.""" + SIM_OUTPUT.sys_log_level = LogLevel.DEBUG + SIM_OUTPUT.write_sys_log_to_terminal = True + + test_string = str(uuid4()) + + syslog.debug(test_string) + syslog.info(test_string) + syslog.warning(test_string) + syslog.error(test_string) + syslog.critical(test_string) + + captured = "".join(capsys.readouterr()) + + assert test_string in captured + assert "DEBUG" in captured + assert "INFO" in captured + assert "WARNING" in captured + assert "ERROR" in captured + assert "CRITICAL" in captured + + +def test_info_sys_log_level(syslog, capsys): + """Test that the debug log level logs debug syslogs and above.""" + SIM_OUTPUT.sys_log_level = LogLevel.INFO + SIM_OUTPUT.write_sys_log_to_terminal = True + + test_string = str(uuid4()) + + syslog.debug(test_string) + syslog.info(test_string) + syslog.warning(test_string) + syslog.error(test_string) + syslog.critical(test_string) + + captured = "".join(capsys.readouterr()) + + assert test_string in captured + assert "DEBUG" not in captured + assert "INFO" in captured + assert "WARNING" in captured + assert "ERROR" in captured + assert "CRITICAL" in captured + + +def test_warning_sys_log_level(syslog, capsys): + """Test that the debug log level logs debug syslogs and above.""" + SIM_OUTPUT.sys_log_level = LogLevel.WARNING + SIM_OUTPUT.write_sys_log_to_terminal = True + + test_string = str(uuid4()) + + syslog.debug(test_string) + syslog.info(test_string) + syslog.warning(test_string) + syslog.error(test_string) + syslog.critical(test_string) + + captured = "".join(capsys.readouterr()) + + assert test_string in captured + assert "DEBUG" not in captured + assert "INFO" not in captured + assert "WARNING" in captured + assert "ERROR" in captured + assert "CRITICAL" in captured + + +def test_error_sys_log_level(syslog, capsys): + """Test that the debug log level logs debug syslogs and above.""" + SIM_OUTPUT.sys_log_level = LogLevel.ERROR + SIM_OUTPUT.write_sys_log_to_terminal = True + + test_string = str(uuid4()) + + syslog.debug(test_string) + syslog.info(test_string) + syslog.warning(test_string) + syslog.error(test_string) + syslog.critical(test_string) + + captured = "".join(capsys.readouterr()) + + assert test_string in captured + assert "DEBUG" not in captured + assert "INFO" not in captured + assert "WARNING" not in captured + assert "ERROR" in captured + assert "CRITICAL" in captured + + +def test_critical_sys_log_level(syslog, capsys): + """Test that the debug log level logs debug syslogs and above.""" + SIM_OUTPUT.sys_log_level = LogLevel.CRITICAL + SIM_OUTPUT.write_sys_log_to_terminal = True + + test_string = str(uuid4()) + + syslog.debug(test_string) + syslog.info(test_string) + syslog.warning(test_string) + syslog.error(test_string) + syslog.critical(test_string) + + captured = "".join(capsys.readouterr()) + + assert test_string in captured + assert "DEBUG" not in captured + assert "INFO" not in captured + assert "WARNING" not in captured + assert "ERROR" not in captured + assert "CRITICAL" in captured diff --git a/tests/unit_tests/_primaite/_simulator/_system/test_software.py b/tests/unit_tests/_primaite/_simulator/_system/test_software.py new file mode 100644 index 00000000..6f680012 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/test_software.py @@ -0,0 +1,35 @@ +from typing import Dict + +import pytest + +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import IOSoftware, SoftwareHealthState + + +class TestSoftware(Service): + def describe_state(self) -> Dict: + pass + + +@pytest.fixture(scope="function") +def software(file_system): + return TestSoftware( + name="TestSoftware", + port=Port.ARP, + file_system=file_system, + sys_log=SysLog(hostname="test_service"), + protocol=IPProtocol.TCP, + ) + + +def test_software_creation(software): + assert software is not None + + +def test_software_set_health_state(software): + assert software.health_state_actual == SoftwareHealthState.UNUSED + software.set_health_state(SoftwareHealthState.GOOD) + assert software.health_state_actual == SoftwareHealthState.GOOD diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py new file mode 100644 index 00000000..069e6ea2 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -0,0 +1,47 @@ +from typing import Callable, Dict, List, Literal, Tuple + +import pytest +from pydantic import ValidationError + +from primaite.simulator.core import SimComponent + + +class TestIsolatedSimComponent: + """Test the SimComponent class in isolation.""" + + def test_data_validation(self): + """ + Test that our derived class does not interfere with pydantic data validation. + + This test may seem like it's simply validating pydantic data validation, but + actually it is here to give us assurance that any custom functionality we add + to the SimComponent does not interfere with pydantic. + """ + + class TestComponent(SimComponent): + name: str + size: Tuple[float, float] + + def describe_state(self) -> Dict: + return {} + + comp = TestComponent(name="computer", size=(5, 10)) + assert isinstance(comp, TestComponent) + + with pytest.raises(ValidationError): + invalid_comp = TestComponent(name="computer", size="small") # noqa + + def test_serialisation(self): + """Validate that our added functionality does not interfere with pydantic.""" + + class TestComponent(SimComponent): + name: str + size: Tuple[float, float] + + def describe_state(self) -> Dict: + return {} + + comp = TestComponent(name="computer", size=(5, 10)) + dump = comp.model_dump_json() + reconstructed = TestComponent.model_validate_json(dump) + assert dump == reconstructed.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/test_sim_container.py b/tests/unit_tests/_primaite/_simulator/test_sim_container.py new file mode 100644 index 00000000..4543259d --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/test_sim_container.py @@ -0,0 +1,16 @@ +from primaite.simulator.sim_container import Simulation + + +def test_creating_empty_simulation(): + """Check that no errors occur when trying to setup a simulation without providing parameters""" + empty_sim = Simulation() + + +def test_empty_sim_state(): + """Check that describe_state has the right subcomponents.""" + empty_sim = Simulation() + sim_state = empty_sim.describe_state() + network_state = empty_sim.network.describe_state() + domain_state = empty_sim.domain.describe_state() + assert sim_state["network"] == network_state + assert sim_state["domain"] == domain_state