Skip to content

Commit

Permalink
Add parse_config and parse_config_file builtins (#726)
Browse files Browse the repository at this point in the history
* Add parse_config(parser, config) builtin

This builtin can be used to parse configuration snippets in-line in Rego policies,
making it easier to write unit tests in the same configuration language that will
be tested.

Signed-off-by: James Alseth <james@jalseth.me>

* Add parse_config_file(path) builtin

Enables users to parse arbitrary config files from the Rego policies.

Signed-off-by: James Alseth <james@jalseth.me>
  • Loading branch information
jalseth committed Jul 26, 2022
1 parent b671542 commit 6c782e8
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 13 deletions.
7 changes: 6 additions & 1 deletion acceptance.bats
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@
[ "$status" -eq 1 ]
}

@test "Can verify unit tests using parse_config() and parse_config_file builtins()" {
run ./conftest verify -p examples/hcl2/policy examples/hcl2
[ "$status" -eq 0 ]
}

@test "Can combine configs and reference by file" {
run ./conftest test -p examples/hcl1/policy/gke_combine.rego examples/hcl1/gke.tf --combine --parser hcl1 --all-namespaces
[ "$status" -eq 0 ]
Expand Down Expand Up @@ -433,4 +438,4 @@
[ "$status" -eq 1 ]
[[ "$output" =~ "undefined function opa.runtime" ]]
[[ "$output" =~ "undefined function http.send" ]]
}
}
123 changes: 123 additions & 0 deletions builtins/parse_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package builtins

import (
"fmt"
"io/ioutil"
"path/filepath"

"github.com/open-policy-agent/conftest/parser"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/ast/location"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/types"
)

func init() {
registerParseConfig()
registerParseConfigFile()
}

func registerParseConfig() {
decl := rego.Function{
Name: "parse_config",
Decl: types.NewFunction(
types.Args(types.S, types.S), // parser name, configuration
types.NewObject(nil, types.NewDynamicProperty(types.S, types.NewAny())), // map[string]interface{} aka JSON
),
}
rego.RegisterBuiltin2(&decl, parseConfig)
}

func registerParseConfigFile() {
decl := rego.Function{
Name: "parse_config_file",
Decl: types.NewFunction(
types.Args(types.S), // path to configuration file
types.NewObject(nil, types.NewDynamicProperty(types.S, types.NewAny())), // map[string]interface{} aka JSON
),
}
rego.RegisterBuiltin1(&decl, parseConfigFile)
}

// parseConfig takes a parser name and configuration as strings and returns the
// parsed configuration as a Rego object. This can be used to parse all of the
// configuration formats conftest supports in-line in Rego policies.
func parseConfig(bctx rego.BuiltinContext, op1, op2 *ast.Term) (*ast.Term, error) {
args, err := decodeArgs([]*ast.Term{op1, op2})
if err != nil {
return nil, fmt.Errorf("decode args: %w", err)
}
parserName, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("parser name %v [%T] is not expected type string", args[0], args[0])
}
config, ok := args[1].(string)
if !ok {
return nil, fmt.Errorf("config %v [%T] is not expected type string", args[1], args[1])
}
parser, err := parser.New(parserName)
if err != nil {
return nil, fmt.Errorf("create config parser: %w", err)
}

return toAST(bctx, parser, []byte(config))
}

// parseConfigFile takes a config file path, parses the config file, and
// returns the parsed configuration as a Rego object.
func parseConfigFile(bctx rego.BuiltinContext, op1 *ast.Term) (*ast.Term, error) {
args, err := decodeArgs([]*ast.Term{op1})
if err != nil {
return nil, fmt.Errorf("decode args: %w", err)
}
file, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("file %v [%T] is not expected type string", args[0], args[0])
}
filePath := filepath.Join(filepath.Dir(bctx.Location.File), file)
parser, err := parser.NewFromPath(filePath)
if err != nil {
return nil, fmt.Errorf("create config parser: %w", err)
}
contents, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("read config file %s: %w", filePath, err)
}

return toAST(bctx, parser, contents)
}

