🔒 보안

SOPS

Secrets OPerationS

암호화된 파일 편집 도구 - YAML/JSON 값을 선택적으로 암호화하여 Git에 안전하게 저장하는 Mozilla 오픈소스

📖 상세 설명

SOPS(Secrets OPerationS)는 Mozilla에서 개발한 암호화된 파일 편집 도구입니다. YAML, JSON, ENV, INI 파일에서 키(key)는 평문으로 유지하고 값(value)만 선택적으로 암호화하는 것이 특징입니다. 이 덕분에 암호화된 파일도 구조를 파악할 수 있어 코드 리뷰, diff, 병합 충돌 해결이 가능합니다.

SOPS는 다양한 키 관리 시스템(KMS)을 지원합니다. AWS KMS, Google Cloud KMS, Azure Key Vault 같은 클라우드 KMS와 HashiCorp Vault, age, PGP를 모두 지원하며, 여러 키를 동시에 사용할 수 있습니다. 예를 들어 개발팀은 age 키로, 프로덕션 CI/CD는 AWS KMS로 복호화하도록 설정할 수 있습니다. 여러 키 중 하나만 있으면 복호화되므로 키 로테이션이나 팀원 변경 시에도 유연합니다.

SOPS의 큰 장점은 에디터 통합입니다. `sops secrets.yaml` 명령어로 파일을 열면 자동으로 복호화된 상태로 편집기가 열리고, 저장 후 닫으면 다시 암호화됩니다. 따라서 일반 파일 편집과 동일한 워크플로우로 시크릿을 관리할 수 있습니다. Vim, VS Code 플러그인도 있어 IDE에서 직접 편집할 수 있습니다.

GitOps 환경에서 SOPS는 Flux, ArgoCD와 네이티브로 통합됩니다. Flux의 kustomize-controller는 SOPS 암호화된 시크릿 파일을 자동으로 복호화하여 클러스터에 적용합니다. ArgoCD도 SOPS 플러그인을 통해 암호화된 매니페스트를 처리합니다. 이를 통해 Kubernetes 시크릿을 포함한 전체 인프라를 Git으로 관리할 수 있습니다.

💻 코드 예제

SOPS 설치 및 초기 설정

# macOS 설치
brew install sops

# Linux 설치
wget https://github.com/getsops/sops/releases/download/v3.8.1/sops-v3.8.1.linux.amd64
chmod +x sops-v3.8.1.linux.amd64
sudo mv sops-v3.8.1.linux.amd64 /usr/local/bin/sops

# age 키 생성 (간단한 로컬 암호화용)
# age 설치: brew install age
age-keygen -o ~/.sops/age/keys.txt
# 출력 예: AGE-SECRET-KEY-1QFSMEH... (이것이 비공개키)
# 공개키: age1... (이것으로 암호화)

# AWS KMS 키 사용 시 IAM 권한 필요
# aws kms create-key --description "SOPS encryption key"

.sops.yaml 설정 파일 (프로젝트 루트)

# .sops.yaml - 프로젝트 루트에 위치
# 경로 패턴별로 다른 암호화 키 적용

creation_rules:
  # 개발 환경 - age 키 사용
  - path_regex: secrets/development/.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

  # 스테이징 환경 - AWS KMS 사용
  - path_regex: secrets/staging/.*\.yaml$
    kms: arn:aws:kms:ap-northeast-2:123456789:key/abc-def-123

  # 프로덕션 환경 - AWS KMS + age (둘 중 하나로 복호화 가능)
  - path_regex: secrets/production/.*\.yaml$
    kms: arn:aws:kms:ap-northeast-2:123456789:key/prod-key-456
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

  # Kubernetes 시크릿 - 특정 키만 암호화
  - path_regex: k8s/.*secrets?\.yaml$
    kms: arn:aws:kms:ap-northeast-2:123456789:key/k8s-secrets-key
    encrypted_regex: ^(data|stringData)$

  # Terraform 변수 - .tfvars 파일
  - path_regex: terraform/.*\.tfvars$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

파일 암호화 및 복호화

# 새 파일 암호화 (생성과 동시에)
# .sops.yaml의 creation_rules가 자동 적용됨
sops secrets/production/database.yaml
# 편집기가 열리고, 저장 후 닫으면 암호화됨

# 기존 평문 파일을 암호화
sops -e secrets.yaml > secrets.enc.yaml

# 암호화된 파일을 평문으로 복호화 (stdout으로 출력)
sops -d secrets.enc.yaml

