Developing Value Modifiers

Recently, the new value modifiers feature was introduced into Sigma and the feedback from the community contained some good ideas for further modifiers. In this blog post I will show you, how custom value modifiers can be implemented, which is a quite simple process. Writing a small Python class is mostly sufficient for many modifiers.

The relevant code is located in the directory tools/sigma/parser/modifiers/ in the Sigma repository. Sigma distinguishes between transformation and type modifiers. This blog post starts with transformation modifiers.

Transformations

A simple example for a transformation modifier is the SigmaBase64Modifier. Let's use this as an example to walk through an implementation of this modifier type:

class SigmaBase64Modifier(ListOrStringModifierMixin, SigmaTransformModifier):
    """Encode strings with Base64"""
    identifier = "base64"
    active = True

    def apply_str(self, val : str):
        return b64encode(val.encode()).decode()

The location of a new modifier in the Sigma converter code is quite important, as there's an automatic discovery process for modifier classes that searches in the path mentioned above (tools/sigma/parser/modifiers). Further, the class property active must be set to True, else the class is ignored by the automatic discovery.

As you can see, a transformation modifier is implemented by subclassing the class sigma.parser.modifiers.base.SigmaTransformModifier. Further, the mixin ListOrStringModifierMixin from the module sigma.parser.modifiers.mixins is useful for most modifiers because it simplifies the handling of value lists if it doesn't differs from handling of plain string values. But let first start with the other declarations in the header of the class.

The property identifier is the name used in Sigma rules after the pipe characters to apply the modifier to a value (here: fieldname|base64). Further, the modifier base classes check the types of the value against the list valid_input_types. The implementation of the base64 modifier inherits this from ListOrStringModifierMixin which is declared as follows:

    valid_input_types = (list, tuple, str, )

This means that it accepts lists and tuples as well as plain strings. The type checking can also be customized by overriding the validate method if more than a simple comparison against a list is required, but this is a more advanced topic that is not in the scope of this post.

After this, the tranformation logic begins in the apply_str method that receives the value that should be transformed as parameter. It's code is not special to Sigma or value modifiers, it's just the way you would handle Base64 encoding of strings in Python and the result is returned to the caller. Because of the usage of the ListOrStringModifierMixin you don't need to care about Sigma rules with lists of values that should be encoded. The method apply_str is called for each list item or once if there's only a plain value.

Behind the scenes, the value to be tranformed is passed to the constructor on initialization of the modifier class instance. By default, the constructor just performs the type validation and stores the value into the value property of the object instance (SigmaModifier class in sigma.parser.modifiers.base):

    def __init__(self, value):
        """Initialize modifier class. Store value or result of value transformation."""
        self.value = value
        if not self.validate():
            raise SigmaModifierValueTypeError

After the initialization, the apply method is called. As the value may also be a modifier class (remember: value modifiers can be chained), it is a good idea to attempt to call the apply method on the value and use the returned value if it works, as it's done by the implementation in the SigmaModifier base class. The apply implementation of the ListOrStringModifierMixin checks the type and dispatches between calls to apply_str if the value is a plain string and apply_list that iterates apply_str over the list.

Modifiers must not necessarily return plain values or lists. They can also return logic trees consisting of condition classes from sigma.parser.condition module. The modifier SigmaAllValuesModifier utilizes this to override the Sigma default behavior of ORing list items instead of the default AND linking. But that's enough for this blog post.

Types

Type modifiers have the same possibilities as transformations. It is totally valid to conduct transformations on the value passed to the modifier. By default the value is simply passed to the caller. The main purpose of type modifiers is to signalize to the backend, that this value requires some special handling. Therefore, the SigmaRegularExpressionModifier implementation is rather simple and contains no further logic.

The important part of the modifier is implemented in the backends. If a type modifier is encountered, the generateTypedValueNode of the class is called with the modifier instance and must be handled accordingly by returning a query part that makes sense or store something for later processing. Type modifiers can be handled more conveniently with the SingleTextQueryBackend base class. Here, the implementation of the generateTypedValueNode looks up the typedValueExpression class property that is a dictionary of type class keys which map to string templates with exactly one string placeholder that is filled with the value returned by the apply method of the modifier class. The ElasticsearchQuerystringBackend is a good example. This declaration implements regular expressions for Elasticsearch query strings:

    typedValueExpression = {
                SigmaRegularExpressionModifier: "/%s/"
            }

That's it, no code needed, just declarations!