Skip to content

Commit c432083

Browse files
committed
feat: add conftest policy for provider version pinning in modules (#203)
1 parent cae60ba commit c432083

File tree

11 files changed

+246
-20
lines changed

11 files changed

+246
-20
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,6 @@ modules/**/.terraform.lock.hcl
4949

5050
# ignore infracost resources
5151
.infracost/
52+
53+
# test fixtures
54+
conftest-policies/testdata/

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ repos:
66
- id: terraform_fmt
77
- id: terraform_docs
88
- id: terraform_validate
9-
exclude: '^[^/]+$|^modules/certificate/'
9+
exclude: '^[^/]+$|^modules/certificate/|^conftest-policies/testdata/'
1010
- id: terraform_tflint
1111
args:
1212
- "--args=--config=__GIT_WORKING_DIR__/.tflint.hcl"
1313
- "--args=--disable-rule=terraform_standard_module_structure"
1414
- "--args=--disable-rule=terraform_unused_required_providers"
15+
exclude: '^conftest-policies/testdata/'
1516
- id: terraform_checkov
1617
args:
1718
- --args=--quiet
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import rego.v1
4+
5+
test_dynamodb_unencrypted_denied if {
6+
result := deny_dynamodb_unencrypted with input as {"resource": {"aws_dynamodb_table": {"unencrypted": [{"server_side_encryption": false}]}}}
7+
count(result) == 1
8+
}
9+
10+
test_dynamodb_encrypted_allowed if {
11+
result := deny_dynamodb_unencrypted with input as {"resource": {"aws_dynamodb_table": {"encrypted": [{"server_side_encryption": true}]}}}
12+
count(result) == 0
13+
}
14+
15+
test_dynamodb_pitr_disabled_denied if {
16+
result := deny_dynamodb_pitr_disabled with input as {"resource": {"aws_dynamodb_table": {"no_pitr": [{"point_in_time_recovery": false}]}}}
17+
count(result) == 1
18+
}
19+
20+
test_dynamodb_pitr_enabled_allowed if {
21+
result := deny_dynamodb_pitr_disabled with input as {"resource": {"aws_dynamodb_table": {"with_pitr": [{"point_in_time_recovery": true}]}}}
22+
count(result) == 0
23+
}
24+
25+
test_dynamodb_replica_missing_warning if {
26+
result := deny_dynamodb_replica_missing with input as {"resource": {"aws_dynamodb_table": {"single_region": [{"billing_mode": "PAY_PER_REQUEST"}]}}}
27+
count(result) == 1
28+
}
29+
30+
test_dynamodb_with_replica_allowed if {
31+
result := deny_dynamodb_replica_missing with input as {"resource": {"aws_dynamodb_table": {"multi_region": [{"billing_mode": "PAY_PER_REQUEST", "replica": [{"region_name": "us-east-1"}]}]}}}
32+
count(result) == 0
33+
}
34+
35+
test_dynamodb_provisioned_without_replica_allowed if {
36+
result := deny_dynamodb_replica_missing with input as {"resource": {"aws_dynamodb_table": {"provisioned": [{"billing_mode": "PROVISIONED"}]}}}
37+
count(result) == 0
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package main
2+
3+
import rego.v1
4+
5+
deny_unpinned_provider_version contains msg if {
6+
some path
7+
some block in input.resource.terraform[path]
8+
some name, provider in block.required_providers
9+
version_constraint := object.get(provider, "version", "")
10+
version_constraint != ""
11+
startswith(version_constraint, ">=")
12+
not contains(version_constraint, "~>")
13+
not contains(version_constraint, "<")
14+
msg := sprintf(
15+
"%s/versions.tf: provider '%s' has loose version constraint '%s' - pin to specific version or use '~>' for minor version pinning",
16+
[path, name, version_constraint],
17+
)
18+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package main
2+
3+
import rego.v1
4+
5+
test_rds_instance_unencrypted_denied if {
6+
result := deny_rds_unencrypted with input as {"resource": {"aws_db_instance": {"unencrypted": [{"storage_encrypted": false}]}}}
7+
count(result) == 1
8+
}
9+
10+
test_rds_instance_encrypted_allowed if {
11+
result := deny_rds_unencrypted with input as {"resource": {"aws_db_instance": {"encrypted": [{"storage_encrypted": true}]}}}
12+
count(result) == 0
13+
}
14+
15+
test_rds_instance_default_encrypted_allowed if {
16+
result := deny_rds_unencrypted with input as {"resource": {"aws_db_instance": {"default": [{}]}}}
17+
count(result) == 0
18+
}
19+
20+
test_rds_cluster_unencrypted_denied if {
21+
result := deny_rds_unencrypted with input as {"resource": {"aws_rds_cluster": {"unencrypted": [{"storage_encrypted": false}]}}}
22+
count(result) == 1
23+
}
24+
25+
test_rds_cluster_encrypted_allowed if {
26+
result := deny_rds_unencrypted with input as {"resource": {"aws_rds_cluster": {"encrypted": [{"storage_encrypted": true}]}}}
27+
count(result) == 0
28+
}
29+
30+
test_rds_cluster_instance_unencrypted_denied if {
31+
result := deny_rds_unencrypted with input as {"resource": {"aws_rds_cluster_instance": {"unencrypted": [{"storage_encrypted": false}]}}}
32+
count(result) == 1
33+
}
34+
35+
test_rds_cluster_instance_encrypted_allowed if {
36+
result := deny_rds_unencrypted with input as {"resource": {"aws_rds_cluster_instance": {"encrypted": [{"storage_encrypted": true}]}}}
37+
count(result) == 0
38+
}
39+
40+
test_rds_skip_final_snapshot_denied if {
41+
result := deny_rds_skip_final_snapshot with input as {"resource": {"aws_db_instance": {"no_snapshot": [{"skip_final_snapshot": true}]}}}
42+
count(result) == 1
43+
}
44+
45+
test_rds_skip_final_snapshot_allowed if {
46+
result := deny_rds_skip_final_snapshot with input as {"resource": {"aws_db_instance": {"with_snapshot": [{"skip_final_snapshot": false}]}}}
47+
count(result) == 0
48+
}
49+
50+
test_rds_cluster_skip_final_snapshot_denied if {
51+
result := deny_rds_skip_final_snapshot with input as {"resource": {"aws_rds_cluster": {"no_snapshot": [{"skip_final_snapshot": true}]}}}
52+
count(result) == 1
53+
}
54+
55+
test_rds_cluster_skip_final_snapshot_allowed if {
56+
result := deny_rds_skip_final_snapshot with input as {"resource": {"aws_rds_cluster": {"with_snapshot": [{"skip_final_snapshot": false}]}}}
57+
count(result) == 0
58+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package main
2+
3+
import rego.v1
4+
5+
test_s3_bucket_public_acl_denied if {
6+
result := deny_s3_public_access with input as {"resource": {"aws_s3_bucket": {"public": [{"acl": "public-read"}]}}}
7+
count(result) == 1
8+
}
9+
10+
test_s3_bucket_private_acl_allowed if {
11+
result := deny_s3_public_access with input as {"resource": {"aws_s3_bucket": {"private": [{"acl": "private"}]}}}
12+
count(result) == 0
13+
}
14+
15+
test_s3_bucket_log_delivery_acl_allowed if {
16+
result := deny_s3_public_access with input as {"resource": {"aws_s3_bucket": {"logs": [{"acl": "log_delivery_write"}]}}}
17+
count(result) == 0
18+
}
19+
20+
test_s3_public_access_block_disabled_denied if {
21+
result := deny_s3_public_access_block_disabled with input as {"resource": {"aws_s3_bucket_public_access_block": {"bad": [{"block_public_acls": false}]}}}
22+
count(result) == 1
23+
}
24+
25+
test_s3_public_access_block_enabled_allowed if {
26+
result := deny_s3_public_access_block_disabled with input as {"resource": {"aws_s3_bucket_public_access_block": {"good": [{"block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true}]}}}
27+
count(result) == 0
28+
}
29+
30+
test_s3_public_access_block_partial_allowed if {
31+
result := deny_s3_public_access_block_disabled with input as {"resource": {"aws_s3_bucket_public_access_block": {"partial": [{"block_public_acls": true, "block_public_policy": false}]}}}
32+
count(result) == 1
33+
}

conftest-policies/security_group_rules.rego

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package main
33
import rego.v1
44

55
deny_insecure_security_group_ingress contains msg if {
6-
some name, block in input.resource.aws_security_group
7-
some rule in block.ingress
8-
rule.cidr_blocks
9-
some cidr in rule.cidr_blocks
6+
some name, blocks in input.resource.aws_security_group
7+
some block in blocks
8+
some rule in object.get(block, "ingress", [])
9+
some cidr in object.get(rule, "cidr_blocks", [])
1010
cidr == "0.0.0.0/0"
1111
msg := sprintf(
1212
"aws_security_group.%s: ingress rule allows 0.0.0.0/0 - restrict to specific IPs",
@@ -15,10 +15,10 @@ deny_insecure_security_group_ingress contains msg if {
1515
}
1616

1717
deny_insecure_security_group_ingress contains msg if {
18-
some name, block in input.resource.aws_security_group
19-
some rule in block.ingress
20-
rule.ipv6_cidr_blocks
21-
some cidr in rule.ipv6_cidr_blocks
18+
some name, blocks in input.resource.aws_security_group
19+
some block in blocks
20+
some rule in object.get(block, "ingress", [])
21+
some cidr in object.get(rule, "ipv6_cidr_blocks", [])
2222
cidr == "::/0"
2323
msg := sprintf(
2424
"aws_security_group.%s: ingress rule allows ::/0 - restrict to specific IPv6 ranges",
@@ -27,10 +27,10 @@ deny_insecure_security_group_ingress contains msg if {
2727
}
2828

2929
deny_insecure_security_group_egress contains msg if {
30-
some name, block in input.resource.aws_security_group
31-
some rule in block.egress
32-
rule.cidr_blocks
33-
some cidr in rule.cidr_blocks
30+
some name, blocks in input.resource.aws_security_group
31+
some block in blocks
32+
some rule in object.get(block, "egress", [])
33+
some cidr in object.get(rule, "cidr_blocks", [])
3434
cidr == "0.0.0.0/0"
3535
msg := sprintf(
3636
"aws_security_group.%s: egress rule allows 0.0.0.0/0 - restrict to specific IPs",
@@ -39,10 +39,10 @@ deny_insecure_security_group_egress contains msg if {
3939
}
4040

4141
deny_insecure_security_group_egress contains msg if {
42-
some name, block in input.resource.aws_security_group
43-
some rule in block.egress
44-
rule.ipv6_cidr_blocks
45-
some cidr in rule.ipv6_cidr_blocks
42+
some name, blocks in input.resource.aws_security_group
43+
some block in blocks
44+
some rule in object.get(block, "egress", [])
45+
some cidr in object.get(rule, "ipv6_cidr_blocks", [])
4646
cidr == "::/0"
4747
msg := sprintf(
4848
"aws_security_group.%s: egress rule allows ::/0 - restrict to specific IPv6 ranges",
@@ -51,7 +51,8 @@ deny_insecure_security_group_egress contains msg if {
5151
}
5252

5353
deny_security_group_without_description contains msg if {
54-
some name, block in input.resource.aws_security_group
54+
some name, blocks in input.resource.aws_security_group
55+
some block in blocks
5556
object.get(block, "description", "") == ""
5657
msg := sprintf(
5758
"aws_security_group.%s: security group must have a description",
@@ -60,7 +61,8 @@ deny_security_group_without_description contains msg if {
6061
}
6162

6263
deny_security_group_rule_without_description contains msg if {
63-
some name, block in input.resource.aws_security_group
64+
some name, blocks in input.resource.aws_security_group
65+
some block in blocks
6466
some rule in object.get(block, "ingress", [])
6567
object.get(rule, "description", "") == ""
6668
msg := sprintf(
@@ -70,7 +72,8 @@ deny_security_group_rule_without_description contains msg if {
7072
}
7173

7274
deny_security_group_rule_without_description contains msg if {
73-
some name, block in input.resource.aws_security_group
75+
some name, blocks in input.resource.aws_security_group
76+
some block in blocks
7477
some rule in object.get(block, "egress", [])
7578
object.get(rule, "description", "") == ""
7679
msg := sprintf(
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package main
2+
3+
import rego.v1
4+
5+
test_sg_ingress_0_0_0_0_denied if {
6+
result := deny_insecure_security_group_ingress with input as {"resource": {"aws_security_group": {"bad": [{"ingress": [{"cidr_blocks": ["0.0.0.0/0"]}]}]}}}
7+
count(result) == 1
8+
}
9+
10+
test_sg_ingress_specific_ip_allowed if {
11+
result := deny_insecure_security_group_ingress with input as {"resource": {"aws_security_group": {"good": [{"ingress": [{"cidr_blocks": ["10.0.0.0/8"]}]}]}}}
12+
count(result) == 0
13+
}
14+
15+
test_sg_ingress_ipv6_all_denied if {
16+
result := deny_insecure_security_group_ingress with input as {"resource": {"aws_security_group": {"bad_ipv6": [{"ingress": [{"ipv6_cidr_blocks": ["::/0"]}]}]}}}
17+
count(result) == 1
18+
}
19+
20+
test_sg_egress_0_0_0_0_denied if {
21+
result := deny_insecure_security_group_egress with input as {"resource": {"aws_security_group": {"bad": [{"egress": [{"cidr_blocks": ["0.0.0.0/0"]}]}]}}}
22+
count(result) == 1
23+
}
24+
25+
test_sg_egress_specific_ip_allowed if {
26+
result := deny_insecure_security_group_egress with input as {"resource": {"aws_security_group": {"good": [{"egress": [{"cidr_blocks": ["10.0.0.0/8"]}]}]}}}
27+
count(result) == 0
28+
}
29+
30+
test_sg_without_description_denied if {
31+
result := deny_security_group_without_description with input as {"resource": {"aws_security_group": {"no_desc": [{}]}}}
32+
count(result) == 1
33+
}
34+
35+
test_sg_with_description_allowed if {
36+
result := deny_security_group_without_description with input as {"resource": {"aws_security_group": {"with_desc": [{"description": "Security group"}]}}}
37+
count(result) == 0
38+
}
39+
40+
test_sg_rule_without_description_denied if {
41+
result := deny_security_group_rule_without_description with input as {"resource": {"aws_security_group": {"bad": [{"ingress": [{"description": ""}]}]}}}
42+
count(result) == 1
43+
}
44+
45+
test_sg_rule_with_description_allowed if {
46+
result := deny_security_group_rule_without_description with input as {"resource": {"aws_security_group": {"good": [{"ingress": [{"description": "Allow HTTP"}]}]}}}
47+
count(result) == 0
48+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
terraform {
2+
required_providers {
3+
aws = {
4+
source = "hashicorp/aws"
5+
version = ">= 4.66.0"
6+
}
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
terraform {
2+
required_providers {
3+
aws = {
4+
source = "hashicorp/aws"
5+
version = "= 4.66.0"
6+
}
7+
}
8+
}

0 commit comments

Comments
 (0)