Merged PR 541: Config item that allows services and applications to return actual health states without the need to scan

## Summary
Added:
- services_requires_scan
- applications_requires_scan

To allow the agents to get the actual health state of services and applications without the need to scan

## Test process
https://dev.azure.com/ma-dev-uk/PrimAITE/_git/PrimAITE/pullrequest/541?_a=files&path=/tests/unit_tests/_primaite/_game/_agent/test_observations.py

## Checklist
- [X] PR is linked to a **work item**
- [X] **acceptance criteria** of linked ticket are met
- [X] 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
- [X] updated the **change log**
- [X] ran **pre-commit** checks for code style
- [ ] attended to any **TO-DOs** left in the code

Related work items: #2864
This commit is contained in:
Czar Echavez
2024-09-13 10:24:31 +00:00
7 changed files with 298 additions and 13 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Log observation space data by episode and step. - Log observation space data by episode and step.
- ACL's are no longer applied to layer-2 traffic. - ACL's are no longer applied to layer-2 traffic.
- Added `services_requires_scan` and `applications_requires_scan` to agent observation space config to allow the agents to be able to see actual health states of services and applications without requiring scans (Default `True`, set to `False` to allow agents to see actual health state without scanning).
## [3.3.0] - 2024-09-04 ## [3.3.0] - 2024-09-04
### Added ### Added

View File

@@ -52,6 +52,14 @@ class HostObservation(AbstractObservation, identifier="HOST"):
""" """
If True, files and folders must be scanned to update the health state. If False, true state is always shown. If True, files and folders must be scanned to update the health state. If False, true state is always shown.
""" """
services_requires_scan: Optional[bool] = None
"""
If True, services must be scanned to update the health state. If False, true state is always shown.
"""
applications_requires_scan: Optional[bool] = None
"""
If True, applications must be scanned to update the health state. If False, true state is always shown.
"""
include_users: Optional[bool] = True include_users: Optional[bool] = True
"""If True, report user session information.""" """If True, report user session information."""
@@ -71,6 +79,8 @@ class HostObservation(AbstractObservation, identifier="HOST"):
monitored_traffic: Optional[Dict], monitored_traffic: Optional[Dict],
include_num_access: bool, include_num_access: bool,
file_system_requires_scan: bool, file_system_requires_scan: bool,
services_requires_scan: bool,
applications_requires_scan: bool,
include_users: bool, include_users: bool,
) -> None: ) -> None:
""" """
@@ -106,6 +116,12 @@ class HostObservation(AbstractObservation, identifier="HOST"):
:param file_system_requires_scan: If True, the files and folders must be scanned to update the health state. :param file_system_requires_scan: If True, the files and folders must be scanned to update the health state.
If False, the true state is always shown. If False, the true state is always shown.
:type file_system_requires_scan: bool :type file_system_requires_scan: bool
:param services_requires_scan: If True, services must be scanned to update the health state.
If False, the true state is always shown.
:type services_requires_scan: bool
:param applications_requires_scan: If True, applications must be scanned to update the health state.
If False, the true state is always shown.
:type applications_requires_scan: bool
:param include_users: If True, report user session information. :param include_users: If True, report user session information.
:type include_users: bool :type include_users: bool
""" """
@@ -119,7 +135,7 @@ class HostObservation(AbstractObservation, identifier="HOST"):
# Ensure lists have lengths equal to specified counts by truncating or padding # Ensure lists have lengths equal to specified counts by truncating or padding
self.services: List[ServiceObservation] = services self.services: List[ServiceObservation] = services
while len(self.services) < num_services: while len(self.services) < num_services:
self.services.append(ServiceObservation(where=None)) self.services.append(ServiceObservation(where=None, services_requires_scan=services_requires_scan))
while len(self.services) > num_services: while len(self.services) > num_services:
truncated_service = self.services.pop() truncated_service = self.services.pop()
msg = f"Too many services in Node observation space for node. Truncating service {truncated_service.where}" msg = f"Too many services in Node observation space for node. Truncating service {truncated_service.where}"
@@ -127,7 +143,9 @@ class HostObservation(AbstractObservation, identifier="HOST"):
self.applications: List[ApplicationObservation] = applications self.applications: List[ApplicationObservation] = applications
while len(self.applications) < num_applications: while len(self.applications) < num_applications:
self.applications.append(ApplicationObservation(where=None)) self.applications.append(
ApplicationObservation(where=None, applications_requires_scan=applications_requires_scan)
)
while len(self.applications) > num_applications: while len(self.applications) > num_applications:
truncated_application = self.applications.pop() truncated_application = self.applications.pop()
msg = f"Too many applications in Node observation space for node. Truncating {truncated_application.where}" msg = f"Too many applications in Node observation space for node. Truncating {truncated_application.where}"
@@ -263,6 +281,10 @@ class HostObservation(AbstractObservation, identifier="HOST"):
folder_config.file_system_requires_scan = config.file_system_requires_scan folder_config.file_system_requires_scan = config.file_system_requires_scan
for nic_config in config.network_interfaces: for nic_config in config.network_interfaces:
nic_config.include_nmne = config.include_nmne nic_config.include_nmne = config.include_nmne
for service_config in config.services:
service_config.services_requires_scan = config.services_requires_scan
for application_config in config.applications:
application_config.applications_requires_scan = config.applications_requires_scan
services = [ServiceObservation.from_config(config=c, parent_where=where) for c in config.services] 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] applications = [ApplicationObservation.from_config(config=c, parent_where=where) for c in config.applications]
@@ -293,5 +315,7 @@ class HostObservation(AbstractObservation, identifier="HOST"):
monitored_traffic=config.monitored_traffic, monitored_traffic=config.monitored_traffic,
include_num_access=config.include_num_access, include_num_access=config.include_num_access,
file_system_requires_scan=config.file_system_requires_scan, file_system_requires_scan=config.file_system_requires_scan,
services_requires_scan=config.services_requires_scan,
applications_requires_scan=config.applications_requires_scan,
include_users=config.include_users, include_users=config.include_users,
) )

