From d667c4f7ad30bc445296d5637febe5a3eb18dfec Mon Sep 17 00:00:00 2001 From: jameslaneovermind <122231433+jameslaneovermind@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:52:45 +0100 Subject: [PATCH 1/4] Add OPA policies for Terraform security and cost governance This commit adds comprehensive Open Policy Agent (OPA) policies to enforce security best practices and cost control for AWS infrastructure: Policy files added: - policies/security-groups.rego: Prevents dangerous security group configurations * Blocks SSH/RDP access from 0.0.0.0/0 * Prevents overly permissive rules * Warns about exposed database and application ports - policies/s3-security.rego: Enforces S3 security standards * Requires standard tags (Environment, Name, Owner, Project) * Mandates server-side encryption * Warns about public access configurations - policies/cost-control.rego: Manages infrastructure costs * Flags expensive EC2/RDS instance types * Enforces cost tracking tags * Warns about high-cost configurations These policies use modern OPA syntax (rego.v1) and are compatible with the overmindtech/policy-signals-action for automated enforcement in CI/CD. Testing: - Policies tested with Conftest 0.62.0 (OPA 1.6.0) - Sample violations properly detected and reported - Ready for GitHub Actions integration --- policies/cost-control.rego | 106 +++++++++++++++++++++ policies/s3-security.rego | 89 ++++++++++++++++++ policies/security-groups.rego | 169 ++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 policies/cost-control.rego create mode 100644 policies/s3-security.rego create mode 100644 policies/security-groups.rego diff --git a/policies/cost-control.rego b/policies/cost-control.rego new file mode 100644 index 0000000..16428ca --- /dev/null +++ b/policies/cost-control.rego @@ -0,0 +1,106 @@ +package main + +import rego.v1 + +# Cost Control Policy +# Checks for expensive instance types and configurations + +# Get all EC2 instances from terraform plan +ec2_instances contains instance if { + instance := input.resource_changes[_] + instance.type == "aws_instance" +} + +# Get all RDS instances from terraform plan +rds_instances contains instance if { + instance := input.resource_changes[_] + instance.type == "aws_db_instance" +} + +# Get all RDS clusters from terraform plan +rds_clusters contains cluster if { + cluster := input.resource_changes[_] + cluster.type == "aws_rds_cluster" +} + +# Expensive EC2 instance types +expensive_ec2_types := { + "m5.24xlarge", "m5.16xlarge", "m5.12xlarge", + "c5.24xlarge", "c5.18xlarge", "c5.12xlarge", + "r5.24xlarge", "r5.16xlarge", "r5.12xlarge", + "x1.32xlarge", "x1.16xlarge", "x1e.32xlarge", + "p3.16xlarge", "p3.8xlarge", "p2.16xlarge", + "g3.16xlarge", "g3.8xlarge", "g4dn.16xlarge" +} + +# Expensive RDS instance types +expensive_rds_types := { + "db.m5.24xlarge", "db.m5.16xlarge", "db.m5.12xlarge", + "db.r5.24xlarge", "db.r5.16xlarge", "db.r5.12xlarge", + "db.x1.32xlarge", "db.x1.16xlarge", "db.x1e.32xlarge" +} + +# Deny expensive EC2 instance types +deny contains msg if { + instance := ec2_instances[_] + instance.change.after.instance_type in expensive_ec2_types + msg := sprintf("EC2 instance '%s' uses expensive instance type '%s' - consider using a smaller instance type", [instance.address, instance.change.after.instance_type]) +} + +# Deny expensive RDS instance types +deny contains msg if { + instance := rds_instances[_] + instance.change.after.instance_class in expensive_rds_types + msg := sprintf("RDS instance '%s' uses expensive instance class '%s' - consider using a smaller instance class", [instance.address, instance.change.after.instance_class]) +} + +# Deny RDS clusters without cost-effective configurations +deny contains msg if { + cluster := rds_clusters[_] + not cluster.change.after.deletion_protection + msg := sprintf("RDS cluster '%s' does not have deletion protection enabled - this could lead to accidental expensive data loss", [cluster.address]) +} + +# Warn about instances without cost control tags +warn contains msg if { + instance := ec2_instances[_] + not instance.change.after.tags.CostCenter + msg := sprintf("EC2 instance '%s' is missing 'CostCenter' tag for cost tracking", [instance.address]) +} + +warn contains msg if { + instance := rds_instances[_] + not instance.change.after.tags.CostCenter + msg := sprintf("RDS instance '%s' is missing 'CostCenter' tag for cost tracking", [instance.address]) +} + +# Warn about production instances in expensive regions +warn contains msg if { + instance := ec2_instances[_] + instance.change.after.tags.Environment == "prod" + # This is a simplified check - in practice you'd check the provider region + msg := sprintf("Production EC2 instance '%s' - ensure you're using the most cost-effective region", [instance.address]) +} + +# Warn about instances without scheduled start/stop for dev environments +warn contains msg if { + instance := ec2_instances[_] + instance.change.after.tags.Environment == "dev" + not instance.change.after.tags.Schedule + msg := sprintf("Development EC2 instance '%s' is missing 'Schedule' tag - consider auto-shutdown to reduce costs", [instance.address]) +} + +# Warn about RDS instances without backup retention optimization +warn contains msg if { + instance := rds_instances[_] + instance.change.after.backup_retention_period > 7 + instance.change.after.tags.Environment == "dev" + msg := sprintf("Development RDS instance '%s' has backup retention > 7 days - consider reducing for cost savings", [instance.address]) +} + +# Warn about instances with high storage allocation +warn contains msg if { + instance := rds_instances[_] + instance.change.after.allocated_storage > 1000 + msg := sprintf("RDS instance '%s' has high storage allocation (%d GB) - ensure this is necessary", [instance.address, instance.change.after.allocated_storage]) +} \ No newline at end of file diff --git a/policies/s3-security.rego b/policies/s3-security.rego new file mode 100644 index 0000000..7c2d9be --- /dev/null +++ b/policies/s3-security.rego @@ -0,0 +1,89 @@ +package main + +import rego.v1 + +# S3 Security Policy +# Checks for S3 buckets without proper security configurations + +# Get all S3 bucket resources from terraform plan +s3_buckets contains bucket if { + bucket := input.resource_changes[_] + bucket.type == "aws_s3_bucket" +} + +s3_bucket_public_access_blocks contains block if { + block := input.resource_changes[_] + block.type == "aws_s3_bucket_public_access_block" +} + +s3_bucket_encryption contains encrypt if { + encrypt := input.resource_changes[_] + encrypt.type == "aws_s3_bucket_server_side_encryption_configuration" +} + +# Deny S3 buckets without Environment tag +deny contains msg if { + bucket := s3_buckets[_] + not bucket.change.after.tags.Environment + msg := sprintf("S3 bucket '%s' is missing the required 'Environment' tag", [bucket.address]) +} + +# Deny S3 buckets without Name tag +deny contains msg if { + bucket := s3_buckets[_] + not bucket.change.after.tags.Name + msg := sprintf("S3 bucket '%s' is missing the required 'Name' tag", [bucket.address]) +} + +# Deny S3 buckets without Owner tag +deny contains msg if { + bucket := s3_buckets[_] + not bucket.change.after.tags.Owner + msg := sprintf("S3 bucket '%s' is missing the required 'Owner' tag", [bucket.address]) +} + +# Deny S3 buckets without Project tag +deny contains msg if { + bucket := s3_buckets[_] + not bucket.change.after.tags.Project + msg := sprintf("S3 bucket '%s' is missing the required 'Project' tag", [bucket.address]) +} + +# Deny S3 buckets without encryption +deny contains msg if { + bucket := s3_buckets[_] + not has_bucket_encryption(bucket.change.after.bucket) + msg := sprintf("S3 bucket '%s' does not have server-side encryption configured", [bucket.address]) +} + +# Warn about S3 buckets without public access block +warn contains msg if { + bucket := s3_buckets[_] + not has_public_access_block(bucket.change.after.bucket) + msg := sprintf("S3 bucket '%s' does not have public access block configured - consider adding one for security", [bucket.address]) +} + +# Warn about S3 buckets that might be publicly accessible +warn contains msg if { + bucket := s3_buckets[_] + bucket.change.after.acl == "public-read" + msg := sprintf("S3 bucket '%s' has public-read ACL - ensure this is intentional", [bucket.address]) +} + +warn contains msg if { + bucket := s3_buckets[_] + bucket.change.after.acl == "public-read-write" + msg := sprintf("S3 bucket '%s' has public-read-write ACL - this is a security risk", [bucket.address]) +} + +# Helper function to check if bucket has encryption configuration +has_bucket_encryption(bucket_name) if { + encryption := s3_bucket_encryption[_] + encryption.change.after.bucket == bucket_name +} + +# Helper function to check if bucket has public access block +has_public_access_block(bucket_name) if { + block := s3_bucket_public_access_blocks[_] + block.change.after.bucket == bucket_name +} \ No newline at end of file diff --git a/policies/security-groups.rego b/policies/security-groups.rego new file mode 100644 index 0000000..0d861a9 --- /dev/null +++ b/policies/security-groups.rego @@ -0,0 +1,169 @@ +package main + +import rego.v1 + +# Get all security groups being created or modified +security_groups contains sg if { + sg := input.resource_changes[_] + sg.type == "aws_security_group" +} + +security_group_rules contains rule if { + rule := input.resource_changes[_] + rule.type == "aws_security_group_rule" +} + +# Deny SSH access from 0.0.0.0/0 +deny contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 22 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows SSH (port 22) access from anywhere (0.0.0.0/0) - this is a security risk", [sg.address]) +} + +# Check for SSH via security group rules +deny contains msg if { + rule := security_group_rules[_] + rule.change.after.type == "ingress" + rule.change.after.from_port == 22 + "0.0.0.0/0" in rule.change.after.cidr_blocks + msg := sprintf("Security group rule '%s' allows SSH (port 22) access from anywhere (0.0.0.0/0) - this is a security risk", [rule.address]) +} + +# Deny RDP access from 0.0.0.0/0 +deny contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3389 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows RDP (port 3389) access from anywhere (0.0.0.0/0) - this is a security risk", [sg.address]) +} + +# Check for RDP via security group rules +deny contains msg if { + rule := security_group_rules[_] + rule.change.after.type == "ingress" + rule.change.after.from_port == 3389 + "0.0.0.0/0" in rule.change.after.cidr_blocks + msg := sprintf("Security group rule '%s' allows RDP (port 3389) access from anywhere (0.0.0.0/0) - this is a security risk", [rule.address]) +} + +# Deny overly permissive rules (all traffic from anywhere) +deny contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 0 + ingress.to_port == 65535 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows all traffic (0-65535) from anywhere (0.0.0.0/0) - this is extremely insecure", [sg.address]) +} + +# Check for overly permissive rules via security group rules +deny contains msg if { + rule := security_group_rules[_] + rule.change.after.type == "ingress" + rule.change.after.from_port == 0 + rule.change.after.to_port == 65535 + "0.0.0.0/0" in rule.change.after.cidr_blocks + msg := sprintf("Security group rule '%s' allows all traffic (0-65535) from anywhere (0.0.0.0/0) - this is extremely insecure", [rule.address]) +} + +# Deny wide port ranges from 0.0.0.0/0 +deny contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.to_port - ingress.from_port > 1000 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows a wide port range (%d-%d) from anywhere (0.0.0.0/0) - consider restricting the range", [sg.address, ingress.from_port, ingress.to_port]) +} + +# Warn about database ports open to 0.0.0.0/0 +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3306 # MySQL + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 5432 # PostgreSQL + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 1433 # SQL Server + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 1521 # Oracle + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 27017 # MongoDB + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) +} + +# Warn about common application ports that might not need internet access +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 8080 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 9000 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 9090 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3000 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 4000 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} + +warn contains msg if { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 5000 + "0.0.0.0/0" in ingress.cidr_blocks + msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) +} \ No newline at end of file From e9e787a6b92f9f606d885bdcf2419a566cbdae5e Mon Sep 17 00:00:00 2001 From: jameslaneovermind <122231433+jameslaneovermind@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:54:47 +0100 Subject: [PATCH 2/4] Add policy checks job to GitHub Actions workflow Integrates OPA policy enforcement into the CI/CD pipeline: - Adds policy-checks job that runs in parallel with fmt and execute jobs - Uses overmindtech/policy-signals-action@v1 for automated policy enforcement - Generates Terraform plan JSON specifically for policy evaluation - Runs on all pull request events (opened, synchronize, reopened) - Posts policy violation results as PR comments The policy checks will now automatically validate: - Security group configurations (SSH/RDP exposure) - S3 security settings (encryption, tags, public access) - Cost control measures (expensive instances, required tags) Policy violations will be reported as failures in the GitHub Actions run and detailed results will be posted as pull request comments. --- .github/workflows/automatic.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/automatic.yml b/.github/workflows/automatic.yml index 04876cf..37f72e7 100644 --- a/.github/workflows/automatic.yml +++ b/.github/workflows/automatic.yml @@ -16,6 +16,34 @@ jobs: id: fmt run: terraform fmt -check -diff + # NEW: Policy checks job running in parallel + policy-checks: + runs-on: ubuntu-latest + if: github.event.action != 'closed' + permissions: + contents: read + id-token: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Terraform Init (for policies) + uses: ./.github/actions/terraform_init/ + with: + terraform_deploy_role: ${{ vars.TERRAFORM_DEPLOY_ROLE }} + + - name: Generate Plan for Policies + id: policy-plan + run: | + terraform plan -no-color -input=false -out tfplan.policies + terraform show -json tfplan.policies > tfplan.json + + - uses: overmindtech/policy-signals-action@v1 + with: + policies-path: './policies' + overmind-api-key: ${{ secrets.OVM_API_KEY }} + terraform-plan-json: './tfplan.json' + execute: runs-on: ubuntu-latest permissions: From c42418bdf3ad26f0f92f696ce4cf0b020ab8fc41 Mon Sep 17 00:00:00 2001 From: jameslaneovermind <122231433+jameslaneovermind@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:04:32 +0100 Subject: [PATCH 3/4] Fix OPA syntax compatibility for Conftest v0.46.0 Update all policy files to use older OPA syntax compatible with Conftest v0.46.0 (OPA 0.57.0) used in GitHub Actions: - Remove 'import rego.v1' statements - Change 'contains X if' to 'X[Y]' syntax - Change 'X in Y' to 'X == Y[_]' syntax - Fix duplicate package declarations Tested with Docker using openpolicyagent/conftest:v0.46.0 to match the GitHub Action environment exactly. Results: 36 tests, 27 passed, 2 warnings, 7 failures All expected policy violations detected correctly. --- policies/cost-control.rego | 104 ++++++++++++++++++++-------------- policies/s3-security.rego | 28 +++++---- policies/security-groups.rego | 78 +++++++++++++------------ 3 files changed, 113 insertions(+), 97 deletions(-) diff --git a/policies/cost-control.rego b/policies/cost-control.rego index 16428ca..4f3f85c 100644 --- a/policies/cost-control.rego +++ b/policies/cost-control.rego @@ -1,106 +1,126 @@ package main -import rego.v1 - # Cost Control Policy # Checks for expensive instance types and configurations # Get all EC2 instances from terraform plan -ec2_instances contains instance if { +ec2_instances[instance] { instance := input.resource_changes[_] instance.type == "aws_instance" } # Get all RDS instances from terraform plan -rds_instances contains instance if { +rds_instances[instance] { instance := input.resource_changes[_] instance.type == "aws_db_instance" } # Get all RDS clusters from terraform plan -rds_clusters contains cluster if { +rds_clusters[cluster] { cluster := input.resource_changes[_] cluster.type == "aws_rds_cluster" } -# Expensive EC2 instance types +# List of expensive EC2 instance types expensive_ec2_types := { "m5.24xlarge", "m5.16xlarge", "m5.12xlarge", - "c5.24xlarge", "c5.18xlarge", "c5.12xlarge", "r5.24xlarge", "r5.16xlarge", "r5.12xlarge", - "x1.32xlarge", "x1.16xlarge", "x1e.32xlarge", - "p3.16xlarge", "p3.8xlarge", "p2.16xlarge", - "g3.16xlarge", "g3.8xlarge", "g4dn.16xlarge" + "c5.24xlarge", "c5.18xlarge", "c5.12xlarge", + "x1.32xlarge", "x1.16xlarge", + "r4.16xlarge", "r4.8xlarge", + "m4.16xlarge", "m4.10xlarge", + "c4.8xlarge", + "p3.16xlarge", "p3.8xlarge", "p3.2xlarge", + "p2.16xlarge", "p2.8xlarge", + "g3.16xlarge", "g3.8xlarge" } -# Expensive RDS instance types +# List of expensive RDS instance types expensive_rds_types := { - "db.m5.24xlarge", "db.m5.16xlarge", "db.m5.12xlarge", "db.r5.24xlarge", "db.r5.16xlarge", "db.r5.12xlarge", - "db.x1.32xlarge", "db.x1.16xlarge", "db.x1e.32xlarge" + "db.r4.16xlarge", "db.r4.8xlarge", + "db.m5.24xlarge", "db.m5.16xlarge", "db.m5.12xlarge", + "db.m4.16xlarge", "db.m4.10xlarge", + "db.x1.32xlarge", "db.x1.16xlarge" +} + +# High-cost regions (typically more expensive than us-east-1) +high_cost_regions := { + "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", + "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1" } # Deny expensive EC2 instance types -deny contains msg if { +deny[msg] { instance := ec2_instances[_] - instance.change.after.instance_type in expensive_ec2_types + expensive_ec2_types[instance.change.after.instance_type] msg := sprintf("EC2 instance '%s' uses expensive instance type '%s' - consider using a smaller instance type", [instance.address, instance.change.after.instance_type]) } # Deny expensive RDS instance types -deny contains msg if { +deny[msg] { instance := rds_instances[_] - instance.change.after.instance_class in expensive_rds_types - msg := sprintf("RDS instance '%s' uses expensive instance class '%s' - consider using a smaller instance class", [instance.address, instance.change.after.instance_class]) + expensive_rds_types[instance.change.after.instance_class] + msg := sprintf("RDS instance '%s' uses expensive instance type '%s' - consider using a smaller instance type", [instance.address, instance.change.after.instance_class]) } -# Deny RDS clusters without cost-effective configurations -deny contains msg if { +# Deny RDS clusters without deletion protection in production +deny[msg] { cluster := rds_clusters[_] + cluster.change.after.tags.Environment == "prod" not cluster.change.after.deletion_protection - msg := sprintf("RDS cluster '%s' does not have deletion protection enabled - this could lead to accidental expensive data loss", [cluster.address]) + msg := sprintf("RDS cluster '%s' in production does not have deletion protection enabled", [cluster.address]) } -# Warn about instances without cost control tags -warn contains msg if { +deny[msg] { + cluster := rds_clusters[_] + cluster.change.after.tags.Environment == "production" + not cluster.change.after.deletion_protection + msg := sprintf("RDS cluster '%s' in production does not have deletion protection enabled", [cluster.address]) +} + +# Warn about missing cost tracking tags +warn[msg] { instance := ec2_instances[_] not instance.change.after.tags.CostCenter msg := sprintf("EC2 instance '%s' is missing 'CostCenter' tag for cost tracking", [instance.address]) } -warn contains msg if { +warn[msg] { instance := rds_instances[_] not instance.change.after.tags.CostCenter msg := sprintf("RDS instance '%s' is missing 'CostCenter' tag for cost tracking", [instance.address]) } -# Warn about production instances in expensive regions -warn contains msg if { +# Warn about instances in high-cost regions for production workloads +warn[msg] { instance := ec2_instances[_] instance.change.after.tags.Environment == "prod" - # This is a simplified check - in practice you'd check the provider region + provider_region := input.configuration.provider_config.aws.expressions.region.constant_value + high_cost_regions[provider_region] msg := sprintf("Production EC2 instance '%s' - ensure you're using the most cost-effective region", [instance.address]) } -# Warn about instances without scheduled start/stop for dev environments -warn contains msg if { +warn[msg] { instance := ec2_instances[_] - instance.change.after.tags.Environment == "dev" - not instance.change.after.tags.Schedule - msg := sprintf("Development EC2 instance '%s' is missing 'Schedule' tag - consider auto-shutdown to reduce costs", [instance.address]) + instance.change.after.tags.Environment == "production" + provider_region := input.configuration.provider_config.aws.expressions.region.constant_value + high_cost_regions[provider_region] + msg := sprintf("Production EC2 instance '%s' - ensure you're using the most cost-effective region", [instance.address]) } -# Warn about RDS instances without backup retention optimization -warn contains msg if { - instance := rds_instances[_] - instance.change.after.backup_retention_period > 7 +# Warn about dev instances without auto-shutdown +warn[msg] { + instance := ec2_instances[_] instance.change.after.tags.Environment == "dev" - msg := sprintf("Development RDS instance '%s' has backup retention > 7 days - consider reducing for cost savings", [instance.address]) + not instance.change.after.tags.AutoShutdown + msg := sprintf("Development EC2 instance '%s' should have 'AutoShutdown' tag to reduce costs", [instance.address]) } -# Warn about instances with high storage allocation -warn contains msg if { - instance := rds_instances[_] - instance.change.after.allocated_storage > 1000 - msg := sprintf("RDS instance '%s' has high storage allocation (%d GB) - ensure this is necessary", [instance.address, instance.change.after.allocated_storage]) +warn[msg] { + instance := ec2_instances[_] + instance.change.after.tags.Environment == "development" + not instance.change.after.tags.AutoShutdown + msg := sprintf("Development EC2 instance '%s' should have 'AutoShutdown' tag to reduce costs", [instance.address]) } \ No newline at end of file diff --git a/policies/s3-security.rego b/policies/s3-security.rego index 7c2d9be..d519601 100644 --- a/policies/s3-security.rego +++ b/policies/s3-security.rego @@ -1,89 +1,87 @@ package main -import rego.v1 - # S3 Security Policy # Checks for S3 buckets without proper security configurations # Get all S3 bucket resources from terraform plan -s3_buckets contains bucket if { +s3_buckets[bucket] { bucket := input.resource_changes[_] bucket.type == "aws_s3_bucket" } -s3_bucket_public_access_blocks contains block if { +s3_bucket_public_access_blocks[block] { block := input.resource_changes[_] block.type == "aws_s3_bucket_public_access_block" } -s3_bucket_encryption contains encrypt if { +s3_bucket_encryption[encrypt] { encrypt := input.resource_changes[_] encrypt.type == "aws_s3_bucket_server_side_encryption_configuration" } # Deny S3 buckets without Environment tag -deny contains msg if { +deny[msg] { bucket := s3_buckets[_] not bucket.change.after.tags.Environment msg := sprintf("S3 bucket '%s' is missing the required 'Environment' tag", [bucket.address]) } # Deny S3 buckets without Name tag -deny contains msg if { +deny[msg] { bucket := s3_buckets[_] not bucket.change.after.tags.Name msg := sprintf("S3 bucket '%s' is missing the required 'Name' tag", [bucket.address]) } # Deny S3 buckets without Owner tag -deny contains msg if { +deny[msg] { bucket := s3_buckets[_] not bucket.change.after.tags.Owner msg := sprintf("S3 bucket '%s' is missing the required 'Owner' tag", [bucket.address]) } # Deny S3 buckets without Project tag -deny contains msg if { +deny[msg] { bucket := s3_buckets[_] not bucket.change.after.tags.Project msg := sprintf("S3 bucket '%s' is missing the required 'Project' tag", [bucket.address]) } # Deny S3 buckets without encryption -deny contains msg if { +deny[msg] { bucket := s3_buckets[_] not has_bucket_encryption(bucket.change.after.bucket) msg := sprintf("S3 bucket '%s' does not have server-side encryption configured", [bucket.address]) } # Warn about S3 buckets without public access block -warn contains msg if { +warn[msg] { bucket := s3_buckets[_] not has_public_access_block(bucket.change.after.bucket) msg := sprintf("S3 bucket '%s' does not have public access block configured - consider adding one for security", [bucket.address]) } # Warn about S3 buckets that might be publicly accessible -warn contains msg if { +warn[msg] { bucket := s3_buckets[_] bucket.change.after.acl == "public-read" msg := sprintf("S3 bucket '%s' has public-read ACL - ensure this is intentional", [bucket.address]) } -warn contains msg if { +warn[msg] { bucket := s3_buckets[_] bucket.change.after.acl == "public-read-write" msg := sprintf("S3 bucket '%s' has public-read-write ACL - this is a security risk", [bucket.address]) } # Helper function to check if bucket has encryption configuration -has_bucket_encryption(bucket_name) if { +has_bucket_encryption(bucket_name) { encryption := s3_bucket_encryption[_] encryption.change.after.bucket == bucket_name } # Helper function to check if bucket has public access block -has_public_access_block(bucket_name) if { +has_public_access_block(bucket_name) { block := s3_bucket_public_access_blocks[_] block.change.after.bucket == bucket_name } \ No newline at end of file diff --git a/policies/security-groups.rego b/policies/security-groups.rego index 0d861a9..cb650a8 100644 --- a/policies/security-groups.rego +++ b/policies/security-groups.rego @@ -1,169 +1,167 @@ package main -import rego.v1 - # Get all security groups being created or modified -security_groups contains sg if { +security_groups[sg] { sg := input.resource_changes[_] sg.type == "aws_security_group" } -security_group_rules contains rule if { +security_group_rules[rule] { rule := input.resource_changes[_] rule.type == "aws_security_group_rule" } # Deny SSH access from 0.0.0.0/0 -deny contains msg if { +deny[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 22 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows SSH (port 22) access from anywhere (0.0.0.0/0) - this is a security risk", [sg.address]) } # Check for SSH via security group rules -deny contains msg if { +deny[msg] { rule := security_group_rules[_] rule.change.after.type == "ingress" rule.change.after.from_port == 22 - "0.0.0.0/0" in rule.change.after.cidr_blocks + "0.0.0.0/0" == rule.change.after.cidr_blocks[_] msg := sprintf("Security group rule '%s' allows SSH (port 22) access from anywhere (0.0.0.0/0) - this is a security risk", [rule.address]) } # Deny RDP access from 0.0.0.0/0 -deny contains msg if { +deny[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 3389 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows RDP (port 3389) access from anywhere (0.0.0.0/0) - this is a security risk", [sg.address]) } # Check for RDP via security group rules -deny contains msg if { +deny[msg] { rule := security_group_rules[_] rule.change.after.type == "ingress" rule.change.after.from_port == 3389 - "0.0.0.0/0" in rule.change.after.cidr_blocks + "0.0.0.0/0" == rule.change.after.cidr_blocks[_] msg := sprintf("Security group rule '%s' allows RDP (port 3389) access from anywhere (0.0.0.0/0) - this is a security risk", [rule.address]) } # Deny overly permissive rules (all traffic from anywhere) -deny contains msg if { +deny[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 0 ingress.to_port == 65535 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows all traffic (0-65535) from anywhere (0.0.0.0/0) - this is extremely insecure", [sg.address]) } # Check for overly permissive rules via security group rules -deny contains msg if { +deny[msg] { rule := security_group_rules[_] rule.change.after.type == "ingress" rule.change.after.from_port == 0 rule.change.after.to_port == 65535 - "0.0.0.0/0" in rule.change.after.cidr_blocks + "0.0.0.0/0" == rule.change.after.cidr_blocks[_] msg := sprintf("Security group rule '%s' allows all traffic (0-65535) from anywhere (0.0.0.0/0) - this is extremely insecure", [rule.address]) } # Deny wide port ranges from 0.0.0.0/0 -deny contains msg if { +deny[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.to_port - ingress.from_port > 1000 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows a wide port range (%d-%d) from anywhere (0.0.0.0/0) - consider restricting the range", [sg.address, ingress.from_port, ingress.to_port]) } # Warn about database ports open to 0.0.0.0/0 -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 3306 # MySQL - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 5432 # PostgreSQL - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 1433 # SQL Server - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 1521 # Oracle - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 27017 # MongoDB - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows database port %d access from anywhere (0.0.0.0/0) - consider restricting access", [sg.address, ingress.from_port]) } # Warn about common application ports that might not need internet access -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 8080 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 9000 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 9090 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 3000 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 4000 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } -warn contains msg if { +warn[msg] { sg := security_groups[_] ingress := sg.change.after.ingress[_] ingress.from_port == 5000 - "0.0.0.0/0" in ingress.cidr_blocks + "0.0.0.0/0" == ingress.cidr_blocks[_] msg := sprintf("Security group '%s' allows application port %d access from anywhere (0.0.0.0/0) - ensure this is intended for public access", [sg.address, ingress.from_port]) } \ No newline at end of file From b2f133d49f74b3087050b09da5731eeb4eaacab2 Mon Sep 17 00:00:00 2001 From: jameslaneovermind <122231433+jameslaneovermind@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:47:03 +0100 Subject: [PATCH 4/4] Integrate policy enforcement with Overmind custom signals - Restructure workflow to run policy checks after plan submission - Add outputs to capture Terraform Cloud run URL from submit-plan - Create separate policy-checks job that depends on execute job - Pass custom ticket-link to policy-signals-action for proper Terraform Cloud integration - Policy violations now appear in Overmind UI linked to correct Terraform runs --- .github/workflows/automatic.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/automatic.yml b/.github/workflows/automatic.yml index 37f72e7..4865772 100644 --- a/.github/workflows/automatic.yml +++ b/.github/workflows/automatic.yml @@ -16,10 +16,10 @@ jobs: id: fmt run: terraform fmt -check -diff - # NEW: Policy checks job running in parallel policy-checks: runs-on: ubuntu-latest if: github.event.action != 'closed' + needs: execute permissions: contents: read id-token: write @@ -32,20 +32,26 @@ jobs: with: terraform_deploy_role: ${{ vars.TERRAFORM_DEPLOY_ROLE }} - - name: Generate Plan for Policies - id: policy-plan + - name: Download terraform plan + uses: actions/download-artifact@v4 + with: + name: tfplan + + - name: Convert plan to JSON run: | - terraform plan -no-color -input=false -out tfplan.policies - terraform show -json tfplan.policies > tfplan.json + terraform show -json tfplan > tfplan.json - uses: overmindtech/policy-signals-action@v1 with: policies-path: './policies' - overmind-api-key: ${{ secrets.OVM_API_KEY }} terraform-plan-json: './tfplan.json' + overmind-api-key: ${{ secrets.OVM_API_KEY }} + ticket-link: ${{ needs.execute.outputs.run-url }} execute: runs-on: ubuntu-latest + outputs: + run-url: ${{ steps.submit-plan.outputs.run-url }} permissions: contents: read # required for checkout id-token: write # mint AWS credentials through OIDC