ข้ามไปยังเนื้อหา

Policy as Code and Static Analysis

static analysis tool จับ pattern ที่รู้อยู่แล้วว่าไม่ดีใน HCL ก่อนที่ plan จะรันด้วยซ้ำ ส่วน policy-as-code tool ประเมิน plan ที่คำนวณจริงเทียบกับ rule ขององค์กร แล้วบล็อกไม่ให้ apply ได้แบบอัตโนมัติถ้า plan นั้นผิด rule

terraform validate เช็คแค่ว่า HCL syntax ถูกต้องและ consistent กันภายใน — type ตรงกัน argument ที่ required มีครบ reference resolve ได้ แต่ไม่รู้หรอกว่า instance type t2.micro เป็นตัวเลือกที่สมเหตุสมผลไหม หรือ S3 bucket ควรเปิดให้ public read จริง ๆ หรือเปล่า tool สองประเภทนี้เข้ามาเติมช่องว่างนั้น และทั้งคู่รันเร็ว ก่อนที่จะมีการ plan หรือ apply อะไรเลย

  • tflint provider-aware มาพร้อม plugin rule set (plugin ของ AWS, ของ Azure และอื่น ๆ) ที่จับ mistake ที่ validate มองไม่เห็น เพราะต้องรู้ว่า AWS API จริง ๆ รับอะไรได้บ้าง เช่น instance type ที่ deprecated ไปแล้ว, ARN pattern ที่ผิด, argument ที่ provider เลิก support ไปเงียบ ๆ
  • security และ misconfiguration scanner ในตระกูล checkov pattern-match ตัว HCL เองเทียบกับ configuration ที่รู้อยู่แล้วว่าไม่ดี เช่น S3 bucket ที่เปิด public read access, security group ที่เปิด 0.0.0.0/0 บน port ที่ sensitive, EBS volume ที่ไม่ได้ encrypt tfsec เคยครอบคลุมเรื่องคล้าย ๆ กันนี้และ check เหล่านั้นถูกรวมเข้าไปใน Trivy แล้ว ส่วน checkov ยังเป็นตัวเลือกที่ใช้กันแพร่หลายและ maintain อยู่ต่อเนื่องสำหรับ layer นี้

รันจริง ทั้งสอง tool ผ่าน docker run --rm กับ config เล็ก ๆ ที่ตั้งใจทำให้แย่สามจุด (instance type รุ่นเก่า, S3 bucket แบบ public-read, security group เปิด port 22 ให้ทั้งโลก):

