Β· PathShield Team Β· Tutorials  Β· 11 min read

Terraform Security Scanning in CI/CD - Catch Issues Before Deploy

Stop deploying vulnerable infrastructure. Learn how to integrate Terraform security scanning into your CI/CD pipeline with practical examples and scripts.

Stop deploying vulnerable infrastructure. Learn how to integrate Terraform security scanning into your CI/CD pipeline with practical examples and scripts.

Terraform Security Scanning in CI/CD - Catch Issues Before Deploy

Your Terraform code creates an S3 bucket with public read access. Your security team finds it three weeks later during an audit. By then, you’ve got compliance violations, audit findings, and a very unhappy CISO. This guide shows you how to catch these issues in your CI/CD pipeline, not in production.

Why Terraform Security Scanning Matters

The problem with post-deployment scanning:

  • Security issues are already in production
  • Fixing requires another deployment cycle
  • Creates technical debt and risk exposure
  • Teams resist fixing β€œworking” code

The power of pre-deployment scanning:

  • Catch issues before they reach production
  • Fix is just updating the code
  • No downtime or rollback needed
  • Teams build security habits naturally

The Complete Terraform Security Pipeline

Here’s a production-ready CI/CD pipeline that scans Terraform code before deployment:

# .github/workflows/terraform-security.yml
name: Terraform Security Pipeline

on:
  pull_request:
    paths:
      - 'terraform/**'
      - '*.tf'
  push:
    branches: [main]

env:
  TF_VERSION: '1.6.0'

jobs:
  security-scan:
    name: Security Scan
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
          
      - name: Terraform Init
        run: terraform init -backend=false
        
      - name: Terraform Validate
        run: terraform validate
        
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          framework: terraform
          output_format: sarif
          output_file_path: checkov-results.sarif
          
      - name: Run TFSec
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          soft_fail: false
          
      - name: Run Terrascan
        run: |
          curl -L "$(curl -s https://api.github.com/repos/tenable/terrascan/releases/latest | grep -o -E "https://.+?_Linux_x86_64.tar.gz")" > terrascan.tar.gz
          tar -xf terrascan.tar.gz terrascan && rm terrascan.tar.gz
          ./terrascan scan -t terraform -d .
          
      - name: Run Custom Security Checks
        run: |
          python scripts/custom_tf_security_scan.py
          
      - name: Upload SARIF Results
        uses: github/codeql-action/upload-sarif@v2
        if: always()
        with:
          sarif_file: checkov-results.sarif
          
      - name: Comment PR with Results
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const results = fs.readFileSync('scan-results.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## πŸ”’ Terraform Security Scan Results\n\n\`\`\`\n${results}\n\`\`\``
            });

  plan-and-deploy:
    name: Plan and Deploy
    runs-on: ubuntu-latest
    needs: security-scan
    if: github.ref == 'refs/heads/main'
    
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
          
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: ${{ env.TF_VERSION }}
          
      - name: Terraform Plan
        run: |
          terraform init
          terraform plan -out=tfplan
          
      - name: Scan Terraform Plan
        run: |
          terraform show -json tfplan > tfplan.json
          python scripts/scan_terraform_plan.py tfplan.json
          
      - name: Terraform Apply
        if: success()
        run: terraform apply tfplan

Security Scanning Tools Comparison

1. Checkov (Comprehensive)

# Install Checkov
pip install checkov

# Scan Terraform files
checkov -f main.tf --framework terraform

# Example output:
# Check: CKV_AWS_18: "Ensure the S3 bucket has access logging configured"
# FAILED for resource: aws_s3_bucket.example
# File: /main.tf:1-5

Best for: Comprehensive policy coverage, custom policies, multiple IaC formats

2. TFSec (Fast & Focused)

# Install TFSec
brew install tfsec

# Scan current directory
tfsec .

# Example output:
# Result #1 HIGH S3 bucket is publicly readable
# ──────────────────────────────────────────────────────────────────────────────
#   main.tf:10-14
# 
#   resource "aws_s3_bucket_public_access_block" "example" {
#     bucket = aws_s3_bucket.example.id
#   }

Best for: Speed, Terraform-specific issues, CI/CD integration

3. Terrascan (Policy Engine)

