Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
445da6d
Add terraform/envs/dev/backend.tf
Herenn Sep 1, 2025
fed2ea4
Add terraform/envs/dev/providers.tf
Herenn Sep 1, 2025
35324ad
Add terraform/envs/dev/main.tf
Herenn Sep 1, 2025
8049db0
Add terraform/envs/dev/variables.tf
Herenn Sep 1, 2025
d1999c0
Add terraform/envs/dev/outputs.tf
Herenn Sep 1, 2025
d15942d
Add terraform/envs/stage/backend.tf
Herenn Sep 1, 2025
461f085
Add terraform/envs/stage/providers.tf
Herenn Sep 1, 2025
e9f2f52
Add terraform/envs/stage/main.tf
Herenn Sep 1, 2025
7f96600
Add terraform/envs/stage/variables.tf
Herenn Sep 1, 2025
0784f76
Add terraform/envs/stage/outputs.tf
Herenn Sep 1, 2025
b268915
Add terraform/envs/prod/backend.tf
Herenn Sep 1, 2025
401ed6b
Add terraform/envs/prod/providers.tf
Herenn Sep 1, 2025
57df095
Add terraform/envs/prod/main.tf
Herenn Sep 1, 2025
48e0592
Add terraform/envs/prod/variables.tf
Herenn Sep 1, 2025
f307322
Add terraform/envs/prod/outputs.tf
Herenn Sep 1, 2025
42ed49c
Add terraform/modules/network/main.tf
Herenn Sep 1, 2025
03541af
Add terraform/modules/network/variables.tf
Herenn Sep 1, 2025
85f59ae
Add terraform/modules/network/outputs.tf
Herenn Sep 1, 2025
b52274b
Add terraform/modules/compute/main.tf
Herenn Sep 1, 2025
afefec2
Add terraform/modules/compute/variables.tf
Herenn Sep 1, 2025
00d9f2b
Add terraform/modules/compute/outputs.tf
Herenn Sep 1, 2025
55c0ad9
Add terraform/modules/database/main.tf
Herenn Sep 1, 2025
f9b14f6
Add terraform/modules/database/variables.tf
Herenn Sep 1, 2025
9d706f1
Add terraform/modules/database/outputs.tf
Herenn Sep 1, 2025
fd50ab7
Add policies/digitalocean/security.rego
Herenn Sep 1, 2025
3e3fa21
Add policies/digitalocean/compliance.rego
Herenn Sep 1, 2025
9c37368
Add policy-lib/terraform.rego
Herenn Sep 1, 2025
ce3616a
Add policy-lib/utils.rego
Herenn Sep 1, 2025
dedee87
Add .inframorph-policy.yaml
Herenn Sep 1, 2025
dc9eed9
Add scripts/export_tfplan_json.sh
Herenn Sep 1, 2025
32a551b
Add scripts/summarize_conftest.py
Herenn Sep 1, 2025
d40753a
Add scripts/check_exceptions.py
Herenn Sep 1, 2025
47e29b3
Add scripts/setup_secrets.py
Herenn Sep 1, 2025
4d22331
Add .github/workflows/terraform.yml
Herenn Sep 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Terraform CI/CD

on:
pull_request:
paths:
- "terraform/**"
- "policies/**"
- ".inframorph-policy.yaml"
- "scripts/**"
push:
branches: ["main"]
paths: ["terraform/**"]

permissions:
contents: read
pull-requests: write
id-token: write # For OIDC

env:
TF_INPUT: false
TF_IN_AUTOMATION: true

jobs:
plan-and-policy:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.5

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }} # For OIDC
# Alternative: use access keys
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_DEFAULT_REGION }}

- name: Terraform Init (dev)
working-directory: terraform/envs/dev
run: terraform init -upgrade

- name: Terraform Format Check
working-directory: terraform/envs/dev
run: terraform fmt -check

- name: Terraform Validate
working-directory: terraform/envs/dev
run: terraform validate

- name: Terraform Plan (dev)
working-directory: terraform/envs/dev
run: |
terraform plan -out=tfplan.bin -detailed-exitcode || true
echo "PLAN_EXIT_CODE=$?" >> $GITHUB_ENV

