본문 바로가기!

Terraform Cloud(HCP Terraform)

HCP Terraform(Terraform Cloud)의 Policy(Sentinel)로 보안/거버넌스 정책 적용하기

728x90
반응형

2024년 4월 22일부터 Terraform Cloud의 이름이 HCP Terraform으로 변경되었습니다.

하기 내용에서는 Terraform Cloud 이름 대신 HCP Terraform 이름으로 대체하여 설명을 진행하도록 하겠습니다.

 

 

오늘은  HCP Terraform의 Policy as Code(PaC) 도구인 Sentinel을 사용하여 보안 및 거버넌스 정책을 적용하는 방법에 대해 설명드리겠습니다.

Sentinel을 사용하면 인프라 코드가 조직의 규정과 표준을 준수하도록 강제할 수 있는데 이번 포스트에서는 Sentinel의 개념, 설정 방법, 예제를 통해 HCP Terraform에서 Sentinel을 활용할 수 있는지에 대해 살펴보도록 하겠습니다.

 

 

1. Sentinel 이란?

Sentinel은 HashiCorp에서 제공하는 Policy as Code(PaC) 프레임워크로, 인프라 코드의 실행 전,중,후에 다양한 정책을 적용할 수 있습니다.

Sentinel을 사용하여 조직의 규정과 표준을 자동으로 준수하도록 할 수 있으며, 이를 통해 인프라의 안정성과 일관성을 유지하도록 할 수 있습니다.

 

Sentinel 의 필요성

  • 정책 준수 : 모든 인프라 코드가 조직의 규정과 표준을 준수하도록 강제할 수 있습니다.
  • 자동화된 검증 : 인프라 코드 변경 시 자동으로 정책을 검증하여 오류를 사전에 방지할 수 있습니다.
  • 유연성 : 다양한 조건과 규칙을 정책으로 정의하여 복잡한 거버넌스 요구사항을 충족할 수 있습니다.
  • 중앙 집중 관리 : 중앙에서 정책을 관리하고 적용하여 일관성을 유지할 수 있습니다.

 

 

주요 Sentinel Imports

Sentinel 정책에는 재사용 가능한 라이브러리, 외부 데이터 및 기능에 액세스할 수 있는 Import가 포함될 수 있습니다.

자세한 내용은 Sentinel 문서의 Import를 참조할 수 있습니다.

 

HCP Terraform은 정책 확인과 관련된 Plan, Configuration, State 및 Run에 대한 정책 규칙을 정의하기 위한 4가지 Import를 제공합니다.

 

tfplan (참조링크)

  • terraform plan 명령의 결과로 생성된 데이터를 검토합니다.
  • 이를 통해 terraform이 인프라를 변경하기 전에 Plan에서 변경 사항이 정책을 준수하는지 확인합니다.

tfconfig (참조링크)

  • terraform config를 검토하며, config는 ~.tf 파일 세트를 의미합니다.
  • .tf 파일로 구성된 내용이 정책을 준수하는지 확인합니다.

tfstate (참조링크)

  • terraform state의 데이터를 검토합니다.
  • 이를 통해 현재 배포된 인프라의 상태를 확인하고, 배포된 리소스가 정책에 준수하는지 확인합니다.

tfrun (참조링크)

  • Run과 관련된 데이터를 검토합니다.
  • 이를 통해 실행 중인 Terraform 작업의 데이터를 검토하고, 작업이 정책을 준수하는지 확인합니다.

 

 

Enforcement Levels

Sentinel에서 Enforcement Level을 통해 code의 동작 여부에 대한 pass/fail 을 결정할 수 있습니다.

 

Sentinel Enforcement Level에는 다음과 같이 3가지 방식이 있습니다.

  • Advisory (권고) : 정책에 동작이 실패할 수 있으나, 권고 수준으로 표시되며 동작은 가능합니다.
  • Soft Mandatory : 정책에 동작이 실패 시, 사용자가 허용 후 통과시키게 되면 동작이 가능하게 됩니다. 
    • 해당 설정은 배포가 가능하지만, 결국 사용자가 확인 후 배포를 하기에 정책 위반에 대한 부인을 방지할 수 있습니다.
  • Hard Mandatory : 정책에 위반되면 정책을 제거하지 않는 이상 모든 동작이 실패하게 됩니다.

 

 