$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest --init
Installing "aws" plugin...
Installed "aws" (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.31.0)
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest
2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1:
1: terraform {
Warning: "t1.micro" is previous generation instance type. (aws_instance_previous_type)
on main.tf line 22:
22: instance_type = "t1.micro"

image tflint เริ่มต้นไม่มี rule เฉพาะ cloud เลยสักตัว — AWS ruleset เป็น plugin แยกต่างหาก (tflint-ruleset-aws) ที่ต้องประกาศใน .tflint.hcl แล้วดึงมาด้วย --init นี่คือเหตุผลที่ step นี้ต้องรันก่อน สังเกต rule text จริง: aws_instance_previous_type, “previous generation instance type” — ไม่ใช่ “invalid value” แบบที่ตัวอย่างที่ไม่ได้ verify อาจเดาไว้

$ docker run --rm -v "$PWD:/data" bridgecrew/checkov:latest -d /data --compact --quiet
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group_rule.ssh
File: /main.tf:38-45
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."
FAILED for resource: aws_s3_bucket.reports
File: /main.tf:25-27
Passed checks: 16, Failed checks: 17, Skipped checks: 0

output จริงจาก checkov (ตัดให้เหลือแค่สอง check ที่ตัวอย่างบทนี้ตั้งใจให้ trigger จาก 17 การ fail จริงบน config สองสามตัวที่ไม่ได้ harden เลย — checkov เช็คมากกว่าที่ config เล็ก ๆ แบบนี้จะครอบคลุมได้ทั้งหมด เช่น EBS encryption, IMDSv2, detailed monitoring, S3 versioning) tool ทั้งสองรันกับ configuration ดิบ ๆ เลย ไม่ต้องมี AWS credential ไม่ต้อง plan ไม่ต้องมี state (ยกเว้น step --init ของ ghcr.io/terraform-linters/tflint ที่ต้องดาวน์โหลด ruleset plugin ผ่าน network ครั้งเดียว cache ไว้ใน working directory ที่ mount สำหรับรันครั้งต่อไป) นี่แหละคือเหตุผลที่ควรเป็น gate แรกสุดใน pipeline เพราะเร็ว ถูก และจับ mistake ทั้ง class ได้ก่อนที่ Terraform จะคุยกับ AWS ด้วยซ้ำ

static analysis หยุดอยู่แค่ที่ code จึงตอบคำถามแบบ “plan นี้สร้าง m5.xlarge instance เกินห้าตัวใน production account ไหม” ไม่ได้ เพราะตัวเลขนั้นยังไม่มีอยู่จนกว่าจะคำนวณ plan จริง policy-as-code tool ปิด gap ตรงนี้ด้วยการประเมิน machine-readable output ของ plan ไม่ใช่ตัว HCL

  • Sentinel เป็น policy engine ที่ built-in มากับ HCP Terraform integrate แน่นกับ HCP Terraform และ Terraform Enterprise run — policy ผูกไว้กับ workspace หรือ organization แล้วถูกประเมินอัตโนมัติเป็นส่วนหนึ่งของทุก run ที่นั่น
  • Open Policy Agent (OPA) ใช้ผ่าน CLI ชื่อ conftest เป็น alternative แบบ open-source ที่ provider-agnostic กว่า ใช้ได้กับ CI system ไหนก็ได้ ไม่ว่าจะมี HCP Terraform หรือไม่ ด้วยการประเมิน JSON document ที่คุณส่งเข้าไป

ไอเดียหลักของทั้งสองตัวเหมือนกัน คือมีคนเขียน policy ไว้ครั้งเดียว — “security group ห้ามเปิด ingress จาก 0.0.0.0/0 บน port 22” หรือ “ทุก resource ต้องมี tag cost-center” — แล้วจะประเมินอัตโนมัติกับทุก plan ตั้งแต่นั้นมา บล็อก plan ที่ผิด rule ไม่ให้ apply ได้เลย เป็น version แบบ mechanical และบังคับใช้เสมอของสิ่งที่ human reviewer เดิมต้องคอยจำไปเช็คด้วยตาทุกครั้ง โดยไม่มีวันพลาด

Terminal window
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
policy/ssh.rego
package main
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
resource.change.after.from_port <= 22
resource.change.after.to_port >= 22
msg := sprintf("%s allows ingress from 0.0.0.0/0 on port 22", [resource.address])
}

output จริงกับ plan.json ของ config สามตัวเดียวกัน:

Conftest: v0.70.1
OPA: 1.20.2
FAIL - plan.json - main - aws_security_group_rule.ssh allows ingress from 0.0.0.0/0 on port 22
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

syntax deny contains msg if { ... } สำคัญ: openpolicyagent/conftest:latest bundle OPA 1.20.2 ที่ default เป็น Rego v1 ต้องมี keyword if/contains บนทุก rule — รูปแบบเก่า deny[msg] { ... } (ไม่มี if ไม่มี contains) ที่ tutorial conftest ก่อนปี 2025 หลายที่ยังใช้กัน load ไม่ขึ้นเลย:

$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/ssh.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/ssh.rego:3: rego_parse_error: `contains` keyword is required for partial set rules

ทั้งคู่คือรันจริงกับ rule deny ชื่อเดียวกันเป๊ะ ๆ ต่างกันแค่ syntax conftest อ่าน plan.json ตัวเดียวกับที่คนอ่านเองก็ได้ แล้ว reject run ทันทีที่ deny ให้ message ออกมา — ไม่มี plan ตัวไหนที่ตรง rule นี้ไปถึง apply ได้เลย

ประเมินต้นทุน: Infracost (มาจาก docs เท่านั้น — ไม่ได้รันข้างล่างนี้)

หัวข้อที่มีชื่อว่า “ประเมินต้นทุน: Infracost (มาจาก docs เท่านั้น — ไม่ได้รันข้างล่างนี้)”

