반응형
AI가 Terraform 코드 뽑아줬는데, S3 버킷이 퍼블릭으로 열려있어도 아무도 몰랐던 경험 있으시죠?
핵심 요약
→ AI 코딩툴 속도 ↑ → 배포 빈도 ↑ → 수동 보안 리뷰 병목 → 자동화 필수
→ OWASP Agentic App Top 10 (2026): Agent Goal Hijacking이 최대 리스크 1위
→ Policy-as-Code: 보안·컴플라이언스 규칙을 Rego 코드로 → CI/CD에서 자동 차단
→ OPA (Open Policy Agent): Netflix·Goldman Sachs·Pinterest 프로덕션 사용 중
→ 용도 1: AI 생성 Terraform 코드 → 배포 전 정책 검사 → 위반 시 PR 차단
→ 용도 2: AI 에이전트 툴 호출 → OPA가 허용/거부 결정 → 에이전트가 못 뚫음
→ Conftest: Terraform plan JSON + Rego 정책 → 위반 항목 즉시 출력
→ 에이전트는 "무엇을 할 수 있나"를 스스로 결정하면 안 됨 → OPA가 결정
실전 1 — AI 생성 Terraform 코드 게이트
Claude Code가 Terraform 코드를 뽑아줬다. 빠르게 배포하고 싶지만 보안팀 리뷰 없이 그냥 올리면 안 됨. OPA + Conftest로 자동 차단.
# 설치
brew install opa # OPA 엔진
brew install conftest # IaC 정책 테스트 도구
# main.tf — Claude Code가 생성한 Terraform (문제 있는 버전)
resource "aws_s3_bucket" "data" {
bucket = "my-app-data"
}
resource "aws_s3_bucket_acl" "data" {
bucket = aws_s3_bucket.data.id
acl = "public-read" # ❌ 퍼블릭 접근 허용
}
resource "aws_security_group" "app" {
name = "app-sg"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # ❌ SSH 전체 개방
}
}
resource "aws_iam_role_policy" "app" {
role = aws_iam_role.app.id
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = "*" # ❌ 전체 권한
Resource = "*"
}]
})
}
# policies/security.rego — OPA 정책 (Rego 언어)
package main
# ─────────────────────────────────────
# 정책 1: S3 퍼블릭 ACL 금지
# ─────────────────────────────────────
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_acl"
acl := resource.change.after.acl
public_acls := {"public-read", "public-read-write", "authenticated-read"}
public_acls[acl]
msg := sprintf(
"❌ [S3-001] S3 버킷 '%v'에 퍼블릭 ACL '%v' 금지",
[resource.address, acl]
)
}
# ─────────────────────────────────────
# 정책 2: SSH(22번 포트) 전체 개방 금지
# ─────────────────────────────────────
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group"
ingress := resource.change.after.ingress[_]
ingress.from_port <= 22
ingress.to_port >= 22
ingress.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf(
"❌ [SG-001] 보안그룹 '%v': SSH(22) 전체 개방 금지. 특정 IP만 허용할 것",
[resource.address]
)
}
# ─────────────────────────────────────
# 정책 3: IAM 와일드카드 권한 금지
# ─────────────────────────────────────
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_role_policy"
# policy는 JSON 문자열이므로 파싱
policy := json.unmarshal(resource.change.after.policy)
statement := policy.Statement[_]
statement.Effect == "Allow"
statement.Action == "*"
msg := sprintf(
"❌ [IAM-001] IAM 정책 '%v': Action='*' 와일드카드 금지. 최소 권한 원칙 적용",
[resource.address]
)
}
# ─────────────────────────────────────
# 정책 4: 암호화 미설정 EBS 볼륨 금지
# ─────────────────────────────────────
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_ebs_volume"
not resource.change.after.encrypted
msg := sprintf(
"❌ [EBS-001] EBS 볼륨 '%v': 암호화 미설정. encrypted=true 필요",
[resource.address]
)
}
# ─────────────────────────────────────
# 경고 (차단은 안 하지만 알림)
# ─────────────────────────────────────
warn[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not resource.change.after.monitoring
msg := sprintf(
"⚠️ [EC2-WARN] EC2 '%v': 상세 모니터링 비활성화. monitoring=true 권장",
[resource.address]
)
}
# Terraform plan을 JSON으로 추출 후 Conftest 검사
terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# 정책 검사 실행
conftest test tfplan.json --policy policies/
# 출력 예시:
# FAIL - tfplan.json - main - ❌ [S3-001] S3 버킷 'aws_s3_bucket_acl.data'에 퍼블릭 ACL 'public-read' 금지
# FAIL - tfplan.json - main - ❌ [SG-001] 보안그룹 'aws_security_group.app': SSH(22) 전체 개방 금지
# FAIL - tfplan.json - main - ❌ [IAM-001] IAM 정책 'aws_iam_role_policy.app': Action='*' 와일드카드 금지
# WARN - tfplan.json - main - ⚠️ [EC2-WARN] EC2: 상세 모니터링 비활성화
#
# 3 tests, 0 passed, 1 warning, 3 failures
개념 정리
→ Rego: OPA 전용 정책 언어 — JSON 데이터를 패턴 매칭으로 검사
→ deny[msg]: 위반 발견 시 메시지를 deny 집합에 추가 → Conftest가 FAIL 처리
→ warn[msg]: 차단 없이 경고만 — 즉각 수정 불필요하지만 기록됨
→ input: Terraform plan JSON이 그대로 OPA에 전달됨
→ conftest test: 정책 파일 전체를 한 번에 실행 → 모든 위반 한 번에 출력
실전 2 — AI 에이전트 툴 호출 차단
에이전트가 프롬프트 인젝션으로 조종당하거나 스스로 위험한 결정을 내려도, OPA가 툴 실행 직전에 차단.
# agent_policy_guard.py — OPA를 에이전트 미들웨어로 사용
import httpx
import json
from functools import wraps
# OPA 서버 실행: docker run -p 8181:8181 openpolicyagent/opa run --server
OPA_URL = "http://localhost:8181/v1/data/agent/policy"
async def check_policy(
tool_name: str,
tool_input: dict,
user_role: str,
environment: str
) -> tuple[bool, str]:
"""OPA에 툴 호출 허용 여부 질의"""
# OPA 입력 구성
opa_input = {
"input": {
"tool": tool_name,
"args": tool_input,
"user": {"role": user_role},
"env": environment,
}
}
async with httpx.AsyncClient() as client:
resp = await client.post(OPA_URL, json=opa_input)
result = resp.json()
allowed = result.get("result", {}).get("allow", False)
reason = result.get("result", {}).get("reason", "정책 위반")
return allowed, reason
def policy_guarded(tool_name: str):
"""에이전트 툴에 OPA 정책 게이트 적용하는 데코레이터"""
def decorator(func):
@wraps(func)
async def wrapper(*args, user_role="user", environment="production", **kwargs):
allowed, reason = await check_policy(
tool_name=tool_name,
tool_input=kwargs,
user_role=user_role,
environment=environment,
)
if not allowed:
raise PermissionError(f"[OPA 차단] {tool_name}: {reason}")
return await func(*args, **kwargs)
return wrapper
return decorator
# 에이전트 툴 정의 — 각 툴에 정책 게이트 적용
@policy_guarded("database_query")
async def database_query(query: str, **kwargs) -> list[dict]:
"""DB 쿼리 실행"""
# 실제 DB 쿼리 로직
return []
@policy_guarded("file_delete")
async def file_delete(path: str, **kwargs) -> bool:
"""파일 삭제"""
import os
os.remove(path)
return True
@policy_guarded("send_email")
async def send_email(to: list[str], subject: str, body: str, **kwargs) -> bool:
"""이메일 발송"""
# 실제 이메일 발송 로직
return True
@policy_guarded("deploy_service")
async def deploy_service(service: str, version: str, **kwargs) -> bool:
"""서비스 배포"""
# 실제 배포 로직
return True
# policies/agent_policy.rego — 에이전트 행동 정책
package agent.policy
import future.keywords.if
import future.keywords.in
# 기본값: 거부 (명시적으로 허용된 것만 실행 가능)
default allow := false
default reason := "정책에 의해 차단됨"
# ─────────────────────────────────────
# 읽기 전용 쿼리는 모든 역할 허용
# ─────────────────────────────────────
allow if {
input.tool == "database_query"
is_read_only_query(input.args.query)
}
reason := "읽기 전용 쿼리는 허용됨" if {
input.tool == "database_query"
is_read_only_query(input.args.query)
}
is_read_only_query(query) if {
# SELECT로 시작하는 쿼리만 허용
upper_query := upper(query)
startswith(upper_query, "SELECT")
# DROP, DELETE, UPDATE, INSERT, TRUNCATE 금지
dangerous := {"DROP", "DELETE", "UPDATE", "INSERT", "TRUNCATE", "ALTER"}
not any_keyword(upper_query, dangerous)
}
any_keyword(query, keywords) if {
keyword := keywords[_]
contains(query, keyword)
}
# ─────────────────────────────────────
# 파일 삭제: admin만, 프로덕션 경로 금지
# ─────────────────────────────────────
allow if {
input.tool == "file_delete"
input.user.role == "admin"
not startswith(input.args.path, "/prod/")
not startswith(input.args.path, "/data/")
}
reason := "관리자만 비프로덕션 파일 삭제 가능" if {
input.tool == "file_delete"
input.user.role == "admin"
}
reason := "프로덕션 경로 파일 삭제 금지" if {
input.tool == "file_delete"
startswith(input.args.path, "/prod/")
}
# ─────────────────────────────────────
# 이메일: 수신자 10명 이하, 외부 도메인 금지
# ─────────────────────────────────────
allow if {
input.tool == "send_email"
count(input.args.to) <= 10
all_internal_addresses(input.args.to)
}
all_internal_addresses(addresses) if {
every address in addresses {
endswith(address, "@company.com")
}
}
reason := "대량 이메일 금지 (최대 10명)" if {
input.tool == "send_email"
count(input.args.to) > 10
}
reason := "외부 이메일 주소 금지" if {
input.tool == "send_email"
not all_internal_addresses(input.args.to)
}
# ─────────────────────────────────────
# 배포: 프로덕션 환경은 senior_engineer 이상만
# ─────────────────────────────────────
allow if {
input.tool == "deploy_service"
input.env != "production" # 개발/스테이징은 누구나 가능
}
allow if {
input.tool == "deploy_service"
input.env == "production"
input.user.role in {"senior_engineer", "admin"}
}
reason := "프로덕션 배포는 시니어 엔지니어 이상만 가능" if {
input.tool == "deploy_service"
input.env == "production"
not input.user.role in {"senior_engineer", "admin"}
}
# OPA 서버 실행
docker run -d \
--name opa \
-p 8181:8181 \
-v $(pwd)/policies:/policies \
openpolicyagent/opa run --server /policies
# 정책 테스트 (curl로 직접 질의)
curl -X POST http://localhost:8181/v1/data/agent/policy \
-H "Content-Type: application/json" \
-d '{
"input": {
"tool": "file_delete",
"args": {"path": "/prod/data.db"},
"user": {"role": "user"},
"env": "production"
}
}'
# → {"result": {"allow": false, "reason": "프로덕션 경로 파일 삭제 금지"}}
개념 정리
→ OPA 공식: Input(JSON) + Policy(Rego) + Data(JSON) = Decision(allow/deny)
→ default allow := false: 명시적 허용 없으면 전부 거부 (화이트리스트 방식)
→ 에이전트가 아닌 OPA가 허용 결정: 에이전트가 속아도, 프롬프트 인젝션 당해도 차단
→ OWASP Agentic App Top 10 (2026): Agent Goal Hijacking 1위 → OPA로 방어
→ 정책 변경 → OPA만 업데이트, 에이전트 코드 수정 없음 (관심사 분리)
실전 3 — GitHub Actions CI/CD 통합
AI가 코드 짜고 PR 올리면 자동으로 정책 검사 → 위반 시 머지 차단.
# .github/workflows/policy-check.yml
name: Policy-as-Code 검사
on:
pull_request:
paths:
- '**.tf' # Terraform 파일 변경 시만 실행
- '**.yaml'
- '**.yml'
jobs:
terraform-policy:
name: Terraform 정책 검사
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Terraform 설치
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Conftest 설치
run: |
wget https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz
tar xzf conftest_Linux_x86_64.tar.gz
mv conftest /usr/local/bin/
- name: Terraform Plan 생성
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
- name: 정책 검사 실행
run: |
conftest test tfplan.json \
--policy policies/ \
--output github # GitHub PR 코멘트 형식으로 출력
# 위반 시 exit code 1 → 워크플로우 실패 → 머지 차단
- name: 정책 검사 결과 PR 코멘트
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ❌ Policy-as-Code 검사 실패\n\n' +
'AI 생성 Terraform 코드에서 보안 정책 위반이 발견되었습니다.\n' +
'Actions 탭에서 상세 내용을 확인하고 수정 후 다시 푸시하세요.'
})
# Kubernetes 정책 검사 (별도 job)
kubernetes-policy:
name: Kubernetes 매니페스트 정책 검사
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Conftest로 K8s 매니페스트 검사
run: |
conftest test k8s/ \
--policy policies/kubernetes/ \
--all-namespaces
# policies/kubernetes/security.rego — K8s 정책
package main
# ─────────────────────────────────────
# 컨테이너 root 실행 금지
# ─────────────────────────────────────
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
# runAsNonRoot 미설정 또는 false
not container.securityContext.runAsNonRoot
msg := sprintf(
"❌ [K8S-001] 컨테이너 '%v': root 실행 금지. runAsNonRoot: true 설정 필요",
[container.name]
)
}
# ─────────────────────────────────────
# 리소스 제한 미설정 금지
# ─────────────────────────────────────
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.resources.limits
msg := sprintf(
"❌ [K8S-002] 컨테이너 '%v': resources.limits 미설정. CPU/메모리 제한 필수",
[container.name]
)
}
# ─────────────────────────────────────
# 최신 태그(latest) 사용 금지
# ─────────────────────────────────────
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
endswith(container.image, ":latest")
msg := sprintf(
"❌ [K8S-003] 컨테이너 '%v': ':latest' 태그 금지. 명시적 버전 태그 사용",
[container.name]
)
}
개념 정리
→ PR → Terraform plan 생성 → Conftest 정책 검사 → 위반 시 머지 차단
→ AI가 코드 짜서 PR 올려도 → 정책 게이트 통과해야 머지 가능
→ --output github: PR 코멘트에 위반 항목 자동 표시
→ 정책은 코드 → Git으로 버전 관리, 변경 이력 추적, 코드 리뷰 가능
→ 한 번 정책 작성 → 모든 PR에 자동 적용 → 보안팀 수동 리뷰 부담 제거
마무리
✅ Policy-as-Code 도입 후
→ AI가 뽑은 Terraform에 퍼블릭 S3, 와일드카드 IAM 있어도 → PR 단계에서 자동 차단
→ 에이전트가 프롬프트 인젝션 당해 DB 삭제 시도 → OPA가 툴 레이어에서 차단
→ 보안 정책 변경 → Rego 파일 PR → 코드 리뷰 → 자동 적용 (문서 업데이트 불필요)
→ 감사(Audit) 대비: 모든 정책이 Git에 → "이 시점에 어떤 정책이 적용됐나" 즉시 확인
❌ 수동 리뷰만 하면
→ AI 코드 속도 ↑ → 보안 리뷰 병목 → 리뷰 스킵 → 보안 사고
→ 에이전트 행동 제한 없음 → 프롬프트 인젝션 1번으로 시스템 장악 가능
→ 컴플라이언스 규칙이 문서에만 → 실제 코드와 괴리 → 감사 때 낭패
→ "이 S3 버킷 퍼블릭인지 아닌지" → 수동 확인 → 이미 털린 후에 발견
반응형