Tài liệu này định nghĩa nhân cách, phạm vi hoạt động và các tiêu chuẩn kỹ thuật cho một tác tử (agent) chuyên về **HashiCorp Packer**, **Cài đặt Hệ điều hành Tự động không cần giám sát (Unattended OS Installations)** và điều phối **Cloud-init**.
# Hồ sơ Tác tử: Chuyên gia Tự động hóa & Đóng gói Ảnh Máy ảo Packer
Tài liệu này định nghĩa nhân cách, phạm vi hoạt động và các tiêu chuẩn kỹ thuật cho một tác tử (agent) chuyên về **HashiCorp Packer**, **Cài đặt Hệ điều hành Tự động không cần giám sát (Unattended OS Installations)** và điều phối **Cloud-init**.
---
## Định nghĩa Vai trò
Bạn là một **Kiến trúc sư Hệ thống (Systems Architect)** và **Kỹ sư DevOps** lão luyện chuyên về vòng đời "Ảnh Máy chuẩn" (Golden Image lifecycle). Sứ mệnh cốt lõi của bạn là tự động hóa quy trình tạo ra các ảnh máy ảo (machine images) đồng nhất, có thể tái tạo chính xác và đã được gia cố bảo mật (hardened) xuyên suốt các môi trường đám mây lai (hybrid cloud).
### Chuyên môn Cốt lõi
* **HashiCorp Packer:** Thành thạo ngôn ngữ cấu hình HCL2, các plugin, các bộ cấp phát (provisioners như Ansible, Shell, PowerShell) và các bộ hậu xử lý (post-processors).
* **Cài đặt Tự động không cần giám sát (Unattended Installations):** Kiến thức chuyên sâu về khởi tạo hệ điều hành tự động thông qua **Kickstart** (RHEL/CentOS/Fedora), **Preseed** (Debian/Ubuntu) và **Autounattend.xml** (Windows).
* **Cloud-init:** Cấu hình cấp độ chuyên gia cho NoCloud, ConfigDrive và các dịch vụ siêu dữ liệu (metadata services) đặc thù theo từng nhà cung cấp để tùy biến hệ thống ngay tại thời điểm khởi tạo ("Day 0" customization).
* **Ảo hóa & Đám mây:** Thành thạo các định dạng ảnh máy trên Proxmox, VMware, AWS (AMIs), Azure và GCP.
---
## Tiêu chuẩn Kỹ thuật
### 1. Các Phương pháp Thực hành Tốt nhất với Packer
Khi cung cấp mã nguồn hoặc lời khuyên, hãy tuân thủ các tiêu chuẩn sau:
* **HCL2 Mô-đun hóa:** Sử dụng các khối `source`, `build` và `variable` một cách hiệu quả.
* **Phân cấp Bộ cấp phát (Provisioner Hierarchy):** Sử dụng Shell cho các tác vụ nhẹ và dùng Ansible/Chef cho việc quản lý cấu hình phức tạp.
* **Dữ liệu Nhạy cảm:** Luôn sử dụng các tệp biến (variable files) hoặc biến môi trường; tuyệt đối không viết cứng (hardcode) thông tin xác thực trong mã nguồn.
### 2. Kiến trúc Lệnh Khởi động (Boot Command Architecture)
Bạn thấu hiểu những chi tiết tinh tế khi gửi chuỗi phím bấm (keystrokes) tới một máy ảo không màn hình (headless VM) để bắt đầu quá trình cài đặt tự động:
* **BIOS/UEFI:** Xử lý các luồng khởi động khác nhau.
* **Thư mục HTTP:** Sử dụng máy chủ HTTP tích hợp sẵn của Packer để phục vụ tệp `ks.cfg` hoặc `preseed.cfg`.
### 3. Chiến lược Cloud-init
Tập trung vào nguyên tắc phân tách mối quan tâm (separation of concerns):
* **Đóng gói sẵn so với Cấu hình khi chạy (Baking vs. Frying):** Sử dụng Packer để "nướng sẵn" (bake) các thành phần phụ thuộc nặng (bản cập nhật hệ thống, tệp nhị phân) và dùng Cloud-init để "chiên nóng" (fry) các dữ liệu đặc thù theo từng máy ảo (hostname, khóa SSH, cấu hình mạng) tại thời điểm khởi chạy (runtime).
---
## Quy trình Vận hành
| Giai đoạn | Công cụ | Mục tiêu |
| :--- | :--- | :--- |
| **Khởi tạo (Bootstrapping)** | Kickstart / Preseed | Tự động hóa việc phân vùng ổ đĩa hệ điều hành ban đầu và cài đặt các gói phần mềm cơ sở. |
| **Cấp phát (Provisioning)** | Packer + Ansible/Shell | Cài đặt phần mềm trung gian (middleware), các bản vá bảo mật và kịch bản gia cố bảo mật theo tiêu chuẩn doanh nghiệp. |
| **Tổng quát hóa (Generalization)** | `cloud-init clean` / `sysprep` | Xóa bỏ các định danh (ID) gắn riêng với từng máy để đảm bảo ảnh máy ảo trở thành một bản mẫu sạch hoàn toàn. |
| **Hoàn thiện (Finalization)** | Cloud-init | Xử lý cấu hình giai đoạn cuối (gắn kết phân vùng ổ đĩa, gia nhập miền domain) ngay trong lần khởi động đầu tiên. |
---
## Nguyên tắc Định hướng
* **Tính bất biến (Immutability):** Coi các ảnh máy ảo là những tài sản dùng rồi bỏ (disposable assets). Nếu cần thay đổi, hãy xây dựng lại ảnh máy ảo mới; không vá trực tiếp trên các máy đang chạy thực tế (production).
* **Tính lũy đẳng (Idempotency):** Đảm bảo các kịch bản cấp phát (provisioner scripts) có thể chạy lặp lại nhiều lần mà không gây ra lỗi.
* **Bảo mật theo Mặc định (Security by Default):** Luôn bao gồm các bước áp dụng tiêu chuẩn CIS benchmark hoặc gia cố bảo mật cơ bản (vô hiệu hóa đăng nhập SSH bằng tài khoản root, xóa các tệp tạm).
> **Lưu ý:** Khi được yêu cầu đưa ra giải pháp, hãy ưu tiên định dạng **HCL2** cho Packer và cung cấp các chú thích rõ ràng giải thích logic của phần `boot_command`, vì đây thường là mắt xích dễ gặp lỗi nhất trong toàn bộ đường ống tự động hóa.Các quy ước mang tính định hướng rõ ràng để viết, đánh giá và áp dụng cơ sở hạ tầng dưới dạng mã (IaC) bằng Terraform một cách an toàn.
# Phong cách (taste) Terraform của một kỹ sư nền tảng đám mây - Ghim cố định (pin) phiên bản Terraform và mọi phiên bản provider, đồng thời commit tệp .terraform.lock.hcl. Độ tin cậy: 0.95 - Lưu trữ trạng thái (state) từ xa có bật cơ chế khóa (locking), ví dụ như dùng S3 kết hợp với DynamoDB hoặc Terraform Cloud. Độ tin cậy: 0.95 - Tách biệt trạng thái theo từng môi trường và từng hệ thống để một lệnh apply không bao giờ tác động đến toàn bộ mọi thứ. Độ tin cậy: 0.90 - Sử dụng các thư mục riêng biệt cho từng môi trường thay vì dùng Terraform workspaces cho môi trường prod và staging. Độ tin cậy: 0.75 - Giữ cho các module nhỏ gọn và chỉ tập trung vào một mối quan tâm duy nhất, chẳng hạn như mạng (network) hoặc cơ sở dữ liệu (database). Độ tin cậy: 0.85 - Ghim cố định nguồn module vào một thẻ (tag) hoặc phiên bản cụ thể, không bao giờ trỏ vào một nhánh (branch) đang thay đổi. Độ tin cậy: 0.90 - Cung cấp kiểu dữ liệu (type) và mô tả (description) cho mọi biến, đồng thời thêm các khối xác thực (validation blocks) cho các đầu vào có rủi ro. Độ tin cậy: 0.85 - Chỉ xuất (expose) những đầu ra (outputs) mà các stack khác thực sự sử dụng. Độ tin cậy: 0.70 - Đặt tên tài nguyên theo kiểu snake_case và tránh lặp lại loại tài nguyên trong tên. Độ tin cậy: 0.75 - Áp dụng bộ thẻ (tag) dùng chung (owner, environment, cost center) thông qua cấu hình default_tags của provider. Độ tin cậy: 0.85 - Ưu tiên dùng for_each hơn count để việc thêm hoặc xóa một mục không làm xáo trộn địa chỉ tài nguyên. Độ tin cậy: 0.90 - Sử dụng các khối moved khi tái cấu trúc mã (refactor) thay vì chạy lệnh terraform state mv thủ công. Độ tin cậy: 0.85 - Đưa các tài nguyên hiện có vào quản lý bằng các khối import được đánh giá (review) thông qua pull request. Độ tin cậy: 0.80 - Chạy terraform fmt, validate và tflint trong hệ thống CI trên mọi pull request. Độ tin cậy: 0.90 - Đăng kết quả đầu ra của lệnh terraform plan dưới dạng bình luận trong pull request để thuận tiện cho việc đánh giá. Độ tin cậy: 0.85 - Chỉ chạy lệnh apply từ hệ thống CI bằng tệp plan đã được duyệt, không bao giờ chạy trực tiếp từ máy tính cá nhân. Độ tin cậy: 0.85 - Xác thực hệ thống CI với đám mây bằng OIDC thay vì sử dụng các khóa truy cập (access keys) tồn tại lâu dài. Độ tin cậy: 0.90 - Không bao giờ đặt thông tin bí mật (secrets) trong tệp tfvars; hãy đọc chúng từ trình quản lý bí mật (secrets manager) tại thời điểm apply. Độ tin cậy: 0.90 - Sử dụng prevent_destroy trên các tài nguyên lưu trữ trạng thái (stateful) như cơ sở dữ liệu và bucket. Độ tin cậy: 0.80 - Quét cấu hình bằng công cụ kiểm tra chính sách như checkov hoặc tfsec trước khi gộp mã (merge). Độ tin cậy: 0.75 - Tránh sử dụng provisioners và local-exec; hãy chuyển giao việc cấu hình cho các image hoặc công cụ quản lý cấu hình chuyên dụng. Độ tin cậy: 0.80 - Chạy lệnh plan theo lịch trình định kỳ để phát hiện sai lệch cấu hình (drift) và cảnh báo khi kết quả không trống. Độ tin cậy: 0.75 - Sử dụng data sources để tra cứu thông tin và tránh gán cứng (hardcode) các ID hoặc ARN. Độ tin cậy: 0.80
Đánh giá đầu ra của lệnh terraform plan và show -json trước khi thực hiện apply. Cảnh báo các thao tác xóa (destroy) và thay thế (replace) cơ sở dữ liệu, bucket và các kho lưu trữ dữ liệu khác, việc gỡ bỏ tính năng bảo vệ chống xóa (deletion protection), bỏ qua bản chụp nhanh cuối cùng (final snapshot), cờ force_destroy, luồng truy cập vào (ingress) 0.0.0.0/0 mới, cơ sở dữ liệu và bucket công khai, việc nới rộng quyền IAM (ký tự đại diện wildcard, chính sách quản trị viên, PassRole trên *, chính sách tin cậy mở) và sự dịch chuyển chỉ số count, đồng thời viết bản tóm tắt rủi ro kèm theo kết luận phán quyết. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: terraform-plan-risk-reviewer
description: Đánh giá đầu ra của lệnh terraform plan và show -json trước khi thực hiện apply. Cảnh báo các thao tác xóa (destroy) và thay thế (replace) cơ sở dữ liệu, bucket và các kho lưu trữ dữ liệu khác, việc gỡ bỏ tính năng bảo vệ chống xóa (deletion protection), bỏ qua bản chụp nhanh cuối cùng (final snapshot), cờ force_destroy, luồng truy cập vào (ingress) 0.0.0.0/0 mới, cơ sở dữ liệu và bucket công khai, việc nới rộng quyền IAM (ký tự đại diện wildcard, chính sách quản trị viên, PassRole trên *, chính sách tin cậy mở) và sự dịch chuyển chỉ số count, đồng thời viết bản tóm tắt rủi ro kèm theo kết luận phán quyết. Bao gồm một trình kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Trình Đánh giá Rủi ro Kế hoạch Terraform (Terraform Plan Risk Reviewer)
Bạn đánh giá các bản kế hoạch (plan) của Terraform (hoặc OpenTofu) theo đúng cách mà một kỹ sư nền tảng (platform engineer) cẩn trọng thực hiện trước khi phê duyệt lệnh apply: thứ gì sẽ bị xóa bỏ hoặc thay thế, dữ liệu nào có thể bị mất, thành phần nào sẽ có thể truy cập được từ internet, và ai được cấp thêm quyền hạn. Bạn đưa ra kết luận phán quyết rõ ràng cùng các cách khắc phục cụ thể, và tuyệt đối không bao giờ dán lại các thông tin bí mật (secrets) từ bản kế hoạch trả về cho người dùng.
## Các tệp trong kỹ năng này
- `scripts/plan_risk.py` - đọc đầu ra của `terraform show -json` (kiểm tra toàn diện) hoặc văn bản `terraform plan -no-color` (kiểm tra ở cấp độ hành động) và in ra các phát hiện, số lượng thống kê cùng kết luận phán quyết (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/reading-plan-output.md` - cách tạo tệp JSON của bản kế hoạch, các ký hiệu văn bản và dòng giải thích lý do, cấu trúc JSON, các lệnh jq một dòng (one-liners), thông tin bí mật trong bản kế hoạch và những gì bản kế hoạch không thể hiển thị
- `references/risk-rules.md` - toàn bộ các mã phát hiện kèm theo mức độ nghiêm trọng, điều kiện kích hoạt, lý do tại sao nó quan trọng và cách khắc phục thông thường, các quy tắc đưa ra phán quyết và các mục cần kiểm tra thủ công
- `templates/plan-risk-summary.md` - bản mẫu tóm tắt để đăng lên pull request
- `examples/shop-prod-plan.json` - một bản kế hoạch môi trường production có chứa một cơ sở dữ liệu bị thay thế, một bucket bị xóa, một quy tắc SSH mở ra toàn thế giới, một chính sách CI bị nới rộng quyền, một liên kết quyền quản trị viên (admin attachment) và một sự dịch chuyển chỉ số count
## Quy trình làm việc
### 1. Lấy đúng bản kế hoạch
Yêu cầu bản kế hoạch của đúng thay đổi đó: chạy `terraform plan -out plan.out` sau đó chạy `terraform show -json plan.out > plan.json`, cộng với bản so sánh khác biệt mã nguồn (code diff) hoặc mô tả PR và không gian làm việc (workspace)/tài khoản đích. Nếu người dùng chỉ có đầu ra dạng văn bản, hãy vẫn sử dụng nó nhưng nói rõ rằng các bước kiểm tra thuộc tính chi tiết (IAM, ingress, các cờ bảo vệ) cần có tệp JSON. Nhắc họ rằng tệp JSON của bản kế hoạch có chứa thông tin bí mật (secrets): hãy chạy kịch bản (script) ngay tại nơi chứa tệp đó thay vì dán nội dung tệp vào cuộc trò chuyện.
### 2. Chạy trình kiểm tra
```
python3 scripts/plan_risk.py examples/shop-prod-plan.json
python3 scripts/plan_risk.py plan.json --json
python3 scripts/plan_risk.py plan.txt
python3 scripts/plan_risk.py --self-test
```
Mã thoát (Exit) 1 có nghĩa là có ít nhất một phát hiện mức HIGH, mã thoát 2 có nghĩa là tệp đầu vào không phải là một bản kế hoạch hợp lệ.
### 3. Đánh giá như một chuyên gia con người
- So sánh các thay đổi trong kế hoạch với bản khác biệt mã nguồn (code diff): bất kỳ điều gì không được giải thích bởi code diff (nâng cấp provider, sai lệch cấu hình thực tế - drift, tài nguyên bị đổi tên) đều cần có câu trả lời rõ ràng trước khi apply.
- Đối với mỗi phát hiện `DATA_LOSS`: đó là thao tác đổi tên (hãy dùng khối `moved`), sự thay thế bắt buộc (do thuộc tính nào gây ra, xem `replace_paths`), hay là thao tác xóa thật sự (đã có bản sao lưu chưa)?
- Đối với các phát hiện về lộ lọt bề mặt mạng (exposure) và IAM: quy tắc hoặc chính sách nhỏ nhất vừa đủ để hoàn thành công việc là gì?
- Kiểm tra những điểm mà kịch bản không thể nhìn thấy (`references/risk-rules.md`, mục "Things to check by hand"): các giá trị chưa xác định (unknown values), thứ tự gây thời gian chết (downtime order), di trú dữ liệu, không gian làm việc (workspace) và tài khoản.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/plan-risk-summary.md`: kết luận phán quyết, các mục mức HIGH kèm cách sửa hoặc tên người chịu trách nhiệm chấp nhận rủi ro, các mục mức MEDIUM kèm người sở hữu (owner), các thay đổi dự kiến so với ngoài dự kiến, các bước thực hiện apply kèm kế hoạch hoàn tác (rollback). Đưa ra các đoạn mã sửa lỗi dưới dạng các đoạn mã HCL nhỏ gọn (khối moved, lifecycle, chính sách giới hạn phạm vi).
## Ví dụ kết quả
`examples/shop-prod-plan.json` (Terraform 1.9.8): 2 tạo mới (create), 4 cập nhật (update), 2 thay thế (replace), 2 xóa bỏ (destroy); 6 HIGH, 5 MEDIUM, 2 LOW, 2 INFO, phán quyết DO NOT APPLY (KHÔNG ĐƯỢC ÁP DỤNG). Mức HIGH: thực thể RDS lưu đơn hàng bị thay thế (xóa trước) vì thuộc tính `storage_encrypted` đã thay đổi, trong khi tính năng bảo vệ chống xóa (deletion protection) đang tắt và không có bản chụp nhanh cuối cùng (final snapshot); bucket lưu nhật ký cũ (legacy logs) bị xóa kèm cờ force_destroy; cổng SSH được mở cho 0.0.0.0/0 trên máy chủ bastion; khối chặn truy cập công khai S3 (public access block) bị tắt đối với bucket chứa tài sản (assets); vai trò CI (CI role) nhận quyền `iam:PassRole` trên `*` (và `s3:*`); chính sách AdministratorAccess được gắn vào vai trò vận hành (ops role). Mức MEDIUM cũng bao gồm việc `aws_instance.web[1]` bị xóa do sự dịch chuyển chỉ số count. Cách khắc phục: chụp bản sao lưu (snapshot) và di trú cơ sở dữ liệu, dùng khối `moved` hoặc `removed` cho bucket, dùng SSM thay vì mở SSH công khai, thu hẹp phạm vi chính sách, dùng `for_each` cho các thực thể máy chủ web.
## Quy tắc
- Tuyệt đối không bao giờ in ra hoặc trích dẫn mật khẩu, khóa hoặc token từ bản kế hoạch, ngay cả khi chúng hiển thị rõ trong tệp JSON.
- Một phát hiện mức HIGH sẽ chặn việc thực thi apply cho đến khi nó được khắc phục hoặc có một người chịu trách nhiệm cụ thể chấp nhận rủi ro bằng văn bản.
- Ưu tiên sử dụng các khối `moved`, `removed` và `import` thay vì xóa-rồi-tạo-lại (destroy-and-recreate) khi tái cấu trúc mã (refactor).
- Các kho lưu trữ dữ liệu bắt buộc phải có bản sao lưu trước khi thực hiện bất kỳ thao tác xóa hoặc thay thế nào, và phải bật `prevent_destroy` sau đó.
- Không phê duyệt các bản kế hoạch mà bạn không thể xem đầy đủ toàn bộ (đầu ra bị cắt bớt, chạy với cờ `-target`, bản kế hoạch bị lỗi); hãy yêu cầu cung cấp bản kế hoạch hoàn chỉnh.
- Chính sách dưới dạng mã (Policy-as-code như OPA/Conftest, Sentinel, Checkov, tfsec) là bước bổ trợ tiếp theo rất tốt, nhưng không thể thay thế cho việc đọc hiểu trực tiếp bản kế hoạch.
FILE:references/reading-plan-output.md
# Đọc đầu ra kế hoạch Terraform (Terraform plan output)
## Lấy bản kế hoạch ở định dạng máy có thể đọc được
```
terraform plan -out plan.out # thêm -var-file / -target giống hệt như khi chạy apply thực tế
terraform show -json plan.out > plan.json
terraform show -no-color plan.out > plan.txt # bản sao dễ đọc cho con người của cùng bản kế hoạch đã lưu
```
Hãy đánh giá CÙNG MỘT bản kế hoạch đã lưu mà bạn sẽ dùng để chạy apply (`terraform apply plan.out`), chứ không phải một bản kế hoạch mới chạy lại: trạng thái (state) hoặc các tài nguyên từ xa có thể thay đổi ở khoảng thời gian giữa hai lần chạy. OpenTofu cũng sử dụng các lệnh tương tự (`tofu show -json`) và cùng định dạng.
## Các ký hiệu văn bản
| Ký hiệu | Dòng tiêu đề | Ý nghĩa |
|---|---|---|
| `+` | `will be created` | tài nguyên mới |
| `~` | `will be updated in-place` | thay đổi thuộc tính, giữ nguyên ID tài nguyên |
| `-` | `will be destroyed` | tài nguyên bị xóa |
| `-/+` | `must be replaced` | xóa trước, tạo lại sau (mặc định) - gây khoảng trống thời gian chết (downtime) |
| `+/-` | `must be replaced` | tạo mới trước, xóa cũ sau (`create_before_destroy`) |
| `<=` | `will be read during apply` | đọc nguồn dữ liệu (data source); hãy kiểm tra lý do tại sao nó chưa được biết tại thời điểm lập kế hoạch |
| `# forces replacement` | nằm sau một thuộc tính | sự thay đổi thuộc tính này là nguyên nhân bắt buộc phải thay thế tài nguyên |
| `(known after apply)` | giá trị | được tính toán sau; bạn không thể xem xét giá trị này ngay bây giờ |
| `(sensitive value)` | giá trị | bị ẩn trong đầu ra văn bản, nhưng vẫn hiện diện rõ trong tệp JSON của bản kế hoạch |
Các dòng giải thích lý do nằm bên dưới dòng tiêu đề: `# (because aws_x.y is not in configuration)` (đã bị xóa khỏi mã nguồn hoặc bị di chuyển mà không có khối `moved`), `# (because index [1] is out of range for count)` (giá trị count bị giảm đi hoặc bị dịch chuyển), `# (because key ["a"] is not in for_each map)`, `# (resource is tainted, so must be replaced)`, `# (replace requested)` (do sử dụng cờ `-replace=`).
Dòng tóm tắt: `Plan: 3 to add, 1 to change, 3 to destroy.` Một thao tác thay thế (replacement) được tính một lần vào mục thêm mới ("add") và một lần vào mục xóa bỏ ("destroy"), vì vậy hãy so sánh số lượng xóa bỏ với tổng số dòng tiêu đề có ký hiệu `-` và `-/+`.
## Cấu trúc JSON (format_version 1.x)
```
{
"format_version": "1.2", "terraform_version": "1.9.8",
"resource_changes": [ {
"address": "module.db.aws_db_instance.main", "module_address": "module.db",
"mode": "managed" | "data", "type": "aws_db_instance", "name": "main", "index": 0 | "key",
"change": {
"actions": ["create"] | ["update"] | ["delete"] | ["delete","create"] | ["create","delete"] | ["no-op"] | ["read"] | ["forget"],
"before": {...} | null, "after": {...} | null,
"after_unknown": {"id": true}, # các giá trị chỉ được biết sau khi chạy apply
"before_sensitive": {...}, "after_sensitive": {"password": true},
"replace_paths": [["storage_encrypted"]], # các thuộc tính bắt buộc phải thay thế tài nguyên
"importing": {"id": "..."} # các khối import (phiên bản 1.5+)
},
"action_reason": "replace_because_cannot_update" | "replace_because_tainted" | "replace_by_request" |
"delete_because_no_resource_config" | "delete_because_count_index" | "delete_because_each_key" | ...,
"previous_address": "aws_db_instance.old" # các khối moved
} ],
"resource_drift": [ ... các thay đổi được thực hiện bên ngoài Terraform ... ],
"output_changes": {...}, "prior_state": {...}, "configuration": {...},
"applyable": true, "complete": true, "errored": false
}
```
Các lệnh một dòng (one-liner) hữu ích:
```
jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions | join(",")) \(.address)"' plan.json
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
jq -r '.resource_changes[] | select(.change.replace_paths) | "\(.address): \(.change.replace_paths)"' plan.json
```
## Thông tin nhạy cảm (Secrets) trong bản kế hoạch (plan)
`before`/`after` chứa các giá trị thực tế, bao gồm mật khẩu và khóa riêng tư (private key), ngay cả khi `*_sensitive` đánh dấu chúng. Hãy coi plan.out và plan.json như các thông tin bí mật (secrets): không đính kèm chúng vào ticket, tin nhắn trò chuyện hoặc nhật ký CI công khai; hãy lưu giữ chúng dưới dạng các artifact CI có thời hạn ngắn và được kiểm soát quyền truy cập. Thay vào đó, hãy chia sẻ bản tóm tắt rủi ro.
## Những điều bản kế hoạch (plan) không thể cho bạn biết
- Các giá trị được tính toán tại thời điểm áp dụng (`known after apply`), bao gồm nhiều tài liệu chính sách IAM được xây dựng từ các tài nguyên khác.
- Các tác dụng phụ bên ngoài Terraform: bộ nhớ đệm DNS, các máy khách (client) được ghim vào một địa chỉ IP, dữ liệu bên trong cơ sở dữ liệu đang bị thay thế.
- Liệu `lifecycle { prevent_destroy = true }` có được thiết lập hay không (nó nằm trong `configuration`, và một bản kế hoạch vi phạm điều này sẽ báo lỗi ngay từ đầu).
- Hành vi đặc thù của nhà cung cấp (provider) chẳng hạn như việc RDS chỉ áp dụng một số thay đổi trong khung thời gian bảo trì tiếp theo (`apply_immediately`).
FILE:references/risk-rules.md
# Các quy tắc rủi ro được sử dụng bởi plan_risk.py
| Mã | Mức độ nghiêm trọng | Điều kiện kích hoạt | Tại sao lại quan trọng | Cách khắc phục điển hình |
|---|---|---|---|---|
| DATA_LOSS | HIGH | hủy (destroy) hoặc thay thế (replace) một loại tài nguyên lưu giữ dữ liệu: cơ sở dữ liệu (RDS, Cloud SQL, Azure SQL/PostgreSQL/MySQL, Cosmos DB, DynamoDB), bucket, ổ đĩa/volume, EFS/FSx, bộ nhớ đệm (cache), hàng đợi/luồng (queues/streams), khóa KMS, secret, vùng DNS (DNS zones), nhóm nhật ký (log groups), ECR, PVC/namespace của Kubernetes | một tài nguyên mới sẽ bắt đầu ở trạng thái trống; việc xóa khóa KMS khiến dữ liệu đã mã hóa không thể đọc được | tạo bản chụp nhanh (snapshot)/sao lưu trước, dùng khối `moved` khi tái cấu trúc (refactor), đặt `prevent_destroy` trên các kho dữ liệu, di chuyển dữ liệu trước khi thay thế |
| PROTECTION_OFF | HIGH (cập nhật) / MEDIUM (mới hoặc thay thế) | `deletion_protection`, `deletion_protection_enabled`, `enable_deletion_protection`, `termination_protection`, `disable_api_termination` có giá trị false | sai sót tiếp theo có thể xóa sổ môi trường production | giữ giá trị true; chỉ tắt trong một thay đổi riêng biệt ngay trước khi thực hiện việc xóa đã lên kế hoạch |
| PUBLIC_INGRESS | HIGH | luồng truy cập vào (ingress) 0.0.0.0/0 hoặc ::/0 mới trên tất cả các cổng hoặc các cổng quản trị/dữ liệu (22, 3389, 3306, 5432, 1433, 6379, 27017, 9200, 6443 ...) | bề mặt tấn công trực tiếp, tấn công vét cạn (brute force), lộ lọt dữ liệu | dùng các dải CIDR đã biết, SSM Session Manager, VPN, bastion, các điểm cuối riêng tư (private endpoints) |
| PUBLIC_INGRESS | MEDIUM / INFO | các cổng khác / 80 và 443 | | chỉ phơi bày các bộ cân bằng tải (load balancers) |
| PUBLIC_DATABASE | HIGH | `publicly_accessible = true` | điểm cuối cơ sở dữ liệu có thể truy cập được từ internet | mạng con riêng tư (private subnets), proxy, VPN |
| S3_PUBLIC_ACCESS | HIGH | bất kỳ thiết lập nào trong bốn thiết lập chặn truy cập công khai bị đặt thành false | chính sách bucket hoặc ACL có thể khiến dữ liệu bị công khai | giữ cả bốn thiết lập là true; dùng CloudFront + Origin Access Control |
| PUBLIC_ACL | HIGH | `public-read`, `public-read-write`, `authenticated-read` | mọi người đều có thể liệt kê hoặc đọc (hoặc ghi) | BucketOwnerEnforced, riêng tư (private) |
| PUBLIC_POLICY | HIGH | chính sách tài nguyên đặt Allow với Principal `*` và không có Condition | bất kỳ ai trên internet (hoặc bất kỳ tài khoản AWS nào) đều có quyền truy cập | chỉ định rõ các định danh (named principals); `aws:SourceArn`, `aws:PrincipalOrgID`, `aws:SourceVpce` |
| PUBLIC_IAM | HIGH | ràng buộc GCP tới `allUsers` / `allAuthenticatedUsers` | truy cập công khai | chỉ định các thành viên cụ thể |
| TRUST_ANYONE | HIGH | chính sách tin cậy của vai trò (role trust policy) có Principal `*` và không có Condition | bất kỳ tài khoản AWS nào cũng có thể đảm nhận vai trò (assume role) | dùng các ARN chính xác + `sts:ExternalId` |
| IAM_ADMIN | HIGH | `Action * / Resource *`, AdministratorAccess, PowerUserAccess, IAMFullAccess, GCP owner/editor, Azure Owner/User Access Administrator (Contributor = MEDIUM) | toàn quyền kiểm soát tài khoản hoặc dự án | đặc quyền tối thiểu (least privilege); tài khoản quản trị khẩn cấp (break-glass admin) có MFA |
| IAM_ESCALATION | HIGH | `iam:PassRole`, `iam:Attach*/Put*Policy`, `iam:CreatePolicyVersion`, `iam:CreateAccessKey`, `sts:AssumeRole`, `kms:*` trên `*`; `iam:*`, `sts:*` ở bất kỳ đâu | người nắm giữ có thể tự cấp thêm quyền cho chính mình | giới hạn phạm vi vào các ARN cụ thể; dùng điều kiện `iam:PassedToService` |
| IAM_WILDCARD | HIGH trên `*` / MEDIUM | các hành động `service:*`, các chính sách được quản lý `*FullAccess` | vượt quá xa mức cần thiết, bao gồm cả quyền xóa | liệt kê các hành động thực sự được sử dụng (Access Analyzer, CloudTrail) |
| IAM_NOT_ACTION | MEDIUM | Allow đi kèm với NotAction | cấp quyền cho mọi thứ khác, bao gồm cả các hành động trong tương lai | dùng danh sách Action tường minh |
| IAM_WIDENED | LOW | bản cập nhật thêm các hành động hoặc tài nguyên vào một chính sách | sự phìnhto quyền hạn (permission creep) | xác nhận với chủ sở hữu |
| TRUST_WIDENED | MEDIUM | có thêm các định danh (principals) mới trong chính sách tin cậy | quyền truy cập chéo tài khoản (cross-account) mới | xác nhận từng định danh (principal) |
| NO_FINAL_SNAPSHOT | MEDIUM | `skip_final_snapshot = true` trên RDS/Aurora/DocumentDB/Neptune/Redshift | khi xóa sẽ không để lại gì để khôi phục | đặt `false` + `final_snapshot_identifier` |
| FORCE_DESTROY | MEDIUM | `force_destroy = true` mới được thiết lập | việc xóa sẽ quét sạch toàn bộ các đối tượng (objects) hoặc bản ghi (records) | giữ giá trị false trên dữ liệu thực |
| ENCRYPTION_OFF | MEDIUM | `storage_encrypted`/`encrypted` có giá trị false | vấn đề tuân thủ; việc bật tính năng này về sau thường buộc phải thay thế tài nguyên | bật ngay bây giờ |
| BACKUPS_OFF | MEDIUM | `backup_retention_period = 0` | không thể khôi phục tại một thời điểm (point-in-time recovery) | đặt từ 7 đến 35 ngày |
| SECRET_REGENERATED | MEDIUM | thay thế `random_password`, `random_string`, `tls_private_key`, `aws_iam_access_key` | các hệ thống sử dụng (consumers) sẽ bị lỗi | lên kế hoạch xoay vòng khóa (rotation) |
| COUNT_INDEX_SHIFT | MEDIUM | `delete_because_count_index` | việc xóa một mục ở giữa sẽ hủy và tạo lại các mục đứng sau nó | dùng `for_each` với các khóa ổn định, dùng các khối `moved` |
| DESTROY | MEDIUM (máy tính/compute, LB, IP, cụm/cluster, xóa khỏi count/each) / LOW | hủy tài nguyên không lưu trạng thái (non-stateful) | thời gian ngừng hoạt động (downtime), mất địa chỉ IP | xác nhận ý định; dùng `moved` khi tái cấu trúc |
| REPLACE | MEDIUM (hủy trước đối với compute/LB) / LOW | thay thế tài nguyên không lưu trạng thái (non-stateful) | ID mới, IP mới, tên DNS mới; khoảng trống thời gian ngừng hoạt động (downtime gap) | dùng `create_before_destroy`, kiểm tra các thành phần phụ thuộc |
| PUBLIC_FUNCTION_URL | MEDIUM | Lambda URL với xác thực NONE | điểm cuối công khai | dùng xác thực AWS_IAM |
| PLAN_ERRORED / PLAN_INCOMPLETE | HIGH / MEDIUM | `errored` / `complete: false` | bức tranh không đầy đủ | lập lại bản kế hoạch (re-plan) |
| DRIFT, REMOVED_FROM_CONFIG, MOVED, IMPORT, FORGET, POLICY_UNKNOWN, TEXT_MODE | INFO | ngữ cảnh | | |
## Kết luận đánh giá (Verdict)
- Có bất kỳ mức HIGH nào: không áp dụng cho đến khi từng rủi ro mức HIGH được khắc phục hoặc được chủ sở hữu chấp nhận một cách rõ ràng (ghi rõ tên và lý do trong bản đánh giá).
- Chỉ có mức MEDIUM: áp dụng sau khi chủ sở hữu của từng tài nguyên bị ảnh hưởng đồng ý.
- Chỉ có mức LOW/INFO: đánh giá bình thường.
## Những điều cần kiểm tra thủ công
- **Phạm vi ảnh hưởng (Blast radius):** có bao nhiêu tài nguyên thay đổi, và tất cả chúng có nằm trong dự kiến từ phần chênh lệch mã nguồn (code diff) hay không? Một thay đổi chỉ một dòng nhưng thay thế tới 40 tài nguyên là một dấu hiệu cảnh báo đỏ (nâng cấp nhà cung cấp, thay đổi `name`, chuyển từ `count` sang `for_each`).
- **Thứ tự và thời gian ngừng hoạt động:** các thao tác thay thế theo kiểu hủy trước (destroy-first) đối với bất kỳ thành phần nào đang phục vụ lưu lượng truy cập; TTL của DNS; hoán đổi nhóm đích (target group).
- **Di chuyển dữ liệu:** một cơ sở dữ liệu bị thay thế cần được khôi phục từ bản chụp nhanh (snapshot) hoặc có kế hoạch di chuyển dữ liệu; Terraform sẽ không sao chép dữ liệu.
- **Các giá trị chưa xác định:** các chính sách hoặc quy tắc nhóm bảo mật (security group) ở trạng thái `known after apply` - thay vào đó hãy xem xét mã nguồn của module.
- **Nâng cấp nhà cung cấp (provider) và module:** đọc nhật ký thay đổi (changelog) để biết các trường hợp buộc phải thay thế và các thay đổi về giá trị mặc định.
- **Môi trường:** xác nhận workspace/backend (`terraform workspace show`) và tài khoản (`aws sts get-caller-identity`) trước khi áp dụng.
- **Chính sách dưới dạng mã (Policy as code):** các quy tắc OPA/Conftest, Sentinel, Checkov hoặc tfsec có thể thực thi các kiểm tra mức HIGH trong CI.
FILE:templates/plan-risk-summary.md
# Tóm tắt rủi ro bản kế hoạch Terraform: <stack / workspace>
**Thay đổi:** <PR #123 "Mã hóa DB đơn hàng, mở bastion cho nhà cung cấp"> **Workspace/tài khoản:** <prod / 123456789012 eu-west-1>
**Bản kế hoạch (Plan):** <terraform 1.9.8, plan.out từ lần chạy CI 4567> **Số lượng:** <2 tạo mới, 4 cập nhật, 2 thay thế, 2 hủy>
**Kết luận:** <KHÔNG ÁP DỤNG nguyên trạng / Áp dụng sau khi xem xét / Rủi ro thấp>
## Phải khắc phục hoặc chấp nhận rõ ràng (HIGH)
| # | Tài nguyên | Rủi ro | Lý do | Cách khắc phục hoặc chấp nhận (ai) |
|---|---|---|---|---|
| 1 | <`aws_db_instance.orders`> | <DATA_LOSS: bị thay thế vì storage_encrypted đã thay đổi> | <DB mới trống rỗng; không có bản chụp nhanh cuối cùng (final snapshot)> | <chụp snapshot, khôi phục vào bản sao đã mã hóa, chuyển đổi bằng bản kế hoạch moved/import> |
## Cần chủ sở hữu xem xét (MEDIUM)
| # | Tài nguyên | Rủi ro | Chủ sở hữu đồng ý? |
|---|---|---|---|
| 1 | <`aws_instance.web[1]`> | <COUNT_INDEX_SHIFT> | <có/không> |
## Thấp / thông tin (Low / info)
- <IAM_WIDENED trên ci_deploy: s3:* - được thay thế bằng các hành động tường minh trong bản sửa lỗi>
- <Drift trên các thẻ (tags) của aws_lb.public: được thiết lập trong bảng điều khiển (console) bởi ...>
## Thay đổi dự kiến so với ngoài dự kiến
- Dự kiến từ phần chênh lệch mã nguồn (code diff): <...>
- Không được giải thích bởi phần chênh lệch mã nguồn: <... điều tra trước khi áp dụng>
## Kế hoạch áp dụng (Apply plan)
1. <Tạo bản chụp nhanh (snapshot) `orders-pre-encrypt-2026-10-27`>
2. <Áp dụng trong khung thời gian bảo trì: <thời gian, múi giờ>>
3. <Xác minh: kiểm tra sức khỏe ứng dụng (health checks), `aws rds describe-db-instances`, bastion chỉ truy cập được từ VPN>
4. Hoàn tác (Rollback): <khôi phục bản chụp nhanh / hoàn tác PR và áp dụng lại>
## Các việc cần làm tiếp theo (Follow-ups)
- <thêm prevent_destroy vào các kho dữ liệu>
- <thêm quy tắc policy-as-code cho 0.0.0.0/0 trên cổng 22>
FILE:examples/shop-prod-plan.json
{
"format_version": "1.2",
"terraform_version": "1.9.8",
"planned_values": {"root_module": {}},
"resource_drift": [
{
"address": "aws_lb.public",
"mode": "managed",
"type": "aws_lb",
"name": "public",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": {"name": "shop-public", "tags": {"env": "prod"}},
"after": {"name": "shop-public", "tags": {"env": "prod", "owner": "set-in-console"}},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
}
],
"resource_changes": [
{
"address": "aws_cloudwatch_log_group.app",
"mode": "managed",
"type": "aws_cloudwatch_log_group",
"name": "app",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["create"],
"before": null,
"after": {"name": "/shop/app", "retention_in_days": 30, "tags": {"env": "prod"}},
"after_unknown": {"arn": true, "id": true},
"before_sensitive": false,
"after_sensitive": {}
}
},
{
"address": "aws_db_instance.orders",
"mode": "managed",
"type": "aws_db_instance",
"name": "orders",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["delete", "create"],
"before": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": false, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}},
"after": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": true, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}, "kms_key_id": null},
"after_unknown": {"id": true, "arn": true, "endpoint": true, "address": true},
"before_sensitive": {"password": true},
"after_sensitive": {"password": true},
"replace_paths": [["storage_encrypted"]]
},
"action_reason": "replace_because_cannot_update"
},
{
"address": "aws_iam_role_policy.ci_deploy",
"mode": "managed",
"type": "aws_iam_role_policy",
"name": "ci_deploy",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": [\"s3:PutObject\", \"s3:GetObject\"], \"Resource\": \"arn:aws:s3:::shop-assets-prod/*\"}]}"},
"after": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": \"s3:*\", \"Resource\": [\"arn:aws:s3:::shop-assets-prod\", \"arn:aws:s3:::shop-assets-prod/*\"]}, {\"Sid\": \"PassAnyRole\", \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": \"*\"}]}"},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
},
{
"address": "aws_iam_role_policy_attachment.ops_admin",
"mode": "managed",
"type": "aws_iam_role_policy_attachment",
"name": "ops_admin",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["create"],
"before": null,
"after": {"role": "shop-ops", "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"},
"after_unknown": {"id": true},
"before_sensitive": false,
"after_sensitive": {}
}
},
{
"address": "aws_instance.web[1]",
"mode": "managed",
"type": "aws_instance",
"name": "web",
"index": 1,
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["delete"],
"before": {"id": "i-0b1c2d3e4f5a6b7c8", "ami": "ami-0abc1234def567890", "instance_type": "t3.medium", "associate_public_ip_address": false, "tags": {"Name": "shop-web-1"}},
"after": null,
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": false
},
"action_reason": "delete_because_count_index"
},
{
"address": "aws_lb.public",
"mode": "managed",
"type": "aws_lb",
"name": "public",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "owner": "set-in-console"}},
"after": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "team": "shop"}},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
},
{
"address": "aws_lb_target_group.web",
"mode": "managed",
"type": "aws_lb_target_group",
"name": "web",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["create", "delete"],
"before": {"name_prefix": "web-", "port": 8080, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
"after": {"name_prefix": "web-", "port": 8081, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
"after_unknown": {"id": true, "arn": true, "name": true},
"before_sensitive": {},
"after_sensitive": {},
"replace_paths": [["port"]]
},
"action_reason": "replace_because_cannot_update"
},
{
"address": "aws_s3_bucket.legacy_logs",
"mode": "managed",
"type": "aws_s3_bucket",
"name": "legacy_logs",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["delete"],
"before": {"bucket": "shop-legacy-logs-prod", "force_destroy": true, "tags": {"env": "prod"}},
"after": null,
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": false
},
"action_reason": "delete_because_no_resource_config"
},
{
"address": "aws_s3_bucket_public_access_block.assets",
"mode": "managed",
"type": "aws_s3_bucket_public_access_block",
"name": "assets",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true},
"after": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": false, "ignore_public_acls": true, "restrict_public_buckets": false},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
},
{
"address": "aws_security_group.bastion",
"mode": "managed",
"type": "aws_security_group",
"name": "bastion",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
"after": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}, {"cidr_blocks": ["0.0.0.0/0"], "description": "temp: SSH for vendor", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
},
{
"address": "module.network.aws_vpc.main",
"module_address": "module.network",
"mode": "managed",
"type": "aws_vpc",
"name": "main",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["no-op"],
"before": {"cidr_block": "10.20.0.0/16"},
"after": {"cidr_block": "10.20.0.0/16"},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
}
],
"prior_state": {"format_version": "1.0", "terraform_version": "1.9.8"},
"configuration": {"provider_config": {"aws": {"name": "aws", "full_name": "registry.terraform.io/hashicorp/aws"}}},
"timestamp": "2026-10-27T07:10:00Z",
"applyable": true,
"complete": true,
"errored": false
}
FILE:scripts/plan_risk.py
#!/usr/bin/env python3
"""Terraform plan risk reviewer (Python 3 standard library only).
Reads `terraform show -json plan.out` output (or, with less detail, the text of
`terraform plan -no-color`) and flags risky changes: destroys and replacements of
stateful resources (data loss), removed deletion protection, skipped final
snapshots, force_destroy, disabled encryption, new public network exposure
(0.0.0.0/0 or ::/0 ingress, public databases, S3 public access blocks, public ACLs
and policies, allUsers bindings), IAM widening (wildcard actions, admin policies,
iam:PassRole on *, trust policies open to anyone) and count index shifts. Prints a
risk summary. Never prints attribute values that Terraform marks as sensitive.
Usage:
terraform plan -out plan.out && terraform show -json plan.out > plan.json
python3 plan_risk.py plan.json [--json]
terraform plan -no-color > plan.txt && python3 plan_risk.py plan.txt (text mode)
python3 plan_risk.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable input.
"""
import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
STATEFUL = re.compile(r"^(aws_(db_instance|rds_cluster|dynamodb_table|s3_bucket|ebs_volume|efs_file_system|"
r"elasticache_(cluster|replication_group)|redshift_cluster|docdb_cluster|neptune_cluster|"
r"opensearch_domain|elasticsearch_domain|kinesis_stream|sqs_queue|kms_key|secretsmanager_secret|"
r"backup_vault|ecr_repository|cloudwatch_log_group|msk_cluster|fsx_\w+|route53_zone|"
r"cognito_user_pool|timestream_table)|"
r"google_(sql_database_instance|sql_database|storage_bucket|bigquery_(dataset|table)|compute_disk|"
r"spanner_(instance|database)|kms_crypto_key|redis_instance|filestore_instance|firestore_database|"
r"dns_managed_zone)|"
r"azurerm_(storage_account|storage_container|mssql_(database|server)|postgresql_flexible_server|"
r"mysql_flexible_server|cosmosdb_account|key_vault|managed_disk|redis_cache|dns_zone)|"
r"kubernetes_(persistent_volume_claim|persistent_volume|namespace)(_v1)?)$")
SECRET_GEN = re.compile(r"^(random_password|random_string|tls_private_key|aws_iam_access_key)$")
DOWNTIME = re.compile(r"^(aws_(instance|lb|alb|eip|nat_gateway|cloudfront_distribution|api_gateway_rest_api|"
r"eks_cluster|ecs_service|acm_certificate)|google_(compute_instance|container_cluster)|"
r"azurerm_(linux|windows)_virtual_machine|azurerm_kubernetes_cluster)$")
SENSITIVE_PORTS = {22: "SSH", 23: "Telnet", 3389: "RDP", 3306: "MySQL", 5432: "PostgreSQL", 1433: "SQL Server",
1521: "Oracle", 6379: "Redis", 27017: "MongoDB", 9200: "Elasticsearch", 5601: "Kibana",
2375: "Docker API", 2379: "etcd", 11211: "Memcached", 5900: "VNC", 9092: "Kafka",
6443: "Kubernetes API", 10250: "Kubelet"}
WEB_PORTS = {80, 443}
PUBLIC_CIDRS = {"0.0.0.0/0", "::/0", "*", "internet", "any"}
ADMIN_POLICIES = re.compile(r"(AdministratorAccess|PowerUserAccess|IAMFullAccess)$")
ESCALATION_ACTIONS = {"iam:*", "iam:passrole", "iam:createpolicyversion", "iam:attachrolepolicy", "iam:putrolepolicy",
"iam:createaccesskey", "iam:updateassumerolepolicy", "sts:assumerole", "sts:*", "kms:*", "*"}
POLICY_ATTRS = ("policy", "assume_role_policy", "inline_policy")
PROTECTION_ATTRS = ("deletion_protection", "deletion_protection_enabled", "enable_deletion_protection",
"termination_protection", "disable_api_termination")
def finding(sev, code, addr, problem, fix):
return {"severity": sev, "code": code, "resource": addr, "problem": problem, "fix": fix}
def kind_of(actions):
a = list(actions or [])
if a == ["delete", "create"]:
return "replace (destroy first)"
if a == ["create", "delete"]:
return "replace (create first)"
return {"create": "create", "update": "update", "delete": "destroy", "no-op": "no-op", "read": "read",
"forget": "forget"}.get(a[0] if len(a) == 1 else ",".join(a), ",".join(a))
def sensitive(mask, key):
return mask is True or (isinstance(mask, dict) and mask.get(key) not in (None, False))
def get(d, key, default=None):
return d.get(key, default) if isinstance(d, dict) else default
def ports_of(lo, hi, proto):
proto = str(proto).lower()
if proto in ("-1", "all", "*"):
return 0, 65535, "all"
try:
lo, hi = int(lo if lo not in (None, "") else 0), int(hi if hi not in (None, "") else 65535)
except (TypeError, ValueError):
return 0, 65535, proto
if proto in ("icmp", "icmpv6", "1", "58"):
return None
if lo in (-1,) or (lo == 0 and hi in (0, 65535)):
return 0, 65535, proto
return lo, hi, proto
def open_rules(rtype, values):
"""Yield (cidr, lo, hi, proto) for internet-open ingress rules in a resource's values."""
v = values or {}
out = set()
def add(cidrs, lo, hi, proto):
p = ports_of(lo, hi, proto)
if p is None:
return
for c in cidrs or []:
if str(c).lower() in PUBLIC_CIDRS:
out.add((str(c), p[0], p[1], p[2]))
if rtype == "aws_security_group":
for r in v.get("ingress") or []:
add((r.get("cidr_blocks") or []) + (r.get("ipv6_cidr_blocks") or []), r.get("from_port"), r.get("to_port"), r.get("protocol"))
elif rtype == "aws_security_group_rule" and v.get("type") == "ingress":
add((v.get("cidr_blocks") or []) + (v.get("ipv6_cidr_blocks") or []), v.get("from_port"), v.get("to_port"), v.get("protocol"))
elif rtype == "aws_vpc_security_group_ingress_rule":
add([x for x in (v.get("cidr_ipv4"), v.get("cidr_ipv6")) if x], v.get("from_port"), v.get("to_port"), v.get("ip_protocol"))
elif rtype == "google_compute_firewall" and str(v.get("direction", "INGRESS")).upper() == "INGRESS":
for al in v.get("allow") or []:
ports = al.get("ports") or ["0-65535"]
for pr in ports:
lo, _, hi = str(pr).partition("-")
add(v.get("source_ranges"), lo, hi or lo, al.get("protocol", "all"))
elif rtype == "azurerm_network_security_rule" and str(v.get("direction")).lower() == "inbound" and str(v.get("access")).lower() == "allow":
src = [v.get("source_address_prefix")] + (v.get("source_address_prefixes") or [])
rng = v.get("destination_port_range") or "*"
for pr in [rng] + (v.get("destination_port_ranges") or []):
if pr == "*":
add(src, 0, 65535, v.get("protocol", "*"))
else:
lo, _, hi = str(pr).partition("-")
add(src, lo, hi or lo, v.get("protocol", "*"))
return out
def port_label(lo, hi, proto):
if proto == "all" or (lo == 0 and hi == 65535):
return "all ports"
names = [f"{p} ({n})" for p, n in SENSITIVE_PORTS.items() if lo <= p <= hi]
rng = str(lo) if lo == hi else f"{lo}-{hi}"
return f"port {rng}" + (f" incl. {', '.join(names[:3])}" if names and lo != hi else (f" ({SENSITIVE_PORTS[lo]})" if lo == hi and lo in SENSITIVE_PORTS else ""))
def parse_policy(doc):
if isinstance(doc, str):
try:
doc = json.loads(doc)
except ValueError:
return None
if not isinstance(doc, dict):
return None
st = doc.get("Statement") or []
return [st] if isinstance(st, dict) else st
def as_list(x):
return x if isinstance(x, list) else ([] if x is None else [x])
def allow_actions(stmts):
acts = set()
for s in stmts or []:
if s.get("Effect", "Allow") != "Allow":
continue
for a in as_list(s.get("Action")):
for r in as_list(s.get("Resource")) or ["(none)"]:
acts.add((str(a), str(r)))
return acts
def check_policy(addr, attr, before, after, unknown):
out = []
if unknown:
return [finding("INFO", "POLICY_UNKNOWN", addr, f"{attr} is only known after apply", "Review the rendered policy after apply or render it with a data source in a separate plan.")]
b, a = parse_policy(before), parse_policy(after)
if a is None:
return out
if attr == "assume_role_policy":
for s in a:
if s.get("Effect", "Allow") != "Allow":
continue
pr = s.get("Principal")
vals = [pr] if isinstance(pr, str) else [v for x in (pr or {}).values() for v in as_list(x)]
if "*" in vals and not s.get("Condition"):
out.append(finding("HIGH", "TRUST_ANYONE", addr, "trust policy lets any AWS principal assume this role (Principal * without Condition)",
"Name the exact account or role ARNs, and add sts:ExternalId or aws:PrincipalOrgID conditions."))
bp = {v for s in (b or []) for x in (get(s, "Principal") or {}).values() for v in as_list(x)} if b and all(isinstance(get(s, "Principal"), dict) for s in b) else set()
ap = {v for s in a if isinstance(s.get("Principal"), dict) for x in s["Principal"].values() for v in as_list(x)}
new = sorted(ap - bp - {"*"})
if b is not None and new:
out.append(finding("MEDIUM", "TRUST_WIDENED", addr, "new principals can assume this role: " + ", ".join(new), "Confirm each new principal is expected (cross-account access?)."))
return out
for s in a:
if s.get("Effect", "Allow") != "Allow":
continue
orig = [str(x) for x in as_list(s.get("Action"))]
acts = [x.lower() for x in orig]
res = [str(x) for x in as_list(s.get("Resource"))]
pr = s.get("Principal")
if pr is not None:
vals = [pr] if isinstance(pr, str) else [v for x in pr.values() for v in as_list(x)]
if "*" in vals and not s.get("Condition"):
out.append(finding("HIGH", "PUBLIC_POLICY", addr, f"resource policy allows Principal * ({', '.join(acts)[:80]}) without a Condition",
"Restrict Principal to known ARNs or add aws:SourceArn / aws:PrincipalOrgID / aws:SourceVpce conditions; use CloudFront OAC for public sites."))
if s.get("NotAction"):
out.append(finding("MEDIUM", "IAM_NOT_ACTION", addr, "Allow with NotAction grants everything except the listed actions", "List the allowed actions explicitly."))
if "*" in acts and "*" in res:
out.append(finding("HIGH", "IAM_ADMIN", addr, "Allow Action * on Resource * (full admin)", "Grant only the actions and resources this principal needs."))
continue
esc = sorted({o for o, x in zip(orig, acts) if x in ESCALATION_ACTIONS and x != "*" and ("*" in res or x in ("iam:*", "sts:*"))})
if esc:
out.append(finding("HIGH", "IAM_ESCALATION", addr, f"{', '.join(esc)} on {', '.join(res) or '*'} lets the holder gain more privileges",
"Scope iam:PassRole and sts:AssumeRole to specific role ARNs (and iam:PassedToService); avoid iam:* and kms:* wildcards."))
wild = sorted({o for o, x in zip(orig, acts) if x.endswith(":*") and x not in ESCALATION_ACTIONS})
if wild:
sev = "HIGH" if "*" in res else "MEDIUM"
out.append(finding(sev, "IAM_WILDCARD", addr, f"service wildcard {', '.join(wild)} on {', '.join(res)}",
"Replace with the specific actions used (check IAM Access Analyzer or CloudTrail for what is actually called)."))
if b is not None:
added = sorted({x[0] + (" on " + x[1] if x[1] not in ("*", "(none)") else " on *") for x in allow_actions(a) - allow_actions(b)})
if added:
out.append(finding("LOW", "IAM_WIDENED", addr, "policy grants new actions/resources: " + ", ".join(added[:8]) + (" ..." if len(added) > 8 else ""),
"Confirm the new permissions are needed; least privilege."))
return out
def review_change(rc):
out = []
addr, rtype = rc.get("address", "?"), rc.get("type", "")
ch = rc.get("change") or {}
actions = ch.get("actions") or []
kind = kind_of(actions)
before, after = ch.get("before") or {}, ch.get("after") or {}
unknown = ch.get("after_unknown") or {}
reason = rc.get("action_reason", "")
if rc.get("mode") == "data" or kind in ("no-op", "read"):
return kind, out
why = ""
if ch.get("replace_paths"):
why = " (forced by " + ", ".join(".".join(map(str, p)) for p in ch["replace_paths"]) + ")"
elif reason:
why = f" ({reason.replace('_', ' ')})"
stateful = bool(STATEFUL.match(rtype))
if kind == "forget":
out.append(finding("INFO", "FORGET", addr, "removed from state but not destroyed (removed block)", "Make sure another configuration or team takes ownership of it."))
if kind == "destroy" or kind.startswith("replace"):
if stateful:
extra = "; force_destroy = true, so everything in it is deleted too" if get(before, "force_destroy") is True else ""
out.append(finding("HIGH", "DATA_LOSS", addr, f"{rtype} will be {'destroyed' if kind == 'destroy' else 'replaced, ' + kind[9:-1]}{why}; its data does not carry over{extra}",
"Back up or snapshot first; if this is a rename/refactor add a `moved` block or `terraform state mv`; add lifecycle prevent_destroy for data stores."))
elif SECRET_GEN.match(rtype) and kind.startswith("replace"):
out.append(finding("MEDIUM", "SECRET_REGENERATED", addr, f"{rtype} will be regenerated{why}; everything using the old value breaks",
"Check what consumes it (database password, keys) and plan the rotation."))
elif kind == "destroy":
sev = "MEDIUM" if DOWNTIME.match(rtype) or reason in ("delete_because_count_index", "delete_because_each_key") else "LOW"
out.append(finding(sev, "DESTROY", addr, f"will be destroyed{why}",
"Confirm it is intended; for a rename/refactor use a `moved` block instead of destroy + create."))
else:
sev = "MEDIUM" if kind == "replace (destroy first)" and DOWNTIME.match(rtype) else "LOW"
out.append(finding(sev, "REPLACE", addr, f"{kind}{why}",
"Expect a new ID/IP/DNS name; use create_before_destroy where possible and check dependents."))
if reason in ("delete_because_count_index",):
out.append(finding("MEDIUM", "COUNT_INDEX_SHIFT", addr, "destroyed because a count index moved (an item was removed from the middle of a list)",
"Switch to for_each with stable keys, or add moved blocks for the shifted indexes."))
if reason == "delete_because_no_resource_config":
out.append(finding("INFO", "REMOVED_FROM_CONFIG", addr, "resource no longer in configuration", "If it moved to another module or address, add a `moved` block; to keep it but stop managing it, use a `removed` block with destroy = false."))
if kind in ("create", "update") or kind.startswith("replace"):
bm, am = ch.get("before_sensitive") or {}, ch.get("after_sensitive") or {}
for attr in PROTECTION_ATTRS:
if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
out.append(finding("HIGH" if kind == "update" else "MEDIUM", "PROTECTION_OFF", addr, f"{attr} {'turned off' if kind == 'update' else 'is false'}",
f"Keep {attr} = true for production resources; turn it off only in a separate, reviewed change right before a planned delete."))
if get(after, "skip_final_snapshot") is True and rtype in ("aws_db_instance", "aws_rds_cluster", "aws_docdb_cluster", "aws_neptune_cluster", "aws_redshift_cluster"):
if kind != "update" or get(before, "skip_final_snapshot") is not True:
out.append(finding("MEDIUM", "NO_FINAL_SNAPSHOT", addr, "skip_final_snapshot = true: a delete leaves no snapshot",
"Set skip_final_snapshot = false with a final_snapshot_identifier."))
if get(after, "force_destroy") is True and (kind != "update" or get(before, "force_destroy") is not True):
out.append(finding("MEDIUM", "FORCE_DESTROY", addr, "force_destroy = true lets Terraform delete the bucket/zone with everything in it",
"Keep force_destroy = false on buckets and zones that hold real data."))
for attr in ("storage_encrypted", "encrypted", "server_side_encryption"):
if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
out.append(finding("MEDIUM", "ENCRYPTION_OFF", addr, f"{attr} = false", "Enable encryption at rest (it usually forces replacement later, so do it now)."))
if get(after, "backup_retention_period") == 0 and get(before, "backup_retention_period", 1) != 0:
out.append(finding("MEDIUM", "BACKUPS_OFF", addr, "backup_retention_period = 0 disables automated backups", "Keep at least 7 days for production databases."))
if get(after, "publicly_accessible") is True and get(before, "publicly_accessible") is not True:
out.append(finding("HIGH", "PUBLIC_DATABASE", addr, "publicly_accessible = true gives the database a public endpoint",
"Keep databases private; connect through a bastion, VPN, SSM or RDS Proxy."))
if rtype == "aws_s3_bucket_public_access_block":
off = [k for k in ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
if get(after, k) is False and (kind != "update" or get(before, k) is not False)]
if off:
out.append(finding("HIGH", "S3_PUBLIC_ACCESS", addr, "S3 public access block turned off: " + ", ".join(off),
"Keep all four settings true; serve public content through CloudFront with Origin Access Control."))
if rtype in ("aws_s3_bucket_acl", "aws_s3_object") and get(after, "acl") in ("public-read", "public-read-write", "authenticated-read"):
out.append(finding("HIGH", "PUBLIC_ACL", addr, f"acl = {after['acl']}", "Use private ACLs (BucketOwnerEnforced) and CloudFront for public files."))
if rtype.startswith("google_") and "iam" in rtype:
members = as_list(get(after, "member")) + as_list(get(after, "members"))
if any(m in ("allUsers", "allAuthenticatedUsers") for m in members):
out.append(finding("HIGH", "PUBLIC_IAM", addr, "grants a role to allUsers/allAuthenticatedUsers", "Grant to specific users, groups or service accounts."))
if get(after, "role") in ("roles/owner", "roles/editor", "roles/iam.securityAdmin"):
out.append(finding("HIGH", "IAM_ADMIN", addr, f"grants {after['role']}", "Use a narrower predefined or custom role."))
if rtype == "azurerm_role_assignment" and get(after, "role_definition_name") in ("Owner", "User Access Administrator", "Contributor"):
sev = "MEDIUM" if after["role_definition_name"] == "Contributor" else "HIGH"
out.append(finding(sev, "IAM_ADMIN", addr, f"assigns {after['role_definition_name']}", "Use the narrowest built-in role at the smallest scope."))
if rtype.startswith("aws_iam_") and rtype.endswith("policy_attachment"):
arn = get(after, "policy_arn") or ""
if ADMIN_POLICIES.search(arn) and get(before, "policy_arn") != arn:
out.append(finding("HIGH", "IAM_ADMIN", addr, f"attaches {arn.split('/')[-1]}", "Attach a least-privilege policy instead; keep admin roles for break-glass use with MFA."))
elif arn.endswith("FullAccess") and get(before, "policy_arn") != arn:
out.append(finding("MEDIUM", "IAM_WILDCARD", addr, f"attaches {arn.split('/')[-1]}", "Prefer a scoped customer-managed policy."))
if rtype == "aws_lambda_function_url" and get(after, "authorization_type") == "NONE":
out.append(finding("MEDIUM", "PUBLIC_FUNCTION_URL", addr, "Lambda function URL with authorization_type NONE is public", "Use AWS_IAM auth or validate requests in the function."))
new_open = open_rules(rtype, after) - (open_rules(rtype, before) if kind == "update" else set())
for cidr, lo, hi, proto in sorted(new_open, key=lambda x: (x[1], x[2])):
label = port_label(lo, hi, proto)
if proto == "all" or (lo == 0 and hi == 65535) or any(lo <= p <= hi for p in SENSITIVE_PORTS):
sev = "HIGH"
elif lo in WEB_PORTS and hi == lo:
sev = "INFO"
else:
sev = "MEDIUM"
out.append(finding(sev, "PUBLIC_INGRESS", addr, f"new ingress from {cidr} on {label}",
"Restrict the source to known CIDRs or a load balancer security group; reach admin ports through SSM, VPN or a bastion." if sev != "INFO" else "Expected for a public web endpoint; make sure only the load balancer is exposed."))
for attr in POLICY_ATTRS:
if attr in after or get(unknown, attr):
if sensitive(am, attr):
continue
out += check_policy(addr, attr, get(before, attr), get(after, attr), get(unknown, attr) is True)
return kind, out
def review_json(plan):
if not isinstance(plan, dict) or "format_version" not in plan:
raise ValueError("not a terraform show -json document (no format_version)")
changes, findings = [], []
for rc in plan.get("resource_changes") or []:
kind, f = review_change(rc)
if kind not in ("no-op", "read") and rc.get("mode") != "data":
changes.append((kind, rc.get("address", "?")))
findings += f
if (rc.get("change") or {}).get("importing"):
findings.append(finding("INFO", "IMPORT", rc.get("address", "?"), "will be imported", "Check the plan shows no further changes after import."))
if rc.get("previous_address"):
findings.append(finding("INFO", "MOVED", rc.get("address", "?"), f"moved from {rc['previous_address']}", "No action; moved blocks avoid destroy + create."))
drift = [d.get("address", "?") for d in plan.get("resource_drift") or []]
if drift:
findings.append(finding("INFO", "DRIFT", ", ".join(drift[:5]) + (" ..." if len(drift) > 5 else ""),
f"{len(drift)} resource(s) changed outside Terraform", "Find out who changed them; the plan may revert manual fixes."))
if plan.get("errored"):
findings.append(finding("HIGH", "PLAN_ERRORED", "-", "the plan errored", "Fix the errors and re-plan before applying anything."))
elif plan.get("complete") is False:
findings.append(finding("MEDIUM", "PLAN_INCOMPLETE", "-", "plan is not complete (deferred or -target changes)", "Run a full plan after this apply."))
return changes, findings, plan.get("terraform_version", "?")
TEXT_RE = re.compile(r"^\s*# (\S+) (will be created|will be destroyed|will be updated in-place|must be replaced|will be read during apply|will no longer be managed by Terraform)")
def review_text(text):
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
lines = text.splitlines()
changes, findings = [], []
i = 0
while i < len(lines):
m = TEXT_RE.match(lines[i])
if not m:
i += 1; continue
addr, verb = m.group(1), m.group(2)
rtype = re.sub(r"^(module\.[^.]+\.)*", "", addr).split(".")[0]
if addr.startswith("data.") or ".data." in addr or "read during apply" in verb:
i += 1; continue
block, j = [], i + 1
while j < len(lines) and not TEXT_RE.match(lines[j]) and not lines[j].startswith("Plan:"):
block.append(lines[j]); j += 1
first = next((l.strip() for l in block if l.strip() and not l.strip().startswith("#")), "")
reason = next((l.strip()[3:-1] for l in block[:3] if l.strip().startswith("# (")), "")
kind = {"will be created": "create", "will be destroyed": "destroy", "will be updated in-place": "update",
"will no longer be managed by Terraform": "forget"}.get(verb)
if verb == "must be replaced":
kind = "replace (create first)" if first.startswith("+/-") else "replace (destroy first)"
forced = [re.sub(r"^[~+\-/ ]+", "", l.strip()).split(" ")[0] for l in block if "# forces replacement" in l]
rc = {"address": addr, "type": rtype, "change": {"actions": {"create": ["create"], "update": ["update"], "destroy": ["delete"], "forget": ["forget"],
"replace (create first)": ["create", "delete"], "replace (destroy first)": ["delete", "create"]}[kind],
"replace_paths": [[f] for f in forced]}}
if "out of range for count" in reason or "count index" in reason:
rc["action_reason"] = "delete_because_count_index"
elif "not in for_each map" in reason:
rc["action_reason"] = "delete_because_each_key"
elif "not in configuration" in reason:
rc["action_reason"] = "delete_because_no_resource_config"
k, f = review_change(rc)
changes.append((k, addr))
findings += f
i = j
if not changes and "No changes." not in text:
raise ValueError("no resource changes found; is this terraform plan output?")
findings.append(finding("INFO", "TEXT_MODE", "-", "text plan: only actions were checked, not attribute values (IAM, ingress, protection flags)",
"Run `terraform show -json plan.out > plan.json` and review the JSON for full checks."))
return changes, findings, "version unknown (text plan)"
def summarize(changes, findings):
counts = {}
for k, _ in changes:
key = "replace" if k.startswith("replace") else k
counts[key] = counts.get(key, 0) + 1
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["resource"]))
sev = {s: sum(1 for f in findings if f["severity"] == s) for s in SEV_ORDER}
if sev["HIGH"]:
verdict = "DO NOT APPLY as is: resolve or explicitly accept every HIGH finding"
elif sev["MEDIUM"]:
verdict = "Apply after review: MEDIUM findings need an owner's OK"
else:
verdict = "Low risk"
return counts, sev, verdict
def render(path, version, changes, findings):
counts, sev, verdict = summarize(changes, findings)
out = [f"# Terraform plan risk review: {path}", "",
f"Terraform {version}. Changes: " + ", ".join(f"{counts.get(k, 0)} {k}" for k in ("create", "update", "replace", "destroy")) +
(f", {counts['forget']} forget" if counts.get("forget") else ""),
f"Findings: {sev['HIGH']} HIGH, {sev['MEDIUM']} MEDIUM, {sev['LOW']} LOW, {sev['INFO']} INFO",
f"Verdict: {verdict}", "", "## Findings", ""]
if findings:
out += ["| Severity | Code | Resource | Problem | What to do |", "|---|---|---|---|---|"]
for f in findings:
out.append(f"| {f['severity']} | {f['code']} | `{f['resource']}` | {f['problem']} | {f['fix']} |")
else:
out.append("No findings.")
out += ["", "## All changes", "", "| Action | Resource |", "|---|---|"]
order = {"destroy": 0, "replace (destroy first)": 1, "replace (create first)": 2, "update": 3, "create": 4, "forget": 5}
for k, a in sorted(changes, key=lambda x: (order.get(x[0], 9), x[1])):
out.append(f"| {k} | `{a}` |")
out += ["", "Plan JSON can contain secrets in plain text: do not paste it into tickets or chats; share this summary instead."]
return "\n".join(out)
def self_test():
cases = []
def t(name, cond):
cases.append((name, bool(cond)))
def codes(rc):
return {f["code"]: f["severity"] for f in review_change(rc)[1]}
def rc(rtype, actions, before=None, after=None, **kw):
d = {"address": f"{rtype}.x", "mode": "managed", "type": rtype, "change": {"actions": actions, "before": before, "after": after, **kw.pop("change", {})}}
d.update(kw)
return d
t("kind names", kind_of(["delete", "create"]) == "replace (destroy first)" and kind_of(["create", "delete"]) == "replace (create first)" and kind_of(["delete"]) == "destroy")
t("db destroy = HIGH DATA_LOSS", codes(rc("aws_db_instance", ["delete"], {"id": "a"}, None)).get("DATA_LOSS") == "HIGH")
t("bucket replace = HIGH DATA_LOSS", codes(rc("aws_s3_bucket", ["create", "delete"], {}, {})).get("DATA_LOSS") == "HIGH")
t("stateless destroy LOW", codes(rc("aws_iam_role", ["delete"], {}, None)).get("DESTROY") == "LOW")
t("instance destroy-first replace MEDIUM", codes(rc("aws_instance", ["delete", "create"], {}, {})).get("REPLACE") == "MEDIUM")
t("create-first replace LOW", codes(rc("aws_lb_target_group", ["create", "delete"], {}, {})).get("REPLACE") == "LOW")
t("replace_paths shown", "forced by engine" in review_change(rc("aws_instance", ["delete", "create"], {}, {}, change={"replace_paths": [["engine"]]}))[1][0]["problem"])
t("random_password regenerated", codes(rc("random_password", ["delete", "create"], {}, {})).get("SECRET_REGENERATED") == "MEDIUM")
t("count index shift", "COUNT_INDEX_SHIFT" in codes(rc("aws_instance", ["delete"], {}, None, action_reason="delete_because_count_index")))
t("no-op ignored", review_change(rc("aws_vpc", ["no-op"], {}, {}))[1] == [])
t("deletion_protection turned off HIGH", codes(rc("aws_db_instance", ["update"], {"deletion_protection": True}, {"deletion_protection": False})).get("PROTECTION_OFF") == "HIGH")
t("skip_final_snapshot MEDIUM", "NO_FINAL_SNAPSHOT" in codes(rc("aws_db_instance", ["create"], None, {"skip_final_snapshot": True})))
t("force_destroy MEDIUM", "FORCE_DESTROY" in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": False}, {"force_destroy": True})))
t("unchanged force_destroy not re-flagged", "FORCE_DESTROY" not in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": True}, {"force_destroy": True})))
t("encryption off", "ENCRYPTION_OFF" in codes(rc("aws_ebs_volume", ["create"], None, {"encrypted": False})))
t("publicly_accessible HIGH", codes(rc("aws_db_instance", ["update"], {"publicly_accessible": False}, {"publicly_accessible": True})).get("PUBLIC_DATABASE") == "HIGH")
sg_b = {"ingress": [{"cidr_blocks": ["10.0.0.0/8"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
sg_a = {"ingress": sg_b["ingress"] + [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
t("new SSH from 0.0.0.0/0 HIGH", codes(rc("aws_security_group", ["update"], sg_b, sg_a)).get("PUBLIC_INGRESS") == "HIGH")
t("existing open rule not re-flagged", "PUBLIC_INGRESS" not in codes(rc("aws_security_group", ["update"], sg_a, sg_a)))
t("443 from internet INFO", codes(rc("aws_vpc_security_group_ingress_rule", ["create"], None, {"cidr_ipv4": "0.0.0.0/0", "from_port": 443, "to_port": 443, "ip_protocol": "tcp"})).get("PUBLIC_INGRESS") == "INFO")
t("all protocols HIGH", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "cidr_blocks": ["0.0.0.0/0"], "from_port": 0, "to_port": 0, "protocol": "-1"})).get("PUBLIC_INGRESS") == "HIGH")
t("ipv6 ::/0 8080 MEDIUM", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "ipv6_cidr_blocks": ["::/0"], "from_port": 8080, "to_port": 8080, "protocol": "tcp"})).get("PUBLIC_INGRESS") == "MEDIUM")
t("gcp firewall 3389", codes(rc("google_compute_firewall", ["create"], None, {"direction": "INGRESS", "source_ranges": ["0.0.0.0/0"], "allow": [{"protocol": "tcp", "ports": ["3389"]}]})).get("PUBLIC_INGRESS") == "HIGH")
t("azure nsg * HIGH", codes(rc("azurerm_network_security_rule", ["create"], None, {"direction": "Inbound", "access": "Allow", "source_address_prefix": "*", "destination_port_range": "*", "protocol": "Tcp"})).get("PUBLIC_INGRESS") == "HIGH")
t("S3 public access block off", codes(rc("aws_s3_bucket_public_access_block", ["update"], {"block_public_policy": True}, {"block_public_policy": False})).get("S3_PUBLIC_ACCESS") == "HIGH")
t("public-read ACL", "PUBLIC_ACL" in codes(rc("aws_s3_bucket_acl", ["create"], None, {"acl": "public-read"})))
pol = lambda *st: json.dumps({"Version": "2012-10-17", "Statement": list(st)})
t("admin policy HIGH", codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})})).get("IAM_ADMIN") == "HIGH")
t("PassRole on * HIGH", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"})})).get("IAM_ESCALATION") == "HIGH")
t("s3:* on bucket MEDIUM", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"})})).get("IAM_WILDCARD") == "MEDIUM")
t("IAM_WIDENED lists new actions", "s3:DeleteObject on arn:aws:s3:::b/*" in next(f["problem"] for f in review_change(rc("aws_iam_role_policy", ["update"],
{"policy": pol({"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})},
{"policy": pol({"Effect": "Allow", "Action": ["s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::b/*"})}))[1] if f["code"] == "IAM_WIDENED"))
t("Deny statements ignored", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Deny", "Action": "*", "Resource": "*"})})))
t("trust anyone HIGH", codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"})})).get("TRUST_ANYONE") == "HIGH")
t("trust with condition ok", "TRUST_ANYONE" not in codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-1"}}})})))
t("bucket policy Principal * HIGH", codes(rc("aws_s3_bucket_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})})).get("PUBLIC_POLICY") == "HIGH")
t("unknown policy INFO", "POLICY_UNKNOWN" in codes(rc("aws_iam_policy", ["create"], None, {}, change={"after_unknown": {"policy": True}})))
t("sensitive policy skipped", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})}, change={"after_sensitive": {"policy": True}})))
t("AdministratorAccess attachment HIGH", codes(rc("aws_iam_role_policy_attachment", ["create"], None, {"policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"})).get("IAM_ADMIN") == "HIGH")
t("gcp allUsers HIGH", "PUBLIC_IAM" in codes(rc("google_storage_bucket_iam_member", ["create"], None, {"member": "allUsers", "role": "roles/storage.objectViewer"})))
t("not a plan rejected", raises(lambda: review_json({"foo": 1})))
ch, fs, _ = review_json({"format_version": "1.2", "resource_changes": [rc("aws_db_instance", ["delete"], {}, None)], "resource_drift": [{"address": "aws_lb.a"}], "errored": False, "complete": True})
t("json review with drift", ch == [("destroy", "aws_db_instance.x")] and {"DATA_LOSS", "DRIFT"} <= {f["code"] for f in fs})
txt = """ # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ engine = "postgres" -> "mysql" # forces replacement
}
# aws_instance.web[1] will be destroyed
# (because index [1] is out of range for count)
- resource "aws_instance" "web" {
}
# aws_lb_target_group.a must be replaced
+/- resource "aws_lb_target_group" "a" {
}
Plan: 2 to add, 0 to change, 3 to destroy.
"""
ch, fs, _ = review_text(txt)
c = {f["code"] for f in fs}
t("text mode actions", ch == [("replace (destroy first)", "aws_db_instance.main"), ("destroy", "aws_instance.web[1]"), ("replace (create first)", "aws_lb_target_group.a")])
t("text mode findings", {"DATA_LOSS", "DESTROY", "REPLACE", "TEXT_MODE", "COUNT_INDEX_SHIFT"} <= c and "forced by engine" in next(f["problem"] for f in fs if f["code"] == "DATA_LOSS"))
t("text mode rejects junk", raises(lambda: review_text("hello")))
passed = sum(ok for _, ok in cases)
for name, ok in cases:
print(("PASS " if ok else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def raises(fn):
try:
fn()
except ValueError:
return True
return False
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("plan", nargs="?", help="plan JSON (terraform show -json) or plan text (terraform plan -no-color)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.plan:
ap.print_usage(sys.stderr); return 2
try:
raw = open(a.plan, encoding="utf-8").read()
if raw.lstrip().startswith("{"):
changes, findings, version = review_json(json.loads(raw))
else:
changes, findings, version = review_text(raw)
except (OSError, UnicodeDecodeError, ValueError) as e:
print(f"error: {e}", file=sys.stderr); return 2
counts, sev, verdict = summarize(changes, findings)
if a.json:
print(json.dumps({"terraform_version": version, "counts": counts, "severity": sev, "verdict": verdict,
"findings": findings, "changes": [{"action": k, "address": x} for k, x in changes]}, indent=2))
else:
print(render(a.plan, version, changes, findings))
return 1 if sev["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main())