static analysis กับ policy-as-code ทั้งคู่ตอบคำถาม “ปลอดภัย/compliant ไหม” ไม่มีตัวไหนตอบ “ราคาเท่าไหร่” Infracost เติม gap ตรงนี้โดยเฉพาะ และในที่นี้มาจาก docs ไม่ได้รัน — มันต้องการ pricing API/cloud pricing catalog ที่ environment แบบ offline นี้เข้าไม่ถึง อ้างอิงจาก docs ของ Infracost เอง: “Infracost shifts cloud costs left — into your engineering workflow. It helps you write cost-aware infrastructure (Terraform, CloudFormation, AWS CDK) across AWS, Azure, and Google Cloud.” และเฉพาะสำหรับ CI: “Add Infracost to your pull requests to give your team a safety net — every PR gets an automatic comment showing the cost diff, FinOps policy violations, and tagging issues introduced by the change, so cost impact gets discussed alongside the code review.” เหมือน conftest มันอ่าน JSON output ของ plan ได้โดยตรงเช่นกัน: “Infracost can also estimate costs from a Terraform plan JSON file (the output of terraform show -json), which is useful with HCP Terraform / Enterprise, Bitbucket Pipelines, and costs that only resolve at plan time.” — www.infracost.io/docs รูปแบบตั้งใจให้คุ้น ๆ กัน: input terraform show -json ตัวเดียวกับที่บทนี้พิสูจน์ output จริงไปแล้ว เพียงแต่เป็นอีกมุมหนึ่ง (ต้นทุน) บน artifact เดียวกับที่ policy-as-code เช็ค compliance

ชวนให้คิดว่า tflint/checkov กับ Sentinel/OPA ทำงานเดียวกันซ้ำสองรอบ แต่จริง ๆ ทั้งสองเช็คคนละเรื่องกันเลย

  • static analysis เช็ค ตัว code เอง ไม่ขึ้นกับว่า change จริง ๆ คืออะไร จึง flag S3 bucket ที่ public เหมือนกันหมด ไม่ว่า run นี้จะสร้าง bucket เดียวหรือร้อยตัว
  • policy-as-code เช็ค change ที่ plan ไว้จริง ๆ และ reference ค่าที่คำนวณจริงได้ ซึ่งมีอยู่ก็ต่อเมื่อ plan ถูกสร้างขึ้นแล้ว เช่น instance count จริง, ค่า tag จริง, CIDR block จริงที่ resolve ได้หลังจาก variable interpolation กับ module composition รันเสร็จ rule แบบ “บล็อก plan ที่จะเหลือ instance หลัง load balancer น้อยกว่าสองตัว” ไม่มี equivalent ที่ระดับ HCL ดิบเลย เพราะไม่มีทางรู้ instance count ได้โดยไม่คำนวณ plan ก่อน

ทั้งสอง stage อยู่ใน pipeline เดียวกันได้ เรียงต่อกัน static check ที่ถูกและเร็วก่อน จับ mistake ทั้ง class ได้ทันที ตามด้วย policy evaluation กับ plan ที่คำนวณแล้ว จับ violation ที่เฉพาะเจาะจงกับสถานการณ์จริง ซึ่งจะเห็นได้ก็ต่อเมื่อ Terraform รู้แน่ชัดแล้วว่ากำลังจะทำอะไร

flowchart LR
  hcl["HCL code"] --> lint["tflint / checkov: static checks"]
  lint -->|passes| plan["terraform plan"]
  plan --> policy["Sentinel / OPA + conftest: policy check"]
  policy -->|passes| apply["apply"]
  policy -->|violates a rule| blocked["Blocked, never applied"]
HCL ผ่าน static analysis ก่อน แล้วคำนวณ plan และเช็คกับ policy ก่อนอนุญาตให้ apply

