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. Nhưng hãy nghĩ về nó như xé một trang khỏi cuốn nhật ký đã photo gửi cho hai chục người: xé trang gốc chẳng thu hồi được các bản photo, và nội dung viết trên đó — nếu là mật mã két sắt — thì vẫn mở được két dù trang giấy đã biến mất. Bài này đo đúng hai chuyện mà cái thở phào bỏ qua: xoá khỏi file có xoá khỏi lịch sử git không, và kể cả khi xoá sạch khỏi mọi commit thì 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.

Ba phép đo, một chuỗi hệ quả: (1) 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; (2) 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, nên lỡ lộ thì xoay vòng trước dọn lịch sử sau; (3) 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 hook và, tốt hơn nữa, đừng để bí mật ở dạng chuỗi trong mã ngay từ đầu.

Mẫu số chung

Hai phát hiện lõi ở đây — một kho bất biến không bao giờ quên, và dọn bản sao đã lộ không thu hồi được quyền — không riêng gì git; chúng đúng với mọi nơi có "cấp phát quyền" tách khỏi "nơi bằng chứng xuất hiện".

Trong thế giới Java, đây đúng là chuyện một JWT hay session token lỡ lộ: xoá nó khỏi log, khỏi mã, khỏi mọi chỗ hiển thị không làm nó hết hiệu lực, vì token vẫn hợp lệ tới khi hết hạn hoặc tới khi bạn thu hồi ở nguồn — thêm nó vào danh sách thu hồi, hoặc xoay khoá ký để vô hiệu cả loạt. Cùng bài học "sửa bản sao ≠ thu hồi quyền": muốn giết token phải tác động lên nơi cấp nó, không phải nơi nó xuất hiện.

Trong Go, cùng nguyên tắc với API key trong service: một key rò ra log hay repo chỉ chết khi bạn xoá nó khỏi store cấp key (rotate), không phải khi bạn git rm; và các store append-only mà Go hay dùng làm audit (event log, WAL) cũng "không quên" y như git — đừng thiết kế dựa trên giả định xoá được một bản ghi đã ghi.

Còn ở cơ sở dữ liệu, đây là hai mặt rất trực tiếp: một mật khẩu DB lộ ra phải ALTER ROLE ... PASSWORD (xoay ở nguồn), xoá nó khỏi file cấu hình là vô nghĩa; và bản thân DB cũng không quên — dữ liệu bạn DELETE vẫn có thể còn trong WAL, trong backup, trong bản sao replica, đúng như commit cũ còn trong git clone của người khác. Muốn dữ liệu nhạy cảm thật sự biến mất phải tính tới mọi bản sao đó, không chỉ hàng hiện tại.

Mẫu số chung: khi một bí mật lộ, có hai sự thật cứng: cái quyền nó cấp sống ở một nơi khác với chỗ nó xuất hiện (nên phải thu hồi tại nguồn, không phải xoá bản sao), và các kho bất biến/nhân bản (git, log, WAL, backup, clone của người khác) không bao giờ quên (nên đừng tin 'đã xoá'). Vì thế câu hỏi đúng khi lỡ lộ một bí mật không phải "làm sao xoá nó cho sạch" mà là "cái này cấp quyền gì, và tôi thu hồi quyền đó ở nguồn bằng cách nào" — dọn dấu vết chỉ là việc sau, và luôn là việc phụ.

Muốn tự soi 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.