View File

@@ -45,7 +45,13 @@ class NodesObservation(AbstractObservation, identifier="NODES"):
include_num_access: Optional[bool] = None include_num_access: Optional[bool] = None
"""Flag to include the number of accesses.""" """Flag to include the number of accesses."""
file_system_requires_scan: bool = True file_system_requires_scan: bool = True
"""If True, the folder must be scanned to update the health state. Tf False, the true state is always shown.""" """If True, the folder must be scanned to update the health state. If False, the true state is always shown."""
services_requires_scan: bool = True
"""If True, the services must be scanned to update the health state.
If False, the true state is always shown."""
applications_requires_scan: bool = True
"""If True, the applications must be scanned to update the health state.
If False, the true state is always shown."""
include_users: Optional[bool] = True include_users: Optional[bool] = True
"""If True, report user session information.""" """If True, report user session information."""
num_ports: Optional[int] = None num_ports: Optional[int] = None
@@ -193,6 +199,10 @@ class NodesObservation(AbstractObservation, identifier="NODES"):
host_config.include_num_access = config.include_num_access host_config.include_num_access = config.include_num_access
if host_config.file_system_requires_scan is None: if host_config.file_system_requires_scan is None:
host_config.file_system_requires_scan = config.file_system_requires_scan host_config.file_system_requires_scan = config.file_system_requires_scan
if host_config.services_requires_scan is None:
host_config.services_requires_scan = config.services_requires_scan
if host_config.applications_requires_scan is None:
host_config.applications_requires_scan = config.applications_requires_scan
if host_config.include_users is None: if host_config.include_users is None:
host_config.include_users = config.include_users host_config.include_users = config.include_users

View File