func decodeArgs(args []*ast.Term) ([]interface{}, error) {
decoded := make([]interface{}, len(args))
for i, arg := range args {
iface, err := ast.ValueToInterface(arg.Value, nil)
if err != nil {
return nil, fmt.Errorf("ast.ValueToInterface: %w", err)
}
decoded[i] = iface
}

return decoded, nil
}

func toAST(bctx rego.BuiltinContext, parser parser.Parser, contents []byte) (*ast.Term, error) {
var cfg map[string]interface{}
if err := parser.Unmarshal(contents, &cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
val, err := ast.InterfaceToValue(cfg)
if err != nil {
return nil, fmt.Errorf("convert config to ast.Value: %w", err)
}
var loc *location.Location
if bctx.Location != nil {
loc = bctx.Location
} else {
loc = &ast.Location{
File: "-", // stdin
Text: contents,
}
}

return &ast.Term{Value: val, Location: loc}, nil
}
75 changes: 75 additions & 0 deletions builtins/parse_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package builtins

import (
"context"
"strings"
"testing"

"github.com/open-policy-agent/conftest/parser"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
)

func TestParseConfig(t *testing.T) {
testCases := []struct {
desc string
parser string
config string
wantErrMsg string
}{
{
desc: "No parser supplied",
wantErrMsg: "create config parser",
},
{
desc: "Invalid parser supplied",
parser: "no-such-parser",
wantErrMsg: "create config parser",
},
{
desc: "Invalid YAML",
parser: parser.YAML,
config: "```NOTVALID!",
wantErrMsg: "unmarshal config",
},
{
desc: "Empty YAML",
parser: parser.YAML,
},
{
desc: "Valid YAML",
parser: parser.YAML,
config: `some_field: some_value
another_field:
- arr1
- arr2`,
},
}

for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
pv, err := ast.InterfaceToValue(tc.parser)
if err != nil {
t.Fatalf("Could not convert parser %q to ast.Value: %v", tc.parser, err)
}
cv, err := ast.InterfaceToValue(tc.config)
if err != nil {
t.Fatalf("Could not convert config %q to ast.Value: %v", tc.config, err)
}

bctx := rego.BuiltinContext{Context: context.Background()}
_, err = parseConfig(bctx, ast.NewTerm(pv), ast.NewTerm(cv))
if err == nil && tc.wantErrMsg == "" {
return
}
if err != nil && tc.wantErrMsg == "" {
t.Errorf("Error was returned when no error was expected: %v", err)
return
}
if !strings.Contains(err.Error(), tc.wantErrMsg) {
t.Errorf("Error %q does not contain expected string %q", err.Error(), tc.wantErrMsg)
return
}
})
}
}
118 changes: 116 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ As of today Conftest supports:

### Testing/Verifying Policies

