15 ยท A Policy-as-Code PR Bot for Terraform: Security, Rules and Cost in Every Review

Code Repository / projects / 15-policy-as-code-pr-bot
Project 15 ยท AWS CloudFormation

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

  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= 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)

README.md ยท 54 lines
# 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
```
template.yaml ยท 98 lines
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
github/workflows/policy-bot.yml ยท 98 lines
# 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
policy/aws.rego ยท 97 lines
# 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])
}
sample-terraform/main.tf ยท 55 lines
# 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.