Writing Custom Rules with Rego
Overview
Open Policy Agent (OPA) provides Rego, an open source policy language with versatile resource inspection features for determining cloud security posture. In Datadog, you can write custom rules with Rego to control the security of your infrastructure.
The template module
Defining a rule starts with a Rego policy, defined inside a module. CSM Misconfigurations uses a module template like the one below to simplify writing rules:
package datadog
import data.datadog.output as dd_output
import future.keywords.contains
import future.keywords.if
import future.keywords.in
eval(resource_type) = "skip" if {
# Logic that evaluates to true if the resource should be skipped
} else = "pass" {
# Logic that evaluates to true if the resource is compliant
} else = "fail" {
# Logic that evaluates to true if the resource is not compliant
}
# This part remains unchanged for all rules
results contains result if {
some resource in input.resources[input.main_resource_type]
result := dd_output.format(resource, eval(resource))
}
Take a close look at each part of this module to understand how it works.
Import statements
The first line contains the declaration package datadog
. A package groups Rego modules into a single namespace, allowing modules to be imported safely. Currently, importing user modules is not a feature of custom rules. All posture management rules are grouped under the datadog
namespace. For your results to be returned properly, group your rules under the package datadog
namespace.
import future.keywords.contains
import future.keywords.if
import future.keywords.in
The next three statements import the OPA-provided keywords contains
, if
, and in
. These keywords allow defining rules with more expressive syntax to improve readability. Note: Importing all keywords with import future.keywords
is not recommended.
import data.datadog.output as dd_output
The next line imports the Datadog helper method, which formats your results to the specifications of the Datadog posture management system. datadog.output
is a Rego module with a format method that expects your resource as the first argument, and a string, pass
, fail
, or skip
as the second argument, describing the outcome of the inspection of your resource.
Rules
After the import statements comes the first rule in the template module:
eval(resource) = "skip" if {
resource.skip_me
} else = "pass" {
resource.should_pass
} else = "fail" {
true
}
The rule evaluates the resource, and provides the outcome as a string depending on the state of the resource. You can change the order of pass
, fail
, and skip
according to your needs. The rule above has fail
as a default, if skip_me
and should_pass
are false or nonexistent in your resource. Alternatively, you can make pass
the default:
eval(resource) = "skip" if {
resource.skip_me
} else = "fail" {
resource.should_fail
} else = "pass" {
true
}
Results
The final section of the template module builds your set of results:
# This part remains unchanged for all rules
results contains result if {
some resource in input.resources[input.main_resource_type]
result := dd_output.format(resource, eval(resource))
}
This section passes through all resources from the main resource type and evaluates them. It creates an array of results to be processed by the posture management system. The some keyword declares the local variable resource
, which comes from the array of main resources. The eval
rule is executed on every resource, returning a pass
, fail
, or skip
. The dd_output.format
rule formats the resource and evaluation correctly to be processed by cloud security.
This section of the policy does not need to be modified. Instead, when you select your main resource type in the Choose your main resource type dropdown when cloning rules, it is inserted in this section of the policy. You can also access the array of your resources through input.resources.some_resource_type
, replacing some_resource_type
with the main resource type that you chose, for example, gcp_iam_policy
.
Other ways to write rules
The template helps you start writing custom rules. You aren’t required to follow it. You can instead clone an existing default rule, or you write your own rule from scratch. However, for the posture management system to interpret your results, they must be called results
in your Rego module and be formatted as follows:
[
{
"result": "pass" OR "fail" OR "skip",
"resource_id": "some_resource_id",
"resource_type": "some_resource_type"
}
]
More complex rules
The above rule example evaluates basic true or false flags like should_pass
in your resource. Consider a rule that expresses a logical OR
, for example:
bad_port_range(resource) {
resource.port >= 100
resource.port <= 200
} else {
resource.port >= 300
resource.port <= 400
}
This rule evaluates to true if the port
is between 100
and 200
, or between 300
and 400
, inclusive. For this, you can define your eval
rule as follows:
eval(resource) = "skip" if {
not resource.port
} else = "fail" {
bad_port_range(resource)
} else = "pass" {
true
}
This skips the resource if it has no port
attribute, and fails it if it falls within one of the two “bad” ranges.
Sometimes you want to examine more than one resource type in your rule. To do this, you can select some related resource types in the dropdown under Advanced Rule Options. You can then access the arrays of related resources through input.resources.related_resource_type
, replacing related_resource_type
with whatever related resource you would like to access.
When writing a policy for more than one resource type, it can be time consuming to loop through all instances of a related resource type for each main resource. Take the following example:
eval(iam_service_account) = "fail" if {
some key in input.resources.gcp_iam_service_account_key
key.parent == iam_service_account.resource_name
key.key_type == "USER_MANAGED"
} else = "pass" {
true
}
# This part remains unchanged for all rules
results contains result if {
some resource in input.resources[input.main_resource_type]
result := dd_output.format(resource, eval(resource))
}
This rule determines whether there are any instances of gcp_iam_service_account_key
that are user managed and match to a gcp_iam_service_account
(the resource selected as the main resource type). If the service account has a key that is user managed, it produces a fail
result. The eval
rule is executed on every service account, and loops through every service account key to find one that matches the account, resulting in a complexity of O(MxN)
, where M is the number of service accounts and N is the number of service account keys.
To improve the time complexity significantly, build a set of key parents that are user managed with a set comprehension:
user_managed_keys_parents := {key_parent |
some key in input.resources.gcp_iam_service_account_key
key.key_type == "USER_MANAGED"
key_parent = key.parent
}
To find out if your service account has a user managed key, query the set in O(1)
time:
eval(iam_service_account) = "fail" if {
user_managed_keys_parents[iam_service_account.resource_name]
} else = "pass" {
true
}
The new time complexity is O(M+N)
. Rego provides set, object, and array comprehensions to help you build composite values to query.
Find out more
Read the Rego documentation for more context around rules, modules, packages, comprehensions, and for specific guidance around writing custom rules.
Further reading
Additional helpful documentation, links, and articles: