Skip to main content

3.13 — Mini case study

Summary

Sự cố Git tốn kém nhất và cũng dễ xảy ra nhất: một file appsettings.json chứa chuỗi kết nối database production bị commit và push lên một repository public. Điều quan trọng nhất phải hiểu ngay: xoá commit không đủ. Kể từ giây phút nó được push, phải coi như bí mật đã bị lộ — GitHub đã lưu vào nhật ký sự kiện, các dịch vụ quét bí mật tự động đã đọc được, và bất kỳ ai fork hay clone đều có bản sao. Thứ tự xử lý quyết định mức thiệt hại: đổi mật khẩu trước, dọn lịch sử sau. Làm ngược lại là dành 30 phút viết lại lịch sử trong khi mật khẩu vẫn đang bị dùng.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Xử lý sự cố lộ bí mật theo đúng thứ tự ưu tiên.
  • Hiểu vì sao xoá commit không xoá được bí mật.
  • Viết lại lịch sử Git một cách an toàn.
  • Ngăn sự cố lặp lại bằng ba lớp phòng thủ.

Nội dung bài học​

3.13.1 — Tình huống​

14:32  Lập trình viên mới commit "chore: add environment config"
14:33 git push origin main
14:35 Nhận email từ GitHub: "Possible valid secrets found in your repository"
14:36 Kiểm tra: appsettings.json chứa chuỗi kết nối SQL Server production
kèm tài khoản sa và mật khẩu

Repository là public. Bí mật đã ở trên mạng 3 phút.

Phản ứng đầu tiên của hầu hết mọi người — và là phản ứng sai:

# SAI — làm việc này đầu tiên
git rm --cached appsettings.json
git commit -m "chore: remove config file"
git push

Việc này mất 2 phút và không bảo vệ được gì: commit cũ vẫn nằm trong lịch sử, và mật khẩu vẫn dùng được.

3.13.2 — Thứ tự đúng​

Bước 1 (ngay lập tức) — đổi mật khẩu.

ALTER LOGIN sa WITH PASSWORD = 'mat-khau-moi-rat-manh';

Đây là việc duy nhất thật sự chặn được thiệt hại. Mọi thứ khác chỉ là dọn dẹp.

Nếu bí mật là khoá API, thu hồi khoá đó ở nhà cung cấp. Nếu là khoá ký JWT, đổi khoá — chấp nhận việc mọi người dùng bị đăng xuất, vì đó rẻ hơn nhiều so với để khoá bị lộ.

Bước 2 — kiểm tra xem đã bị dùng chưa.

-- SQL Server: xem các kết nối gần đây
SELECT login_name, host_name, program_name, login_time
FROM sys.dm_exec_sessions
WHERE login_name = 'sa'
ORDER BY login_time DESC;

Tìm kết nối từ địa chỉ IP lạ. Nếu có, phạm vi sự cố rộng hơn nhiều so với "lộ mật khẩu" — phải kiểm tra dữ liệu đã bị đọc hay sửa gì.

Bước 3 — xoá file khỏi lịch sử.

# git-filter-repo — công cụ được khuyến nghị hiện nay
pip install git-filter-repo
git filter-repo --path appsettings.json --invert-paths

git push origin --force --all
git push origin --force --tags

git filter-repo thay thế git filter-branch cũ — nhanh hơn rất nhiều và ít cạm bẫy hơn.

Cảnh báo: đây là viết lại lịch sử. Mọi người trong đội phải clone lại, và các nhánh đang mở sẽ gặp vấn đề. Thông báo cho cả đội trước khi chạy.

Bước 4 — liên hệ GitHub. Ngay cả sau force push, commit cũ vẫn truy cập được qua URL trực tiếp trong một thời gian. Mở ticket với GitHub Support để yêu cầu xoá cache.

Bước 5 — kiểm tra fork. Nếu repository đã bị fork, các fork đó vẫn giữ lịch sử cũ và bạn không xoá được chúng. Đây là lý do bước 1 quan trọng hơn mọi bước còn lại.

3.13.3 — Vì sao xoá commit không đủ​