# Install Terrascan
curl -L "$(curl -s https://api.github.com/repos/tenable/terrascan/releases/latest | grep -o -E "https://.+?_Linux_x86_64.tar.gz")" > terrascan.tar.gz
tar -xf terrascan.tar.gz terrascan

# Scan with custom policies
./terrascan scan -p policies/ -t terraform

Best for: Custom policy engines, compliance frameworks, detailed reporting

Custom Security Scanning Script

#!/usr/bin/env python3
"""
Custom Terraform security scanner
Checks for startup-specific security issues
"""

import os
import re
import json
import sys
from pathlib import Path

class TerraformSecurityScanner:
    def __init__(self):
        self.findings = []
        self.critical_count = 0
        self.high_count = 0
        
    def scan_directory(self, directory='.'):
        """Scan all .tf files in directory"""
        tf_files = list(Path(directory).rglob('*.tf'))
        
        print(f"Scanning {len(tf_files)} Terraform files...")
        
        for tf_file in tf_files:
            self.scan_file(tf_file)
            
    def scan_file(self, file_path):
        """Scan individual Terraform file"""
        try:
            with open(file_path, 'r') as f:
                content = f.read()
                
            # Check for common security issues
            self.check_hardcoded_secrets(file_path, content)
            self.check_public_resources(file_path, content)
            self.check_encryption(file_path, content)
            self.check_network_security(file_path, content)
            self.check_iam_policies(file_path, content)
            
        except Exception as e:
            print(f"Error scanning {file_path}: {e}")
    
    def check_hardcoded_secrets(self, file_path, content):
        """Check for hardcoded secrets"""
        secret_patterns = [
            (r'password\s*=\s*"[^"]*"', 'Hardcoded password'),
            (r'secret_key\s*=\s*"[^"]*"', 'Hardcoded secret key'),
            (r'access_key\s*=\s*"AKIA[0-9A-Z]{16}"', 'AWS access key'),
            (r'private_key\s*=\s*"-----BEGIN', 'Private key in code'),
            (r'token\s*=\s*"[a-zA-Z0-9]{20,}"', 'Hardcoded token')
        ]
        
        for pattern, description in secret_patterns:
            if re.search(pattern, content, re.IGNORECASE):
                self.add_finding(
                    file_path, 'CRITICAL', 'Hardcoded Secret',
                    f"{description} found in {file_path}"
                )
    
    def check_public_resources(self, file_path, content):
        """Check for publicly accessible resources"""
        
        # S3 bucket public access
        if 'aws_s3_bucket_public_access_block' not in content:
            if 'aws_s3_bucket' in content:
                self.add_finding(
                    file_path, 'HIGH', 'S3 Security',
                    'S3 bucket without public access block'
                )
        
        # RDS public access
        if re.search(r'publicly_accessible\s*=\s*true', content):
            self.add_finding(
                file_path, 'CRITICAL', 'Database Security',
                'RDS instance configured as publicly accessible'
            )
        
        # Security groups with 0.0.0.0/0
        if re.search(r'cidr_blocks\s*=\s*\["0\.0\.0\.0/0"\]', content):
            self.add_finding(
                file_path, 'HIGH', 'Network Security',
                'Security group allows access from anywhere (0.0.0.0/0)'
            )
    
    def check_encryption(self, file_path, content):
        """Check for encryption configurations"""
        
        # S3 bucket encryption
        if 'aws_s3_bucket' in content and 'aws_s3_bucket_server_side_encryption_configuration' not in content:
            self.add_finding(
                file_path, 'MEDIUM', 'Encryption',
                'S3 bucket without server-side encryption'
            )
        
        # RDS encryption
        if 'aws_db_instance' in content and 'storage_encrypted = true' not in content:
            self.add_finding(
                file_path, 'MEDIUM', 'Encryption',
                'RDS instance without encryption at rest'
            )
        
        # EBS encryption
        if 'aws_instance' in content and 'encrypted = true' not in content:
            self.add_finding(
                file_path, 'MEDIUM', 'Encryption',
                'EC2 instance without encrypted EBS volumes'
            )
    
    def check_network_security(self, file_path, content):
        """Check network security configurations"""
        
        # VPC configuration
        if 'aws_instance' in content and 'subnet_id' not in content:
            self.add_finding(
                file_path, 'MEDIUM', 'Network Security',
                'EC2 instance without explicit subnet configuration'
            )
        
        # Default security group usage
        if re.search(r'security_groups\s*=\s*\["default"\]', content):
            self.add_finding(
                file_path, 'LOW', 'Network Security',
                'Using default security group'
            )
    
    def check_iam_policies(self, file_path, content):
        """Check IAM policy configurations"""
        
        # Overly permissive policies
        if re.search(r'"Action"\s*:\s*"\*"', content):
            self.add_finding(
                file_path, 'HIGH', 'IAM Security',
                'IAM policy with wildcard actions (*)'
            )
        
        if re.search(r'"Resource"\s*:\s*"\*"', content):
            self.add_finding(
                file_path, 'MEDIUM', 'IAM Security',
                'IAM policy with wildcard resources (*)'
            )
        
        # Root account usage
        if re.search(r'"Principal"\s*:\s*"\*"', content):
            self.add_finding(
                file_path, 'CRITICAL', 'IAM Security',
                'Policy allows access from any principal (*)'
            )
    
    def add_finding(self, file_path, severity, category, description):
        """Add security finding"""
        self.findings.append({
            'file': str(file_path),
            'severity': severity,
            'category': category,
            'description': description
        })
        
        if severity == 'CRITICAL':
            self.critical_count += 1
        elif severity == 'HIGH':
            self.high_count += 1
    
    def generate_report(self):
        """Generate security report"""
        print(f"\n{'='*80}")
        print("TERRAFORM SECURITY SCAN RESULTS")
        print(f"{'='*80}")
        
        if not self.findings:
            print("βœ… No security issues found!")
            return True
        
        print(f"Total findings: {len(self.findings)}")
        print(f"Critical: {self.critical_count}")
        print(f"High: {self.high_count}")
        
        # Group findings by severity
        by_severity = {}
        for finding in self.findings:
            severity = finding['severity']
            if severity not in by_severity:
                by_severity[severity] = []
            by_severity[severity].append(finding)
        
        # Show critical and high severity issues
        for severity in ['CRITICAL', 'HIGH']:
            if severity in by_severity:
                print(f"\n{severity} SEVERITY:")
                for finding in by_severity[severity]:
                    print(f"  πŸ“ {finding['file']}")
                    print(f"     {finding['category']}: {finding['description']}")
        
        # Fail if critical or high severity issues found
        return self.critical_count == 0 and self.high_count == 0
    
    def generate_sarif(self, output_file='terraform-security.sarif'):
        """Generate SARIF output for GitHub integration"""
        sarif_output = {
            "version": "2.1.0",
            "runs": [{
                "tool": {
                    "driver": {
                        "name": "Custom Terraform Scanner",
                        "version": "1.0.0"
                    }
                },
                "results": []
            }]
        }
        
        for finding in self.findings:
            result = {
                "ruleId": finding['category'].replace(' ', '_').lower(),
                "level": finding['severity'].lower(),
                "message": {
                    "text": finding['description']
                },
                "locations": [{
                    "physicalLocation": {
                        "artifactLocation": {
                            "uri": finding['file']
                        }
                    }
                }]
            }
            sarif_output["runs"][0]["results"].append(result)
        
        with open(output_file, 'w') as f:
            json.dump(sarif_output, f, indent=2)
        
        print(f"SARIF report generated: {output_file}")