- name: Export Plan JSON
if: env.PLAN_EXIT_CODE != '1'
run: |
bash scripts/export_tfplan_json.sh terraform/envs/dev/tfplan.bin terraform/envs/dev/tfplan.json

- name: Install Conftest
run: |
curl -L https://github.com/open-policy-agent/conftest/releases/download/v0.56.0/conftest_Linux_x86_64.tar.gz | tar xz
sudo mv conftest /usr/local/bin/

- name: Policy Check
if: env.PLAN_EXIT_CODE != '1'
run: |
conftest test terraform/envs/dev/tfplan.json --policy policies --output json > conftest.json || true
python3 scripts/summarize_conftest.py conftest.json > policy_summary.md

- name: Post PR Comment
if: github.event_name == 'pull_request' && env.PLAN_EXIT_CODE != '1'
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
path: policy_summary.md

- name: Check for Blocking Violations
if: env.PLAN_EXIT_CODE != '1'
run: |
python3 scripts/check_exceptions.py conftest.json .inframorph-policy.yaml

- name: Upload Plan Artifact
if: env.PLAN_EXIT_CODE == '2'
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: |
terraform/envs/dev/tfplan.bin
terraform/envs/dev/tfplan.json
policy_summary.md

apply:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.5

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_DEFAULT_REGION }}

- name: Terraform Init (prod)
working-directory: terraform/envs/prod
run: terraform init -upgrade

- name: Terraform Plan (prod)
working-directory: terraform/envs/prod
run: terraform plan

- name: Terraform Apply (prod)
working-directory: terraform/envs/prod
run: terraform apply -auto-approve
13 changes: 13 additions & 0 deletions .inframorph-policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
default_severity: medium
exceptions:
- expires_at: '2025-12-31'
id: example_exception
reason: Temporary exception for migration
rules:
- aws.security.no_ssh_from_anywhere
fail_on:
- high
- critical
providers:
- digitalocean
version: 1
95 changes: 95 additions & 0 deletions policies/digitalocean/compliance.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package digitalocean.compliance

# Require specific tags for all resources
deny[res] {
input.resource_type in [
"digitalocean_droplet",
"digitalocean_database_cluster",
"digitalocean_spaces_bucket",
"digitalocean_loadbalancer"
]
not input.config.tags
res := {
"address": input.address,
"rule": "digitalocean.compliance.tags_required",
"severity": "medium",
"message": sprintf("Resource %s must have tags", [input.address])
}
}

deny[res] {
input.resource_type in [
"digitalocean_droplet",
"digitalocean_database_cluster",
"digitalocean_spaces_bucket",
"digitalocean_loadbalancer"
]
input.config.tags
not input.config.tags.environment
res := {
"address": input.address,
"rule": "digitalocean.compliance.environment_tag_required",
"severity": "medium",
"message": sprintf("Resource %s missing required tag: environment", [input.address])
}
}

deny[res] {
input.resource_type in [
"digitalocean_droplet",
"digitalocean_database_cluster",
"digitalocean_spaces_bucket",
"digitalocean_loadbalancer"
]
input.config.tags
not input.config.tags.project
res := {
"address": input.address,
"rule": "digitalocean.compliance.project_tag_required",
"severity": "medium",
"message": sprintf("Resource %s missing required tag: project", [input.address])
}
}

# Require specific regions for compliance
deny[res] {
input.resource_type in [
"digitalocean_droplet",
"digitalocean_database_cluster"
]
not input.config.region in ["nyc1", "nyc3", "ams3", "sgp1", "lon1", "fra1"]
res := {
"address": input.address,
"rule": "digitalocean.compliance.approved_regions",
"severity": "medium",
"message": sprintf("Resource %s must use approved regions", [input.address])
}
}

# Require VPC for production resources
deny[res] {
input.resource_type == "digitalocean_droplet"
input.config.tags[_] == "environment:production"
not input.config.vpc_uuid
res := {
"address": input.address,
"rule": "digitalocean.compliance.production_vpc_required",
"severity": "high",
"message": sprintf("Production droplet %s must be in a VPC", [input.name])
}
}

