Define new profiles (language extensions)

Language extensions are typically defined as part of the definition of an Actuator profile. The following elements can be extended:

  • targets;

  • arguments;

  • response results;

  • actuator properties (already covered in the specifier Section).

The list of Actions MUST NOT be extended (see Sec. 3.1.4 of the Language Specification).

It is strongly recommended to follow the same naming conventions as adopted by otupy (see the Developer Guide).

Add new actuator profiles

To add a new profile within otupy source code, create a new directory in the src/otupy/profiles folder. Preferably name the folder with the nsid of the profile to be developed (e.g., slpf for the Statless Packet Filter profile).

Define the profile

A Profile is identified by its namespace identifier and a unique name. Although there are not specific data structure defined by the specification, otupy wrap this information in a Profile class. The idea is to keep track of all profiles available within the system.

When a new profile is being defined, a new class can be defined and derived from the base base Profile class. It must be registered as a new profile by the @extension decorator. Just define the nsid and name class attributes in the new profile. This is an example of profile definition for the SLPF:

import otupy as oc2

nsid = 'slpf'

@oc2.extension(nsid = nsid)
class Profile(oc2.Profile):
    """ SLPF Profile

        Defines the namespace identifier and the name of the SLPF Profile.
    """
    nsid = nsid
    name = 'http://docs.oasis-open.org/openc2/oc2slpf/v1.0/oc2slpf-v1.0.md'

(see below for additional information about the extension decorator.)

Create the actuator specifier

The core specification does not dictate the internal structure of an Actuator. Indeed, the Actuator field in the Command can be assigned different types, depending on the specific actuator profile. A new type must therefore be defined for each actuator profile, and it must be declared as such to allow otupy to correctly encode/decode it. The specifiers are the structure of any Actuator that implements the profile. They are defined by the Profile Specification (e.g., see Sec. 2.1.4 for the SLPF specifier).

To declare a Actuator specifier, use the @actuator decorator. This decorator automatically registers the new Actuator, enriches its definition with internal data, and makes it available to both Producers and Consumers. The following is an example for the SLPF actuator:

@oc2.actuator(nsid=Profile.nsid)
class Specifiers(oc2.Map):
    fieldtypes = dict(hostname=str, named_group=str, asset_id=str, asset_tuple = [str])

In this case, the actuator specifiers are derived from Map, according to the definition in the SLPF specification (see the User Guide for Map documentation).

The specifier definition should also implement the __init__ method to initialize class instances. It is also recommended to define a __str__ function to provide a human-readable representation of the actuator profile in log messages.

Define new types

Adding new types can be done within each new Profile. Beaware that new types usually needs to support the “copy-constructor”, i.e., they must support initialization from an object of the same type.

Define new targets

Targets are just specific OpenC2 data types that can be used as Target in Commands. Any data type can be made a Target by decorating it with the @target(name, nsid) tag. This decorator automatically manages all the stuff to register new targets in otupy. It takes two arguments, namely:

  • name: the name associated to each type (e.g., see Sec. 3.3.1.2 in the Language Specification);

  • nsid: the namespace identifier of the profile that defines the Target (default to None, which means a Target defined in the

core specification).

This is an example for the SLPF profile:

@target(name='rule_number', nsid=Profile.nsid)
class RuleID(int):
    pass

(note that the @target decorator must be imported with its namespace according to common Python rules.)

Extend Map-based types

Extensions are currently possible for Map and derived base structures (MapOf). For instance, both arguments (Args) and response results (Results) are currently extensible. To make additional data types extensible, they must be decorated with the @extensible tag.

An extension must be declared with the @extension(nsid) decorator, where nsid is an argument that provides the namespace identifier where the extension is defined. Then, the extension class is derived from the base class. The extension must define only the additional fields that are not included in the base type.

The following is a generic example for an extension (Ext) of the base type (Base) in the namespace xxxx:

@extension(nsid='xxxx')
class Ext(Base):
   fieldtypes = {'new_field': new_field_type, ... }

The decorator manages all additional fields and declaration that are necessary to use the extension in otupy.

It is possible (and this is the preferred approach) to define the extension with the same name of the base class. By proper referring to base and extended elements within the corresponding namespace, name collisions are avoided and the naming remains more uniform. For instance, the Args element and its extension could be unambigously referred to in the code in the following way:

import otupy as oc2

import otupy.profiles.slpf as slpf

args = Args(...)        # <- This instantiate the base Args class
args = slpf.Args(...)   # <- This instantiate the extended Args class derived in the slpf profile

The extension of Args and Results will likely be based on additional structures. Define them as well in the profile folder. As best practice, data and target types should be defined in two different modules (datatypes and targettypes, respecitvely, see the Developer guide.

Recursive definitions

There may singular cases where an object is recursive, namely it contains another object of the same time. Such an example is represented by the Process target, which internally may carry an instance of its parent. However, Python does not allow to define such types in a straighforward way.

Recursion should be used with care, to avoid infinite or anyway too deep dependencies. otupy addresses this issue by providing a specific design pattern. It is based on the Python typing.Self annotation and the @make_recursive decorator provided by some otupy classes (e.g., Map). The design pattern entails the following step - use the typing.Self annotation for any field that should be instantiated to the same class in which it is defined; - use the @make_recursive decorator in front of the class definition.

This is an example for the Process target:

from typing import Self
...
@Map.make_recursive
class Process(Map):
   fieldtypes = {'pid': int, 'name': str, 'cwd': str, 'executable': File, 'parent': <b>Self</b>, 'command_line': str}

As a result, the Map class has the following fieldtypes definition:

fieldtypes = {'pid': int, 'name': str, 'cwd': str, 'executable': File, 'parent': <b>Process</b>, 'command_line': str}

The @make_recursive decorator is implemented for each base type (e.g., Map). Check the code documentation to know what base types actually implement this helper.

Syntax validation

Profiles are likely to restrict the possible combination of Actions, Target, and Args. Since these restrictions are common to all ``Actuator``s, they can be defined only once within the profile. Specific functions must be exported to perform the validation; the internal implementation does not need to follow any specific template. Note, however, that actuators are not expected to implement any possible Action/Target pair and support all Arguments described by the profile. For this reason, behind profile validation, each specific actuator will implement its internal validation.

Export modules and data

Even if this step is not strictly required, it is recommended to pack every new definition under the main profile namespace. This simplifies access to exported data and structures. This operation can be done by importing all data, classes, and functions to be exported in the __init__.py module. Such elements can then be imported and used in a very simple and natural way under their profile namespace (which is very similar to what expected by the specifications):

import otupy.profiles.slpf as slpf

Command(target=slpf.rule_number, ...)
slpf.Args(...)