When authoring policies, it is helpful to test them. Consult the Rego testing documentation at
https://www.openpolicyagent.org/docs/latest/policy-testing/ for details on testing syntax and approach.
When authoring policies, it is helpful to test them. Consult the Rego [testing documentation](https://www.openpolicyagent.org/docs/latest/policy-testing)
for details on testing syntax and approach.

Following the example above, with a policy file in `policy/deployment.rego`, you would create your
tests in `policy/deployment_test.rego` by convention. You can then use `conftest verify` to execute
Expand All @@ -97,3 +97,117 @@ conftest verify --policy ./policy
```

Further documentation can be found using `conftest verify -h`

#### Writing Unit Tests

When writing unit tests, it is common to use the `with` keyword to override the
`input` and `data` documents. For example:

```rego
test_foo {
input := {
"abc": 123,
"foo": ["bar", "baz"],
}
deny with input as input
}
```

However, it can be burdensome to craft the `input` values by hand when the
configurations you are testing are of different formats, especially when they
can be dynamic and their source does not closely align to key-value objects
like Rego requires. A common example is Hashicorp Configuration Language (HCL)
used by Terraform and other products.

To alleviate this issue, conftest provides a builtin function `parse_config`
which takes the parser type and configuration as arguments and parses the
configuration for use in Rego polciies. This is the same logic that conftest
uses when testing configurations, only exposed as a Rego function. The example
below shows how to use this to parse an AWS Terraform configuration and use it
in a unit test.

**deny.rego**

```rego
deny[msg] {
proto := input.resource.aws_alb_listener[lb].protocol
proto == "HTTP"
msg = sprintf("ALB `%v` is using HTTP rather than HTTPS", [lb])
}
```

**deny_test.rego**

```rego
test_deny_alb_http {
cfg := parse_config("hcl2", `
resource "aws_alb_listener" "lb_with_http" {
protocol = "HTTP"
}
`)
deny with input as cfg
}
test_deny_alb_https {
cfg := parse_config("hcl2", `
resource "aws_alb_listener" "lb_with_https" {
protocol = "HTTPS"
}
`)
not deny with input as cfg
}
test_deny_alb_protocol_unspecified {
cfg := parse_config("hcl2", `
resource "aws_alb_listener" "lb_with_unspecified_protocol" {
foo = "bar"
}
`)
not deny with input as cfg
}
```

For the full list of supported parsers and their names, please refer to the
constants [defined in the parser package](https://github.com/open-policy-agent/conftest/blob/master/parser/parser.go).

If you prefer to have your configuration snippets outside of the Rego unit test
(for syntax highlighting, etc.) you can use the `parse_config_file` builtin. It
accepts the path to the config file as its only parameter and returns the
parsed configuration as a Rego object. The example below shows denying Azure
disks with encryption disabled.

> **:information_source: NOTE:** The file path argument is relative to the
> location of the Rego unit test file.
> **:information_source: NOTE:** Using this function performs disk I/O which
> can significantly slow down tests.
**deny.rego**

```rego
deny[msg] {
disk = input.resource.azurerm_managed_disk[name]
has_field(disk, "encryption_settings")
disk.encryption_settings.enabled != true
msg = sprintf("Azure disk `%v` is not encrypted", [name])
}
```

**deny_test.rego**

```rego
test_unencrypted_azure_disk {
cfg := parse_config_file("unencrypted_azure_disk.tf")
deny with input as cfg
}
```

**unencrypted_azure_disk.tf**

```hcl
resource "azurerm_managed_disk" "sample" {
encryption_settings {
enabled = false
}
}
```
10 changes: 8 additions & 2 deletions examples/hcl2/policy/deny_test.rego
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ test_correctly_encrypted_azure_disk {
}

test_unencrypted_azure_disk {
deny["Azure disk `sample` is not encrypted"] with input as {"resource": {"azurerm_managed_disk": {"sample": {"encryption_settings": {"enabled": false}}}}}
cfg := parse_config_file("unencrypted_azure_disk.tf")
deny["Azure disk `sample` is not encrypted"] with input as cfg
}

test_fails_with_http_alb {
deny["ALB `name` is using HTTP rather than HTTPS"] with input as {"resource": {"aws_alb_listener": {"name": {"protocol": "HTTP"}}}}
cfg := parse_config("hcl2", `
resource "aws_alb_listener" "name" {
protocol = "HTTP"
}
`)
deny["ALB `name` is using HTTP rather than HTTPS"] with input as cfg
}
5 changes: 5 additions & 0 deletions examples/hcl2/policy/unencrypted_azure_disk.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
resource "azurerm_managed_disk" "sample" {
encryption_settings {
enabled = false
}
}
16 changes: 8 additions & 8 deletions examples/hcl2/terraform.tf
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
resource "aws_security_group_rule" "my-rule" {
type = "ingress"
cidr_blocks = ["0.0.0.0/0"]
type = "ingress"
cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_alb_listener" "my-alb-listener"{
port = "80"
protocol = "HTTP"
resource "aws_alb_listener" "my-alb-listener" {
port = "80"
protocol = "HTTP"
}

resource "aws_db_security_group" "my-group" {

}

resource "azurerm_managed_disk" "source" {
encryption_settings {
enabled = false
}
encryption_settings {
enabled = false
}
}

0 comments on commit 6c782e8

Please sign in to comment.