@@ -1,7 +1,7 @@
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
from __future__ import annotations from __future__ import annotations
from typing import Dict from typing import Dict, Optional
from gymnasium import spaces from gymnasium import spaces
from gymnasium.core import ObsType from gymnasium.core import ObsType
@@ -19,7 +19,10 @@ class ServiceObservation(AbstractObservation, identifier="SERVICE"):
service_name: str service_name: str
"""Name of the service, used for querying simulation state dictionary""" """Name of the service, used for querying simulation state dictionary"""
def __init__(self, where: WhereType) -> None: services_requires_scan: Optional[bool] = None
"""If True, services must be scanned to update the health state. If False, true state is always shown."""
def __init__(self, where: WhereType, services_requires_scan: bool) -> None:
""" """
Initialise a service observation instance. Initialise a service observation instance.
@@ -28,6 +31,7 @@ class ServiceObservation(AbstractObservation, identifier="SERVICE"):
:type where: WhereType :type where: WhereType
""" """
self.where = where self.where = where
self.services_requires_scan = services_requires_scan
self.default_observation = {"operating_status": 0, "health_status": 0} self.default_observation = {"operating_status": 0, "health_status": 0}
def observe(self, state: Dict) -> ObsType: def observe(self, state: Dict) -> ObsType:
@@ -44,7 +48,9 @@ class ServiceObservation(AbstractObservation, identifier="SERVICE"):
return self.default_observation return self.default_observation
return { return {
"operating_status": service_state["operating_state"], "operating_status": service_state["operating_state"],
"health_status": service_state["health_state_visible"], "health_status": service_state["health_state_visible"]
if self.services_requires_scan
else service_state["health_state_actual"],
} }
@property @property
@@ -70,7 +76,9 @@ class ServiceObservation(AbstractObservation, identifier="SERVICE"):
:return: Constructed service observation instance. :return: Constructed service observation instance.
:rtype: ServiceObservation :rtype: ServiceObservation
""" """
return cls(where=parent_where + ["services", config.service_name]) return cls(
where=parent_where + ["services", config.service_name], services_requires_scan=config.services_requires_scan
)
class ApplicationObservation(AbstractObservation, identifier="APPLICATION"): class ApplicationObservation(AbstractObservation, identifier="APPLICATION"):
@@ -82,7 +90,12 @@ class ApplicationObservation(AbstractObservation, identifier="APPLICATION"):
application_name: str application_name: str
"""Name of the application, used for querying simulation state dictionary""" """Name of the application, used for querying simulation state dictionary"""
def __init__(self, where: WhereType) -> None: applications_requires_scan: Optional[bool] = None
"""
If True, applications must be scanned to update the health state. If False, true state is always shown.
"""
def __init__(self, where: WhereType, applications_requires_scan: bool) -> None:
""" """
Initialise an application observation instance. Initialise an application observation instance.
@@ -92,6 +105,7 @@ class ApplicationObservation(AbstractObservation, identifier="APPLICATION"):
:type where: WhereType :type where: WhereType
""" """
self.where = where self.where = where
self.applications_requires_scan = applications_requires_scan
self.default_observation = {"operating_status": 0, "health_status": 0, "num_executions": 0} self.default_observation = {"operating_status": 0, "health_status": 0, "num_executions": 0}
# TODO: allow these to be configured in yaml # TODO: allow these to be configured in yaml
@@ -128,7 +142,9 @@ class ApplicationObservation(AbstractObservation, identifier="APPLICATION"):
return self.default_observation return self.default_observation
return { return {
"operating_status": application_state["operating_state"], "operating_status": application_state["operating_state"],
"health_status": application_state["health_state_visible"], "health_status": application_state["health_state_visible"]
if self.applications_requires_scan
else application_state["health_state_actual"],
"num_executions": self._categorise_num_executions(application_state["num_executions"]), "num_executions": self._categorise_num_executions(application_state["num_executions"]),
} }
@@ -161,4 +177,7 @@ class ApplicationObservation(AbstractObservation, identifier="APPLICATION"):
:return: Constructed application observation instance. :return: Constructed application observation instance.
:rtype: ApplicationObservation :rtype: ApplicationObservation
""" """
return cls(where=parent_where + ["applications", config.application_name]) return cls(
where=parent_where + ["applications", config.application_name],
applications_requires_scan=config.applications_requires_scan,
)

View File

@@ -39,6 +39,8 @@ def test_host_observation(simulation):
folders=[], folders=[],
network_interfaces=[], network_interfaces=[],
file_system_requires_scan=True, file_system_requires_scan=True,
services_requires_scan=True,
applications_requires_scan=True,
include_users=False, include_users=False,
) )

View File