def main():
    scanner = TerraformSecurityScanner()
    scanner.scan_directory()
    
    success = scanner.generate_report()
    scanner.generate_sarif()
    
    # Exit with error code if issues found
    sys.exit(0 if success else 1)

if __name__ == '__main__':
    main()

Terraform Plan Security Scanning

Scan the actual deployment plan, not just the code:

#!/usr/bin/env python3
"""
Terraform Plan Security Scanner
Analyzes terraform plan JSON for security issues
"""

import json
import sys

def scan_terraform_plan(plan_file):
    """Scan terraform plan for security issues"""
    
    with open(plan_file, 'r') as f:
        plan = json.load(f)
    
    findings = []
    
    for change in plan.get('resource_changes', []):
        resource_type = change.get('type')
        resource_name = change.get('name')
        change_actions = change.get('change', {}).get('actions', [])
        
        if 'create' not in change_actions and 'update' not in change_actions:
            continue
            
        after_values = change.get('change', {}).get('after', {})
        
        # Check S3 buckets
        if resource_type == 'aws_s3_bucket':
            # Check for versioning
            if not after_values.get('versioning', {}).get('enabled'):
                findings.append({
                    'resource': f"{resource_type}.{resource_name}",
                    'severity': 'MEDIUM',
                    'issue': 'S3 bucket without versioning'
                })
        
        # Check RDS instances
        elif resource_type == 'aws_db_instance':
            if after_values.get('publicly_accessible'):
                findings.append({
                    'resource': f"{resource_type}.{resource_name}",
                    'severity': 'CRITICAL',
                    'issue': 'RDS instance publicly accessible'
                })
            
            if not after_values.get('storage_encrypted'):
                findings.append({
                    'resource': f"{resource_type}.{resource_name}",
                    'severity': 'HIGH',
                    'issue': 'RDS instance without encryption'
                })
        
        # Check security groups
        elif resource_type == 'aws_security_group':
            ingress_rules = after_values.get('ingress', [])
            for rule in ingress_rules:
                cidr_blocks = rule.get('cidr_blocks', [])
                if '0.0.0.0/0' in cidr_blocks:
                    from_port = rule.get('from_port', 0)
                    to_port = rule.get('to_port', 65535)
                    
                    if from_port in [22, 3389, 3306, 5432]:  # Risky ports
                        findings.append({
                            'resource': f"{resource_type}.{resource_name}",
                            'severity': 'CRITICAL',
                            'issue': f'Security group allows {from_port}-{to_port} from anywhere'
                        })
    
    # Report findings
    if findings:
        print(f"🚨 Found {len(findings)} security issues in Terraform plan:")
        
        critical = [f for f in findings if f['severity'] == 'CRITICAL']
        if critical:
            print(f"\nCRITICAL Issues ({len(critical)}):")
            for finding in critical:
                print(f"  ❌ {finding['resource']}: {finding['issue']}")
            
            print("\n❌ Deployment blocked due to critical security issues!")
            sys.exit(1)
        
        high = [f for f in findings if f['severity'] == 'HIGH']
        if high:
            print(f"\nHIGH Issues ({len(high)}):")
            for finding in high:
                print(f"  ⚠️  {finding['resource']}: {finding['issue']}")
    
    else:
        print("βœ… No security issues found in Terraform plan")

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("Usage: python scan_terraform_plan.py <plan.json>")
        sys.exit(1)
    
    scan_terraform_plan(sys.argv[1])

