Files
2025-11-29 17:51:02 +08:00

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

References