@@ -29,7 +29,9 @@ def test_service_observation(simulation):
ntp_server = pc.software_manager.software.get("NTPServer") ntp_server = pc.software_manager.software.get("NTPServer")
assert ntp_server assert ntp_server
service_obs = ServiceObservation(where=["network", "nodes", pc.hostname, "services", "NTPServer"]) service_obs = ServiceObservation(
where=["network", "nodes", pc.hostname, "services", "NTPServer"], services_requires_scan=True
)
assert service_obs.space["operating_status"] == spaces.Discrete(7) assert service_obs.space["operating_status"] == spaces.Discrete(7)
assert service_obs.space["health_status"] == spaces.Discrete(5) assert service_obs.space["health_status"] == spaces.Discrete(5)
@@ -54,7 +56,9 @@ def test_application_observation(simulation):
web_browser: WebBrowser = pc.software_manager.software.get("WebBrowser") web_browser: WebBrowser = pc.software_manager.software.get("WebBrowser")
assert web_browser assert web_browser
app_obs = ApplicationObservation(where=["network", "nodes", pc.hostname, "applications", "WebBrowser"]) app_obs = ApplicationObservation(
where=["network", "nodes", pc.hostname, "applications", "WebBrowser"], applications_requires_scan=True
)
web_browser.close() web_browser.close()
observation_state = app_obs.observe(simulation.describe_state()) observation_state = app_obs.observe(simulation.describe_state())

View File

