diff --git a/.github/workflows/automatic.yml b/.github/workflows/automatic.yml index 04876cf..4865772 100644 --- a/.github/workflows/automatic.yml +++ b/.github/workflows/automatic.yml @@ -16,8 +16,42 @@ jobs: id: fmt run: terraform fmt -check -diff + policy-checks: + runs-on: ubuntu-latest + if: github.event.action != 'closed' + needs: execute + 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: Download terraform plan + uses: actions/download-artifact@v4 + with: + name: tfplan + + - name: Convert plan to JSON + run: | + terraform show -json tfplan > tfplan.json + + - uses: overmindtech/policy-signals-action@v1 + with: + policies-path: './policies' + 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 diff --git a/policies/cost-control.rego b/policies/cost-control.rego new file mode 100644 index 0000000..4f3f85c --- /dev/null +++ b/policies/cost-control.rego @@ -0,0 +1,126 @@ +package main + +# Cost Control Policy +# Checks for expensive instance types and configurations + +# Get all EC2 instances from terraform plan +ec2_instances[instance] { + instance := input.resource_changes[_] + instance.type == "aws_instance" +} + +# Get all RDS instances from terraform plan +rds_instances[instance] { + instance := input.resource_changes[_] + instance.type == "aws_db_instance" +} + +# Get all RDS clusters from terraform plan +rds_clusters[cluster] { + cluster := input.resource_changes[_] + cluster.type == "aws_rds_cluster" +} + +# List of expensive EC2 instance types +expensive_ec2_types := { + "m5.24xlarge", "m5.16xlarge", "m5.12xlarge", + "r5.24xlarge", "r5.16xlarge", "r5.12xlarge", + "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" +} + +# List of expensive RDS instance types +expensive_rds_types := { + "db.r5.24xlarge", "db.r5.16xlarge", "db.r5.12xlarge", + "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[msg] { + instance := ec2_instances[_] + 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[msg] { + instance := rds_instances[_] + 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 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' in production does not have deletion protection enabled", [cluster.address]) +} + +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[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 instances in high-cost regions for production workloads +warn[msg] { + instance := ec2_instances[_] + instance.change.after.tags.Environment == "prod" + 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[msg] { + instance := ec2_instances[_] + 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 dev instances without auto-shutdown +warn[msg] { + instance := ec2_instances[_] + instance.change.after.tags.Environment == "dev" + not instance.change.after.tags.AutoShutdown + msg := sprintf("Development EC2 instance '%s' should have 'AutoShutdown' tag to reduce costs", [instance.address]) +} + +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 new file mode 100644 index 0000000..d519601 --- /dev/null +++ b/policies/s3-security.rego @@ -0,0 +1,87 @@ +package main + +# S3 Security Policy +# Checks for S3 buckets without proper security configurations + +# Get all S3 bucket resources from terraform plan +s3_buckets[bucket] { + bucket := input.resource_changes[_] + bucket.type == "aws_s3_bucket" +} + +s3_bucket_public_access_blocks[block] { + block := input.resource_changes[_] + block.type == "aws_s3_bucket_public_access_block" +} + +s3_bucket_encryption[encrypt] { + encrypt := input.resource_changes[_] + encrypt.type == "aws_s3_bucket_server_side_encryption_configuration" +} + +# Deny S3 buckets without Environment tag +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[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[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[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[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[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[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[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) { + 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) { + 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..cb650a8 --- /dev/null +++ b/policies/security-groups.rego @@ -0,0 +1,167 @@ +package main + +# Get all security groups being created or modified +security_groups[sg] { + sg := input.resource_changes[_] + sg.type == "aws_security_group" +} + +security_group_rules[rule] { + rule := input.resource_changes[_] + rule.type == "aws_security_group_rule" +} + +# Deny SSH access from 0.0.0.0/0 +deny[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 22 + "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[msg] { + rule := security_group_rules[_] + rule.change.after.type == "ingress" + rule.change.after.from_port == 22 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3389 + "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[msg] { + rule := security_group_rules[_] + rule.change.after.type == "ingress" + rule.change.after.from_port == 3389 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 0 + ingress.to_port == 65535 + "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[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" == 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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.to_port - ingress.from_port > 1000 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3306 # MySQL + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 5432 # PostgreSQL + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 1433 # SQL Server + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 1521 # Oracle + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 27017 # MongoDB + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 8080 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 9000 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 9090 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 3000 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 4000 + "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[msg] { + sg := security_groups[_] + ingress := sg.change.after.ingress[_] + ingress.from_port == 5000 + "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