Một ngày nào đó bạn phát hiện mình đã lỡ commit một khoá API vào git. Phản xạ đầu tiên gần như luôn giống nhau: mở file ra, xoá dòng đó đi, commit lại với thông điệp "chuyển bí mật sang biến môi trường", rồi thở phào. File sạch rồi, xong.
Bài này đo hai chuyện mà cái thở phào đó bỏ qua. Thứ nhất: xoá khỏi file có xoá khỏi lịch sử git không? Thứ hai, sâu hơn: kể cả khi xoá được sạch khỏi mọi commit, cái khoá đó có còn nguy hiểm không? Tôi dựng lại cả hai trong container dùng một lần với bí mật giả (chuỗi rõ ràng là giả, không phải khoá thật của ai), đo 3 lần, rồi tự dọn. Không nhắm vào hệ thống của ai khác.
Đo 1: xoá khỏi file, quét lại — sạch chưa?
Tôi tạo một repo git, commit một config.py có hai bí mật giả, rồi ở commit sau "sửa" nó thành đọc từ biến môi trường — đúng quy trình chuẩn ai cũng làm. Sau đó quét bằng regex bắt định dạng khoá AWS và token, theo hai phạm vi: chỉ file hiện tại, và toàn bộ lịch sử.
quet WORKING-TREE (file hien tai): 0 ket qua khop
quet LICH SU GIT (moi commit): 2 ket qua khop
-> file hien tai sach, nhung bi mat VAN nam trong lich su:
API_TOKEN = "tok_live_FAKE1234567890abcdefFAKE"
AWS_KEY = "AKIAFAKEFAKEFAKE0000"
Kết quả giống hệt qua cả 3 lần. Quét file hiện tại: 0 — trông sạch tinh. Quét lịch sử: 2 — cả hai bí mật vẫn nằm nguyên trong commit cũ, chỉ cần git show cái commit đó là đọc lại được từng ký tự.
Đây là điểm đầu tiên, và nó đi ngược trực giác: git là một cỗ máy không quên. Mỗi commit là một ảnh chụp bất biến; commit sau không ghi đè commit trước mà chỉ nối tiếp. "Xoá" một dòng trong commit mới chỉ tạo thêm một ảnh chụp không có dòng đó — bản cũ vẫn còn, ai git clone về đều có đủ. Một công cụ quét chỉ nhìn thư mục làm việc (như phần lớn linter chạy tay) sẽ báo "sạch" rất tự tin, trong khi bí mật vẫn phơi ra cho bất kỳ ai đọc lịch sử.
Muốn quét đúng thì phải quét cả lịch sử. Các công cụ chuyên dụng như gitleaks hay trufflehog đi qua từng commit chính vì lý do này; lệnh thủ công tối giản là:
# quet moi commit, khong chi file hien tai
git rev-list --all | while read c; do
git grep -nE 'AKIA[A-Z0-9]{16}|tok_live_[A-Za-z0-9]+' "$c"
done
Đo 2: giả sử xoá được sạch lịch sử — an toàn chưa?
Đây mới là phần hay bị hiểu sai nhất. Giả sử bạn đã làm tới cùng: dùng git filter-repo hoặc rebase để bôi sạch bí mật khỏi mọi commit, rồi force-push. Lịch sử giờ không còn dấu vết. Xong chưa?
Tôi dựng một máy chủ nhỏ giữ danh sách token hợp lệ, rồi cho "kẻ tấn công" thử dùng token đã lộ sau từng bước dọn dẹp git:
sau khi xoa khoi file, KE TAN CONG dung token ro ri:
vua commit sua -> token con hieu luc? True
xoa commit cu (rebase) -> token con hieu luc? True
force-push -> token con hieu luc? True
XOAY VONG (rotate) -> token con hieu luc? False
Nhìn vào cột phải: token vẫn còn hiệu lực sau mọi thao tác git. Rebase, force-push, viết lại lịch sử — không cái nào đụng tới token cả. Lý do hiển nhiên khi nói ra: cái token là một bản ghi trên máy chủ cấp credential (AWS, Stripe, cơ sở dữ liệu...), không phải trong repo của bạn. Sửa repo là sửa một bản sao đã lộ, không phải sửa cái credential thật. Và một khi bí mật đã được push lên một nơi công khai, bạn phải coi như nó đã bị người khác chép về ngay lập tức — GitHub thậm chí có bot quét mọi commit công khai trong vài giây.
Chỉ có một dòng làm token chết: xoay vòng (rotate) — vô hiệu khoá cũ, cấp khoá mới. Đó là hành động duy nhất tác động lên đúng nơi đang giữ quyền. Toàn bộ nghi thức viết lại lịch sử git chỉ dọn bằng chứng, không dọn rủi ro. Thứ tự đúng khi lỡ commit một bí mật vì thế là: xoay vòng trước, dọn git sau — và nếu chỉ làm được một việc, hãy làm việc xoay vòng.
Đo 3: cửa sổ phơi nhiễm dài bao nhiêu?
Nếu credential đã lộ đằng nào cũng phải xoay vòng, thì giá trị của việc quét nằm ở chỗ khác: nó rút ngắn khoảng thời gian bí mật sống mà bạn chưa biết. Tôi tính "cửa sổ phơi nhiễm" — từ lúc commit tới lúc phát hiện để xoay — theo ba cách bố trí quét:
quet CI moi push ~ 2 phut
quet dinh ky hang dem ~ 24 gio
khong quet, phat hien khi bi lam dung ~ 720 gio (30 ngay)
Chênh lệch là ba bậc độ lớn. Quét ngay trong CI mỗi lần push kéo cửa sổ từ hàng tháng xuống vài phút: bí mật bị chặn gần như ngay khi nó vừa xuất hiện, thường là trước cả khi push lên được nhánh chính. Quét hằng đêm đã tốt hơn nhiều so với không quét, nhưng vẫn để hở cả một ngày. Không quét gì thì bạn thường chỉ biết khi hoá đơn đám mây tăng vọt hoặc dữ liệu đã ra ngoài.
Điều đáng nói: quét không ngăn được việc commit bí mật, nó chỉ thu hẹp cửa sổ. Muốn chặn tận gốc thì cần thêm lớp trước đó — một pre-commit hook từ chối commit có chuỗi nghi là khoá, để bí mật không bao giờ vào tới lịch sử. Quét CI là lưới thứ hai cho những gì lọt qua lưới thứ nhất.
Ghép lại
Ba phép đo, một chuỗi hệ quả:
- Xoá khỏi file không xoá khỏi lịch sử. Quét phải đi qua mọi commit, không chỉ thư mục làm việc — nếu không bạn nhận một chữ "sạch" sai.
- Sửa git không vô hiệu credential. Rebase và force-push chỉ dọn bản sao đã lộ; token vẫn dùng được cho tới khi xoay vòng. Lỡ lộ thì xoay vòng trước, dọn lịch sử sau.
- Quét sớm thu hẹp cửa sổ phơi nhiễm từ hàng tháng xuống vài phút — nhưng nó là lưới an toàn, không phải hàng rào. Hàng rào là
pre-commit hookvà, tốt hơn nữa, đừng để bí mật ở dạng chuỗi trong mã: đọc từ biến môi trường hay dịch vụ quản lý bí mật ngay từ đầu.
Cả Series này lặp lại một mô-típ: chỗ tưởng an toàn thường là chỗ trực giác đánh lừa. Ở đây trực giác nói "xoá dòng là xong", còn phép đo nói bí mật vẫn nằm trong lịch sử và credential vẫn sống. Hai cái "vẫn còn" đó là toàn bộ lý do vì sao rò rỉ bí mật khó dọn hơn vẻ ngoài của nó.
Thử ba mươi giây
Trong một repo của chính bạn, chạy:
# Bi mat nao tung xuat hien trong lich su? (khong chi file hien tai)
git rev-list --all | while read c; do
git grep -nIE 'AKIA[A-Z0-9]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|tok_live_[A-Za-z0-9]+' "$c"
done | sort -u
Nếu có bất kỳ dòng nào hiện ra, thứ tự việc cần làm là: xoay vòng ngay khoá đó (vô hiệu bản cũ, cấp bản mới), rồi mới tính đến dọn lịch sử. Đảo thứ tự lại là để cửa mở trong lúc đi lau sàn.