Skip to content

Commit 6c782e8

Browse files
authored
Add parse_config and parse_config_file builtins (open-policy-agent#726)
* 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 <[email protected]> * Add parse_config_file(path) builtin Enables users to parse arbitrary config files from the Rego policies. Signed-off-by: James Alseth <[email protected]>
1 parent b671542 commit 6c782e8

File tree

8 files changed

+344
-13
lines changed

8 files changed

+344
-13
lines changed

acceptance.bats

+6-1
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,11 @@
258258
[ "$status" -eq 1 ]
259259
}
260260

261+
@test "Can verify unit tests using parse_config() and parse_config_file builtins()" {
262+
run ./conftest verify -p examples/hcl2/policy examples/hcl2
263+
[ "$status" -eq 0 ]
264+
}
265+
261266
@test "Can combine configs and reference by file" {
262267
run ./conftest test -p examples/hcl1/policy/gke_combine.rego examples/hcl1/gke.tf --combine --parser hcl1 --all-namespaces
263268
[ "$status" -eq 0 ]
@@ -433,4 +438,4 @@
433438
[ "$status" -eq 1 ]
434439
[[ "$output" =~ "undefined function opa.runtime" ]]
435440
[[ "$output" =~ "undefined function http.send" ]]
436-
}
441+
}

builtins/parse_config.go

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package builtins
2+
3+
import (
4+
"fmt"
5+
"io/ioutil"
6+
"path/filepath"
7+
8+
"github.com/open-policy-agent/conftest/parser"
9+
"github.com/open-policy-agent/opa/ast"
10+
"github.com/open-policy-agent/opa/ast/location"
11+
"github.com/open-policy-agent/opa/rego"
12+
"github.com/open-policy-agent/opa/types"
13+
)
14+
15+
func init() {
16+
registerParseConfig()
17+
registerParseConfigFile()
18+
}
19+
20+
func registerParseConfig() {
21+
decl := rego.Function{
22+
Name: "parse_config",
23+
Decl: types.NewFunction(
24+
types.Args(types.S, types.S), // parser name, configuration
25+
types.NewObject(nil, types.NewDynamicProperty(types.S, types.NewAny())), // map[string]interface{} aka JSON
26+
),
27+
}
28+
rego.RegisterBuiltin2(&decl, parseConfig)
29+
}
30+
31+
func registerParseConfigFile() {
32+
decl := rego.Function{
33+
Name: "parse_config_file",
34+
Decl: types.NewFunction(
35+
types.Args(types.S), // path to configuration file
36+
types.NewObject(nil, types.NewDynamicProperty(types.S, types.NewAny())), // map[string]interface{} aka JSON
37+
),
38+
}
39+
rego.RegisterBuiltin1(&decl, parseConfigFile)
40+
}
41+
42+
// parseConfig takes a parser name and configuration as strings and returns the
43+
// parsed configuration as a Rego object. This can be used to parse all of the
44+
// configuration formats conftest supports in-line in Rego policies.
45+
func parseConfig(bctx rego.BuiltinContext, op1, op2 *ast.Term) (*ast.Term, error) {
46+
args, err := decodeArgs([]*ast.Term{op1, op2})
47+
if err != nil {
48+
return nil, fmt.Errorf("decode args: %w", err)
49+
}
50+
parserName, ok := args[0].(string)
51+
if !ok {
52+
return nil, fmt.Errorf("parser name %v [%T] is not expected type string", args[0], args[0])
53+
}
54+
config, ok := args[1].(string)
55+
if !ok {
56+
return nil, fmt.Errorf("config %v [%T] is not expected type string", args[1], args[1])
57+
}
58+
parser, err := parser.New(parserName)
59+
if err != nil {
60+
return nil, fmt.Errorf("create config parser: %w", err)
61+
}
62+
63+
return toAST(bctx, parser, []byte(config))
64+
}
65+
66+
// parseConfigFile takes a config file path, parses the config file, and
67+
// returns the parsed configuration as a Rego object.
68+
func parseConfigFile(bctx rego.BuiltinContext, op1 *ast.Term) (*ast.Term, error) {
69+
args, err := decodeArgs([]*ast.Term{op1})
70+
if err != nil {
71+
return nil, fmt.Errorf("decode args: %w", err)
72+
}
73+
file, ok := args[0].(string)
74+
if !ok {
75+
return nil, fmt.Errorf("file %v [%T] is not expected type string", args[0], args[0])
76+
}
77+
filePath := filepath.Join(filepath.Dir(bctx.Location.File), file)
78+
parser, err := parser.NewFromPath(filePath)
79+
if err != nil {
80+
return nil, fmt.Errorf("create config parser: %w", err)
81+
}
82+
contents, err := ioutil.ReadFile(filePath)
83+
if err != nil {
84+
return nil, fmt.Errorf("read config file %s: %w", filePath, err)
85+
}
86+
87+
return toAST(bctx, parser, contents)
88+
}
89+
90+
func decodeArgs(args []*ast.Term) ([]interface{}, error) {
91+
decoded := make([]interface{}, len(args))
92+
for i, arg := range args {
93+
iface, err := ast.ValueToInterface(arg.Value, nil)
94+
if err != nil {
95+
return nil, fmt.Errorf("ast.ValueToInterface: %w", err)
96+
}
97+
decoded[i] = iface
98+
}
99+
100+
return decoded, nil
101+
}
102+
103+
func toAST(bctx rego.BuiltinContext, parser parser.Parser, contents []byte) (*ast.Term, error) {
104+
var cfg map[string]interface{}
105+
if err := parser.Unmarshal(contents, &cfg); err != nil {
106+
return nil, fmt.Errorf("unmarshal config: %w", err)
107+
}
108+
val, err := ast.InterfaceToValue(cfg)
109+
if err != nil {
110+
return nil, fmt.Errorf("convert config to ast.Value: %w", err)
111+
}
112+
var loc *location.Location
113+
if bctx.Location != nil {
114+
loc = bctx.Location
115+
} else {
116+
loc = &ast.Location{
117+
File: "-", // stdin
118+
Text: contents,
119+
}
120+
}
121+
122+
return &ast.Term{Value: val, Location: loc}, nil
123+
}

