Terraform 파일을 apply 하지 않고 파일 내용만 읽어서 설정 오류를 찾는 정적 점검을 Checkov로 해 봅니다. 열린 S3 버킷, 전 세계에 열린 SSH, 암호화하지 않은 EBS 볼륨을 일부러 넣은 연습용 main.tf를 점검하고 고친 뒤, 같은 파일을 Trivy로도 점검해 결과를 비교합니다. 이 글의 취약한 설정은 직접 만든 연습용이며 실제 서비스에 쓰면 안 됩니다. 이 파일은 점검용이며 terraform init, plan, apply를 실행하지 않습니다. AWS 계정이나 자격 증명은 필요하지 않고 클라우드 호출도 하지 않습니다.
준비물
| 항목 | 내용 |
|---|---|
| Checkov | 3.3.29 (pip install checkov==3.3.29로 고정) |
| Python | 3.12.10 (가상환경에 pip로 설치) |
| Docker | Docker Desktop (Trivy 실행용) |
| Trivy | 0.76.0 (이미지 aquasec/trivy:0.76.0) |
| 실행 환경 | Windows Git Bash |
| 예상 소요 시간 | 약 25분 |
명령은 Windows의 Git Bash 기준입니다. 가상환경 활성화는 Linux와 macOS에서 source .venv/bin/activate입니다. 가상환경 크기는 약 260MB입니다. 이 글의 건수(12/20, 36/8 등)는 Checkov 3.3.29 기준이며 버전이 다르면 달라질 수 있습니다. 한글이 든 파일을 읽을 때 생기는 인코딩 문제는 4절과 아래 자주 막히는 곳에 적었습니다.
mkdir cr-checkov-lab && cd cr-checkov-lab
python -m venv .venv
source .venv/Scripts/activate
pip install checkov==3.3.29
checkov --version
mkdir bad fixed
기대 출력의 마지막 줄입니다.
3.3.29
1. 설정 오류가 든 main.tf 만들기
퍼블릭 읽기 ACL이 있는 버킷, 암호화와 버전 관리가 없는 버킷, 0.0.0.0/0에 SSH가 열린 보안 그룹, 암호화하지 않은 EBS 볼륨을 한 파일에 넣습니다. 이 파일은 점검용이며 terraform init, plan, apply를 실행하지 않습니다.
cat > bad/main.tf <<'EOF'
resource "aws_s3_bucket" "assets" {
bucket = "cr-checkov-demo-assets"
acl = "public-read"
}
resource "aws_s3_bucket" "logs" {
bucket = "cr-checkov-demo-logs"
}
resource "aws_security_group" "ssh" {
name = "cr-checkov-demo-ssh"
description = "ssh access"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_ebs_volume" "data" {
availability_zone = "ap-northeast-2a"
size = 20
}
EOF
2. 점검하고 실패 항목 읽기
-d는 점검할 폴더입니다. --compact는 코드 블록을 빼고, --quiet는 실패 항목만 보여 줍니다. 출력이 길어 앞부분만 봅니다.
checkov -d bad --compact --quiet | head -28
기대 출력입니다. 항목 순서는 실행마다 다를 수 있습니다.
terraform scan results:
Passed checks: 12, Failed checks: 20, Skipped checks: 0
Check: CKV_AWS_23: "Ensure every security group and rule has a description"
FAILED for resource: aws_security_group.ssh
File: \main.tf:10-20
Guide: https://docs.prismacloud.io/...
한 건은 네 줄로 읽습니다.
| 줄 | 읽는 법 |
|---|---|
Check: CKV_AWS_23 |
검사 ID와 설명입니다. skip 주석과 --check 옵션에 이 ID를 씁니다. |
FAILED for resource |
문제가 있는 리소스 종류.이름입니다. |
File: \main.tf:10-20 |
파일과 리소스가 차지한 줄 범위입니다. Windows에서는 역슬래시로 나옵니다. |
Guide |
이유와 수정 방법을 적은 문서 주소입니다. |
같은 검사가 버킷마다 한 번씩 반복되므로 건수(20)는 문제 종류보다 많습니다. 실패한 검사 ID가 몇 가지인지는 5절의 json 출력으로 세어 보며, 13가지입니다.