@@ -1,10 +1,11 @@
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
import json
from typing import List from typing import List
import pytest import pytest
import yaml import yaml
from primaite.game.agent.observations import ObservationManager from primaite.game.agent.observations import ApplicationObservation, ObservationManager, ServiceObservation
from primaite.game.agent.observations.file_system_observations import FileObservation, FolderObservation from primaite.game.agent.observations.file_system_observations import FileObservation, FolderObservation
from primaite.game.agent.observations.host_observations import HostObservation from primaite.game.agent.observations.host_observations import HostObservation
@@ -130,3 +131,227 @@ class TestFileSystemRequiresScan:
[], files=[], num_files=0, include_num_access=False, file_system_requires_scan=False [], files=[], num_files=0, include_num_access=False, file_system_requires_scan=False
) )
assert obs_not_requiring_scan.observe(folder_state)["health_status"] == 3 assert obs_not_requiring_scan.observe(folder_state)["health_status"] == 3
class TestServicesRequiresScan:
@pytest.mark.parametrize(
("yaml_option_string", "expected_val"),
(
("services_requires_scan: true", True),
("services_requires_scan: false", False),
(" ", True),
),
)
def test_obs_config(self, yaml_option_string, expected_val):
"""Check that the default behaviour is to set service_requires_scan to True."""
obs_cfg_yaml = f"""
type: CUSTOM
options:
components:
- type: NODES
label: NODES
options:
hosts:
- hostname: domain_controller
- hostname: web_server
services:
- service_name: WebServer
- service_name: DNSClient
- hostname: database_server
folders:
- folder_name: database
files:
- file_name: database.db
- hostname: backup_server
services:
- service_name: FTPServer
- hostname: security_suite
- hostname: client_1
- hostname: client_2
num_services: 3
num_applications: 0
num_folders: 1
num_files: 1
num_nics: 2
include_num_access: false
{yaml_option_string}
include_nmne: true
monitored_traffic:
icmp:
- NONE
tcp:
- DNS
routers:
- hostname: router_1
num_ports: 0
ip_list:
- 192.168.1.10
- 192.168.1.12
- 192.168.1.14
- 192.168.1.16
- 192.168.1.110
- 192.168.10.21
- 192.168.10.22
- 192.168.10.110
wildcard_list:
- 0.0.0.1
port_list:
- 80
- 5432
protocol_list:
- ICMP
- TCP
- UDP
num_rules: 10
- type: LINKS
label: LINKS
options:
link_references:
- router_1:eth-1<->switch_1:eth-8
- router_1:eth-2<->switch_2:eth-8
- switch_1:eth-1<->domain_controller:eth-1
- switch_1:eth-2<->web_server:eth-1
- switch_1:eth-3<->database_server:eth-1
- switch_1:eth-4<->backup_server:eth-1
- switch_1:eth-7<->security_suite:eth-1
- switch_2:eth-1<->client_1:eth-1
- switch_2:eth-2<->client_2:eth-1
- switch_2:eth-7<->security_suite:eth-2
- type: "NONE"
label: ICS
options: {{}}
"""
cfg = yaml.safe_load(obs_cfg_yaml)
manager = ObservationManager.from_config(cfg)
hosts: List[HostObservation] = manager.obs.components["NODES"].hosts
for i, host in enumerate(hosts):
services: List[ServiceObservation] = host.services
for j, service in enumerate(services):
val = service.services_requires_scan
print(f"host {i} service {j} {val}")
assert val == expected_val # Make sure services require scan by default
def test_services_requires_scan(self):
state = {"health_state_actual": 3, "health_state_visible": 1, "operating_state": 1}
obs_requiring_scan = ServiceObservation([], services_requires_scan=True)
assert obs_requiring_scan.observe(state)["health_status"] == 1 # should be visible value
obs_not_requiring_scan = ServiceObservation([], services_requires_scan=False)
assert obs_not_requiring_scan.observe(state)["health_status"] == 3 # should be actual value
class TestApplicationsRequiresScan:
@pytest.mark.parametrize(
("yaml_option_string", "expected_val"),
(
("applications_requires_scan: true", True),
("applications_requires_scan: false", False),
(" ", True),
),
)
def test_obs_config(self, yaml_option_string, expected_val):
"""Check that the default behaviour is to set applications_requires_scan to True."""
obs_cfg_yaml = f"""
type: CUSTOM
options:
components:
- type: NODES
label: NODES
options:
hosts:
- hostname: domain_controller
- hostname: web_server
- hostname: database_server
folders:
- folder_name: database
files:
- file_name: database.db
- hostname: backup_server
- hostname: security_suite
- hostname: client_1
applications:
- application_name: WebBrowser
- hostname: client_2
applications:
- application_name: WebBrowser
- application_name: DatabaseClient
num_services: 0
num_applications: 3
num_folders: 1
num_files: 1
num_nics: 2
include_num_access: false
{yaml_option_string}
include_nmne: true
monitored_traffic:
icmp:
- NONE
tcp:
- DNS
routers:
- hostname: router_1
num_ports: 0
ip_list:
- 192.168.1.10
- 192.168.1.12
- 192.168.1.14
- 192.168.1.16
- 192.168.1.110
- 192.168.10.21
- 192.168.10.22
- 192.168.10.110
wildcard_list:
- 0.0.0.1
port_list:
- 80
- 5432
protocol_list:
- ICMP
- TCP
- UDP
num_rules: 10
- type: LINKS
label: LINKS
options:
link_references:
- router_1:eth-1<->switch_1:eth-8
- router_1:eth-2<->switch_2:eth-8
- switch_1:eth-1<->domain_controller:eth-1
- switch_1:eth-2<->web_server:eth-1
- switch_1:eth-3<->database_server:eth-1
- switch_1:eth-4<->backup_server:eth-1
- switch_1:eth-7<->security_suite:eth-1
- switch_2:eth-1<->client_1:eth-1
- switch_2:eth-2<->client_2:eth-1
- switch_2:eth-7<->security_suite:eth-2
- type: "NONE"
label: ICS
options: {{}}
"""
cfg = yaml.safe_load(obs_cfg_yaml)
manager = ObservationManager.from_config(cfg)
hosts: List[HostObservation] = manager.obs.components["NODES"].hosts
for i, host in enumerate(hosts):
services: List[ServiceObservation] = host.services
for j, service in enumerate(services):
val = service.services_requires_scan
print(f"host {i} service {j} {val}")
assert val == expected_val # Make sure applications require scan by default
def test_applications_requires_scan(self):
state = {"health_state_actual": 3, "health_state_visible": 1, "operating_state": 1, "num_executions": 1}
obs_requiring_scan = ApplicationObservation([], applications_requires_scan=True)
assert obs_requiring_scan.observe(state)["health_status"] == 1 # should be visible value
obs_not_requiring_scan = ApplicationObservation([], applications_requires_scan=False)
assert obs_not_requiring_scan.observe(state)["health_status"] == 3 # should be actual value