Policy as Code and Static Analysis
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”static analysis tool อย่าง tflint กับ checkov-family scanner จับ pattern ที่ไม่ดีใน HCL ดิบก่อนที่ plan จะรันด้วยซ้ำ ส่วน policy-as-code tool อย่าง Sentinel กับ Open Policy Agent evaluate plan ที่ compute จริงเทียบกับ rule ขององค์กร และ pipeline สำหรับ production ต้องมีทั้งสอง layer ไม่ใช่แค่อันเดียว
Static analysis คือ check ที่เร็วและถูกก่อน plan รัน
หัวข้อที่มีชื่อว่า “Static analysis คือ check ที่เร็วและถูกก่อน plan รัน”terraform validate ยืนยันได้แค่ว่า HCL syntax ถูกต้องและ internal consistent เท่านั้น แต่ไม่มีความเห็นว่า config นั้นเป็น ไอเดียที่ดี หรือเปล่า มี tool เสริมสองตัวที่มาปิด gap นี้ ทั้งคู่รันเสร็จในไม่กี่วินาทีกับ config ดิบ ไม่มีการเรียก Azure API และไม่เกี่ยวกับ state เลย
tflintจับ mistake เฉพาะของ provider ที่validateไม่มีทางรู้ได้ เพราะvalidateไม่เข้าใจว่า argument ของazurerm_linux_virtual_machineมีความหมายอะไรกับ Azure จริง ๆ รู้แค่ว่า HCL parse ผ่าน plugintflint-ruleset-azurermเพิ่ม rule ที่รู้จัก Azure เข้าไปบน core linter เช่น VM size string ที่ invalid, argument ที่ deprecated แต่ยัง parse ผ่านโดยไม่ทำอะไรแล้ว หรือ naming pattern ที่ผิด restriction การตั้งชื่อ resource ของ Azure เอง- security และ misconfiguration scanner —
checkov(Bridgecrew/Prisma Cloud) หรือtrivy configของ Aqua Security (ตัวที่tfsecเองก็ชี้ผู้ใช้ไปหา เพราะtfsecเข้าสู่โหมด maintenance แล้ว) — pattern-match ตัว HCL เองเทียบกับ library ของ configuration ที่รู้กันว่าไม่ดี ไม่เกี่ยวกับว่า run นี้กำลังจะ change อะไรจริง ๆ เลย storage account ที่ตั้งallow_nested_items_to_be_public = true, managed disk ที่ไม่ตั้ง encryption argument, network security group rule ที่เปิด0.0.0.0/0ให้ management port ทั้งหมดนี้เป็น finding ที่ scanner ยกได้จากแค่อ่านไฟล์.tfเอง ก่อนที่initหรือplanจะแตะ Azure ด้วยซ้ำ
รันจริง ทั้งสอง tool ผ่าน docker run --rm กับ config เล็ก ๆ ที่ตั้งใจทำให้แย่: VM size ที่ invalid, storage account ที่เปิด public blob access, และ NSG rule ที่เปิด RDP (3389) ให้ทั้งโลก:
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest --initInstalling "azurerm" plugin...Installed "azurerm" (source: github.com/terraform-linters/tflint-ruleset-azurerm, version: 0.28.0)
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1: 1: terraform {
Error: "Standard_D999_v99" is an invalid value as size (azurerm_linux_virtual_machine_invalid_size)
on main.tf line 53: 53: size = "Standard_D999_v99"rule name จริงคือ azurerm_linux_virtual_machine_invalid_size และเป็น Error severity จริง (ไม่ใช่ Warning) — ต่างกันทั้งชื่อและ severity จาก azurerm_invalid_instance_type แบบ “Warning” ที่เดาไว้ ruleset azurerm เป็น plugin แยกต่างหาก (tflint-ruleset-azurerm) ที่ดึงมาด้วย --init จาก .tflint.hcl ที่ประกาศ version/source เหมือนกับ AWS ruleset ในคอร์สพี่น้อง
$ docker run --rm -v "$PWD:/data" bridgecrew/checkov:latest -d /data --compact --quietCheck: CKV_AZURE_59: "Ensure that Storage accounts disallow public access" FAILED for resource: azurerm_storage_account.main File: /main.tf:...
Check: CKV_AZURE_9: "Ensure that RDP access is restricted from the internet" FAILED for resource: azurerm_network_security_rule.rdp File: /main.tf:...output จริงจาก checkov (ตัดให้สั้น) check ID จริงสำหรับ storage account ที่เปิด public access คือ CKV_AZURE_59 ไม่ใช่ CKV_AZURE_35 — CKV_AZURE_35 ก็เป็น check จริงเหมือนกัน แต่คือ “Ensure default network access rule for Storage Accounts is set to deny” ซึ่งเป็น rule ที่เกี่ยวข้องแต่คนละเรื่องกัน เป็นเรื่อง default ของ network firewall ไม่ใช่ setting allow_nested_items_to_be_public ระดับ blob ที่ config นี้ผิดจริง ทั้งสองตัวรันก่อน terraform plan ใน CI pipeline และควร fail pipeline เร็ว ๆ ถ้าเจอปัญหาจริง ไม่มีเหตุผลต้องรอ plan ที่ใช้เวลาหลายนาทีกับ Azure state จริง ๆ แค่เพื่อมาเจอทีหลังว่า storage account ถูก config ผิดจนเปิด public blob access
jobs: static-analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: terraform-linters/setup-tflint@v4 - run: tflint --init - run: tflint --recursive - name: Run checkov with: directory: .bridgecrewio/checkov-action@master (ref แบบ branch ที่เปลี่ยนได้ตลอด) ถูก pin ข้างบนเป็น @v12.1347.0 — tag release จริงปัจจุบันจาก github.com/bridgecrewio/checkov-action/releases ณ เวลาที่เขียนบทนี้ @master ใน workflow แปลว่าทุก run จะดึงอะไรก็ตามที่ default branch ของ action มีอยู่วันนั้น — เป็นความเสี่ยงแบบ supply-chain (upstream ถูก compromise หรือ breaking change ที่ไม่ได้ review ไปถึง CI ของทุกคนที่ใช้ทันที) ที่การ pin ไปที่ tag แบบ immutable ปิดช่องนี้ได้
Policy as code คือการ evaluate ตัว plan เอง
หัวข้อที่มีชื่อว่า “Policy as code คือการ evaluate ตัว plan เอง”static analysis มองแค่ HCL ตามที่เขียนไว้เท่านั้น จึงไม่รู้เลยว่า change ที่ compute จริงจะทำอะไรกับ Azure subscription จริง ๆ Policy as code ปิด gap นี้ด้วยการ evaluate output แบบ machine-readable ของ terraform plan (หรือ terragrunt run --all plan) เทียบกับ policy ที่เขียนไว้ แล้ว block apply ถ้า plan นั้นผิด policy
มี tool สอง ตัวที่ครองพื้นที่นี้
- Sentinel คือ policy engine ในตัวของ HCP Terraform policy เขียนด้วยภาษาของ Sentinel เอง แล้ว attach เข้ากับ workspace รันอัตโนมัติกับทุก plan ที่ HCP Terraform สร้าง plan ที่ fail policy แบบ
hard-mandatoryจะ apply ไม่ได้เลย - Open Policy Agent (OPA) ปกติจับคู่กับ
conftestสำหรับ Terraform เป็น alternative แบบ open-source ที่ไม่ผูกกับ provider ไหน เพราะรันกับ JSON representation ของ plan (terraform show -json tfplan) จึงใช้ได้ใน CI system ไหนก็ได้ ไม่ว่าจะใช้ HCP Terraform หรือไม่ GitHub Actions job รันconftestกับ plan JSON ได้เหมือนที่รันtflintกับ HCL
# produce a plan, then convert it to JSON for policy evaluationterraform plan -out=tfplanterraform show -json tfplan > tfplan.json
# conftest evaluates the plan JSON against Rego policies in ./policydocker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test tfplan.json -p policypackage main
deny contains msg if { resource := input.resource_changes[_] resource.type == "azurerm_storage_account" resource.change.after.allow_nested_items_to_be_public == true msg := sprintf("Storage account %s allows public blob access", [resource.name])}terraform plan จริงกับ azurerm resource จริงต้องมี Azure session จริง — พิสูจน์สองครั้ง สอง error จริงต่างกันแล้วแต่วิธีที่ (ไม่) ใส่ credential: ไม่ config authentication อะไรเลย ได้ unable to build authorizer for Resource Manager API: could not configure AzureCli Authorizer: ... exec: "az": executable file not found in $PATH; ใส่ค่า service-principal ปลอมชัดเจน Terraform ไปได้ไกลกว่าเดิม จริง ๆ ไปถึง Azure AD ผ่าน network เลย แต่โดน reject จริงที่นั่น: AADSTS90002: Tenant '00000000-0000-0000-0000-000000000002' not found ทั้งคู่ไม่ได้ plan JSON จริงของ azurerm ให้เอาไปรัน conftest ต่อใน environment แบบ offline นี้
สิ่งที่พิสูจน์ได้จริง: syntax Rego v1 ของ policy ถูกต้องและ evaluate ได้สะอาด (รันจริงด้วย conftest ไม่มี match เลย — ถูกต้องแล้วเพราะไม่มี azurerm_storage_account อยู่ใน plan ที่สร้างจาก resource local_file แบบ cloud-agnostic ของคอร์สนี้):
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy1 test, 1 passed, 0 warnings, 0 failures, 0 exceptionsแล้วแยกออกไปอีกครั้ง กับ fixture ที่เขียนขึ้นเองตาม shape จริงและมี document ไว้ของ terraform show -json (resource_changes[].type/.change.after) — เป็น fixture สำหรับ unit-test policy — ไม่ได้ claim ว่าเป็น output จริงจาก terraform plan เลย เป็น fixture แบบที่ทีมจะเก็บไว้ใน policy/testdata/ เพื่อ test Rego rule โดยไม่ต้องมี plan จริง — policy ตัวเดียวกันนี้ fire จริง:
// fixture-violation.json — hand-authored policy test fixture, not terraform plan output{ "resource_changes": [{ "address": "azurerm_storage_account.main", "type": "azurerm_storage_account", "change": { "actions": ["create"], "after": { "allow_nested_items_to_be_public": true } } }]}$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test fixture-violation.json -p policyFAIL - fixture-violation.json - main - Storage account main allows public blob access
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptionsรันจริงสามครั้งรวมกันพิสูจน์สิ่งที่สำคัญ: Rego syntax ถูกต้องภายใต้ OPA ที่ conftest bundle มา (Rego v1 ต้องมี if/contains — requirement เดียวกับที่พิสูจน์ไว้ในตัวอย่าง AWS ของคอร์สพี่น้อง), มันเงียบถูกต้องกับ plan ที่ไม่มี resource ตรง และมัน fire ถูกต้องทันทีที่ resource_changes มี shape ตรงกับที่มันเขียนไว้จะจับ สิ่งเดียวที่พิสูจน์แบบ offline ไม่ได้คือขั้นตอนให้ azurerm plan จริงไปถึง shape นั้น — ขั้นนั้นต้องมี Azure session จริงที่คอร์สนี้ไม่มี
ไอเดียร่วมกันของ Sentinel กับ OPA เหมือนกันไม่ว่าทีมจะใช้ตัวไหน rule ที่เขียนไว้อย่างเช่น “ห้าม storage account ไหนเปิด public blob access” หรือ “ทุก resource ต้องมี tag cost-center” จะ evaluate เทียบกับ plan ที่เจาะจง และ plan ที่ผิด rule จะถูก block ไม่ให้ apply ได้ การจับ violation เกิดหลังจาก plan compute change เสร็จแล้ว แต่ต้องก่อน apply จะรันได้เสมอ
ประเมินต้นทุน: Infracost (มาจาก docs เท่านั้น — ไม่ได้รันข้างล่างนี้)
หัวข้อที่มีชื่อว่า “ประเมินต้นทุน: Infracost (มาจาก docs เท่านั้น — ไม่ได้รันข้างล่างนี้)”static analysis กับ policy-as-code ทั้งคู่ตอบคำถาม “ปลอดภัย/compliant ไหม” ไม่มีตัวไหนตอบ “ราคาเท่าไหร่” Infracost เติม gap นี้ และในที่นี้มาจาก docs ไม่ได้รัน — มันต้องการ 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 ของ 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
สอง layer ที่เสริมกัน ไม่ใช่ check ซ้ำ
หัวข้อที่มีชื่อว่า “สอง layer ที่เสริมกัน ไม่ใช่ check ซ้ำ”น่าจะคิดว่า tflint/checkov กับ Sentinel/OPA เป็นสองวิธีที่ทำอย่างเดียวกัน แต่จริง ๆ ทั้งสองตัว check คนละอย่างกัน
- static analysis (
tflint,checkov) evaluate ตัว code เอง ไม่เกี่ยวกับว่า run นี้กำลัง change อะไรจริง ๆ จึงบอกได้ว่า resource block นั้น config ผิด แม้ resource block นั้นจะไม่ได้ถูก apply จริงใน plan รอบนี้เลยก็ตาม - policy as code (Sentinel, OPA) evaluate change ที่ plan ไว้ โดย reference ค่าจริงที่ compute ได้ ซึ่งมีอยู่ก็ต่อเมื่อ plan รันไปแล้วเท่านั้น เช่น “plan นี้ทำลาย production database ตัวนี้โดยเฉพาะ” หรือ “cost รายเดือนรวมที่ plan นี้เพิ่มเกิน threshold” ทั้งสองอย่างนี้รู้ไม่ได้จากแค่อ่าน HCL ดิบ
rule แบบ “block plan ไหนก็ตามที่ทำลาย resource ที่ tag environment = production อยู่” เป็นตัวอย่างชัดเจนของสิ่งที่ policy-as-code เท่านั้นที่ enforce ได้ เพราะขึ้นกับว่า plan ที่เจาะจงตัวนั้น เสนอจะทำอะไรจริง ๆ ไม่ใช่ pattern ที่เห็นได้จาก HCL เอง การรันทั้งสอง layer แปลว่า pattern ที่รู้กันว่าไม่ดีถูก reject ในไม่กี่วินาทีก่อน plan จะเริ่มด้วยซ้ำ และ rule เฉพาะองค์กรเกี่ยวกับ change ที่เสนอจริงก็ยัง enforce อยู่ก่อน apply layer ไหนก็ไม่ทำให้อีก layer หนึ่งซ้ำซ้อน
flowchart LR hcl["HCL configuration"] -->|fast static checks| lint["tflint + checkov"] lint -->|passes| plan["terraform plan"] plan -->|plan JSON| policy["Sentinel or OPA / conftest"] policy -->|passes| apply["terraform apply"] lint -->|fails| block1["Pipeline blocked"] policy -->|fails| block2["Pipeline blocked"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”tool ทุกตัวในบทนี้ตอบคำถามคนละข้อในจุดคนละจุดของ pipeline “HCL syntax ถูกไหม” (validate) ไม่ต้องมี context เลย “HCL ใช้ pattern ที่รู้อยู่แล้วว่าไม่ดีไหม” (tflint/checkov) ต้องมี provider rule catalog แต่ไม่ต้องมี plan “change ที่คำนวณจริงนี้ผิด policy ไหม” (Sentinel/OPA) ตอบก่อนมี plan ไม่ได้ — พิสูจน์ข้างบนว่า azurerm เองต้องมี Azure session จริงที่ environment นี้ให้ไม่ได้ ซึ่งเป็นเหตุผลที่การพิสูจน์ในบทนี้ทำเป็นชั้น ๆ: syntax Rego ถูกต้อง, behavior ตอนไม่ match, behavior ตอน fixture fire แต่ละอย่างพิสูจน์แยกกันแทนที่จะปลอมว่าเป็น live run เดียวจบ “ราคาเท่าไหร่” (Infracost) เป็นมุมที่สี่บน plan ที่คำนวณแล้วตัวเดียวกัน รันผิดลำดับก็เสีย plan ไปกับ code ที่รู้อยู่แล้วว่าแย่ตั้งแต่แรก หรือไม่ก็ดอง static check ที่ใช้เวลาห้าวินาทีไว้จนหลัง plan ที่แพงกว่า (หรือสำหรับ azurerm คือ plan แบบ offline ที่เป็นไปไม่ได้เลย) รันเสร็จ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ได้อะไร | เสียอะไร |
|---|---|---|
tflint พร้อม tflint-ruleset-azurerm | จับ mistake เฉพาะ Azure (VM size ที่ invalid, argument ที่ deprecated) ที่ validate มองไม่เห็น | ต้อง --init ดึง plugin ผ่าน network และ image เปล่า ๆ แทบไม่จับอะไรที่เฉพาะ Azure เลย |
rule catalog กว้าง ๆ ของ checkov | รันครั้งเดียวเจอ misconfiguration จริงได้หลายข้อ (พิสูจน์ข้างบน: public storage access, RDP ที่เปิด) | check ID กับความหมายจริงจำสับสนกันง่าย — พิสูจน์ข้างบน CKV_AZURE_35 กับ CKV_AZURE_59 ทั้งคู่มีจริงแต่เช็คคนละเรื่อง |
OPA/conftest แทน Sentinel | open-source ใช้ได้กับ CI system ไหนก็ได้ ไม่ต้องพึ่ง HCP Terraform | พิสูจน์กับ azurerm plan จริงแบบ offline ไม่ได้ — บทนี้พิสูจน์ syntax validity กับ fixture behavior แยกกันแทนที่จะเป็น live run เดียว และบอกตรง ๆ ว่ามี gap ตรงนี้ |
pin checkov-action เป็น tag จริงแทน @master | CI run reproducible ได้ action ที่ upstream เปลี่ยนไม่ทำให้ pipeline ของทุกคนเปลี่ยนไปด้วยข้ามคืน | ต้องมีคนคอย bump pin เป็นระยะเพื่อได้ check ใหม่ ๆ — @master ที่ไม่ pin ได้สิ่งนั้น “ฟรี” แลกกับ reproducibility เป็นศูนย์ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
tflintจับ security misconfiguration แบบเดียวกับที่checkovจับ” พิสูจน์ข้างบน: ทั้งคู่เจอคนละอย่างบน config เดียวกัน —tflintflag VM size ที่ invalid ส่วนcheckovflag public blob access กับ RDP rule ที่เปิด เป็น rule catalog ที่เสริมกัน ไม่ใช่ทับกัน - “
CKV_AZURE_35กับCKV_AZURE_59คือ check เดียวกันแค่คนละ ID” พิสูจน์ข้างบน:CKV_AZURE_59คือ “Ensure that Storage accounts disallow public access” (ตัวที่ config บทนี้ผิดจริง) ส่วนCKV_AZURE_35เป็น check จริงคนละตัวเกี่ยวกับ default action ของ network firewall ของ storage account - “
@masterบน GitHub Action ก็โอเคตราบใดที่ action นั้น trust ได้” trust ได้วันนี้ไม่ได้บอกอะไรเลยว่าพรุ่งนี้อะไรจะอยู่บนmaster— พิสูจน์ข้างบนด้วยการ pin เป็น tag จริงปัจจุบัน (v12.1347.0) แทน ซึ่งเป็นวิธีเดียวที่ทำให้ behavior ของ workflow คงที่จนกว่าจะมีคนตั้งใจ bump มันเอง - “ถ้า
azurermplan จริงรันแบบ offline ไม่ได้ ก็ test policy สำหรับมันไม่ได้เลย” พิสูจน์ข้างบน: Rego syntax, กรณี clean-pass, กรณี fixture-fire ทั้งหมดเป็น fact จริงที่พิสูจน์แยกกันได้เกี่ยวกับ policy นั้น มีแค่ขั้นสุดท้าย “รันกับ live plan จริง” เท่านั้นที่ต้องมี Azure credential จริง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลา review AI-generated policy/static-analysis tooling สำหรับ Terraform บน Azure ให้เช็ค:
.tflint.hclที่ไม่เคยประกาศplugin "azurerm" { ... }—tflintbinary เปล่า ๆ แทบไม่จับอะไรที่เฉพาะ Azure เลย พิสูจน์ข้างบน (มีแค่ warningrequired_versionทั่วไปตอนไม่ config plugin)uses: bridgecrewio/checkov-action@master(หรือ action อื่นที่ pin ด้วยชื่อ branch แทน version tag) — พิสูจน์ข้างบน ควรเป็น release tag จริงอย่างv12.1347.0แล้วเช็คซ้ำเป็นระยะกับ releases page ของ action นั้นเอง- Rego policy ที่ใช้
deny[msg] { ... }โดยไม่มีif/containsกับconftest/OPA image เวอร์ชันปัจจุบัน — พิสูจน์ข้างบน (ใน check เดียวกันของบท AWS พี่น้อง) นี่คือ parse error จริงภายใต้ default Rego v1 ของ OPA 1.20.2 - checkov finding ที่อ้างด้วย check ID ที่เดาหรือจำผิด แทนที่จะเป็น ID ที่ checkov รายงานจริง — พิสูจน์ข้างบนด้วย
CKV_AZURE_35เทียบกับCKV_AZURE_59สอง check จริงที่ปนกันง่าย - โค้ดที่ claim ว่า “พิสูจน์” policy กับ
azurermplan จริงใน environment ที่ไม่มี Azure credential เลย — version ที่ซื่อสัตย์ พิสูจน์ในบทนี้ คือแยกสิ่งที่พิสูจน์ได้จริง (syntax, clean-pass, fixture-fire) ออกจากสิ่งที่ต้องมี credential จริง
🐛 หา bug ในโค้ดนี้
plugin "azurerm" {enabled = true}เฉลย
plugin block นี้ขาด version กับ source สองตัวที่ tflint --init ต้องใช้จริง ๆ ถึงจะดึงอะไรได้ พิสูจน์ข้างบน การ init จริงในบทนี้ pin ทั้งคู่ (version = "0.28.0", source = "github.com/terraform-linters/tflint-ruleset-azurerm") — ถ้าไม่มี CI run จะ fail ตอน install plugin หรือไม่ก็ได้ version ที่ plugin manager เลือก default ให้แบบเงียบ ๆ ซึ่งไม่ reproducible ข้าม runner