builtins/parse_config_test.go

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package builtins
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/open-policy-agent/conftest/parser"
9+
"github.com/open-policy-agent/opa/ast"
10+
"github.com/open-policy-agent/opa/rego"
11+
)
12+
13+
func TestParseConfig(t *testing.T) {
14+
testCases := []struct {
15+
desc string
16+
parser string
17+
config string
18+
wantErrMsg string
19+
}{
20+
{
21+
desc: "No parser supplied",
22+
wantErrMsg: "create config parser",
23+
},
24+
{
25+
desc: "Invalid parser supplied",
26+
parser: "no-such-parser",
27+
wantErrMsg: "create config parser",
28+
},
29+
{
30+
desc: "Invalid YAML",
31+
parser: parser.YAML,
32+
config: "```NOTVALID!",
33+
wantErrMsg: "unmarshal config",
34+
},
35+
{
36+
desc: "Empty YAML",
37+
parser: parser.YAML,
38+
},
39+
{
40+
desc: "Valid YAML",
41+
parser: parser.YAML,
42+
config: `some_field: some_value
43+
another_field:
44+
- arr1
45+
- arr2`,
46+
},
47+
}
48+
49+
for _, tc := range testCases {
50+
t.Run(tc.desc, func(t *testing.T) {
51+
pv, err := ast.InterfaceToValue(tc.parser)
52+
if err != nil {
53+
t.Fatalf("Could not convert parser %q to ast.Value: %v", tc.parser, err)
54+
}
55+
cv, err := ast.InterfaceToValue(tc.config)
56+
if err != nil {
57+
t.Fatalf("Could not convert config %q to ast.Value: %v", tc.config, err)
58+
}
59+
60+
bctx := rego.BuiltinContext{Context: context.Background()}
61+
_, err = parseConfig(bctx, ast.NewTerm(pv), ast.NewTerm(cv))
62+
if err == nil && tc.wantErrMsg == "" {
63+
return
64+
}
65+
if err != nil && tc.wantErrMsg == "" {
66+
t.Errorf("Error was returned when no error was expected: %v", err)
67+
return
68+
}
69+
if !strings.Contains(err.Error(), tc.wantErrMsg) {
70+
t.Errorf("Error %q does not contain expected string %q", err.Error(), tc.wantErrMsg)
71+
return
72+
}
73+
})
74+
}
75+
}

docs/index.md

+116-2
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ As of today Conftest supports:
8585

8686
### Testing/Verifying Policies
8787

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

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

