Examples of IaC custom rules

Example of a simple boolean rule

You can find a full example of this guide in this OPA Playground and the snyk/custom-rules-example repository.

Assume you have generated a new rule, CUSTOM-RULE-1 using the SDK , that is, snyk-iac-rules template --rule CUSTOM-RULE-1 and have a very simple fixture file containing a Terraform resource:

rules/CUSTOM-RULE-1/fixtures/denied.tf
resource "aws_redshift_cluster" "denied" {
  cluster_identifier = "tf-redshift-cluster"
  node_type          = "dc1.large"
  tags = {
  }
}

Now, modify the generated Rego to enforce resources tagged with an owner:

  1. Create a variable [name] to enumerate across all of the aws_redshift_cluster resources. This variable can be named anything you like, for example, i, j, name, and so on.

  2. Store this into the resource variable by assigning the value to it with a walrus operator :=; e.g. resource := input.resource.aws_redshift_cluster[name]

  3. Check whether the owner tag exists for each resource; to do that, check if the path resource.tags.owner is defined. If it is undefined, it will evaluate as undefined. So, use the NOT keyword in front of it, which will evaluate to TRUE; for example,not resource.tags.owner

The modified Rego is:

rules/CUSTOM-RULE-1/main.rego
package rules

deny[msg] {
    resource := input.resource.aws_redshift_cluster[name]
    not resource.tags.owner

    msg := {
        "publicId": "CUSTOM-RULE-1",
        "title": "Missing an owner from tag",
        "severity": "medium",
        "msg": sprintf("input.resource.aws_redshift_cluster[%s].tags", [name]),
        "issue": "",
        "impact": "",
        "remediation": "",
        "references": [],
    }
}

To understand how the Rego code evaluates the Terraform file provided earlier, have a look at how the SDK is able to parse a fixture file into JSON.

Snyk recommends always validating that your rule is correct by updating and running the unit tests.

The test for this rule verifies that the Rego rule can identify that the fixture at the beginning of this guide is invalid:

rules/CUSTOM-RULE-1/main_test.rego
package rules

import data.lib
import data.lib.testing

test_CUSTOM_RULE_1 {
	# array containing test cases where the rule is allowed
	allowed_test_cases := [{
		"want_msgs": [],
		"fixture": "allowed.tf",
	}]

	# array containing cases where the rule is denied
	denied_test_cases := [{
		"want_msgs": ["input.resource.aws_redshift_cluster[denied].tags"],
		"fixture": "denied.tf",
	}]

	test_cases := array.concat(allowed_test_cases, denied_test_cases)
	testing.evaluate_test_cases("CUSTOM-RULE-1", "./rules/CUSTOM-RULE-1/fixtures", test_cases)
}

Example with logical AND

Try and extend the preceding example and update the rule to allow all cases that satisfy two conditions:

  1. A resource has an “owner” tag AND

  2. A resource has a “description” tag

To test this new condition, generate a new rule CUSTOM-RULE-2 using the template command and write the following fixture file:

rules/CUSTOM-RULE-2/fixtures/denied.tf
resource "aws_redshift_cluster" "denied" {
  cluster_identifier = "tf-redshift-cluster"
  node_type          = "dc1.large"
  tags = {
    owner = "team-123"
  }
}

Joining multiple expressions together expresses logical AND.

  • You can do this with the ; operator.

  • Or, you can omit the ; (AND) operator by splitting expressions across multiple lines.

The logical AND is covered also in the OPA documentation.

rules/CUSTOM-RULE-2/main.rego
package rules

aws_redshift_cluster_tags_present(resource) {
    resource.tags.owner
    resource.tags.description
}

deny[msg] {
    resource := input.resource.aws_redshift_cluster[name]
    not aws_redshift_cluster_tags_present(resource)
    msg := {
        "publicId": "CUSTOM-RULE-2",
        "title": "Missing a description and an owner from the tag",
        "severity": "medium",
        "msg": sprintf("input.resource.aws_redshift_cluster[%s].tags", [name]),
        "issue": "",
        "impact": "",
        "remediation": "",
        "references": [],
    }
}