# 암호화된 파일을 제자리에서 복호화 (파일 덮어쓰기)
sops -d -i secrets.enc.yaml

# 특정 키로 명시적 암호화
sops --age age1ql3z7hjy54pw3... -e secrets.yaml > secrets.enc.yaml

# AWS KMS로 암호화
sops --kms arn:aws:kms:ap-northeast-2:123456789:key/my-key -e secrets.yaml

# 편집 모드로 열기 (복호화 → 편집 → 재암호화)
sops secrets.enc.yaml

# 특정 값만 추출 (jq처럼)
sops -d --extract '["database"]["password"]' secrets.enc.yaml

암호화된 파일 예시

# secrets/production/database.yaml (암호화 후)
# 키는 평문, 값만 암호화됨
database:
    host: ENC[AES256_GCM,data:Mj3p...,iv:...,tag:...,type:str]
    port: ENC[AES256_GCM,data:NjQ=,iv:...,tag:...,type:int]
    username: ENC[AES256_GCM,data:YWRt...,iv:...,tag:...,type:str]
    password: ENC[AES256_GCM,data:c3Vw...,iv:...,tag:...,type:str]

redis:
    host: ENC[AES256_GCM,data:cmVk...,iv:...,tag:...,type:str]
    password: ENC[AES256_GCM,data:cmVk...,iv:...,tag:...,type:str]

sops:
    kms:
        - arn: arn:aws:kms:ap-northeast-2:123456789:key/prod-key-456
          created_at: "2024-01-15T10:30:00Z"
          enc: AQICAHj...
    age:
        - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24...
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2024-01-15T10:30:00Z"
    mac: ENC[AES256_GCM,data:...,tag:...,type:str]
    version: 3.8.1

Flux GitOps 통합

# flux-system/gotk-sync.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: infrastructure
  namespace: flux-system
spec:
  interval: 10m
  path: ./infrastructure
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  # SOPS 복호화 활성화
  decryption:
    provider: sops
    secretRef:
      name: sops-age  # age 비공개키를 담은 Secret

---
# SOPS age 키를 Kubernetes Secret으로 저장
# kubectl create secret generic sops-age \
#   --namespace=flux-system \
#   --from-file=age.agekey=~/.sops/age/keys.txt

apiVersion: v1
kind: Secret
metadata:
  name: sops-age
  namespace: flux-system
type: Opaque
data:
  age.agekey: QUdFLVNFQ1JFVC1LRVkt...  # base64 encoded age key

Python에서 SOPS 파일 읽기

import subprocess
import yaml
import json
from pathlib import Path
from typing import Any, Optional
import os

class SOPSClient:
    """SOPS 암호화된 파일을 다루는 Python 클라이언트"""

    def __init__(self, age_key_file: Optional[str] = None):
        """
        Args:
            age_key_file: age 비공개키 파일 경로 (환경변수 SOPS_AGE_KEY_FILE로도 설정 가능)
        """
        if age_key_file:
            os.environ['SOPS_AGE_KEY_FILE'] = age_key_file

    def decrypt_file(self, file_path: str) -> dict:
        """SOPS 파일을 복호화하여 dict로 반환"""
        result = subprocess.run(
            ['sops', '-d', file_path],
            capture_output=True,
            text=True
        )

        if result.returncode != 0:
            raise RuntimeError(f"SOPS decryption failed: {result.stderr}")

        file_ext = Path(file_path).suffix.lower()
        if file_ext in ['.yaml', '.yml']:
            return yaml.safe_load(result.stdout)
        elif file_ext == '.json':
            return json.loads(result.stdout)
        else:
            return {'content': result.stdout}

    def encrypt_data(self, data: dict, output_path: str,
                     file_type: str = 'yaml') -> None:
        """dict를 SOPS로 암호화하여 파일로 저장"""
        if file_type == 'yaml':
            content = yaml.dump(data, default_flow_style=False)
            input_type = '--input-type=yaml'
            output_type = '--output-type=yaml'
        else:
            content = json.dumps(data, indent=2)
            input_type = '--input-type=json'
            output_type = '--output-type=json'

        result = subprocess.run(
            ['sops', '-e', input_type, output_type, '/dev/stdin'],
            input=content,
            capture_output=True,
            text=True
        )

        if result.returncode != 0:
            raise RuntimeError(f"SOPS encryption failed: {result.stderr}")

        Path(output_path).write_text(result.stdout)

    def get_value(self, file_path: str, key_path: str) -> Any:
        """암호화된 파일에서 특정 값 추출"""
        # key_path 예: "database.password" 또는 '["database"]["password"]'
        json_path = ''.join(f'["{k}"]' for k in key_path.split('.'))

        result = subprocess.run(
            ['sops', '-d', '--extract', json_path, file_path],
            capture_output=True,
            text=True
        )

        if result.returncode != 0:
            raise RuntimeError(f"SOPS extraction failed: {result.stderr}")

        return result.stdout.strip()

    def update_value(self, file_path: str, key_path: str, value: Any) -> None:
        """암호화된 파일의 특정 값 업데이트"""
        # 복호화
        data = self.decrypt_file(file_path)

        # 중첩된 키 업데이트
        keys = key_path.split('.')
        current = data
        for key in keys[:-1]:
            current = current[key]
        current[keys[-1]] = value

        # 재암호화
        file_ext = Path(file_path).suffix.lower()
        file_type = 'yaml' if file_ext in ['.yaml', '.yml'] else 'json'
        self.encrypt_data(data, file_path, file_type)