3. 설정 고치기
핵심 오류는 CKV_AWS_20(퍼블릭 읽기 ACL), CKV_AWS_24(SSH 전체 개방), CKV_AWS_3(EBS 암호화 없음)입니다. 이들과 함께 버전 관리, 암호화, 퍼블릭 액세스 차단, 접근 로그까지 고친 파일입니다. S3 설정은 별도 리소스로 분리해 적었고 SSH 대역은 연습용 사설 대역 10.0.0.0/24입니다.
cat > fixed/main.tf <<'EOF'
resource "aws_kms_key" "data" {
description = "cr-checkov-demo data key"
enable_key_rotation = true
}
resource "aws_s3_bucket" "assets" {
bucket = "cr-checkov-demo-assets"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
}
}
resource "aws_s3_bucket_logging" "assets" {
bucket = aws_s3_bucket.assets.id
target_bucket = aws_s3_bucket.logs.id
target_prefix = "assets/"
}
resource "aws_s3_bucket" "logs" {
bucket = "cr-checkov-demo-logs"
}
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
}
}
resource "aws_security_group" "ssh" {
name = "cr-checkov-demo-ssh"
description = "ssh access from the office network only"
ingress {
description = "ssh from office"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/24"]
}
}
resource "aws_ebs_volume" "data" {
availability_zone = "ap-northeast-2a"
size = 20
encrypted = true
kms_key_id = aws_kms_key.data.arn
}
EOF
checkov -d fixed --compact --quiet | grep -E "Passed checks|Check:"
기대 출력입니다.
Passed checks: 36, Failed checks: 8, Skipped checks: 0
Check: CKV2_AWS_64: "Ensure KMS key Policy is defined"
Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"
Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"
Check: CKV2_AWS_61: "Ensure that an S3 bucket has a lifecycle configuration"
...
20건이 8건으로 줄었습니다. 남은 것은 KMS 키 정책, 이벤트 알림, 수명 주기, 교차 리전 복제, 보안 그룹 연결 여부입니다. 이 연습 환경에는 해당하지 않는 항목이라 다음 절에서 사유를 남기고 넘깁니다.

4. 사유를 남기고 검사 예외 처리하기
리소스 블록 안에 #checkov:skip=<검사 ID>:<사유> 형식으로 적습니다. 공식 문서의 Suppressing and Skipping Policies 형식이며 사유는 선택이지만 출력에 그대로 남으므로 적어 둡니다. 사유에 한글을 쓰므로 먼저 PYTHONUTF8=1을 지정합니다. 지정하지 않으면 Windows에서 Checkov가 UnicodeDecodeError로 종료합니다(아래 자주 막히는 곳). 아래 awk는 리소스 시작 줄 바로 아래에 주석을 넣으며 GNU sed와 달리 macOS에서도 같게 동작합니다. 두 번째 보안 그룹 사유는 연습용 예시 문구입니다. 실제로는 근거가 있을 때만 사유를 적습니다.
export PYTHONUTF8=1
awk '
{ print }
/^resource "aws_kms_key" "data"/ { print " #checkov:skip=CKV2_AWS_64:연습용 키라 기본 키 정책을 사용합니다" }
/^resource "aws_s3_bucket" "/ {
print " #checkov:skip=CKV2_AWS_62:연습용 버킷이라 이벤트 알림을 쓰지 않습니다"
print " #checkov:skip=CKV_AWS_144:단일 리전 연습용이라 교차 리전 복제를 쓰지 않습니다"
print " #checkov:skip=CKV2_AWS_61:연습용 버킷이라 수명 주기 규칙을 두지 않습니다" }
/^resource "aws_security_group" "ssh"/ { print " #checkov:skip=CKV2_AWS_5:연습용 예시 사유입니다. 실제로는 근거가 있을 때만 적습니다" }
' fixed/main.tf > fixed/main.tf.new && mv fixed/main.tf.new fixed/main.tf
checkov -d fixed --compact --quiet; echo "exit=$?"
checkov -d fixed --compact | grep -A3 SKIPPED | head -9
기대 출력입니다.
Passed checks: 36, Failed checks: 0, Skipped checks: 8
exit=0
SKIPPED for resource: aws_s3_bucket.assets
Suppress comment: 연습용 버킷이라 이벤트 알림을 쓰지 않습니다
File: \main.tf:7-12
실패 0건에 종료 코드 0입니다. 예외 처리한 항목은 사라지지 않고 SKIPPED와 Suppress comment로 남아서 나중에 사유를 검토할 수 있습니다. 반대로 --skip-check는 해당 검사를 실행하지 않아 출력에도 남지 않으므로, 리소스 단위의 예외는 주석으로 적는 편이 기록에 유리합니다.