Snyk recommends always validating that your rule is correct by updating and running the unit tests.

The test for this rule will look the same as the one for CUSTOM-RULE-1, but the name of the test and the first two arguments passed to the testing.evaluate_test_cases function will differ:

rules/CUSTOM-RULE-2/main_test.rego
package rules

import data.lib
import data.lib.testing

test_CUSTOM_RULE_2 {
	# array containing test cases where the rule is allowed
	allowed_test_cases := [{
		"want_msgs": [],
		"fixture": "allowed.tf",
	}]
	# array containing cases where the rule is denied
	denied_test_cases := [{
		"want_msgs": ["input.resource.aws_redshift_cluster[denied].tags"],
		"fixture": "denied.tf",
	}]
	test_cases := array.concat(allowed_test_cases, denied_test_cases)
	testing.evaluate_test_cases("CUSTOM-RULE-2", "./rules/CUSTOM-RULE-2/fixtures", test_cases)
}

Example with logical OR

You can also rewrite the rule above by combining the NOT operator with the OR functionality.

Update the example in a new rule CUSTOM-RULE-3, to deny all cases that fail either of the two conditions, to deny all aws_redshift_cluster resources that are missing either:

  1. an “owner” tag , OR

  2. A “description” tag

For this, use two new fixture files, one for each case:

rules/CUSTOM-RULE-3/fixtures/denied1.tf
resource "aws_redshift_cluster" "denied1" {
  cluster_identifier = "tf-redshift-cluster"
  node_type          = "dc1.large"
  tags = {
    owner = "team-123@corp-domain.com"
  }
}
rules/CUSTOM-RULE-3/fixtures/denied2.tg
resource "aws_redshift_cluster" "denied2" {
  cluster_identifier = "tf-redshift-cluster"
  node_type          = "dc1.large"
  tags = {
    description = "description",
  }
}

To express logical OR in Rego, define multiple rules or functions with the same name. This is also described in the OPA documentation for Logical OR.

First, add a function that will implement the NOT for each tag. Then, call this function with the resource:

rules/CUSTOM-RULE-3/main.rego
package rules

aws_redshift_cluster_tags_missing(resource) {
    not resource.tags.owner
}

aws_redshift_cluster_tags_missing(resource) {
    not resource.tags.description
}

deny[msg] {
    resource := input.resource.aws_redshift_cluster[name]
    aws_redshift_cluster_tags_missing(resource)
    msg := {
        "publicId": "CUSTOM-RULE-3",
        "title": "Missing a description or an owner from the tag",
        "severity": "medium",
        "msg": sprintf("input.resource.aws_redshift_cluster[%s].tags", [name]),
        "issue": "",
        "impact": "",
        "remediation": "",
        "references": [],
    }
}

This will successfully return all the rules that deny.

Snyk recommends always validating that your rule is correct by updating and running the unit tests.

The test for this rule will now contain multiple test cases, to show that the logical OR works as expected:

rules/CUSTOM-RULE-3/main_test.rego
package rules

import data.lib
import data.lib.testing

test_CUSTOM_RULE_3 {
	# array containing test cases where the rule is allowed
	allowed_test_cases := [{
		"want_msgs": [],
		"fixture": "allowed.tf",
	}]
	# array containing cases where the rule is denied
	denied_test_cases := [{
		"want_msgs": ["input.resource.aws_redshift_cluster[denied1].tags"],
		"fixture": "denied1.tf",
	},{
		"want_msgs": ["input.resource.aws_redshift_cluster[denied2].tags"],
		"fixture": "denied2.tf",
	}]
	test_cases := array.concat(allowed_test_cases, denied_test_cases)
	testing.evaluate_test_cases("CUSTOM-RULE-3", "./rules/CUSTOM-RULE-3/fixtures", test_cases)
}