Securitatea Infrastructure as Code: Bune practici de securitate Terraform
Infrastructure as Code (IaC) permite infrastructura reproductibila si controlata prin versionare, dar introduce si riscuri de securitate daca nu este gestionata corespunzator. Acest ghid acopera practici complete de securitate pentru deploymenturile Terraform.
Scanarea de securitate cu tfsec
Configuratie de baza tfsec
# .tfsec.yml
minimum_severity: MEDIUM
exclude:
- aws-vpc-no-excessive-port-access # Handled by security groups
severity_overrides:
AWS001: ERROR # S3 bucket without encryption
AWS002: ERROR # S3 bucket without logging
AWS017: WARNING # Unencrypted EBS volume
custom_checks_dir: .tfsec/custom_checksDescoperiri frecvente de securitate si remedieri
# NESIGUR: S3 bucket fara criptare
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
}
# SIGUR: S3 bucket cu criptare si controale de securitate
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
tags = {
Environment = "production"
DataClass = "confidential"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.s3_key.arn
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_logging" "data" {
bucket = aws_s3_bucket.data.id
target_bucket = aws_s3_bucket.logs.id
target_prefix = "s3-access-logs/data-bucket/"
}
# NESIGUR: Security group cu ingress complet deschis
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"] # tfsec:ignore:aws-vpc-no-public-ingress-sgr
}
}
# SIGUR: Security group cu reguli specifice
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "web-security-group"
}
}
resource "aws_security_group_rule" "web_https" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.allowed_cidrs]
security_group_id = aws_security_group.web.id
description = "HTTPS from allowed networks"
}
resource "aws_security_group_rule" "web_egress" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.web.id
description = "Allow all outbound traffic"
}Policy as Code cu OPA
Definirea politicilor Terraform
# policy/terraform/security.rego
package terraform.security
import future.keywords.in
# Interzice bucket-uri S3 necriptate
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
resource.change.after.server_side_encryption_configuration == null
msg := sprintf(
"S3 bucket '%s' must have server-side encryption enabled",
[resource.address]
)
}
# Interzice bucket-uri S3 publice
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_public_access_block(resource.address)
msg := sprintf(
"S3 bucket '%s' must have public access block configured",
[resource.address]
)
}
has_public_access_block(bucket_address) {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_public_access_block"
contains(resource.address, bucket_address)
}
# Interzice instante RDS necriptate
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.storage_encrypted != true
msg := sprintf(
"RDS instance '%s' must have storage encryption enabled",
[resource.address]
)
}
# Interzice instante RDS fara protectie la stergere
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.deletion_protection != true
msg := sprintf(
"RDS instance '%s' must have deletion protection enabled",
[resource.address]
)
}
# Necesita taguri specifice
required_tags := {"Environment", "Owner", "CostCenter"}
deny[msg] {
resource := input.resource_changes[_]
resource.change.after.tags != null
missing := required_tags - {tag | resource.change.after.tags[tag]}
count(missing) > 0
msg := sprintf(
"Resource '%s' is missing required tags: %v",
[resource.address, missing]
)
}
# Interzice politici IAM excesiv de permisive
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
policy := json.unmarshal(resource.change.after.policy)
statement := policy.Statement[_]
statement.Effect == "Allow"
statement.Action[_] == "*"
statement.Resource == "*"
msg := sprintf(
"IAM policy '%s' grants overly permissive access",
[resource.address]
)
}
# Interzice instante EC2 fara IMDSv2
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not resource.change.after.metadata_options[0].http_tokens == "required"
msg := sprintf(
"EC2 instance '%s' must require IMDSv2",
[resource.address]
)
}Integrarea OPA in CI/CD
# .github/workflows/terraform-security.yml
name: Terraform Security
on:
pull_request:
paths:
- '**.tf'
- '**.tfvars'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.0
- name: Terraform Init
run: terraform init -backend=false
- name: Terraform Plan
run: terraform plan -out=tfplan.binary
- name: Convert Plan to JSON
run: terraform show -json tfplan.binary > tfplan.json
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2
with:
version: latest
- name: Run OPA Policy Check
run: |
opa eval --format pretty \
--data policy/terraform/ \
--input tfplan.json \
"data.terraform.security.deny" > policy-results.txt
if grep -q "deny" policy-results.txt; then
echo "Policy violations found:"
cat policy-results.txt
exit 1
fi
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
soft_fail: false
additional_args: --minimum-severity MEDIUM
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
soft_fail: false
output_format: sarif
output_file_path: checkov-results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: checkov-results.sarifGestionarea secretelor
Folosirea HashiCorp Vault cu Terraform
# providers.tf
provider "vault" {
address = var.vault_addr
# Use VAULT_TOKEN environment variable or AppRole
auth_login {
path = "auth/approle/login"
parameters = {
role_id = var.vault_role_id
secret_id = var.vault_secret_id
}
}
}
# secrets.tf
data "vault_kv_secret_v2" "database" {
mount = "secret"
name = "database/production"
}
resource "aws_db_instance" "main" {
identifier = "production-db"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.large"
username = data.vault_kv_secret_v2.database.data["username"]
password = data.vault_kv_secret_v2.database.data["password"]
# Security settings
storage_encrypted = true
deletion_protection = true
vpc_security_group_ids = [aws_security_group.database.id]
db_subnet_group_name = aws_db_subnet_group.main.name
}
# Dynamic database credentials
data "vault_database_secret" "app" {
backend = "database"
role = "app-readonly"
}
resource "kubernetes_secret" "db_credentials" {
metadata {
name = "db-credentials"
namespace = "production"
}
data = {
username = data.vault_database_secret.app.username
password = data.vault_database_secret.app.password
}
}Folosirea AWS Secrets Manager
# Create secret
resource "aws_secretsmanager_secret" "api_key" {
name = "production/api-key"
description = "API key for external service"
recovery_window_in_days = 7
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
resource "aws_secretsmanager_secret_version" "api_key" {
secret_id = aws_secretsmanager_secret.api_key.id
secret_string = jsonencode({
api_key = var.api_key # Pass via TF_VAR or Vault
api_secret = var.api_secret
})
lifecycle {
ignore_changes = [secret_string] # Prevent drift after rotation
}
}
# Secret rotation
resource "aws_secretsmanager_secret_rotation" "api_key" {
secret_id = aws_secretsmanager_secret.api_key.id
rotation_lambda_arn = aws_lambda_function.rotation.arn
rotation_rules {
automatically_after_days = 30
}
}
# Reference secret in other resources
data "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = "production/database"
}
locals {
db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)
}Gestionarea variabilelor sensibile
# variables.tf
variable "database_password" {
description = "Database master password"
type = string
sensitive = true
validation {
condition = length(var.database_password) >= 16
error_message = "Database password must be at least 16 characters"
}
}
variable "api_keys" {
description = "API keys for external services"
type = map(object({
key = string
secret = string
}))
sensitive = true
}
# Use random password generation instead of static passwords
resource "random_password" "database" {
length = 32
special = true
# Exclude problematic characters
override_special = "!#$%&*()-_=+[]{}<>:?"
}
# Store generated password in Secrets Manager
resource "aws_secretsmanager_secret_version" "database" {
secret_id = aws_secretsmanager_secret.database.id
secret_string = jsonencode({
username = "admin"
password = random_password.database.result
host = aws_db_instance.main.address
port = aws_db_instance.main.port
database = aws_db_instance.main.db_name
})
}Securitatea fisierului de stare
Stare la distanta cu criptare
# backend.tf
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "production/infrastructure/terraform.tfstate"
region = "us-east-1"
encrypt = true
kms_key_id = "alias/terraform-state-key"
dynamodb_table = "terraform-state-lock"
# Assume role for state access
role_arn = "arn:aws:iam::123456789012:role/TerraformStateAccess"
}
}
# State bucket configuration (in separate bootstrap config)
resource "aws_s3_bucket" "terraform_state" {
bucket = "myorg-terraform-state"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.terraform_state.arn
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# State lock table
resource "aws_dynamodb_table" "terraform_state_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
server_side_encryption {
enabled = true
kms_key_arn = aws_kms_key.terraform_state.arn
}
}
# KMS key for state encryption
resource "aws_kms_key" "terraform_state" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM User Permissions"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow Terraform Role"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/TerraformStateAccess"
}
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = "*"
}
]
})
}Politica IAM pentru accesul la stare
# IAM policy for state access
resource "aws_iam_policy" "terraform_state_access" {
name = "TerraformStateAccess"
description = "Allow access to Terraform state"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3StateAccess"
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
Resource = [
"${aws_s3_bucket.terraform_state.arn}/*"
]
Condition = {
StringEquals = {
"s3:x-amz-server-side-encryption" = "aws:kms"
}
}
},
{
Sid = "S3StateBucketAccess"
Effect = "Allow"
Action = [
"s3:ListBucket",
"s3:GetBucketVersioning"
]
Resource = [
aws_s3_bucket.terraform_state.arn
]
},
{
Sid = "DynamoDBLock"
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = [
aws_dynamodb_table.terraform_state_lock.arn
]
},
{
Sid = "KMSAccess"
Effect = "Allow"
Action = [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
]
Resource = [
aws_kms_key.terraform_state.arn
]
}
]
})
}Scanarea de securitate Checkov
Configuratie Checkov
# .checkov.yml
branch: main
check:
- CKV_AWS_18 # S3 access logging
- CKV_AWS_19 # S3 encryption
- CKV_AWS_20 # S3 no public read
- CKV_AWS_21 # S3 versioning
- CKV_AWS_23 # Security group description
- CKV_AWS_24 # Security group not open to world
- CKV_AWS_25 # Security group egress defined
skip-check:
- CKV_AWS_144 # S3 cross-region replication (not needed)
soft-fail: false
framework:
- terraform
output:
- cli
- json
- sarif
output-file-path: ./reports/
compact: truePolitica Checkov personalizata
# custom_policies/aws_s3_require_encryption_key.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckCategories, CheckResult
class S3BucketRequireKMSEncryption(BaseResourceCheck):
def __init__(self):
name = "Ensure S3 bucket uses KMS encryption (not AES256)"
id = "CKV_CUSTOM_1"
supported_resources = ["aws_s3_bucket_server_side_encryption_configuration"]
categories = [CheckCategories.ENCRYPTION]
super().__init__(name=name, id=id, categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
rules = conf.get("rule", [])
for rule in rules:
if isinstance(rule, dict):
default_encryption = rule.get("apply_server_side_encryption_by_default", [])
for encryption in default_encryption:
if isinstance(encryption, dict):
algorithm = encryption.get("sse_algorithm", [""])[0]
if algorithm == "aws:kms":
kms_key = encryption.get("kms_master_key_id")
if kms_key and kms_key[0]:
return CheckResult.PASSED
return CheckResult.FAILED
check = S3BucketRequireKMSEncryption()Securitatea modulelor
Structura securizata a modulelor
# modules/secure-vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = var.vpc_name
})
}
# Enable VPC Flow Logs
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs.arn
iam_role_arn = aws_iam_role.flow_logs.arn
}
resource "aws_cloudwatch_log_group" "flow_logs" {
name = "/aws/vpc/flow-logs/${var.vpc_name}"
retention_in_days = var.flow_log_retention_days
kms_key_id = var.kms_key_arn
}
# Default security group - deny all
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
# No ingress or egress rules - effectively deny all
tags = {
Name = "default-deny-all"
}
}
# modules/secure-vpc/variables.tf
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid CIDR block"
}
validation {
condition = tonumber(split("/", var.vpc_cidr)[1]) <= 24
error_message = "VPC CIDR must be /24 or larger"
}
}
variable "flow_log_retention_days" {
description = "Retention period for VPC flow logs"
type = number
default = 90
validation {
condition = var.flow_log_retention_days >= 30
error_message = "Flow log retention must be at least 30 days"
}
}
variable "kms_key_arn" {
description = "KMS key ARN for encryption"
type = string
validation {
condition = can(regex("^arn:aws:kms:", var.kms_key_arn))
error_message = "KMS key must be a valid ARN"
}
}Sumar bune practici
- Scaneaza devreme si des - Integreaza tfsec, Checkov si OPA in CI/CD
- Cripteaza totul - Fisiere de stare, outputuri sensibile, toate datele la repaus
- Foloseste stare la distanta - Nu face niciodata commit la fisierele de stare in version control
- Implementeaza principiul privilegiului minim - Permisiuni IAM minime pentru Terraform
- Fixeaza versiunile providerilor - Previne atacurile pe lantul de aprovizionare
- Revizuieste outputul planului - Revizuieste intotdeauna inainte de a aplica schimbari
- Foloseste module - Incapsuleaza bunele practici de securitate in module reutilizabile
- Tagheaza resursele - Permite urmarirea costurilor si auditul de securitate
Urmand aceste practici, poti reduce semnificativ riscurile de securitate din deploymenturile tale Infrastructure as Code.
Sistemul tau AI e conform cu EU AI Act? Evaluare gratuita de risc - afla in 2 minute →