14:33  Push commit chứa bí mật
14:33 GitHub ghi vào event log (công khai qua API)
14:33 Các dịch vụ quét bí mật tự động đọc thấy
14:34 Bot thử kết nối bằng thông tin vừa lấy được
14:35 Bạn nhận cảnh báo
14:40 Bạn xoá commit <- MUỘN 7 phút

Các bot quét GitHub public hoạt động liên tục và phản ứng trong vòng vài giây. Khoảng thời gian từ lúc push tới lúc bạn phát hiện, dù chỉ vài phút, là quá đủ.

Kể cả với repository private, vẫn phải đổi mật khẩu: bất kỳ ai có quyền đọc repo đều thấy được, kể cả người đã rời công ty nhưng quyền chưa bị thu hồi.

Quy tắc: bí mật đã vào Git là bí mật đã lộ. Không có ngoại lệ.

3.13.4 — Ba lớp phòng thủ​

Lớp 1 — .gitignore:

appsettings.Development.json
appsettings.Production.json
appsettings.Local.json
*.env
.env.*
secrets.json
*.pfx
*.key

Chỉ commit appsettings.json chứa giá trị mặc định vô hại, không chứa bí mật nào.

Lớp 2 — pre-commit hook chặn tại máy:

# .git/hooks/pre-commit
#!/bin/bash
if git diff --cached | grep -qiE "(password|secret|api[_-]?key|connectionstring)\s*[:=]\s*['\"][^'\"]{8,}"; then
echo "CẢNH BÁO: có thể chứa bí mật trong thay đổi này"
echo "Kiểm tra lại, hoặc dùng --no-verify nếu chắc chắn an toàn"
exit 1
fi

Dùng Gitleaks sẽ tốt hơn hook tự viết — nó có sẵn hàng trăm mẫu nhận dạng cho khoá của các dịch vụ phổ biến.

Lớp 3 — quản lý bí mật đúng cách:

# Môi trường phát triển: user secrets — lưu NGOÀI thư mục dự án
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;..."
// Production: biến môi trường hoặc vault
var connectionString = builder.Configuration.GetConnectionString("Default");
// Đọc từ biến môi trường ConnectionStrings__Default

Với user-secrets, bí mật nằm ở thư mục người dùng — không thể vô tình commit được, vì nó không nằm trong cây thư mục dự án (bài 15.5).

3.13.5 — Hai sự cố Git khác đáng biết​

Mất commit sau git reset --hard:

# Tưởng đã mất, nhưng reflog còn giữ
git reflog
# a3f8b2c HEAD@{0}: reset: moving to HEAD~3
# 7c1d9e4 HEAD@{1}: commit: feat: add revenue report <- commit "đã mất"

git reset --hard 7c1d9e4 # khôi phục

git reflog ghi lại mọi lần HEAD di chuyển và giữ khoảng 90 ngày. Gần như mọi thứ "mất" trong Git đều tìm lại được ở đây.

Commit nhầm vào main:

git branch feature/revenue-report # tạo nhánh tại commit hiện tại
git reset --hard origin/main # đưa main về trạng thái trên remote
git checkout feature/revenue-report # tiếp tục làm trên nhánh đúng

Thứ tự quan trọng: tạo nhánh trước, reset sau. Ngược lại là mất công việc thật.

3.13.6 — Rà lại code của bạn​

Danh sách rà soát an toàn Git

  • •File cấu hình chứa bí mật nằm trong .gitignore.
  • •Môi trường phát triển dùng user-secrets, không sửa trực tiếp appsettings.
  • •Production đọc bí mật từ biến môi trường hoặc vault.
  • •Có công cụ quét bí mật chạy trước khi commit.
  • •CI có bước quét bí mật trong toàn bộ lịch sử.
  • •Đội biết quy trình xử lý khi lộ bí mật: đổi mật khẩu trước.
  • •Biết git reflog tồn tại và dùng được để khôi phục.
  • •Hiểu rằng bí mật đã vào Git là bí mật đã lộ, kể cả repo private.

Bài tập áp dụng​