tool ทุกตัวในบทนี้ตอบคำถามคนละข้อในจุดคนละจุดของ pipeline และลำดับของ pipeline ก็ตามมาจากว่าคำถามนั้นตอบได้เร็วสุดเมื่อไหร่ “HCL syntax ถูกไหม” (validate) ตอบได้โดยไม่ต้องมี context เลย “HCL ใช้ pattern ที่รู้อยู่แล้วว่าไม่ดีไหม” (tflint/checkov) ต้องมี rule catalog ของ provider แต่ไม่ต้องมี plan “change ที่คำนวณจริงนี้ผิด policy ไหม” (Sentinel/OPA) ตอบไม่ได้ก่อนมี plan เพราะค่าที่กำลังเช็ค (CIDR จริง, instance count จริง) ยังไม่มีอยู่ “ราคาเท่าไหร่” (Infracost) เป็นมุมที่สี่บน plan ที่คำนวณแล้วตัวเดียวกัน รันผิดลำดับก็เสีย plan ไปกับ code ที่รู้อยู่แล้วว่าแย่ตั้งแต่แรก หรือไม่ก็ดอง static check ที่ใช้เวลาห้าวินาทีไว้จนหลัง plan ที่แพงกว่ามากรันเสร็จ

ทางเลือกได้อะไรเสียอะไร
tflint พร้อม provider ruleset pluginจับ mistake เฉพาะ provider (type ที่ deprecated, ARN ผิด) ที่ validate มองไม่เห็นต้อง --init ดึง plugin ผ่าน network ต่อ provider และ image เปล่า ๆ (ไม่มี plugin) แทบไม่จับอะไรที่เฉพาะ cloud เลย
rule catalog กว้าง ๆ ของ checkovรันครั้งเดียวเจอ misconfiguration จริงได้หลายสิบข้อ (พิสูจน์ข้างบน: 17 check fail บน config 3 resource)noise สูงตอนรันครั้งแรกกับ infrastructure ที่มีอยู่แล้ว — ส่วนใหญ่ต้อง baseline/suppress ก่อนจะ enforce ใน CI
OPA/conftest กับ show -json แทน Sentinelopen-source ใช้ได้กับ CI system ไหนก็ได้ ไม่ต้องพึ่ง HCP Terraformต้องเขียน policy เองเป็น Rego และตามให้ทันทั้ง schema ของ terraform show -json และ version ของ Rego ที่ OPA ใช้ (พิสูจน์ข้างบน: Rego v1 ทำให้ rule deny[msg] แบบเก่าพัง)
Infracost จาก docs ไม่ได้พิสูจน์ที่นี่ตอบคำถามที่ทั้ง static analysis และ policy-as-code ตอบไม่ได้ (ต้นทุน)environment นี้เข้าไม่ถึง cloud pricing catalog — claim ข้างบนมาจาก docs ระบุไว้ชัดเจนว่าเป็นแบบนั้น
  • “tflint จับ security misconfiguration แบบเดียวกับที่ checkov จับ” พิสูจน์ข้างบน: ทั้งคู่เจอคนละอย่างบน config เดียวกัน — tflint flag instance type ที่ deprecated กับ required_version ที่หายไป ส่วน checkov flag S3 ACL แบบ public กับ security group ที่เปิด เป็น rule catalog ที่เสริมกัน ไม่ใช่ทับกัน
  • “ถ้า tflint/checkov ผ่าน แปลว่า plan ปลอดภัยพอจะ apply” ทั้งคู่เห็นแค่ HCL ดิบ — พิสูจน์ข้างบน ไม่มี tool ไหนแตะ plan หรือ state เลย policy แบบ “ห้ามมี m5.xlarge เกินห้าตัวใน production” ไม่มี equivalent ที่ระดับ HCL มีแค่ conftest กับ show -json เท่านั้นที่ประเมินแบบนี้ได้
  • “Rego snippet ที่ copy จาก tutorial เก่า ๆ รันกับ conftest เวอร์ชันปัจจุบันได้เหมือนเดิม” พิสูจน์ข้างบน: rule deny[msg] { ... } เดียวกันเป๊ะ (ไม่มี if/contains) ที่ guide เก่าหลายที่ยังโชว์อยู่ load ไม่ขึ้นเลยกับ OPA 1.20.2 ที่ conftest bundle มา — Rego v1 ต้องมี if/contains บนทุก rule body
  • “ต้องรัน Infracost ถึงจะรู้ว่ามันคืออะไร ทำงานยังไง” บทนี้ตั้งใจทำตรงข้าม: section Infracost ข้างบนมาจาก docs ล้วน ๆ ระบุชัดว่าไม่ได้รัน เพราะไม่มี pricing catalog ให้เข้าถึงใน environment นี้ — นี่คือวิธีที่ถูกต้องในการเขียนถึง tool ที่พิสูจน์ในเครื่องไม่ได้ ไม่ใช่แต่งค่า output ขึ้นมาเอง