Policy as Code Examples

Custom Checkov Policy

# policies/S3BucketMustHaveNotificationConfiguration.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import Evaluations

class S3BucketNotificationConfiguration(BaseResourceCheck):
    def __init__(self):
        name = "S3 bucket must have notification configuration"
        id = "CKV_AWS_CUSTOM_1"
        supported_resources = ['aws_s3_bucket']
        categories = []
        super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)

    def scan_resource_conf(self, conf):
        """
        Looks for notification configuration on S3 buckets
        """
        if 'notification' in conf:
            return CheckResult.PASSED
        return CheckResult.FAILED

check = S3BucketNotificationConfiguration()

OPA Rego Policy for Terrascan

# policies/s3_encryption.rego
package accurics

s3_bucket_encryption_missing[retVal] {
    resource_type := "aws_s3_bucket"
    resource := input.resource_changes[_]
    resource.type == resource_type
    
    # Check if server-side encryption is configured
    not resource.change.after.server_side_encryption_configuration
    
    retVal := {
        "Id": "S3.1",
        "ReplaceType": "add",
        "CodeType": "resource",
        "Traverse": sprintf("resource_changes[%d].change.after.server_side_encryption_configuration", [i]),
        "Attribute": "server_side_encryption_configuration",
        "AttributeDataType": "list",
        "Expected": [{"rule": [{"apply_server_side_encryption_by_default": [{"sse_algorithm": "AES256"}]}]}],
        "Actual": null
    }
}

Pre-commit Hooks for Terraform Security

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.81.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_checkov
        args: [--args=--framework terraform]
      - id: terraform_tfsec
        
  - repo: local
    hooks:
      - id: custom-terraform-security
        name: Custom Terraform Security Scan
        entry: python scripts/custom_tf_security_scan.py
        language: python
        files: \.tf$
        pass_filenames: false