# 사용 예시
if __name__ == "__main__":
    # age 키 파일 지정
    sops = SOPSClient(age_key_file=os.path.expanduser("~/.sops/age/keys.txt"))

    # 전체 파일 복호화
    secrets = sops.decrypt_file("secrets/production/database.yaml")
    print(f"DB Host: {secrets['database']['host']}")
    print(f"DB User: {secrets['database']['username']}")

    # 특정 값만 추출
    db_password = sops.get_value(
        "secrets/production/database.yaml",
        "database.password"
    )
    print(f"DB Password: {db_password}")

    # 값 업데이트
    sops.update_value(
        "secrets/production/database.yaml",
        "database.password",
        "new_secure_password_123"
    )

🗣️ 실무에서 이렇게 말해요

  • "SOPS로 시크릿 암호화하면 키 이름은 보이니까 PR에서 어떤 값이 변경됐는지 코드 리뷰할 수 있어요."
  • "개발 환경은 age 키로, 프로덕션은 AWS KMS로 암호화해서 권한 분리할 수 있어요."
  • "Flux가 SOPS 네이티브 지원하니까, 시크릿 매니페스트도 Git에 커밋하고 GitOps로 관리 가능해요."
  • ".sops.yaml에 경로별 규칙 정해두면 팀원들이 파일 만들 때 자동으로 맞는 키로 암호화됩니다."
  • "SOPS가 전체 파일 암호화가 아닌 값만 암호화하는 방식의 장단점은 무엇인가요?"
  • "SOPS와 Sealed Secrets를 비교하고, 각각 어떤 상황에 적합한지 설명해주세요."
  • "SOPS의 키 로테이션은 어떻게 처리하시겠습니까? 여러 키를 동시에 사용하는 이유는 무엇인가요?"
  • "SOPS를 CI/CD 파이프라인에 통합할 때 권한 관리는 어떻게 하시겠습니까?"
  • "이 시크릿 파일 .sops.yaml 규칙 범위 밖이라 평문으로 커밋됐어요. 경로 확인해주세요."
  • "SOPS 메타데이터 섹션에 age 키가 없네요. 개발자들이 로컬에서 복호화 못 합니다."
  • "encrypted_regex로 data 키만 암호화하도록 했는데, stringData도 추가해야 해요."
  • "이 민감 설정은 환경변수보다 SOPS 파일로 관리하는 게 변경 이력 추적에 좋아요."

⚠️ 주의사항

  • .sops.yaml 규칙 누락 주의: .sops.yaml의 path_regex가 파일 경로와 맞지 않으면 암호화 없이 평문으로 저장됩니다. 새 디렉토리나 파일 패턴을 추가할 때 반드시 .sops.yaml도 업데이트하고, pre-commit hook으로 평문 시크릿 커밋을 차단하세요.
  • 키 백업 및 복구 계획: age나 PGP 비공개키를 분실하면 해당 키로만 암호화된 파일은 영구적으로 복호화할 수 없습니다. 클라우드 KMS를 주 키로 사용하고, age 키는 보조로 설정하세요. 키 백업은 별도의 안전한 저장소에 보관해야 합니다.
  • Git 히스토리의 평문 주의: 실수로 평문으로 커밋한 후 암호화해도 Git 히스토리에는 평문이 남아있습니다. 이런 경우 git filter-branch나 BFG Repo-Cleaner로 히스토리를 정리하고, 노출된 시크릿은 즉시 교체해야 합니다.

🔗 관련 용어

📚 더 배우기