Bài 1 — Thử git-filter-repo​

Tạo một repo thử, commit một file chứa chuỗi giả làm bí mật, rồi xoá nó khỏi toàn bộ lịch sử và xác nhận bằng git log --all -- <file>.

Tiêu chí hoàn thành: trước khi xoá, bạn chứng minh được bí mật vẫn đọc được sau khi git rm; sau khi xoá, bạn xác nhận nó đã biến mất.

Gợi ý và lời giải — Bài 1

Gợi ý. Làm phần "chứng minh nó vẫn còn" trước. Đó mới là phần dạy được nhiều nhất.

Lời giải — dựng repo có bí mật rồi xoá bằng cách thông thường:

mkdir thu-bi-mat && cd thu-bi-mat && git init -q -b main
git config user.email t@vi.du && git config user.name "Thử Nghiệm"

echo "app=crm" > appsettings.json
git add -A && git commit -q -m "khởi tạo"

printf 'app=crm\nConnectionStrings__Db=Server=db;Password=MatKhauThat123!\n' > appsettings.json
git add -A && git commit -q -m "thêm cấu hình kết nối"

echo "tính năng A" > a.txt && git add -A && git commit -q -m "tính năng A"

# "Xoá" theo cách nhiều người nghĩ là đủ
git rm -q appsettings.json && git commit -q -m "xoá file cấu hình khỏi repo"
ls
grep -rn "MatKhauThat123" . --exclude-dir=.git || echo " không tìm thấy"
a.txt
không tìm thấy

Trông như đã xong. Nhưng chưa:

git log --all --oneline -- appsettings.json
8141dd6 xoá file cấu hình khỏi repo
efac72e thêm cấu hình kết nối
fc8abd8 khởi tạo
git log -S 'MatKhauThat123' --all --oneline
8141dd6 xoá file cấu hình khỏi repo
efac72e thêm cấu hình kết nối
git show efac72e:appsettings.json
app=crm
ConnectionStrings__Db=Server=db;Password=MatKhauThat123!

Mật khẩu vẫn đọc được bằng một lệnh, bởi bất kỳ ai clone được repo.

Và cách tìm mạnh nhất — quét mọi commit cùng lúc:

git grep -n "MatKhauThat123" $(git rev-list --all)
eb1d230e...:appsettings.json:2:ConnectionStrings__Db=Server=db;Password=MatKhauThat123!
efac72e2...:appsettings.json:2:ConnectionStrings__Db=Server=db;Password=MatKhauThat123!

Đây chính là điều mục 3.13.3 nói: commit là bất biến. Xoá file ở commit mới chỉ thêm một commit nói "file này không còn nữa" — nó không chạm được vào commit cũ, vì sửa commit cũ nghĩa là đổi hash của nó và của mọi commit sau nó.

Xoá thật bằng git-filter-repo:

pip install git-filter-repo        # hoặc: brew install git-filter-repo

cd ..
git clone --mirror thu-bi-mat thu-bi-mat-mirror # LUÔN làm trên bản sao
cd thu-bi-mat-mirror

git filter-repo --path appsettings.json --invert-paths
Parsed 4 commits
New history written in 0.05 seconds...
Completely finished after 0.12 seconds.

Xác nhận:

git log --all --oneline -- appsettings.json     # không còn dòng nào
git grep "MatKhauThat123" $(git rev-list --all) # không còn kết quả

Hoặc giữ file nhưng thay nội dung nhạy cảm:

cat > ../thay-the.txt <<'EOF'
MatKhauThat123!==>***DA-XOA***
EOF

git filter-repo --replace-text ../thay-the.txt
Cách này giữ lại lịch sử của file, chỉ thay chuỗi bí mật.
Hữu ích khi file vẫn cần tồn tại, ví dụ appsettings.Development.json mẫu.

Bốn việc bắt buộc làm sau khi viết lại lịch sử:

1. ĐỔI bí mật đó ngay — đây là việc QUAN TRỌNG NHẤT và phải làm ĐẦU TIÊN
2. Báo mọi người trên đội xoá bản sao cũ và clone lại
3. Đẩy lên: git push --force --all && git push --force --tags
4. Liên hệ nhà cung cấp Git để xoá bản sao trên máy chủ của họ