GitLab CI

# .gitlab-ci.yml
stages:
  - validate
  - security-scan
  - deploy

terraform-security:
  stage: security-scan
  image: bridgecrew/checkov:latest
  script:
    - checkov --framework terraform --output cli --output json --output-file-path checkov-report.json .
  artifacts:
    reports:
      junit: checkov-report.json
    paths:
      - checkov-report.json
  allow_failure: false

Jenkins Pipeline

pipeline {
    agent any
    
    stages {
        stage('Terraform Security Scan') {
            parallel {
                stage('Checkov') {
                    steps {
                        sh 'checkov -f . --framework terraform --output cli'
                    }
                }
                stage('TFSec') {
                    steps {
                        sh 'tfsec . --format json --out tfsec-results.json'
                        publishHTML([
                            allowMissing: false,
                            alwaysLinkToLastBuild: false,
                            keepAll: true,
                            reportDir: '.',
                            reportFiles: 'tfsec-results.json',
                            reportName: 'TFSec Report'
                        ])
                    }
                }
                stage('Custom Scan') {
                    steps {
                        sh 'python scripts/custom_tf_security_scan.py'
                    }
                }
            }
        }
        
        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                sh 'terraform init'
                sh 'terraform plan -out=tfplan'
                sh 'python scripts/scan_terraform_plan.py tfplan.json'
                sh 'terraform apply tfplan'
            }
        }
    }
}

Handling False Positives

Terraform Comments for Exceptions

# Checkov skip comment
resource "aws_s3_bucket" "example" {
  #checkov:skip=CKV_AWS_18:This bucket is for public static content
  bucket = "my-public-static-content"
}

# TFSec skip comment  
resource "aws_security_group" "web" {
  #tfsec:ignore:aws-vpc-no-public-ingress-sgr
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Intentionally public for web traffic
  }
}

Configuration Files for Exceptions

# .checkov.yml
framework:
  - terraform
skip-check:
  - CKV_AWS_18  # S3 bucket logging not required for static content
  - CKV_AWS_52  # S3 bucket MFA delete not required in dev
  
# .tfsec.yml
exclude:
  - aws-s3-enable-bucket-logging
  - aws-vpc-no-public-ingress-sgr

Monitoring and Alerting

# terraform_security_monitor.py
import requests
import json

def send_slack_alert(findings):
    """Send security findings to Slack"""
    
    critical_count = len([f for f in findings if f['severity'] == 'CRITICAL'])
    
    if critical_count > 0:
        webhook_url = os.environ['SLACK_WEBHOOK_URL']
        
        message = {
            "text": f"🚨 Critical Terraform security issues found!",
            "attachments": [{
                "color": "danger",
                "fields": [
                    {
                        "title": "Critical Issues",
                        "value": str(critical_count),
                        "short": True
                    },
                    {
                        "title": "Repository", 
                        "value": os.environ.get('GITHUB_REPOSITORY', 'Unknown'),
                        "short": True
                    }
                ]
            }]
        }
        
        requests.post(webhook_url, json=message)

def create_jira_ticket(findings):
    """Create JIRA ticket for security findings"""
    
    critical_findings = [f for f in findings if f['severity'] == 'CRITICAL']
    
    if critical_findings:
        # JIRA API integration code here
        pass

Conclusion

Terraform security scanning in CI/CD isn’t optionalβ€”it’s essential. The cost of fixing security issues in code is minimal compared to fixing them in production. Start with the tools and scripts in this guide, and gradually build a comprehensive security pipeline.

Your implementation roadmap:

  1. Week 1: Add basic TFSec or Checkov to your CI/CD
  2. Week 2: Implement the custom security scanner
  3. Week 3: Add Terraform plan scanning
  4. Week 4: Set up alerts and dashboards

Remember: The goal isn’t perfect securityβ€”it’s catching obvious issues before they become production problems. These tools and practices will prevent 95% of infrastructure security incidents.

Want automated Terraform security scanning without managing multiple tools? Modern platforms like PathShield can integrate with your CI/CD pipeline and provide comprehensive infrastructure security scanning with a single integration.

Back to Blog

Related Posts

View All Posts Β»