2. HCP Terraform의 Policy Set

HCP Terraform에서는 Sentinel 정책을 Policy set로 그룹화해서 해당 Policy set을 전역적으로 적용하거나 특정 Project 및 Workspace에 적용할 수 있습니다.

 

HCP Terraform Free 티어와 Standard 티어에서는 최대 Policy set 하나와 5개의 Policy를 사용할 수 있으며, Plus 티어부터는 제한없이 사용이 가능합니다.

 

 

 

3. Sentinel 정책 설정 방법

 

3-1) Sentinel 정책 파일 작성 

Sentinel 정책 파일은 두가지 파일을 작성하여야 합니다.

  1. sentinel.hcl
    • 해당 파일은 Sentinel 정책의 설정을 정의하는 파일입니다.
    • policy set 에 포함된 정책의 실행 수준(Enforcement level) 과 해당 정책파일의 위치를 지정합니다.
  2. poc.sentinel (개별 정책 파일)
    • 해당 파일은 실제 Sentinel 정책을 정의하는 파일입니다.
    • 특정 인프라 리소스가 조직의 보안 및 거버넌스 요구사항을 준수하는지 확인하는 규칙과 함수가 포함됩니다.
    • poc 라는 이름은 사용자가 알맞게 바꿔서 작성하시면 됩니다.

 

 

  • poc.sentinel
##### Imports #####

import "tfplan"
import "strings"
import "types"

# This policy uses the Sentinel tfplan import to require that
# all EC2 instances have instance types from an allowed list

##### Functions #####

# Find all resources of a specific type from all modules using the tfplan import
find_resources_from_plan = func(type) {

  resources = {}

  # Iterate over all modules in the tfplan import
  for tfplan.module_paths as path {
    # Iterate over the named resources of desired type in the module
    for tfplan.module(path).resources[type] else {} as name, instances {
      # Iterate over resource instances
      for instances as index, r {

        # Get the address of the instance
        if length(path) == 0 {
          # root module
          address = type + "." + name + "[" + string(index) + "]"
        } else {
          # non-root module
          address = "module." + strings.join(path, ".module.") + "." +
                    type + "." + name + "[" + string(index) + "]"
        }

        # Add the instance to resources map, setting the key to the address
        resources[address] = r
      }
    }
  }

  return resources
}

# Validate that all instances of a specified resource type being modified have
# a specified top-level attribute in a given list
validate_attribute_in_list = func(type, attribute, allowed_values) {

  validated = true

  # Get all resource instances of the specified type
  resource_instances = find_resources_from_plan(type)

  # Loop through the resource instances
  for resource_instances as address, r {

    # Skip resource instances that are being destroyed
    # to avoid unnecessary policy violations.
    # Used to be: if length(r.diff) == 0
    if r.destroy and not r.requires_new {
      print("Skipping resource", address, "that is being destroyed.")
      continue
    }

    # Determine if the attribute is computed
    if r.diff[attribute].computed else false is true {
      print("Resource", address, "has attribute", attribute,
            "that is computed.")
      # If you want computed values to cause the policy to fail,
      # uncomment the next line.
      # validated = false
    } else {
      # Validate that each instance has allowed value
      if r.applied[attribute] else "" not in allowed_values {
        print("Resource", address, "has attribute", attribute, "with value",
              r.applied[attribute] else "",
              "that is not in the allowed list:", allowed_values)
        validated = false
      }
    }

  }
  return validated
}

##### Lists #####

# Allowed EC2 Instance Types
# We don't include t2.nano or t2.micro to illustrate overriding failed policy
allowed_types = [
  "t2.small",
  "t2.medium",
  "t2.large",
  "t3.xlarge"
]


###################################################################################
###################################################################################
###################################################################################
###################################################################################

# This policy uses the Sentinel tfplan import to validate that no security group
# rules have the CIDR "0.0.0.0/0".  It covers both the aws_security_group and
# the aws_security_group_rule resources.

##### Functions #####