Việc thứ tư hay bị quên và nó quan trọng:

GitHub và GitLab giữ các commit "mồ côi" một thời gian, và chúng vẫn truy
cập được qua URL nếu ai đó biết hash.
-> phải mở ticket yêu cầu dọn, không tự làm được.

Và điều đáng nhớ nhất về thứ tự:

Viết lại lịch sử mất vài giờ và gây phiền cho cả đội.
Đổi mật khẩu mất năm phút.

Bí mật đã lộ lên một repo có nhiều người truy cập thì coi như ĐÃ LỘ.
Viết lại lịch sử chỉ để dọn dẹp — nó KHÔNG thay thế việc đổi bí mật.

Đây đúng là thứ tự ở mục 3.13.2, và bài tập này cho thấy vì sao: giữa lúc bạn phát hiện và lúc lịch sử được viết lại, bí mật đó vẫn đọc được bằng một lệnh git show.


Bài 2 — Cài Gitleaks​

Chạy nó trên một dự án thật và xem kết quả quét toàn bộ lịch sử.

Tiêu chí hoàn thành: bạn có kết quả quét, và phân biệt được phát hiện thật với báo động giả — cũng như biết xử lý từng loại.

Gợi ý và lời giải — Bài 2

Gợi ý. Lần quét đầu tiên trên một repo cũ thường ra rất nhiều kết quả. Phần lớn là báo động giả, và chính vì vậy mà nhiều đội bỏ luôn công cụ.

Lời giải — cài và quét:

brew install gitleaks                     # macOS
# hoặc tải bản nhị phân từ trang phát hành cho Linux

gitleaks detect --source . --report-format json --report-path bao-cao.json -v

Kết quả điển hình trên một repo đã chạy vài năm:

Finding:     ConnectionStrings__Db=Server=db;User Id=sa;Password=P@ssw0rd123
Secret: P@ssw0rd123
RuleID: generic-api-key
Entropy: 3.918290
File: src/Crm.Api/appsettings.Development.json
Line: 8
Commit: efac72e28a03e67b9679839a13b221a8582a3f46
Author: Nguyễn Văn A
Date: 2024-03-14T09:22:11Z

...

12:03AM INF scanned ~4.2 MB (1284 commits) in 3.8s
12:03AM WRN leaks found: 23

Phân loại 23 phát hiện:

NhómSốXử lý
Bí mật THẬT còn hiệu lực2Đổi ngay, rồi viết lại lịch sử
Bí mật thật nhưng đã hết hiệu lực4Ghi nhận, viết lại lịch sử khi tiện
Chuỗi kết nối tới database cục bộ9Báo động giả — thêm vào danh sách bỏ qua
Dữ liệu mẫu trong test6Báo động giả
Chuỗi ngẫu nhiên có entropy cao2Báo động giả — ví dụ mã băm, GUID

Cách phân biệt, bằng bốn câu hỏi:

1. Chuỗi này dùng được để truy cập cái gì thật không?
2. Nó có còn hiệu lực không?
3. Nó trỏ tới localhost hay tới hệ thống thật?
4. Nó nằm trong thư mục test hay trong mã chạy production?
"Server=localhost;Database=crm;User Id=sa;Password=Local123"
-> chỉ dùng được trên máy của chính người viết -> báo động giả

"Server=prod-sql.crm.vn;...;Password=Xk9#mP2q"
-> hệ thống thật -> ĐỔI NGAY

Danh sách bỏ qua — để lần quét sau chỉ còn kết quả đáng xem:

# .gitleaks.toml
[extend]
useDefault = true

[[rules]]
id = "generic-api-key"
[rules.allowlist]
paths = [
'''tests/.*''',
'''.*\.Tests/.*''',
'''docs/.*''',
]
regexes = [
'''Server=localhost''',
'''Server=\(localdb\)''',
'''Password=Local\d+''',
]
gitleaks detect --source . --config .gitleaks.toml
12:05AM INF scanned ~4.2 MB (1284 commits) in 3.9s
12:05AM WRN leaks found: 6

