Getting Started
Cyber Range Kyoushi Simulation provides and API and CLI for developing and running state machines for simulating various actors in cyber ranges.
The development API revolves around 6 major components
Simulation state machines are defined by combining these 6 components.
Statemachine¶
The Statemachine
base class provides the high level state machine
execution logic for state machines. In its most basic form it is initialized with a initial state and a list of states.
After initialization a state machine can either be executed autonomously via Statemachine.run()
or manually via Statemachine.execute_step(...)
.
State machines will execute according to the transitions
defined in
its states
. When using Statemachine.run()
execution
will keep executing state transitions until either an end state is reached (defined as None
) or the current state has no outgoing
transitions (i.e., State.next(...)
returns None
).
Other thant the base Statemachine
the API also provides other state machine classes which modify
normal state machine behavior. See the sm module code reference for all available state machine types.
Hint
If you wish to change or extend high basic state machine execution flow you can extend
Statemachine
or one of its sub classes.
Context¶
The state machine execution context is used to pass information between the various states and transitions.
Allowed context types are Dict[str, Any]
or Pydantic Models.
A state machine execution context can be helpful when you complex a state machine for which some of the states
or transitions depend on some shared information or objects. For example if you are working with Selenium
you might want to store your Selenium driver
in the context so that your transitions and states can all access the same Browser instance.
The Statemachine
also implements simple context life cycle through the
Statemachine.setup_context
and
Statemachine.destroy_context
methods. You can use extend this
methods to ensure that your custom context is properly initialized and destroyed in accordance with the state machine execution flow.
Warning
If you do not use Statemachine.run()
and instead choose to manually execute your state machine you have to call the setup and destroy
methods yourself before starting and stopping execution.
While you could also achieve something similar to the context by initializing custom State or Transition classes with
e.g., a shared driver
object it is preferred to use the context for such things as it helps keep your code simple.
Also life cycle management for such custom solutions would have to be implemented and integrated with the state machine as well.
State¶
A State
has zero or more out going transitions and implements the
method State.next(...)
. Each state also must have a unique
name
property so it can be stored in a dictionary (name -> State
).
The next(..)
method used in the state machine execution flow to determine the next transition to execute.
It is important to select the State
type that fits your state machines definition e.g., if you want to implement a
probabilistic finite state machine the ProbabilisticState
might be a good fit.
See the states module for all the provided state types.
Hint
You can also always implement a custom State
if you need a more specific transition selection
e.g., based on some of your context information.
Transitions and Transition Functions¶
While states define the flow of the state machine transitions and transition functions define its actions.
A Transition
always has a name, target state and transition function.
Reminder
A target state of None
indicates the final state i.e., end of execution.
Alternatively you can also use FinalState
if you wish to define end states more explicitly.
The TransitionFunction
is what ultimately contains your custom code.
What actually is executed depends on your state machine e.g., your transition function could use a Selenium driver
to navigate from one page (current state) to another page (target state).
The Transition
object that wraps your transition function
determines how your transition function is executed and handles all state machine related actions (e.g., returning the target state).
You can extend the base transition class if you whish to change this behavior, for example,
the DelayedTransition
class makes it possible the define
a pre or post transition function execution delay. This might be useful when simulating human behavior
as humans tend to think before or after they do something. Alternatively you could also implement a custom transition class
that executes a different transition function depending on the current state or context.
Warning
While the API does not enforce it its recommend that a single transition instance
only has one target state. This is recommend to keep state machine implementations
as understandable as possible. Divergence from normal behavior due to errors are an
exception to this and are made possible through
TransitionExecutionErrors
.
Stateless vs Stateful Transition Functions¶
Transition functions can be defined as either functions (stateless) or as callable objects (stateful).
They only need to implement the TransitionFunction protocol
.
For example the following would be a stateless transition function
def goto_city_transition(
log: BoundLogger,
current_state: str,
context: TravelerContext,
target: Optional[str],
):
print(f"The weather is ok so I am going to {context.chosen_city} now ...")
We call this a stateless transition function, because it does not have its own internal state, i.e., variables that persist across multiple executions. Stateless transition functions can only use the context object to store and read information.
Sometimes this is not enough as you might have many transition functions that require their own state variables e.g., a transition function that increases a count and prints it out. If you have many such functions your context object might contain many many fields that are only ever used by a single transition function and do not necessarily need to be shared with other states and transitions. In such cases you might prefer to store this information outside the context object to avoid this clutter. Another use case for stateful transition functions would be functions that can be configured to do something a specific way depending on the state machine configuration.
As such stateful transition functions can simply be defined as callable objects where the __call__
method
implements the TransitionFunction protocol
.
As objects they can then be initialized based on the configuration and they can also store their own state
as attributes.
class SayHello:
"""Transition function for the initial hello world message"""
def __init__(self, traveler_name: StrictStr, desired_weather: Weather):
self.traveler_name = traveler_name
self.desired_weather = desired_weather
def __call__(
self,
log: BoundLogger,
current_state: str,
context: TravelerContext,
target: Optional[str],
):
print(
f"Hi I am {self.traveler_name}. "
f"I like to travel to cities that have {self.desired_weather} weather."
)
Creating Transition Instances¶
The API provides to ways to create transition objects either by simply constructing one of the transition types with a transition function as its argument e.g.,
hello = transitions.Transition(
name="hello",
transition_function=say_hello,
target="selecting_city",
)
or in case of stateless transitions you can also use a transition decorator
(e.g., transition
) that can directly
wrap a transition function definition into a transition object.
@transitions.transition(target="selecting_city")
def hello(log, current_state: str, context: TravelerContext, target: Optional[str]):
print(
f"Hi I am {context.traveler}. "
f"I like to travel to cities that have {context.desired_weather} weather."
)
Using transition decorators for stateless transitions can save you some extra code and also makes it very clear which transition executes which transition function. As stateful transition functions are classes and only become actual functions after they have been initialized you cannot use transition decorators with them.
Hint
The API provides transition decorators for all its transition types. The decorator name is usually just camel case of the transition types class name.
Statemachine Factories¶
Cyber Range Kyoushi Simulation also provides a CLI script for executing user defined state machines.
For this it provides the concept of StatemachineFactories
to allow
users to make their state machine implementations available to the CLI script. A state machine factory must have a human
readable name, a config class and a build method that can be called
by the CLI to create the state machine instance. The CLI will also load and initialize your config class based on the
user supplied state machine configuration file on run time.
Note
The CLI supports config dict
or Pydantic models as config classes.
It is recommend to use Pydantic as Pydantic models are automatically validated by the CLI.
Hint
See the configuration section for more details on CLI and state machine configuration.
The build method is the most important part of a state machine factory. The CLI will automatically call this function
to initialize the state machine instance as such you have to ensure that all the state, transition setup is done here
and the final state machine object is returned. To keep your code a bit more structured you could also want define
a few helper methods (e.g., build_transitions(..)
) that handle specific parts of the build process and are called
from within the main build method. A simple factory might look like this:
...
class StatemachineFactory(sm.StatemachineFactory):
@property
def name(self) -> str:
return "GettingStartedStatemachineFactory"
@property
def config_class(self):
return dict
def build(self, config: dict):
# setup the states
initial = states.SequentialState("initial", init_transition)
example_state = states.SequentialState("example_state", example_transition)
end = states.FinalState("end")
# Initialize the state machine
return GettingStartedStatemachine(
"initial",
[initial, example_state, end],
)
...
See the examples section for complete example state machine implementations.
Plugin System¶
There are two ways for a user to make their state machine factory accessible for the CLI system. Either through i
n the form of an entry point plugin using the entry point cr_kyoushi.simulation
or through a self contained python
file that declares a StatemachineFactory
class. Both approaches have their advantages and disadvantages.
Entry point plugins only work if the state machine code is installed as a proper python package, but by using this approach you can use multiple python files (modules) to define your state machine. This can potential make your code more readable and manageable.
Python script plugins have the advantage that they can be used as is not package config etc. required, but you will have to keep all your in a single file or have to ensure that imports for your custom modules are resolvable via the python path (e.g., relative imports will cause exceptions). Also with a pythons script plugin you can only provide a single state machine while you could expose multiple entry point plugins using a single package.
Important
Regardless which plugin type you use your StatemachineFactory must have a default
no arguments __init__
method so the plugin system can create an instance of your factory.