15 KiB
15 KiB
Infrastructure-as-Code Security Policies
OPA policies for validating infrastructure-as-code configurations in Terraform, CloudFormation, and other IaC tools.
Table of Contents
Terraform Policies
S3 Bucket Security
package terraform.aws.s3
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_encryption(resource)
msg := sprintf("S3 bucket must have encryption enabled: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_versioning(resource)
msg := sprintf("S3 bucket must have versioning enabled: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_public_access_block"
resource.change.after.block_public_acls == false
msg := sprintf("S3 bucket must block public ACLs: %v", [resource.name])
}
has_encryption(resource) {
resource.change.after.server_side_encryption_configuration
}
has_versioning(resource) {
resource.change.after.versioning[_].enabled == true
}
EC2 Instance Security
package terraform.aws.ec2
# Deny instances without IMDSv2
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not resource.change.after.metadata_options.http_tokens == "required"
msg := sprintf("EC2 instance must use IMDSv2: %v", [resource.name])
}
# Deny instances with public IPs in production
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
resource.change.after.associate_public_ip_address == true
is_production_environment
msg := sprintf("Production EC2 instances cannot have public IPs: %v", [resource.name])
}
# Require monitoring
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
resource.change.after.monitoring != true
msg := sprintf("EC2 instance must have detailed monitoring enabled: %v", [resource.name])
}
is_production_environment {
input.variables.environment == "production"
}
RDS Database Security
package terraform.aws.rds
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
not resource.change.after.storage_encrypted
msg := sprintf("RDS instance must have encryption enabled: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.publicly_accessible == true
msg := sprintf("RDS instance cannot be publicly accessible: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
not resource.change.after.backup_retention_period
msg := sprintf("RDS instance must have backup retention configured: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.backup_retention_period < 7
msg := sprintf("RDS instance must have at least 7 days backup retention: %v", [resource.name])
}
IAM Policy Security
package terraform.aws.iam
# Deny wildcard actions in IAM policies
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
statement := resource.change.after.policy.Statement[_]
statement.Action[_] == "*"
msg := sprintf("IAM policy cannot use wildcard actions: %v", [resource.name])
}
# Deny wildcard resources
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
statement := resource.change.after.policy.Statement[_]
statement.Resource[_] == "*"
statement.Effect == "Allow"
msg := sprintf("IAM policy cannot use wildcard resources with Allow: %v", [resource.name])
}
# Deny policies without conditions for sensitive actions
sensitive_actions := [
"iam:CreateUser",
"iam:DeleteUser",
"iam:AttachUserPolicy",
"kms:Decrypt",
]
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
statement := resource.change.after.policy.Statement[_]
action := statement.Action[_]
sensitive_actions[_] == action
not statement.Condition
msg := sprintf("Sensitive IAM action requires conditions: %v in %v", [action, resource.name])
}
Security Group Rules
package terraform.aws.security_groups
# Deny SSH from internet
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.type == "ingress"
resource.change.after.from_port == 22
resource.change.after.to_port == 22
is_open_to_internet(resource.change.after.cidr_blocks)
msg := sprintf("Security group rule allows SSH from internet: %v", [resource.name])
}
# Deny RDP from internet
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.type == "ingress"
resource.change.after.from_port == 3389
resource.change.after.to_port == 3389
is_open_to_internet(resource.change.after.cidr_blocks)
msg := sprintf("Security group rule allows RDP from internet: %v", [resource.name])
}
# Deny unrestricted ingress
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.type == "ingress"
is_open_to_internet(resource.change.after.cidr_blocks)
not is_allowed_public_port(resource.change.after.from_port)
msg := sprintf("Security group rule allows unrestricted ingress: %v", [resource.name])
}
is_open_to_internet(cidr_blocks) {
cidr_blocks[_] == "0.0.0.0/0"
}
# Allowed public ports (HTTP/HTTPS)
is_allowed_public_port(port) {
port == 80
}
is_allowed_public_port(port) {
port == 443
}
KMS Key Security
package terraform.aws.kms
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_kms_key"
not resource.change.after.enable_key_rotation
msg := sprintf("KMS key must have automatic rotation enabled: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_kms_key"
not resource.change.after.deletion_window_in_days
msg := sprintf("KMS key must have deletion window configured: %v", [resource.name])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_kms_key"
resource.change.after.deletion_window_in_days < 30
msg := sprintf("KMS key deletion window must be at least 30 days: %v", [resource.name])
}
CloudWatch Logging
package terraform.aws.logging
# Require CloudWatch logs for Lambda
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_lambda_function"
not has_cloudwatch_logs(resource.name)
msg := sprintf("Lambda function must have CloudWatch logs configured: %v", [resource.name])
}
has_cloudwatch_logs(function_name) {
resource := input.resource_changes[_]
resource.type == "aws_cloudwatch_log_group"
contains(resource.change.after.name, function_name)
}
AWS CloudFormation
S3 Bucket Security
package cloudformation.aws.s3
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::S3::Bucket"
not has_bucket_encryption(resource)
msg := sprintf("S3 bucket must have encryption: %v", [name])
}
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::S3::Bucket"
not has_versioning(resource)
msg := sprintf("S3 bucket must have versioning enabled: %v", [name])
}
has_bucket_encryption(resource) {
resource.Properties.BucketEncryption
}
has_versioning(resource) {
resource.Properties.VersioningConfiguration.Status == "Enabled"
}
EC2 Security Groups
package cloudformation.aws.ec2
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::EC2::SecurityGroup"
rule := resource.Properties.SecurityGroupIngress[_]
rule.CidrIp == "0.0.0.0/0"
rule.FromPort == 22
msg := sprintf("Security group allows SSH from internet: %v", [name])
}
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::EC2::SecurityGroup"
rule := resource.Properties.SecurityGroupIngress[_]
rule.CidrIp == "0.0.0.0/0"
rule.FromPort == 3389
msg := sprintf("Security group allows RDP from internet: %v", [name])
}
RDS Database
package cloudformation.aws.rds
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::RDS::DBInstance"
not resource.Properties.StorageEncrypted
msg := sprintf("RDS instance must have encryption enabled: %v", [name])
}
deny[msg] {
resource := input.Resources[name]
resource.Type == "AWS::RDS::DBInstance"
resource.Properties.PubliclyAccessible == true
msg := sprintf("RDS instance cannot be publicly accessible: %v", [name])
}
Azure ARM Templates
Storage Account Security
package azure.storage
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Storage/storageAccounts"
not resource.properties.supportsHttpsTrafficOnly
msg := sprintf("Storage account must require HTTPS: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Storage/storageAccounts"
resource.properties.allowBlobPublicAccess == true
msg := sprintf("Storage account must disable public blob access: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Storage/storageAccounts"
not resource.properties.minimumTlsVersion == "TLS1_2"
msg := sprintf("Storage account must use TLS 1.2 minimum: %v", [resource.name])
}
Virtual Machine Security
package azure.compute
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Compute/virtualMachines"
not has_managed_identity(resource)
msg := sprintf("Virtual machine should use managed identity: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Compute/virtualMachines"
not has_disk_encryption(resource)
msg := sprintf("Virtual machine must have disk encryption: %v", [resource.name])
}
has_managed_identity(vm) {
vm.identity.type
}
has_disk_encryption(vm) {
vm.properties.storageProfile.osDisk.encryptionSettings
}
Network Security Groups
package azure.network
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Network/networkSecurityGroups"
rule := resource.properties.securityRules[_]
rule.properties.access == "Allow"
rule.properties.sourceAddressPrefix == "*"
rule.properties.destinationPortRange == "22"
msg := sprintf("NSG allows SSH from internet: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "Microsoft.Network/networkSecurityGroups"
rule := resource.properties.securityRules[_]
rule.properties.access == "Allow"
rule.properties.sourceAddressPrefix == "*"
rule.properties.destinationPortRange == "3389"
msg := sprintf("NSG allows RDP from internet: %v", [resource.name])
}
GCP Deployment Manager
GCS Bucket Security
package gcp.storage
deny[msg] {
resource := input.resources[_]
resource.type == "storage.v1.bucket"
not has_uniform_access(resource)
msg := sprintf("GCS bucket must use uniform bucket-level access: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "storage.v1.bucket"
not has_encryption(resource)
msg := sprintf("GCS bucket must have encryption configured: %v", [resource.name])
}
has_uniform_access(bucket) {
bucket.properties.iamConfiguration.uniformBucketLevelAccess.enabled == true
}
has_encryption(bucket) {
bucket.properties.encryption
}
Compute Instance Security
package gcp.compute
deny[msg] {
resource := input.resources[_]
resource.type == "compute.v1.instance"
not has_service_account(resource)
msg := sprintf("Compute instance should use service account: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "compute.v1.instance"
not has_disk_encryption(resource)
msg := sprintf("Compute instance must have disk encryption: %v", [resource.name])
}
has_service_account(instance) {
instance.properties.serviceAccounts
}
has_disk_encryption(instance) {
instance.properties.disks[_].diskEncryptionKey
}
Firewall Rules
package gcp.network
deny[msg] {
resource := input.resources[_]
resource.type == "compute.v1.firewall"
resource.properties.direction == "INGRESS"
"0.0.0.0/0" == resource.properties.sourceRanges[_]
allowed := resource.properties.allowed[_]
allowed.ports[_] == "22"
msg := sprintf("Firewall rule allows SSH from internet: %v", [resource.name])
}
deny[msg] {
resource := input.resources[_]
resource.type == "compute.v1.firewall"
resource.properties.direction == "INGRESS"
"0.0.0.0/0" == resource.properties.sourceRanges[_]
allowed := resource.properties.allowed[_]
allowed.ports[_] == "3389"
msg := sprintf("Firewall rule allows RDP from internet: %v", [resource.name])
}
Conftest Integration
Example using Conftest for Terraform validation:
# Install conftest
brew install conftest
# Create policy directory
mkdir -p policy
# Write policy (policy/terraform.rego)
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not resource.change.after.server_side_encryption_configuration
msg := sprintf("S3 bucket must have encryption: %v", [resource.name])
}
# Generate Terraform plan
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Run conftest
conftest test tfplan.json
CI/CD Integration
GitHub Actions
name: IaC Policy Validation
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2
- name: Generate Terraform Plan
run: |
terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
- name: Validate with OPA
run: |
opa eval --data policies/ --input tfplan.json \
--format pretty 'data.terraform.deny' > violations.txt
if [ -s violations.txt ]; then
cat violations.txt
exit 1
fi
GitLab CI
iac-validation:
image: openpolicyagent/opa:latest
script:
- terraform init
- terraform plan -out=tfplan.binary
- terraform show -json tfplan.binary > tfplan.json
- opa eval --data policies/ --input tfplan.json 'data.terraform.deny'
only:
- merge_requests