Từ 23 xuống 6 — và giờ mỗi kết quả đều đáng đọc.

Đây là bước quyết định công cụ có được dùng tiếp hay không:
23 kết quả với 17 báo động giả -> đội bỏ qua toàn bộ
6 kết quả, đều thật -> đội xử lý từng cái

Đưa vào CI để bí mật mới không lọt vào:

# .github/workflows/quet-bi-mat.yml
name: Quét bí mật
on: [pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # cần toàn bộ lịch sử
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 là bắt buộc.
Mặc định actions/checkout chỉ lấy MỘT commit -> quét không thấy gì
-> CI xanh, và không ai biết nó đang không kiểm tra gì cả.

Và lớp phòng thủ đứng trước CI: hook lúc commit.

# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Hook chặn TRƯỚC khi commit -> bí mật không bao giờ vào lịch sử
CI chặn TRƯỚC khi merge -> bí mật không vào nhánh chính
Quét định kỳ -> tìm những gì đã lọt qua trước đây

Ba lớp này đúng là ba lớp ở mục 3.13.4, và thứ tự hiệu quả cũng theo thứ tự đó: chặn ở hook rẻ hơn nhiều so với viết lại lịch sử ở bài 1.


Bài 3 — Khôi phục bằng reflog​

Commit vài thay đổi, chạy git reset --hard HEAD~3, rồi dùng git reflog để lấy lại.

Tiêu chí hoàn thành: bạn lấy lại được đúng trạng thái cũ, và biết reflog không cứu được trường hợp nào.

Gợi ý và lời giải — Bài 3

Gợi ý. reflog ghi lại mọi lần HEAD di chuyển, kể cả tới những commit không còn nhánh nào trỏ tới.

Lời giải:

git log --oneline -3
bd3d3f7 gộp lại
58a1e8e gộp lại
6e92803 commit 20
git reset --hard HEAD~2
HEAD is now at 6e92803 commit 20

Hai commit đã biến mất khỏi git log. Nhưng:

git reflog -6
6e92803 HEAD@{0}: reset: moving to HEAD~2
bd3d3f7 HEAD@{1}: rebase (finish): returning to refs/heads/tinh-nang
bd3d3f7 HEAD@{2}: rebase (squash): gộp lại
0a8db9a HEAD@{3}: rebase (squash): # This is a combination of 2 commits.
bdcfb2c HEAD@{4}: rebase (pick): thêm test
58a1e8e HEAD@{5}: rebase (squash): gộp lại
git reset --hard bd3d3f7        # hoặc: git reset --hard HEAD@{1}
HEAD is now at bd3d3f7 gộp lại
git log --oneline -2
bd3d3f7 gộp lại
58a1e8e gộp lại

Lấy lại nguyên vẹn.

Vì sao được: commit không bị xoá khi mất nhánh trỏ tới.

git reset --hard chỉ di chuyển con trỏ nhánh.
Commit cũ vẫn nằm trong kho đối tượng của Git, chỉ là không có tên nào trỏ tới.

reflog giữ danh sách những nơi HEAD từng đi qua
-> đủ để tìm lại hash -> đủ để khôi phục
Thời gian giữ mặc định:
commit còn nhánh trỏ tới : mãi mãi
commit mồ côi, có trong reflog : 90 ngày
commit mồ côi, không có reflog : 30 ngày

Ba tình huống reflog cứu được — gần như mọi tai nạn thường gặp:

git reset --hard <sai chỗ>           # reflog -> quay lại
git rebase <hỏng> # reflog -> quay lại trạng thái trước rebase
git branch -D <nhánh chưa merge> # reflog -> tìm hash cuối của nhánh, tạo lại
git checkout <nhánh khác> # "mất" commit -> thật ra chỉ đang ở nhánh khác
# Tạo lại một nhánh đã xoá nhầm
git reflog | grep "tinh-nang"
git checkout -b tinh-nang <hash>

Và đây là phần quan trọng hơn — ba trường hợp reflog KHÔNG cứu được:

1. Thay đổi chưa bao giờ được commit.

git reset --hard          # xoá mọi thay đổi chưa commit
git checkout -- . # cùng tác dụng với các file đã sửa
reflog chỉ biết về COMMIT.
Thay đổi trong cây làm việc chưa commit -> không có dấu vết nào -> MẤT HẲN
Trừ khi bạn đã: git stash    (stash cũng có reflog riêng: git stash list)
Hoặc IDE có lịch sử cục bộ: JetBrains có "Local History",
VS Code có tính năng timeline — hai thứ này cứu được nhiều lần.

2. File chưa bao giờ được git add.

git clean -fd             # xoá mọi file không được theo dõi
File chưa add -> Git chưa từng biết tới nó -> không khôi phục được

git clean -nd # LUÔN chạy với -n trước để xem nó SẼ xoá gì

3. reflog của người khác không có trên máy bạn.

reflog là CỤC BỘ. Nó không được đẩy lên máy chủ.

Đồng nghiệp force-push xoá mất commit của bạn
-> reflog của bạn vẫn có (nếu bạn từng fetch về)
-> reflog trên máy chủ thì không tồn tại

-> GitHub và GitLab có API xem các sự kiện push gần đây,
đó là cách tìm lại hash đã bị ghi đè.

Quy tắc thực dụng rút ra:

Commit sớm, commit thường xuyên — kể cả commit lộn xộn.

Một commit "lưu tạm" xấu xí có thể dọn sau bằng rebase (bài 3 của 3.11).
Thay đổi chưa commit thì không có gì dọn, vì không có gì cả.

Đây là lập luận thực dụng nhất cho thói quen commit nhỏ và thường xuyên: nó không chỉ làm lịch sử dễ đọc, nó còn đưa công việc của bạn vào vùng mà Git khôi phục được.

Tự kiểm tra​

Frequently asked questions

Vì sao đổi mật khẩu phải làm trước khi dọn lịch sử?

Vì đổi mật khẩu là việc duy nhất thật sự chặn được thiệt hại. Dọn lịch sử mất nhiều phút, và trong thời gian đó mật khẩu cũ vẫn dùng được. Mọi thao tác Git chỉ là dọn dẹp.

Vì sao xoá commit không xoá được bí mật?

Vì ngay khi push, GitHub ghi vào nhật ký sự kiện công khai, các bot quét bí mật đọc được trong vài giây, và bất kỳ ai đã clone hay fork đều giữ bản sao lịch sử cũ. Commit cũ cũng còn truy cập được qua URL trực tiếp một thời gian.

Repository private thì có cần đổi mật khẩu không?

Có. Bất kỳ ai có quyền đọc repo đều thấy được, kể cả người đã rời công ty nhưng quyền chưa bị thu hồi. Quy tắc là bí mật đã vào Git là bí mật đã lộ, không có ngoại lệ.

Vì sao user-secrets an toàn hơn sửa appsettings?

Vì nó lưu bí mật ở thư mục người dùng, hoàn toàn ngoài cây thư mục dự án, nên không thể vô tình commit được kể cả khi ai đó quên cập nhật .gitignore.

git reflog giúp gì?

Nó ghi lại mọi lần HEAD di chuyển và giữ khoảng chín mươi ngày, nên gần như mọi thứ tưởng đã mất sau reset hard hay rebase đều tìm lại được bằng cách reset về mã commit tương ứng.

Commit nhầm vào main thì xử lý theo thứ tự nào?

Tạo nhánh mới tại commit hiện tại trước, rồi mới reset main về trạng thái trên remote, rồi chuyển sang nhánh vừa tạo. Làm ngược thứ tự sẽ mất công việc thật.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Đổi mật khẩu trước, dọn lịch sử sau. Thứ tự này quyết định mức thiệt hại.
  2. Bí mật đã vào Git là bí mật đã lộ, kể cả repository private.
  3. git reflog cứu được gần như mọi thứ tưởng đã mất.

Tham khảo​

Điều hướng​