Skip to content

Commit 806a405

Browse files
authored
feat: add conftest policies for S3 lifecycle and JSON policy enforcement (#199)
* feat: add conftest policies for S3 lifecycle and JSON policy enforcement * refactor: convert aws_iam_policy_document to jsonencode * fix: install conftest and opa in precommit CI workflow
1 parent b953422 commit 806a405

File tree

12 files changed

+621
-206
lines changed

12 files changed

+621
-206
lines changed

.github/workflows/precommit.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ jobs:
4545
chmod +x ./shfmt
4646
sudo mv ./shfmt /usr/local/bin/shfmt
4747
48+
# conftest
49+
curl -sSLo ./conftest.tar.gz \
50+
https://github.com/open-policy-agent/conftest/releases/download/v0.66.0/conftest_0.66.0_Linux_x86_64.tar.gz
51+
tar -xzf ./conftest.tar.gz conftest
52+
chmod +x ./conftest
53+
sudo mv ./conftest /usr/local/bin/conftest
54+
55+
# opa
56+
curl -sSLo ./opa \
57+
https://github.com/open-policy-agent/opa/releases/download/v1.13.2/opa_linux_amd64_static
58+
chmod +x ./opa
59+
sudo mv ./opa /usr/local/bin/opa
60+
4861
pip install pre-commit
4962
pip install checkov==3.2.500
5063

.pre-commit-config.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,18 @@ repos:
6262
hooks:
6363
- id: check-github-actions
6464
- id: check-github-workflows
65+
66+
- repo: https://github.com/anderseknert/pre-commit-opa
67+
rev: v1.5.1
68+
hooks:
69+
- id: opa-fmt
70+
- id: conftest-verify
71+
args: [--policy, conftest-policies/]
72+
73+
- repo: local
74+
hooks:
75+
- id: conftest-terraform
76+
name: Conftest Terraform policy check
77+
entry: conftest test --parser hcl2 --policy conftest-policies/ --no-color
78+
language: system
79+
files: ^(examples|modules)/.*\.tf$

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ The configuration is directly in [.pre-commit-config.yaml](.pre-commit-config.ya
4545
- `check-executables-have-shebangs` / `check-shebang-scripts-are-executable` - script permission sanity
4646
- `no-commit-to-branch` - prevents accidental direct commits to `master` and `main`
4747
- `check-github-actions` / `check-github-workflows` - validates GitHub Actions workflow schema
48+
- `opa-fmt` - OPA/Rego policy file formatting
49+
- `conftest-verify` - runs unit tests for custom Conftest policies
50+
- `conftest-terraform` - validates Terraform files against custom OPA policies
4851

4952
It is possible to manually run all checks on all files using
5053
```
@@ -177,6 +180,97 @@ Lambda functions live in `src/<lambda-name>/` directories with an `index.mjs` (o
177180
## checkov
178181
[Checkov](https://www.checkov.io) is an amazing tool to lint terraform (and other) resources, we use the non-official pre-commit hook by antonbabenko
179182

183+
## Conftest Policies
184+
185+
We use [Conftest](https://www.conftest.dev/) (built on [OPA/Rego](https://www.openpolicyagent.org/)) to enforce custom rules on Terraform files that can't be caught by tflint or checkov. Policies live in `conftest-policies/`.
186+
187+
**Current policies:**
188+
189+
- **JSON policy enforcement** (`json_policy.rego`) -- Bans `data "aws_iam_policy_document"` data sources and raw JSON heredoc strings in policy fields across all AWS resource types (IAM, S3, SNS, SQS, KMS, ECR, OpenSearch, CloudWatch Logs, etc.). Use `jsonencode()` instead.
190+
191+
- **S3 lifecycle rule prefix validation** (`s3_lifecycle.rego`) -- Prevents placing `prefix` as a top-level key in S3 lifecycle rules instead of inside a `filter` block. The wrong syntax causes the expiration rule to apply to ALL objects in the bucket, not just the intended prefix.
192+
193+
**Running manually:**
194+
195+
```bash
196+
# Test a specific file
197+
conftest test --parser hcl2 --policy conftest-policies/ examples/05-aws-complete/storage.tf
198+
199+
# Run policy unit tests
200+
conftest verify --policy conftest-policies/
201+
```
202+
203+
**Adding new policies:**
204+
205+
1. Create a `.rego` file in `conftest-policies/` with `deny_` rules
206+
2. Add tests in a corresponding `_test.rego` file
207+
3. Run `conftest verify --policy conftest-policies/` to validate
208+
4. Run `opa fmt -w conftest-policies/` to format
209+
210+
## JSON Policy Standards
211+
212+
Use `jsonencode()` for all AWS policy fields -- not just IAM, but also S3 bucket policies, KMS key policies, SNS/SQS policies, ECR repository policies, OpenSearch access policies, etc. Do NOT use `data "aws_iam_policy_document"` or raw JSON heredoc strings.
213+
214+
This is enforced by `conftest-policies/json_policy.rego` across all examples and modules.
215+
216+
### Preferred (jsonencode)
217+
218+
```hcl
219+
resource "aws_iam_policy" "example" {
220+
policy = jsonencode({
221+
Version = "2012-10-17"
222+
Statement = [{
223+
Effect = "Allow"
224+
Action = ["s3:GetObject"]
225+
Resource = ["arn:aws:s3:::bucket/*"]
226+
}]
227+
})
228+
}
229+
230+
resource "aws_s3_bucket_policy" "example" {
231+
bucket = aws_s3_bucket.example.id
232+
policy = jsonencode({
233+
Version = "2012-10-17"
234+
Statement = [{
235+
Effect = "Allow"
236+
Principal = "*"
237+
Action = ["s3:GetObject"]
238+
Resource = ["${aws_s3_bucket.example.arn}/*"]
239+
}]
240+
})
241+
}
242+
```
243+
244+
### Avoid (data source)
245+
246+
```hcl
247+
data "aws_iam_policy_document" "example" {
248+
statement {
249+
actions = ["s3:GetObject"]
250+
resources = ["arn:aws:s3:::bucket/*"]
251+
}
252+
}
253+
```
254+
255+
### Avoid (raw JSON heredoc)
256+
257+
```hcl
258+
resource "aws_iam_policy" "example" {
259+
policy = <<EOF
260+
{
261+
"Version": "2012-10-17",
262+
"Statement": [{
263+
"Effect": "Allow",
264+
"Action": ["s3:GetObject"],
265+
"Resource": ["arn:aws:s3:::bucket/*"]
266+
}]
267+
}
268+
EOF
269+
}
270+
```
271+
272+
**Rationale**: jsonencode keeps policy definition inline with the resource, is more readable, avoids extra data source lookups, and produces cleaner terraform plan output.
273+
180274
## Managed Databases
181275

182276
Example 05 shows production-grade Aurora PostgreSQL using the community `terraform-aws-modules/rds-aurora/aws` module with KMS encryption, Performance Insights, CloudWatch log exports, 35-day backup retention, and S3 lifecycle tiering. See `examples/05-aws-complete/database.tf`.

conftest-policies/json_policy.rego

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
import rego.v1
4+
5+
deny_iam_policy_document contains msg if {
6+
some name
7+
input.data.aws_iam_policy_document[name]
8+
msg := sprintf(
9+
"data.aws_iam_policy_document.%s: use jsonencode() for IAM policies instead of aws_iam_policy_document data sources",
10+
[name],
11+
)
12+
}
13+
14+
policy_fields := {
15+
"aws_iam_policy": ["policy"],
16+
"aws_iam_role": ["assume_role_policy"],
17+
"aws_iam_role_policy": ["policy"],
18+
"aws_iam_group_policy": ["policy"],
19+
"aws_iam_user_policy": ["policy"],
20+
"aws_s3_bucket_policy": ["policy"],
21+
"aws_s3_bucket": ["policy"],
22+
"aws_s3_access_point": ["policy"],
23+
"aws_s3control_access_point_policy": ["policy"],
24+
"aws_sns_topic_policy": ["policy"],
25+
"aws_sns_topic": ["policy"],
26+
"aws_sqs_queue_policy": ["policy"],
27+
"aws_sqs_queue": ["policy"],
28+
"aws_kms_key": ["policy"],
29+
"aws_kms_key_policy": ["policy"],
30+
"aws_ecr_repository_policy": ["policy"],
31+
"aws_secretsmanager_secret_policy": ["policy"],
32+
"aws_cloudwatch_log_resource_policy": ["policy_document"],
33+
"aws_opensearch_domain": ["access_policies"],
34+
"aws_opensearch_domain_policy": ["access_policies"],
35+
"aws_elasticsearch_domain_policy": ["access_policies"],
36+
"aws_api_gateway_rest_api_policy": ["policy"],
37+
"aws_glacier_vault": ["access_policy"],
38+
"aws_media_store_container_policy": ["policy"],
39+
"aws_backup_vault_policy": ["policy"],
40+
"aws_efs_file_system_policy": ["policy"],
41+
}
42+
43+
deny_heredoc_json_policy contains msg if {
44+
some res_type, fields in policy_fields
45+
some name
46+
some block in input.resource[res_type][name]
47+
some field in fields
48+
value := object.get(block, field, "")
49+
is_string(value)
50+
startswith(trim_space(value), "{")
51+
msg := sprintf(
52+
"%s.%s: '%s' uses a raw JSON string — use jsonencode() instead",
53+
[res_type, name, field],
54+
)
55+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import rego.v1
4+
5+
test_iam_policy_document_denied if {
6+
result := deny_iam_policy_document with input as {"data": {"aws_iam_policy_document": {"my_policy": [{}]}}}
7+
count(result) == 1
8+
}
9+
10+
test_no_iam_policy_document_allowed if {
11+
result := deny_iam_policy_document with input as {"data": {}}
12+
count(result) == 0
13+
}
14+
15+
test_other_data_source_allowed if {
16+
result := deny_iam_policy_document with input as {"data": {"aws_caller_identity": {"current": [{}]}}}
17+
count(result) == 0
18+
}
19+
20+
test_heredoc_iam_policy_denied if {
21+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_policy": {"bad": [{"name": "test", "policy": "{\n \"Version\": \"2012-10-17\"\n}"}]}}}
22+
count(result) == 1
23+
}
24+
25+
test_heredoc_iam_role_policy_denied if {
26+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_role_policy": {"bad": [{"name": "test", "role": "r", "policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
27+
count(result) == 1
28+
}
29+
30+
test_heredoc_assume_role_policy_denied if {
31+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_role": {"bad": [{"name": "test", "assume_role_policy": "{\n \"Version\": \"2012-10-17\"\n}"}]}}}
32+
count(result) == 1
33+
}
34+
35+
test_heredoc_s3_bucket_policy_denied if {
36+
result := deny_heredoc_json_policy with input as {"resource": {"aws_s3_bucket_policy": {"bad": [{"bucket": "b", "policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
37+
count(result) == 1
38+
}
39+
40+
test_heredoc_sns_topic_policy_denied if {
41+
result := deny_heredoc_json_policy with input as {"resource": {"aws_sns_topic_policy": {"bad": [{"arn": "a", "policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
42+
count(result) == 1
43+
}
44+
45+
test_heredoc_sqs_queue_policy_denied if {
46+
result := deny_heredoc_json_policy with input as {"resource": {"aws_sqs_queue_policy": {"bad": [{"queue_url": "q", "policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
47+
count(result) == 1
48+
}
49+
50+
test_heredoc_kms_key_denied if {
51+
result := deny_heredoc_json_policy with input as {"resource": {"aws_kms_key": {"bad": [{"policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
52+
count(result) == 1
53+
}
54+
55+
test_heredoc_ecr_repository_policy_denied if {
56+
result := deny_heredoc_json_policy with input as {"resource": {"aws_ecr_repository_policy": {"bad": [{"repository": "r", "policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
57+
count(result) == 1
58+
}
59+
60+
test_heredoc_opensearch_domain_denied if {
61+
result := deny_heredoc_json_policy with input as {"resource": {"aws_opensearch_domain": {"bad": [{"domain_name": "d", "access_policies": "{ \"Version\": \"2012-10-17\" }"}]}}}
62+
count(result) == 1
63+
}
64+
65+
test_heredoc_cloudwatch_log_resource_policy_denied if {
66+
result := deny_heredoc_json_policy with input as {"resource": {"aws_cloudwatch_log_resource_policy": {"bad": [{"policy_name": "p", "policy_document": "{ \"Version\": \"2012-10-17\" }"}]}}}
67+
count(result) == 1
68+
}
69+
70+
test_heredoc_glacier_vault_denied if {
71+
result := deny_heredoc_json_policy with input as {"resource": {"aws_glacier_vault": {"bad": [{"name": "v", "access_policy": "{ \"Version\": \"2012-10-17\" }"}]}}}
72+
count(result) == 1
73+
}
74+
75+
test_jsonencode_iam_policy_allowed if {
76+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_policy": {"good": [{"name": "test", "policy": "${jsonencode({\n Version = \"2012-10-17\"\n })}"}]}}}
77+
count(result) == 0
78+
}
79+
80+
test_jsonencode_assume_role_allowed if {
81+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_role": {"good": [{"name": "test", "assume_role_policy": "${jsonencode({\n Version = \"2012-10-17\"\n })}"}]}}}
82+
count(result) == 0
83+
}
84+
85+
test_jsonencode_s3_bucket_policy_allowed if {
86+
result := deny_heredoc_json_policy with input as {"resource": {"aws_s3_bucket_policy": {"good": [{"bucket": "b", "policy": "${jsonencode({\n Version = \"2012-10-17\"\n })}"}]}}}
87+
count(result) == 0
88+
}
89+
90+
test_jsonencode_kms_key_allowed if {
91+
result := deny_heredoc_json_policy with input as {"resource": {"aws_kms_key": {"good": [{"policy": "${jsonencode({\n Version = \"2012-10-17\"\n })}"}]}}}
92+
count(result) == 0
93+
}
94+
95+
test_unrelated_resource_ignored if {
96+
result := deny_heredoc_json_policy with input as {"resource": {"aws_instance": {"test": [{"ami": "ami-123"}]}}}
97+
count(result) == 0
98+
}
99+
100+
test_missing_field_ignored if {
101+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_policy": {"test": [{"name": "test"}]}}}
102+
count(result) == 0
103+
}
104+
105+
test_non_string_value_ignored if {
106+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_policy": {"test": [{"name": "test", "policy": 42}]}}}
107+
count(result) == 0
108+
}
109+
110+
test_empty_resource_block_ignored if {
111+
result := deny_heredoc_json_policy with input as {"resource": {"aws_iam_policy": {"test": [{}]}}}
112+
count(result) == 0
113+
}
114+
115+
test_multiple_violations if {
116+
result := deny_heredoc_json_policy with input as {"resource": {
117+
"aws_iam_policy": {"a": [{"policy": "{ \"Version\": \"2012-10-17\" }"}]},
118+
"aws_s3_bucket_policy": {"b": [{"policy": "{ \"Version\": \"2012-10-17\" }"}]},
119+
}}
120+
count(result) == 2
121+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import rego.v1
4+
5+
deny_s3_lifecycle_prefix_in_locals contains msg if {
6+
some block in input.locals
7+
some name, rules in block
8+
is_array(rules)
9+
some rule in rules
10+
is_lifecycle_rule(rule)
11+
object.get(rule, "prefix", "__absent__") != "__absent__"
12+
msg := sprintf(
13+
"locals.%s: lifecycle rule '%s' has 'prefix' as a top-level key — move it inside a filter block: filter = { prefix = \"...\" }",
14+
[name, rule.id],
15+
)
16+
}
17+
18+
deny_s3_lifecycle_prefix_in_module contains msg if {
19+
some mod_name
20+
some block in input.module[mod_name]
21+
rules := block.lifecycle_rule
22+
is_array(rules)
23+
some rule in rules
24+
is_lifecycle_rule(rule)
25+
object.get(rule, "prefix", "__absent__") != "__absent__"
26+
msg := sprintf(
27+
"module.%s: lifecycle rule '%s' has 'prefix' as a top-level key — move it inside a filter block: filter = { prefix = \"...\" }",
28+
[mod_name, rule.id],
29+
)
30+
}
31+
32+
deny_s3_lifecycle_prefix_in_resource contains msg if {
33+
some name
34+
some block in input.resource.aws_s3_bucket_lifecycle_configuration[name]
35+
some rule in block.rule
36+
object.get(rule, "prefix", "__absent__") != "__absent__"
37+
msg := sprintf(
38+
"aws_s3_bucket_lifecycle_configuration.%s: rule '%s' has 'prefix' as a top-level key — use a filter block instead",
39+
[name, rule.id],
40+
)
41+
}
42+
43+
is_lifecycle_rule(rule) if {
44+
rule.id
45+
object.get(rule, "expiration", null) != null
46+
}
47+
48+
is_lifecycle_rule(rule) if {
49+
rule.id
50+
object.get(rule, "transition", null) != null
51+
}
52+
53+
is_lifecycle_rule(rule) if {
54+
rule.id
55+
object.get(rule, "noncurrent_version_expiration", null) != null
56+
}

0 commit comments

Comments
 (0)