เวลา review AI-generated policy/static-analysis tooling สำหรับ Terraform ให้เช็ค:

  • .tflint.hcl ที่ไม่เคยประกาศ cloud provider plugin เลย (plugin "aws" { ... }) — tflint binary เปล่า ๆ แทบไม่จับอะไรที่เฉพาะ cloud เลย พิสูจน์ข้างบน (มีแค่ warning required_version ทั่วไปตอนไม่ config plugin)
  • Rego policy ที่ใช้ deny[msg] { ... } โดยไม่มี if/contains กับ conftest/OPA image เวอร์ชันปัจจุบัน — พิสูจน์ข้างบน นี่คือ parse error จริงภายใต้ default Rego v1 ของ OPA 1.20.2 ไม่ใช่แค่ความกังวลเรื่อง version-skew ทางทฤษฎี
  • CI step ที่รัน checkov/tflint แต่ไม่เคยเช็ค exit code (continue-on-error: true แล้วไม่มีอะไรอ่านผลต่อ) — scan รันจริง เจอ issue จริง แต่ไม่บล็อกอะไรเลย
  • policy-as-code rule ที่เขียนเทียบกับ terraform.tfstate ที่ parse เอง แทนที่จะเป็น resource_changes[].change.actions/.after ของ terraform show -json — state อธิบายสิ่งที่ เป็นอยู่ ไม่ใช่สิ่งที่ plan ที่รอ apply กำลังจะทำ เลย check ที่อิง state บล็อก plan แย่ ๆ ก่อน apply ไม่ได้
  • Rego policy ไฟล์เดียวใหญ่ ๆ ที่ปน security rule (“ห้ามเปิด SSH”) กับ cost/tagging rule ไว้ด้วยกัน — test และคิดตามยากกว่าแยก policy package ต่อเรื่อง และ skip เฉพาะบางประเภทตอนต้องการ exception ก็ยากกว่า

🐛 หา bug ในโค้ดนี้

.tflint.hcl
plugin "aws" {
enabled = true
}
เฉลย

plugin block นี้ขาด argument สองตัวที่ tflint --init ต้องใช้จริง ๆ ถึงจะดึงอะไรได้: version กับ source พิสูจน์ข้างบน tflint --init กับ config ที่ไม่มี version/source จะ fail ตรง ๆ หรือไม่ก็ resolve ไปที่ default ของ plugin manager แบบเงียบ ๆ ซึ่งไม่แน่นอน — การรันจริงในบทนี้ pin ทั้งคู่ (version = "0.31.0", source = "github.com/terraform-linters/tflint-ruleset-aws") ซึ่งเป็นสิ่งที่ทำให้ CI run reproducible แทนที่จะได้ ruleset version ต่างกันทุกครั้งที่ runner ใหม่

tflint กับ checkov จับอะไรได้ที่ terraform validate ธรรมดาจับไม่ได้
ความต่างหลักระหว่างสิ่งที่ static analysis tool เช็ค กับสิ่งที่ policy-as-code tool อย่าง Sentinel หรือ OPA เช็ค คืออะไร
policy ข้อไหนที่บังคับใช้ได้แค่กับ plan ที่คำนวณแล้ว ไม่ใช่กับ HCL ดิบอย่างเดียว
Sentinel ต่างจาก Open Policy Agent พร้อม conftest ยังไง
error ที่พิสูจน์จริงตัวนี้แสดง fact เฉพาะ version อะไร?
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/ssh.rego:3: rego_parse_error: `if` keyword is required before rule body
ทำไม section Infracost ในบทนี้ถึงระบุชัดว่ามาจาก docs แทนที่จะพิสูจน์ด้วยการรันจริง?