Every Terraform pull request gets security findings, organization-policy violations and the monthly cost change before merge.
README
Format: AWS CloudFormation (OIDC + read-only role) + GitHub Actions + OPA Rego ยท Est. cost: $0 on AWS; GitHub Actions minutes; Infracost free tier
Every Terraform pull request gets an automatic review before merge: security findings, organization policy violations and the monthly cost change, all in one PR comment. Violations block the merge.
Pull request โโถ GitHub Actions โโOIDCโโโถ AWS STS โโถ PlanRole (ReadOnly, no secrets, this repo's PRs only)
โ
โโ terraform plan โ plan.json
โโ Checkov 500+ built-in checks โ ๏ธ
โโ Conftest (OPA) policy/aws.rego (your rules) ๐ blocks merge
โโ Infracost "+$1,107/month" ๐ฐ
โโ one sticky PR comment + required status check
Deploy the AWS side
aws cloudformation deploy --stack-name policy-bot \
--template-file template.yaml --capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides GitHubOrg=my-org GitHubRepo=infrastructure StateBucketName=my-tf-state
(If the account already has the GitHub OIDC provider, add CreateOidcProvider=false.)
Wire up the repo
- Copy
github/workflows/policy-bot.ymlโ.github/workflows/andpolicy/โ repo root. - Repo variables:
AWS_PLAN_ROLE_ARN(stack output),AWS_REGION, optionalTF_WORKING_DIR. - Repo secret:
INFRACOST_API_KEY(free at infracost.io). - Branch protection: require the
policy-bot / reviewcheck.
Demo
Open a PR containing sample-terraform/main.tf. The bot comments something like this (Checkov count and cost are illustrative):
๐ค Policy-as-code review
โ 4 policy violation(s), N security finding(s). | ๐ OPA | aws_s3_bucket.reports is missing required tags: {"cost-center", "owner"} | | ๐ OPA | aws_security_group.web opens port 22 to 0.0.0.0/0 | | ๐ OPA | aws_instance.big requests m7i.8xlarge; add tag approved-large-instance=
after review | ๐ฐ Cost impact: +$1,1xx/month (m7i.8xlarge on-demand)
Fix the issues and push. The comment updates in place and the check turns green.
Test the policies locally
terraform plan -out tfplan && terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/
Clean up
aws cloudformation delete-stack --stack-name policy-bot
Files (5)
# 15 ยท Policy-as-Code PR Bot
**Format:** AWS CloudFormation (OIDC + read-only role) + GitHub Actions + OPA Rego ยท **Est. cost:** $0 on AWS; GitHub Actions minutes; Infracost free tier
Every Terraform pull request gets an automatic review **before merge**: security findings, organization policy
violations and the **monthly cost change**, all in one PR comment. Violations block the merge.
```
Pull request โโถ GitHub Actions โโOIDCโโโถ AWS STS โโถ PlanRole (ReadOnly, no secrets, this repo's PRs only)
โ
โโ terraform plan โ plan.json
โโ Checkov 500+ built-in checks โ ๏ธ
โโ Conftest (OPA) policy/aws.rego (your rules) ๐ blocks merge
โโ Infracost "+$1,107/month" ๐ฐ
โโ one sticky PR comment + required status check
```
## Deploy the AWS side
```bash
aws cloudformation deploy --stack-name policy-bot \
--template-file template.yaml --capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides GitHubOrg=my-org GitHubRepo=infrastructure StateBucketName=my-tf-state
```
(If the account already has the GitHub OIDC provider, add `CreateOidcProvider=false`.)
## Wire up the repo
1. Copy `github/workflows/policy-bot.yml` โ `.github/workflows/` and `policy/` โ repo root.
2. Repo **variables**: `AWS_PLAN_ROLE_ARN` (stack output), `AWS_REGION`, optional `TF_WORKING_DIR`.
3. Repo **secret**: `INFRACOST_API_KEY` (free at infracost.io).
4. Branch protection: require the `policy-bot / review` check.
## Demo
Open a PR containing `sample-terraform/main.tf`. The bot comments something like this (Checkov count and cost are illustrative):
> ## ๐ค Policy-as-code review
> โ **4 policy violation(s), N security finding(s).**
> | ๐ OPA | aws_s3_bucket.reports is missing required tags: {"cost-center", "owner"} |
> | ๐ OPA | aws_security_group.web opens port 22 to 0.0.0.0/0 |
> | ๐ OPA | aws_instance.big requests m7i.8xlarge; add tag approved-large-instance=<ticket> after review |
> ๐ฐ Cost impact: **+$1,1xx/month** (m7i.8xlarge on-demand)
Fix the issues and push. The comment updates in place and the check turns green.
## Test the policies locally
```bash
terraform plan -out tfplan && terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/
```
## Clean up
```bash
aws cloudformation delete-stack --stack-name policy-bot
```
AWSTemplateFormatVersion: '2010-09-09'
Description: >-
Policy-as-code PR bot - AWS side. GitHub Actions authenticates with OIDC (no stored keys)
and assumes a READ-ONLY role that can only run `terraform plan` for pull requests from
one repository. Checkov, OPA/Conftest and Infracost then review the plan and comment on the PR.
Parameters:
GitHubOrg:
Type: String
Description: GitHub organization or user, e.g. my-org
GitHubRepo:
Type: String
Description: Repository name, e.g. infrastructure
StateBucketName:
Type: String
Default: ''
Description: Optional Terraform state bucket the plan role may read (leave blank if none).
CreateOidcProvider:
Type: String
AllowedValues: ['true', 'false']
Default: 'true'
Description: Set false if this account already has the token.actions.githubusercontent.com provider.
Conditions:
CreateProvider: !Equals [!Ref CreateOidcProvider, 'true']
HasStateBucket: !Not [!Equals [!Ref StateBucketName, '']]
Resources:
GitHubOidcProvider:
Type: AWS::IAM::OIDCProvider
Condition: CreateProvider
Properties:
Url: https://token.actions.githubusercontent.com
ClientIdList: [sts.amazonaws.com]
# AWS validates GitHub's certificate chain itself; a thumbprint is still required syntactically.
ThumbprintList: [6938fd4d98bab03faadb97b34396831e3780aea1]
PlanRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${GitHubRepo}-pr-plan-readonly'
Description: Read-only role for terraform plan on pull requests
MaxSessionDuration: 3600
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Federated: !If
- CreateProvider
- !Ref GitHubOidcProvider
- !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com'
Action: sts:AssumeRoleWithWebIdentity
Condition:
StringEquals:
token.actions.githubusercontent.com:aud: sts.amazonaws.com
StringLike:
# Only pull_request workflows from this exact repository.
token.actions.githubusercontent.com:sub: !Sub 'repo:${GitHubOrg}/${GitHubRepo}:pull_request'
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess
Policies:
- PolicyName: deny-secret-and-data-reads
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: NoSecrets
Effect: Deny
Action:
- secretsmanager:GetSecretValue
- ssm:GetParameter
- ssm:GetParameters
- ssm:GetParametersByPath
- kms:Decrypt
Resource: '*'
- Sid: NoObjectReadsExceptState
Effect: Deny
Action: s3:GetObject
NotResource: !If
- HasStateBucket
- !Sub 'arn:${AWS::Partition}:s3:::${StateBucketName}/*'
- !Sub 'arn:${AWS::Partition}:s3:::no-state-bucket-configured/*'
- Sid: NoDataPlaneReads
Effect: Deny
Action:
- dynamodb:GetItem
- dynamodb:Scan
- dynamodb:Query
- dynamodb:BatchGetItem
- rds-data:*
- athena:GetQueryResults
Resource: '*'
Outputs:
PlanRoleArn:
Description: Put this in the repo variable AWS_PLAN_ROLE_ARN
Value: !GetAtt PlanRole.Arn
# Copy to .github/workflows/policy-bot.yml in your Terraform repository.
# Repo variables: AWS_PLAN_ROLE_ARN, AWS_REGION, TF_WORKING_DIR (default "."). Secret: INFRACOST_API_KEY.
name: policy-bot
on:
pull_request:
paths: ['**.tf', '**.tfvars', 'policy/**']
permissions:
id-token: write # OIDC -> AWS, no long-lived keys
contents: read
pull-requests: write # post the review comment
concurrency:
group: policy-bot-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ vars.TF_WORKING_DIR || '.' }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with: { terraform_wrapper: false }
- name: Terraform plan
run: |
terraform init -input=false
terraform plan -input=false -lock=false -out=tfplan
terraform show -json tfplan > plan.json
- name: Checkov (500+ built-in security checks)
id: checkov
continue-on-error: true
run: |
pipx install checkov
checkov -f plan.json --framework terraform_plan --compact --quiet -o cli -o json --output-file-path console,checkov.json
echo "failed=$(jq '[.results.failed_checks // [] | length] | add // 0' checkov.json)" >> "$GITHUB_OUTPUT"
- name: OPA / Conftest (our own rules)
id: opa
continue-on-error: true
run: |
curl -sSL https://github.com/open-policy-agent/conftest/releases/download/v0.56.0/conftest_0.56.0_Linux_x86_64.tar.gz | tar xz conftest
./conftest test plan.json --policy ${{ github.workspace }}/policy --all-namespaces --output json > opa.json || true
echo "failed=$(jq '[.[].failures // [] | length] | add // 0' opa.json)" >> "$GITHUB_OUTPUT"
- uses: infracost/actions/setup@v3
with: { api-key: '${{ secrets.INFRACOST_API_KEY }}' }
- name: Infracost (cost diff)
run: |
infracost breakdown --path plan.json --format json --out-file infracost.json
infracost output --path infracost.json --format github-comment --show-skipped --out-file infracost.md
- name: Comment on the PR
uses: actions/github-script@v7
env:
WORKDIR: ${{ vars.TF_WORKING_DIR || '.' }}
with:
script: |
const fs = require('fs');
const dir = process.env.WORKDIR;
const checkov = JSON.parse(fs.readFileSync(`${dir}/checkov.json`, 'utf8'));
const opa = JSON.parse(fs.readFileSync(`${dir}/opa.json`, 'utf8'));
const ck = [].concat(checkov).flatMap(r => r.results?.failed_checks ?? []);
const op = opa.flatMap(r => r.failures ?? []);
const cost = fs.readFileSync(`${dir}/infracost.md`, 'utf8');
const rows = [
...op.map(f => `| ๐ OPA | ${f.msg} |`),
...ck.slice(0, 25).map(c => `| โ ๏ธ ${c.check_id} | ${c.check_name} โ \`${c.resource}\` |`),
];
const verdict = op.length === 0 && ck.length === 0 ? 'โ
**All policies pass.**'
: `โ **${op.length} policy violation(s), ${ck.length} security finding(s).**`;
const body = [
'## ๐ค Policy-as-code review', verdict, '',
rows.length ? '| Rule | Finding |\n|---|---|\n' + rows.join('\n') : '',
'', '<details><summary>๐ฐ Cost impact</summary>', '', cost, '</details>',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: context.issue.number });
const mine = comments.find(c => c.body.startsWith('## ๐ค Policy-as-code review'));
if (mine) await github.rest.issues.updateComment({ ...context.repo, comment_id: mine.id, body });
else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
- name: Enforce
if: steps.opa.outputs.failed != '0' || steps.checkov.outputs.failed != '0'
run: |
echo "OPA violations: ${{ steps.opa.outputs.failed }} ยท Checkov failures: ${{ steps.checkov.outputs.failed }}"
exit 1
# Organization guardrails evaluated against `terraform show -json` plan output (Conftest / OPA, Rego v1).
package main
import rego.v1
required_tags := {"owner", "cost-center"}
allowed_instance_sizes := {"nano", "micro", "small", "medium", "large", "xlarge"}
managed := [rc |
some rc in input.resource_changes
rc.mode == "managed"
not "delete" in rc.change.actions
]
taggable_types := {"aws_instance", "aws_s3_bucket", "aws_db_instance", "aws_lambda_function", "aws_ecs_service", "aws_dynamodb_table"}
# Tags as planned: provider default_tags land in tags_all; untagged resources have null tags.
resource_tags(after) := after.tags_all if is_object(after.tags_all)
else := after.tags if is_object(after.tags)
else := {}
# 1. Every taggable resource must carry owner + cost-center (directly or via provider default_tags).
deny contains msg if {
some rc in managed
rc.type in taggable_types
tags := resource_tags(rc.change.after)
missing := required_tags - {k | some k, _ in tags}
count(missing) > 0
msg := sprintf("%s is missing required tags: %v", [rc.address, missing])
}
# 2. No public S3 ACLs.
deny contains msg if {
some rc in managed
rc.type == "aws_s3_bucket_acl"
rc.change.after.acl in {"public-read", "public-read-write"}
msg := sprintf("%s uses a public ACL (%s)", [rc.address, rc.change.after.acl])
}
# 3. Public access block must be fully enabled when present.
deny contains msg if {
some rc in managed
rc.type == "aws_s3_bucket_public_access_block"
some setting in ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"]
rc.change.after[setting] == false
msg := sprintf("%s disables %s", [rc.address, setting])
}
# 4. No SSH / RDP open to the internet.
deny contains msg if {
some rc in managed
rc.type == "aws_vpc_security_group_ingress_rule"
rc.change.after.cidr_ipv4 == "0.0.0.0/0"
rc.change.after.from_port <= 22
rc.change.after.to_port >= 22
msg := sprintf("%s opens SSH (22) to 0.0.0.0/0 - use SSM Session Manager", [rc.address])
}
deny contains msg if {
some rc in managed
rc.type == "aws_security_group"
some rule in rc.change.after.ingress
"0.0.0.0/0" in rule.cidr_blocks
some port in [22, 3389]
rule.from_port <= port
rule.to_port >= port
msg := sprintf("%s opens port %d to 0.0.0.0/0", [rc.address, port])
}
# 5. Oversized instances need an explicit, reviewed exception tag.
deny contains msg if {
some rc in managed
rc.type == "aws_instance"
size := split(rc.change.after.instance_type, ".")[1]
not size in allowed_instance_sizes
not rc.change.after.tags["approved-large-instance"]
msg := sprintf("%s requests %s; add tag approved-large-instance=<ticket> after review", [rc.address, rc.change.after.instance_type])
}
# 6. Databases must be encrypted and private.
deny contains msg if {
some rc in managed
rc.type == "aws_db_instance"
rc.change.after.storage_encrypted != true
msg := sprintf("%s must set storage_encrypted = true", [rc.address])
}
deny contains msg if {
some rc in managed
rc.type == "aws_db_instance"
rc.change.after.publicly_accessible == true
msg := sprintf("%s must not be publicly accessible", [rc.address])
}
# Deliberately flawed Terraform for demoing the bot. Open a PR with this and watch it comment.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "reports" {
bucket_prefix = "policy-bot-demo-"
# missing owner / cost-center tags -> OPA rule 1
}
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = false # -> OPA rule 3, Checkov CKV_AWS_53
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_security_group" "web" {
name = "policy-bot-demo"
description = "demo"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # -> OPA rule 4, Checkov CKV_AWS_24
}
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "big" {
ami = data.aws_ami.al2023.id
instance_type = "m7i.8xlarge" # -> OPA rule 5 + a scary Infracost diff
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
owner = "platform"
"cost-center" = "engineering"
}
}
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.