# Find all resources of a specific type from all modules using the tfplan import
find_resources_from_plan = func(type) {

  resources = {}

  # Iterate over all modules in the tfplan import
  for tfplan.module_paths as path {
    # Iterate over the named resources of desired type in the module
    for tfplan.module(path).resources[type] else {} as name, instances {
      # Iterate over resource instances
      for instances as index, r {

        # Get the address of the instance
        if length(path) == 0 {
          # root module
          address = type + "." + name + "[" + string(index) + "]"
        } else {
          # non-root module
          address = "module." + strings.join(path, ".module.") + "." +
                    type + "." + name + "[" + string(index) + "]"
        }

        # Add the instance to resources map, setting the key to the address
        resources[address] = r
      }
    }
  }

  return resources
}

# Validate that all AWS ingress security group rules
# do not have cidr_block 0.0.0.0/0
validate_cidr_blocks = func() {

  validated = true

  # Get all AWS security group rules
  sgr_instances = find_resources_from_plan("aws_security_group_rule")

  # Loop through the resource instances
  for sgr_instances as address, r {

    # Skip resources that are being destroyed
    # to avoid unnecessary policy violations.
    # Used to be: if length(r.diff) == 0
    if r.destroy and not r.requires_new {
      print("Skipping security group rule", address, "that is being destroyed.")
      continue
    }

    # Determine if the attribute is computed
    if (r.diff["type"].computed else false or
        r.diff["cidr_blocks.#"].computed else false) is true {
      print("Security group rule", address,
            "has attributes, type and/or cidr_blocks that are computed.")
      # If you want computed values to cause the policy to fail,
      # uncomment the next line.
      # validated = false
    } else {
      # Validate that each SG rule does not have disallowed value
      # Since cidr_blocks is optional and could be computed,
      # We check that it is present and really a list
      # before checking whether it contains "0.0.0.0/0"
      if r.applied.type is "ingress" and
         r.applied.cidr_blocks else null is not null and
         types.type_of(r.applied.cidr_blocks) is "list" and
         r.applied.cidr_blocks contains "0.0.0.0/0" {
        print("Security group rule", address, "of type ingress",
              "contains disallowed cidr_block 0.0.0.0/0" )
        validated = false
      }
    } // end computed check

  } // end security group rule instances

  # Get all AWS security groups
  sg_instances = find_resources_from_plan("aws_security_group")

  # Loop through the resource instances
  for sg_instances as address, r {

    # Skip resources that are being destroyed
    # to avoid unnecessary policy violations.
    # Used to be: if length(r.diff) == 0
    if r.destroy and not r.requires_new {
      print("Skipping security group", address, "that is being destroyed.")
      continue
    }

    # Check if there are ingress blocks that are not computed
    if (r.diff["ingress.#"].computed else false) is true {
      print("Security group", address, "does not have any ingress blocks",
            "or, they are computed.")
      # If you want computed values to cause the policy to fail,
      # uncomment the next line.
      # validated = false
    } else {
      # Check if r.applied.ingress is a list (to be safe)
      if types.type_of(r.applied.ingress) is "list" {

        ingress_count = 0

        for r.applied.ingress as i {

          # Determine if ingress.<n>.cidr_blocks.# attribute is computed
          # Note that this approach works for Terraform 0.12, but not
          # for Terraform 0.11 which has diff expressions like
          # "ingress.3167104115.cidr_blocks.#" rather than using 0, 1, 2
          # after "ingress".
          ingress_cidr_blocks = "ingress." + string(ingress_count) + ".cidr_blocks.#"
          if (r.diff[ingress_cidr_blocks].computed else false) is true {
            print("Ingress block #", ingress_count, "of security group",
                  address, "has cidr_blocks that is computed.")
            # If you want computed values to cause the policy to fail,
            # uncomment the next line.
            # validated = false
          } else {
            # Validate that the ingress rule does not have disallowed value
            # Since cidr_blocks is optional and could be computed,
            # We check that it is present and really a list
            # before checking whether it contains "0.0.0.0/0"
            if i.cidr_blocks else null is not null and
               types.type_of(i.cidr_blocks) is "list" and
               i.cidr_blocks contains "0.0.0.0/0" {
              print("Ingress block #", ingress_count, "of security group",
                    address, "contains disallowed cidr_block 0.0.0.0/0" )
              validated = false
            }
          } // end cidr_blocks.# computed check

          # increment ingress_count
          ingress_count += 1

        } // end ingress loop

      } // end if r.applied.ingress a list
    } // end if diff[ingress.#] computed

  } // end security group instances

  return validated
}