# Enforce naming conventions
deny[res] {
input.resource_type in [
"digitalocean_droplet",
"digitalocean_database_cluster"
]
not regex.match("^[a-z][a-z0-9-]*[a-z0-9]$", input.config.name)
res := {
"address": input.address,
"rule": "digitalocean.compliance.naming_convention",
"severity": "low",
"message": sprintf("Resource %s name should follow kebab-case convention", [input.address])
}
}
87 changes: 87 additions & 0 deletions policies/digitalocean/security.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package digitalocean.security

# Deny droplets without private networking
deny[res] {
input.resource_type == "digitalocean_droplet"
not input.config.private_networking
res := {
"address": input.address,
"rule": "digitalocean.security.private_networking_required",
"severity": "medium",
"message": sprintf("Droplet %s should enable private networking", [input.name])
}
}

# Require monitoring for production droplets
deny[res] {
input.resource_type == "digitalocean_droplet"
input.config.tags[_] == "environment:production"
not input.config.monitoring
res := {
"address": input.address,
"rule": "digitalocean.security.monitoring_required",
"severity": "medium",
"message": sprintf("Production droplet %s must enable monitoring", [input.name])
}
}

# Deny public Spaces (object storage)
deny[res] {
input.resource_type == "digitalocean_spaces_bucket"
input.config.acl == "public-read"
res := {
"address": input.address,
"rule": "digitalocean.security.no_public_spaces",
"severity": "high",
"message": sprintf("Spaces bucket %s should not be public", [input.name])
}
}

deny[res] {
input.resource_type == "digitalocean_spaces_bucket"
input.config.acl == "public-read-write"
res := {
"address": input.address,
"rule": "digitalocean.security.no_public_spaces",
"severity": "critical",
"message": sprintf("Spaces bucket %s allows public write access", [input.name])
}
}

# Require backups for database clusters
deny[res] {
input.resource_type == "digitalocean_database_cluster"
not input.config.backup_restore.backup_hour
res := {
"address": input.address,
"rule": "digitalocean.security.database_backup_required",
"severity": "high",
"message": sprintf("Database cluster %s should enable automated backups", [input.name])
}
}

# Require encryption for database clusters
deny[res] {
input.resource_type == "digitalocean_database_cluster"
not input.config.storage_size_mib
input.config.engine != "redis" # Redis doesn't support encryption at rest
res := {
"address": input.address,
"rule": "digitalocean.security.database_encryption_recommended",
"severity": "medium",
"message": sprintf("Database cluster %s should consider encryption at rest", [input.name])
}
}

# Warn about small droplet sizes in production
warn[res] {
input.resource_type == "digitalocean_droplet"
input.config.tags[_] == "environment:production"
input.config.size in ["s-1vcpu-1gb", "s-1vcpu-2gb"]
res := {
"address": input.address,
"rule": "digitalocean.security.production_sizing",
"severity": "low",
"message": sprintf("Production droplet %s uses small size, consider scaling up", [input.name])
}
}
32 changes: 32 additions & 0 deletions policy-lib/terraform.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package terraform

# Helper functions for Terraform plan analysis

# Get all resources of a specific type
resources_by_type(resource_type) = resources {
resources := [resource |
resource := input.planned_values.root_module.resources[_]
resource.type == resource_type
]
}

# Get all resource changes of a specific type
resource_changes_by_type(resource_type) = changes {
changes := [change |
change := input.resource_changes[_]
change.type == resource_type
]
}

# Check if a resource has a specific tag
has_tag(resource, tag_name) {
resource.values.tags[tag_name]
}

# Check if a resource has all required tags
has_required_tags(resource, required_tags) {
count([tag |
tag := required_tags[_]
has_tag(resource, tag)
]) == count(required_tags)
}
30 changes: 30 additions & 0 deletions policy-lib/utils.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package utils

# Utility functions for policy evaluation

# Check if a CIDR allows access from anywhere
allows_public_access(cidr_blocks) {
"0.0.0.0/0" in cidr_blocks
}

# Check if a port range includes a specific port
port_range_includes(from_port, to_port, target_port) {
from_port <= target_port
to_port >= target_port
}

# Get severity level as number for comparison
severity_level(severity) = level {
severity_map := {
"low": 1,
"medium": 2,
"high": 3,
"critical": 4
}
level := severity_map[severity]
}

# Check if severity meets threshold
severity_meets_threshold(severity, threshold) {
severity_level(severity) >= severity_level(threshold)
}
Loading
Loading