9999
Further documentation can be found using `conftest verify -h`
100+
101+
#### Writing Unit Tests
102+
103+
When writing unit tests, it is common to use the `with` keyword to override the
104+
`input` and `data` documents. For example:
105+
106+
```rego
107+
test_foo {
108+
input := {
109+
"abc": 123,
110+
"foo": ["bar", "baz"],
111+
}
112+
deny with input as input
113+
}
114+
```
115+
116+
However, it can be burdensome to craft the `input` values by hand when the
117+
configurations you are testing are of different formats, especially when they
118+
can be dynamic and their source does not closely align to key-value objects
119+
like Rego requires. A common example is Hashicorp Configuration Language (HCL)
120+
used by Terraform and other products.
121+
122+
To alleviate this issue, conftest provides a builtin function `parse_config`
123+
which takes the parser type and configuration as arguments and parses the
124+
configuration for use in Rego polciies. This is the same logic that conftest
125+
uses when testing configurations, only exposed as a Rego function. The example
126+
below shows how to use this to parse an AWS Terraform configuration and use it
127+
in a unit test.
128+
129+
**deny.rego**
130+
131+
```rego
132+
deny[msg] {
133+
proto := input.resource.aws_alb_listener[lb].protocol
134+
proto == "HTTP"
135+
msg = sprintf("ALB `%v` is using HTTP rather than HTTPS", [lb])
136+
}
137+
```
138+
139+
**deny_test.rego**
140+
141+
```rego
142+
test_deny_alb_http {
143+
cfg := parse_config("hcl2", `
144+
resource "aws_alb_listener" "lb_with_http" {
145+
protocol = "HTTP"
146+
}
147+
`)
148+
deny with input as cfg
149+
}
150+
151+
test_deny_alb_https {
152+
cfg := parse_config("hcl2", `
153+
resource "aws_alb_listener" "lb_with_https" {
154+
protocol = "HTTPS"
155+
}
156+
`)
157+
not deny with input as cfg
158+
}
159+
160+
test_deny_alb_protocol_unspecified {
161+
cfg := parse_config("hcl2", `
162+
resource "aws_alb_listener" "lb_with_unspecified_protocol" {
163+
foo = "bar"
164+
}
165+
`)
166+
not deny with input as cfg
167+
}
168+
```
169+
170+
For the full list of supported parsers and their names, please refer to the
171+
constants [defined in the parser package](https://github.com/open-policy-agent/conftest/blob/master/parser/parser.go).
172+
173+
If you prefer to have your configuration snippets outside of the Rego unit test
174+
(for syntax highlighting, etc.) you can use the `parse_config_file` builtin. It
175+
accepts the path to the config file as its only parameter and returns the
176+
parsed configuration as a Rego object. The example below shows denying Azure
177+
disks with encryption disabled.
178+
179+
> **:information_source: NOTE:** The file path argument is relative to the
180+
> location of the Rego unit test file.
181+
182+
> **:information_source: NOTE:** Using this function performs disk I/O which
183+
> can significantly slow down tests.
184+
185+
**deny.rego**
186+
187+
```rego
188+
deny[msg] {
189+
disk = input.resource.azurerm_managed_disk[name]
190+
has_field(disk, "encryption_settings")
191+
disk.encryption_settings.enabled != true
192+
msg = sprintf("Azure disk `%v` is not encrypted", [name])
193+
}
194+
```
195+
196+
**deny_test.rego**
197+
198+
```rego
199+
test_unencrypted_azure_disk {
200+
cfg := parse_config_file("unencrypted_azure_disk.tf")
201+
deny with input as cfg
202+
}
203+
```
204+
205+
**unencrypted_azure_disk.tf**
206+
207+
```hcl
208+
resource "azurerm_managed_disk" "sample" {
209+
encryption_settings {
210+
enabled = false
211+
}
212+
}
213+
```

examples/hcl2/policy/deny_test.rego

+8-2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ test_correctly_encrypted_azure_disk {
1717
}
1818

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

2324
test_fails_with_http_alb {
24-
deny["ALB `name` is using HTTP rather than HTTPS"] with input as {"resource": {"aws_alb_listener": {"name": {"protocol": "HTTP"}}}}
25+
cfg := parse_config("hcl2", `
26+
resource "aws_alb_listener" "name" {
27+
protocol = "HTTP"
28+
}
29+
`)
30+
deny["ALB `name` is using HTTP rather than HTTPS"] with input as cfg
2531
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
resource "azurerm_managed_disk" "sample" {
2+
encryption_settings {
3+
enabled = false
4+
}
5+
}

examples/hcl2/terraform.tf

+8-8
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
resource "aws_security_group_rule" "my-rule" {
2-
type = "ingress"
3-
cidr_blocks = ["0.0.0.0/0"]
2+
type = "ingress"
3+
cidr_blocks = ["0.0.0.0/0"]
44
}
55

6-
resource "aws_alb_listener" "my-alb-listener"{
7-
port = "80"
8-
protocol = "HTTP"
6+
resource "aws_alb_listener" "my-alb-listener" {
7+
port = "80"
8+
protocol = "HTTP"
99
}
1010

1111
resource "aws_db_security_group" "my-group" {
1212

1313
}
1414

1515
resource "azurerm_managed_disk" "source" {
16-
encryption_settings {
17-
enabled = false
18-
}
16+
encryption_settings {
17+
enabled = false
18+
}
1919
}

0 commit comments

Comments
 (0)