##### Rules #####

# Call the validation function
sgrs_validated = validate_cidr_blocks()

# Call the validation function
instances_validated = validate_attribute_in_list("aws_instance",
                      "instance_type", allowed_types)

# Main rule
main = rule {
  (sgrs_validated) else true and (instances_validated) else true
}
  • 위 Sentinel 정책은 EC2 인스턴스가 허용된 인스턴스 유형인지 확인하고, 보안 그룹 규칙이 "0.0.0.0/0" CIDR Block을 사용하지 않도록 규정합니다. 
  • sgrs_validated : 보안 그룹 규칙이 유효한지 검증하는 함수의 결과
  • instances_validated : EC2 인스턴의 인스턴스 유형이 허용된 목록에 포함되는지 검증하는 함수의 결과
  • main 이라는 규칙을 통해 두 조건을 모두 충족시켰을때만 pass 되도록 설정하였습니다.

 

 

  • sentinel.hcl
policy "poc" {
    source               = "./poc.sentinel"
    // enforcement_level = "advisory"          
    enforcement_level    = "soft-mandatory"    
    // enforcement_level = "hard-mandatory"
}
  • policy “poc”
    • 정책 이름을 정의합니다. 여기서는 poc라는 정책을 정의하고 있습니다.
  • source = “poc.sentinel”
    • 정책의 소스 파일을 지정합니다. 여기서는 poc.sentinel 파일을 참조합니다.
  • enforcement_level = “soft-mandatory”
    • 정책의 실행 수준을 정의합니다. “soft-mandatory”는 정책이 반드시 통과해야 하지만, 필요한 경우 특정 조건에서 오버라이드할 수 있음을 의미합니다.

 

 

 

3-2) Sentinel 정책 파일을 GitHub Repo에 Push

위에서 작성한 Sentinel 정책 파일을 GitHub Repo를 생성한 후 Push합니다.

해당 과정 생략

 

 

 

 

3-3) HCP Terraform Policy Set 설정

1. HCP Terraform 화면에서 Settings 의 Policy sets 로 이동하여 Connect a new policy set을 클릭합니다.

 

 

2. sentinel code가 있는 VCS를 선택합니다.

 

 

3. sentinel code가 있는 repository를 선택합니다.

 

 

4. Policy framework와 policy가 적용될 Project & Workspace를 선택합니다.

  • Policy framework : Sentinel
  • Name : sentinel-code
  • Scope of policies : Policies enforced on selected projects and workspaces

 

 

5. 등록된 policy set을 확인합니다.

 

 

6. policy 가 적용된 Workspace을 plan & apply를 실행합니다.

 

 

 

7. EC2 Instance 유형이 t3.xlarge 이고 보안 그룹 규칙이 "0.0.0.0/0" CIDR Block을 사용 시 확인

Ingress block # 0 of security group aws_security_group.gitlab_sg[0] contains disallowed cidr_block 0.0.0.0/0
Ingress block # 1 of security group aws_security_group.gitlab_sg[0] contains disallowed cidr_block 0.0.0.0/0
Ingress block # 2 of security group aws_security_group.gitlab_sg[0] contains disallowed cidr_block 0.0.0.0/0

 

 

 

8. EC2 Instance 유형이 t3.xlarge 이고 보안 그룹 규칙이 "10.0.0.0/16" CIDR Block을 사용 시 확인

 

 

 

 

4. 정리

Sentinel을 통해 인프라 코드가 조직의 규정을 준수하도록 강제하고, 자동화된 검증을 통해 오류를 사전에 방지할 수 있음을 확인할 수 있습니다. 

 

Sentinel의 강력한 RBAC 기능과 정책 적용을 통해 조직의 보안 및 거버넌스 요구사항을 충족할 수 있기에 Sentinel의 유연성과 강력한 기능을 활용하여 보다 안전하고 관리하기 쉬운 인프라 환경을 구축해보면 좋을 것 같습니다.

 

이번 포스트에서는 다루지 않았지만 TimeZone API를 활용하여 특정 시간으로 프로비저닝을 제한할 수 있는 방안도 있습니다!

 

728x90
반응형