5. 종료 코드와 출력 형식으로 CI 게이트 만들기
Checkov는 실패가 있으면 종료 코드 1로 끝납니다. 이 값이 CI의 통과와 실패가 됩니다. 아래는 bad 폴더에 옵션별 동작을 확인한 것입니다.
checkov -d bad --quiet --compact --soft-fail > /dev/null; echo "soft-fail exit=$?"
checkov -d bad --check CKV_AWS_20,CKV_AWS_24,CKV_AWS_3 --quiet --compact; echo "gate exit=$?"
checkov -d bad -o json --quiet > report.json; echo "json exit=$?"
python -c "import json; d=json.load(open('report.json')); print(d['summary'])"
python -c "import json; d=json.load(open('report.json')); print(len({c['check_id'] for c in d['results']['failed_checks']}))"
checkov -d bad -o sarif --output-file-path . --quiet > /dev/null; ls -l results_sarif.sarif
마지막 줄 python -c는 실패한 검사 ID의 종류를 세며 13이 출력됩니다. 앞의 summary 줄은 아래 그림에서 확인합니다.
| 옵션 | 동작 |
|---|---|
--soft-fail |
점검은 하되 종료 코드를 0으로 합니다. 도입 초기에 보고만 할 때 씁니다. |
--check ID,ID |
지정한 검사만 실행하고 나머지는 건너뜁니다. 꼭 막을 항목만 게이트로 거는 데 씁니다. 위 결과는 3건 실패에 종료 코드 1이었습니다. |
-o json / -o sarif |
결과를 json 또는 sarif로 냅니다. json의 summary에는 passed, failed, 버전이 들어 있고, sarif는 --output-file-path로 지정한 폴더에 results_sarif.sarif로 저장됩니다. |
--hard-fail-on ID |
공식 문서는 "Exits with a non-zero exit code for specified checks"라고 설명합니다. --soft-fail과 함께 쓰면 지정한 검사만 실패로 처리하며, 이 글에서는 CKV_AWS_20으로 종료 코드 1이 나오는 것을 확인했습니다. |
CI에는 다음처럼 옮깁니다. 알려진 항목을 한꺼번에 막지 않고 핵심 오류가 새로 들어오지 않게 하려면 --check로 시작해 범위를 넓혀 갑니다. 공식 문서(https://www.checkov.io/2.Basics/CLI%20Command%20Reference.html)는 --check를 "Checks to run; any other checks will be skipped"라고 설명합니다. 즉 --check로 고른 검사 외의 나머지 검사는 실행되지 않습니다.
checkov -d . --framework terraform --quiet --compact --check CKV_AWS_20,CKV_AWS_24,CKV_AWS_3
이 게이트는 연습 파일의 모양만 잡습니다. 다른 형태로 쓴 리소스나 다른 포트는 지나갈 수 있어서 게이트 통과가 안전하다는 증거는 아닙니다. 아래 weak 폴더는 22번이 아닌 2222번 포트를 0.0.0.0/0에 연 보안 그룹 규칙과 설정이 빈 버킷입니다.
mkdir weak && cat > weak/main.tf <<'EOF'
resource "aws_s3_bucket" "open" {
bucket = "cr-checkov-demo-open"
}
resource "aws_security_group_rule" "ssh_alt" {
type = "ingress"
security_group_id = "sg-0123456789abcdef0"
from_port = 2222
to_port = 2222
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
EOF
checkov -d weak --check CKV_AWS_20,CKV_AWS_24,CKV_AWS_3 --quiet --compact; echo "gate exit=$?"
checkov -d weak --quiet --compact | grep -c "FAILED for"
terraform scan results:
Passed checks: 2, Failed checks: 0, Skipped checks: 0
gate exit=0
8
세 항목만 거는 게이트는 종료 코드 0으로 통과하지만, 같은 폴더를 모든 검사로 돌리면 8건이 실패합니다.

6. Trivy config로 같은 파일 교차 점검하기
Trivy도 trivy config로 Terraform을 정적 점검합니다. 이미지 스캔과 빌드 게이트는 Trivy로 컨테이너 이미지 취약점을 스캔하고 빌드 게이트 만들기에서 다뤘으므로 여기서는 설정 점검만 봅니다. 현재 폴더를 컨테이너에 연결하는 함수를 먼저 등록합니다. 점검 규칙은 실행할 때 내려받아 캐시 볼륨에 보관합니다.
trivy() { MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W):/work" -w /work -v cr-checkov-trivy-cache:/root/.cache/ aquasec/trivy:0.76.0 "$@"; }
trivy config -q bad | head -16
Linux와 macOS에서는 MSYS_NO_PATHCONV=1을 빼고 $(pwd -W) 대신 $PWD를 씁니다. 첫 줄 요약에서 main.tf에 21건이 나옵니다.

trivy config -q --exit-code 1 bad > trivy-bad.txt; echo "bad exit=$?"
trivy config -q --exit-code 1 fixed > trivy-fixed.txt; echo "fixed exit=$?"
grep -E "^AWS-|Failures" trivy-bad.txt | cut -c1-90 | sort | uniq -c
grep -E "^AWS-|Failures" trivy-fixed.txt
Trivy는 --exit-code를 주지 않으면 실패가 있어도 종료 코드 0입니다. Checkov는 기본값이 1이라 이 점이 다릅니다. 고친 파일은 LOW 한 건(AWS-0089, 로그 버킷에 접근 로그 없음)만 남았습니다. --exit-code의 공식 설명은 "specify exit code when any security issues are found"이고, --severity로 거른 결과만 판정에 쓰입니다. --severity HIGH,CRITICAL을 더해 실제로 실행한 결과입니다.
trivy config -q --exit-code 1 --severity HIGH,CRITICAL fixed > /dev/null; echo "severity exit=$?"
severity exit=0
LOW 한 건만 남은 고친 파일이 HIGH, CRITICAL 기준에서는 통과합니다. 같은 파일을 --severity 없이 --exit-code 1로 돌리면 종료 코드 1입니다(위 fixed exit=1).

같은 bad에 대한 두 도구의 결과를 비교하면 다음과 같습니다.
| 항목 | Checkov 3.3.29 | Trivy 0.76.0 |
|---|---|---|
| 건수 | 20건 (검사 ID 13가지) | 21건 (ID 13가지) |
| 퍼블릭 ACL | CKV_AWS_20 |
AWS-0092 |
| SSH 전체 개방 | CKV_AWS_24 |
AWS-0107 |
| EBS 암호화 없음 | CKV_AWS_3 |
AWS-0026 |
| 퍼블릭 액세스 차단 없음 | CKV2_AWS_6 한 건 |
AWS-0086, 0087, 0091, 0093, 0094로 나뉨 |
| 이벤트 알림, 수명 주기, 교차 리전 복제 | 지적함 | 지적하지 않음 |
| 로그 버킷의 접근 로그 (고친 뒤) | 통과 | AWS-0089 지적 |
| 예외 처리 주석 | #checkov:skip=ID:사유 |
#trivy:ignore:AWS-0089 |
| 실패 시 기본 종료 코드 | 1 | 0 (--exit-code 1 필요) |
핵심 세 오류는 둘 다 잡았습니다. 갈리는 부분은 도구마다 가진 검사 목록의 폭과 묶는 방식입니다. Trivy 공식 문서(https://trivy.dev/docs/latest/guide/coverage/iac/terraform/)는 인라인 # trivy:ignore 주석으로 예외 처리할 수 있다고만 안내하고 상세 문법은 싣지 않습니다. 그래서 #trivy:ignore:<ID> 형식은 직접 실행으로 확인했습니다. 로그 버킷 리소스 바로 윗줄에 #trivy:ignore:AWS-0089를 적었더니 --exit-code 1에서도 종료 코드 0이 되었습니다. 어느 쪽이 맞는지는 팀이 정한 기준으로 판단하고, 한 도구의 검사 목록이 닿지 않는 곳을 다른 도구가 메우는 용도로 함께 쓸 수 있습니다. 시크릿 점검은 gitleaks 글에서 따로 다룹니다.
자주 막히는 곳
한글이 든 skip 주석을 넣었더니 Checkov가 UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d로 종료됩니다. Windows의 기본 인코딩(cp1252)으로 .tf 파일을 읽다가 생깁니다. 이 환경(Windows Git Bash, Python 3.12, Checkov 3.3.29)에서 종료 코드는 2였고 환경에 따라 다를 수 있습니다. Checkov 공식 문서의 종료 코드 2는 --no-fail-on-crash 설명에 나오는 플랫폼 연동 실패이며 이 오류와는 다른 것입니다. 4절처럼 export PYTHONUTF8=1을 지정하면 정상 점검되며(종료 코드 0), 영문 사유만 쓰는 방법도 있습니다. 점검 실패(종료 코드 1)와 구분해서 봅니다.
File: \main.tf:10-20처럼 경로가 역슬래시로 나오고 폴더 이름이 보이지 않습니다. -d에 지정한 폴더를 기준으로 한 상대 경로이고 Windows에서는 구분자가 역슬래시입니다. 폴더가 여럿이면 어느 폴더인지 구분이 어려우니 폴더마다 따로 실행합니다.
항목 순서가 실행할 때마다 달라서 결과를 비교하기 어렵습니다. 같은 파일을 반복해 실행하면 순서가 바뀌는 것을 확인했습니다. 비교는 순서 대신 검사 ID로 하고, 자동화에는 -o json을 씁니다.
Trivy config가 처음 실행할 때 느립니다. 규칙을 내려받으므로 네트워크가 필요하고, 캐시 볼륨을 같이 마운트하면 다음부터는 받지 않습니다.
이어서 해 볼 것
- Docker 이미지로 실행하기: 가상환경 대신 Checkov 공식 이미지로 같은 점검을 돌려 CI 러너에 맞는 방식을 고릅니다. 이미지 이름과 태그는 공식 문서에서 확인합니다.
- 새 리소스 추가해 보기:
aws_instance에 보안 그룹을 연결하면CKV2_AWS_5예외 처리가 필요 없어지는지 확인합니다. - sarif를 code scanning에 올리기:
results_sarif.sarif를github/codeql-action/upload-sarif단계로 GitHub에 올려 PR에 표시하는 구성을 시도해 봅니다.
확인 범위
직접 실행한 부분은 1절부터 6절까지입니다. 환경은 Windows의 Git Bash, Python 3.12.10 가상환경, Checkov 3.3.29, Docker Desktop과 aquasec/trivy:0.76.0입니다. 한글 skip 주석에서 PYTHONUTF8=1을 지정하지 않았을 때의 인코딩 오류도 재현했습니다. 모두 파일을 읽기만 했고 terraform init, plan, apply는 하지 않았으며 AWS를 호출하지 않았습니다.
설치는 pip만 확인했습니다. Checkov의 Docker 이미지와 Linux, macOS 명령(source .venv/bin/activate, awk의 macOS 동작), GitHub Actions 연동은 실행하지 못했습니다. Checkov 옵션은 공식 문서 https://www.checkov.io/2.Basics/CLI%20Command%20Reference.html 과 https://www.checkov.io/2.Basics/Suppressing%20and%20Skipping%20Policies.html 을 읽고 checkov --help로 대조했습니다. --hard-fail-on은 검사 ID 지정의 종료 코드 확인까지만 했고, 심각도로 지정하는 방식은 API 키가 필요하다고 안내되어 실행하지 않았습니다. Trivy는 https://trivy.dev/docs/latest/guide/coverage/iac/terraform/ 에서 인라인 # trivy:ignore 언급을, https://trivy.dev/docs/latest/references/configuration/cli/trivy_config/ 에서 --exit-code 설명을, https://trivy.dev/docs/latest/guide/configuration/filtering/ 에서 --severity 설명을 확인했고 #trivy:ignore:<ID> 상세 문법은 공식 문서에서 찾지 못해 실행 결과만 적었습니다. 검사 목록과 건수는 도구 버전에 따라 달라집니다.
이 글의 다음 단계
- gitleaks로 저장소의 시크릿을 찾고 커밋 전에 막기임시 git 저장소에 가짜 키를 넣고 gitleaks로 커밋 이력을 스캔합니다. JSON과 SARIF 리포트, baseline, 오탐 제외, pre-commit 훅까지 차례로 설정하고 GitHub Actions 예시를 붙입니다.
- Trivy로 컨테이너 이미지 취약점을 스캔하고 빌드 게이트 만들기오래된 베이스 이미지와 고정 버전 패키지로 작은 이미지를 빌드해 Trivy로 스캔하고, 결과 표 읽는 법과 HIGH,CRITICAL 필터, --ignore-unfixed, --exit-code 1 게이트, 만료일이 있는 .trivyignore 예외를 거쳐 베이스 이미지를 올려 통과시킵니다.