</>Học Dev
Bài học

Tuần 3 - Ngày 13: Secrets Management

Tuần 3 – Ngày 13

Mục tiêu học tập

  • Hiểu vì sao secret (API key, password, token, certificate) KHÔNG bao giờ được commit vào source code
  • Nắm pattern .env + .env.example và các bẫy thường gặp khi quản lý biến môi trường
  • Biết cách scrub secret đã lỡ commit khỏi git history bằng BFG/git filter-repo, và tại sao rotate trước khi scrub
  • So sánh các secret managers phổ biến: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Doppler, 1Password
  • Hiểu secret rotation và lease-based credentials
  • Tích hợp gitleaks/truffleHog/GitHub secret scanning vào CI
  • Áp dụng 12-factor app: config tách khỏi code

1. Tại sao secret không bao giờ được commit

Câu chuyện thực tế

2019: Một junior dev commit AWS access key vào public GitHub repo.
      Sau 4 phút, một bot tự động scan GitHub, lấy key, spin up
      100 EC2 instance loại p3.16xlarge để mine cryptocurrency.
      Hoá đơn AWS sáng hôm sau: ~$28,000 USD trong 12 tiếng.

Secret bị leak qua git là vấn đề không hồi tố được:

  • Một khi đã push lên public repo, giả định nó đã bị compromise, kể cả khi bạn xoá ngay sau đó
  • Bot quét GitHub trong vài giây sau khi push
  • Lịch sử git lưu vĩnh viễn: git revert không xoá được, vì commit cũ vẫn tồn tại

Những thứ KHÔNG bao giờ được commit

- API keys (Stripe, OpenAI, Twilio, SendGrid, ...)
- Cloud credentials (AWS access key, GCP service account JSON, Azure SP secret)
- Database connection strings (có password)
- Private keys (SSH, TLS, GPG, JWT signing key)
- OAuth client secrets
- Session secrets / encryption keys
- Webhook signing secrets
- License keys của bên thứ ba

Cái gì được commit?

  • Code (logic)
  • Config public (port number, region, feature flag mặc định)
  • File .env.example (template, không có giá trị thật)
  • Secret được encrypt đúng cách (vd: sops, git-crypt, AWS KMS encrypted) — nhưng vẫn cẩn trọng

2. Bẫy .env thường gặp

Pattern đúng: .env + .env.example

project/.envchasecretTHT,KHÔNGcommit.env.exampletemplate,CÓcommit.gitignorephicódòng".env"src/

.env.example — commit vào repo:

# .env.example — template, KHÔNG có giá trị thật
DATABASE_URL=postgres://user:password@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxx
JWT_SECRET=change_me_to_random_64_chars

.gitignore:

# Secrets
.env
.env.local
.env.*.local
*.pem
*.key
secrets/
credentials.json

Bẫy 1: .env đã commit trước khi thêm vào .gitignore

# Thêm .env vào .gitignore SAU khi đã commit không có tác dụng
echo ".env" >> .gitignore
git add .gitignore
git commit -m "ignore .env"
# .env vẫn còn trong lịch sử các commit trước! Phải scrub history.

Bẫy 2: Commit .env.local, .env.production vì không cover hết

.gitignore chỉ có ".env"
nhưng project có:
  .env.local       ← BỊ COMMIT (ignore không cover)
  .env.production  ← BỊ COMMIT

Cách an toàn: ignore mọi biến thể .env.* trừ .env.example:

.env*
!.env.example

Bẫy 3: Secret trong file config khác

Đừng nghĩ .gitignore là đủ — đôi khi secret nằm trong:

  • config.json, application.yml, appsettings.json
  • terraform.tfvars
  • Notebook Jupyter .ipynb (có thể chứa output có token)
  • File README chứa "ví dụ" với key thật

Bẫy 4: Frontend code "chứa" secret

// React/Next.js client code — TẤT CẢ NEXT_PUBLIC_* bị bundle vào JS gửi xuống browser
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;  // ❌ LEAK

Quy tắc: bất cứ thứ gì có prefix NEXT_PUBLIC_, REACT_APP_, VITE_, PUBLIC_ đều public, không phải secret.


3. Scrub git history (khi đã lỡ commit)

Bước 0: Rotate secret TRƯỚC khi scrub

PRIORITY ORDER (làm theo thứ tự này):

1. Rotate/revoke secret bị leak — đây là thứ DUY NHẤT thật sự dừng được thiệt hại
   - AWS: deactivate access key trong IAM
   - Stripe: roll API key trong dashboard
   - OAuth: revoke refresh token

2. Audit log: kiểm tra secret đã bị dùng từ đâu, khi nào, lúc nào
   - CloudTrail (AWS), Audit logs (Stripe), GitHub Security Log

3. SCRUB git history (chỉ làm SAU khi đã rotate)
   - Nếu chưa rotate mà scrub trước, attacker đã clone repo vẫn có key

Lý do: git history scrub không xoá được secret khỏi clone đã tồn tại của attacker. Chỉ rotate mới chặn được việc dùng secret đó.

BFG Repo-Cleaner

# 1. Backup repo trước
git clone --mirror git@github.com:org/repo.git repo-backup.git

# 2. Clone mirror để clean
git clone --mirror git@github.com:org/repo.git
cd repo.git

# 3. Tạo file list secret cần xoá
cat > secrets.txt <<EOF
AKIAIOSFODNN7EXAMPLE
sk_live_51HtT5oJB6kF
EOF

# 4. Chạy BFG để replace bằng "***REMOVED***"
bfg --replace-text secrets.txt

# 5. Hoặc xoá file hoàn toàn
bfg --delete-files .env

# 6. Dọn dẹp reflog và GC
git reflog expire --expire=now --all
git gc --prune=now --aggressive

# 7. Force push (BẮT BUỘC cảnh báo team)
git push --force
# Cài (Python tool, thay thế git filter-branch đã deprecated)
pip install git-filter-repo

# Xoá file khỏi mọi commit trong lịch sử
git filter-repo --path .env --invert-paths

# Replace string trong mọi commit
echo 'AKIAIOSFODNN7EXAMPLE==>REMOVED' > replacements.txt
git filter-repo --replace-text replacements.txt

# Force push
git push --force --all
git push --force --tags

Sau khi force push

  • Team phải re-clone hoặc chạy git fetch && git reset --hard origin/<branch>
  • Mọi PR/fork đang tồn tại sẽ bị broken — phải rebase
  • GitHub vẫn cache commit cũ trong vài tuần qua direct URL — phải mở support ticket nếu muốn xoá hoàn toàn

4. Secret managers

Application1.Requestsecret(withidentityproof)SecretManagerAuth(IAM/token/OIDC)Authorize(policy)AuditlogEncryptionatrestRotation2.Secretvalue(inmemoryonly,neverondisk)Application

So sánh các giải pháp

ToolHostingAuth modelDynamic secretsBest for
HashiCorp VaultSelf-host hoặc HCP CloudToken, AppRole, JWT, K8s SACó (DB creds, AWS STS)Multi-cloud, enterprise
AWS Secrets ManagerAWS managedIAMCó (RDS, native rotation Lambda)AWS-only stack
AWS Parameter StoreAWS managedIAMKhôngConfig + secret nhẹ, rẻ
GCP Secret ManagerGCP managedGCP IAMKhôngGCP-only stack
Azure Key VaultAzure managedAzure AD / Managed IdentityCó (giới hạn)Azure-only stack
DopplerSaaSService token, OIDCKhôngMulti-env config sync, team UX tốt
1Password SecretsSaaSService AccountKhôngTeam nhỏ, đã dùng 1Password
InfisicalOpen source / SaaSService token, OIDCLimitedSelf-host miễn phí, thay Doppler

Pattern dùng AWS Secrets Manager (Node.js)

import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

const client = new SecretsManagerClient({ region: "ap-southeast-1" });

async function getDbPassword() {
  const cmd = new GetSecretValueCommand({ SecretId: "prod/myapp/db" });
  const res = await client.send(cmd);
  const secret = JSON.parse(res.SecretString);
  return secret.password;
}

App không bao giờ lưu password trong env var hay file. App có IAM Role (qua EC2/ECS Task Role/IRSA) và gọi API mỗi lần khởi động — hoặc cache trong memory với TTL.

Pattern dùng Vault (Python)

import hvac

client = hvac.Client(url="https://vault.example.com")
# K8s service account JWT auth
client.auth.kubernetes.login(role="my-app", jwt=open("/var/run/secrets/kubernetes.io/serviceaccount/token").read())

# Read static secret
secret = client.secrets.kv.v2.read_secret_version(path="myapp/prod/db")
db_password = secret["data"]["data"]["password"]

# Dynamic DB credentials — Vault tạo user PostgreSQL on-demand
creds = client.secrets.database.generate_credentials(name="readonly-role")
# creds["data"]["username"] và creds["data"]["password"] — chỉ valid trong vài giờ

5. Secret rotation và lease-based credentials

Rotation static secret

Tuần 1:  DB password = "abc123"
         App đang dùng "abc123"
                │
Tuần 2:  Rotate → password mới = "xyz789"
         App được redeploy với password mới
         Password cũ "abc123" deactivate sau grace period

AWS Secrets Manager có automatic rotation qua Lambda: tự đổi password trong RDS rồi cập nhật secret. App chỉ cần re-fetch.

Lease-based dynamic credentials (Vault)

App: "Cho tôi credential PostgreSQL"
Vault: "Đây: user=v-app-abc123, password=xxx, TTL=1 giờ"
       → Vault tạo user mới trong PostgreSQL với GRANT phù hợp
       → Sau 1 giờ Vault tự DROP user đó

App restart trong 1 giờ tiếp theo: nhận credential KHÁC

Ưu điểm:

  • Mỗi instance có credential riêng → audit log truy được người dùng
  • Compromise một credential chỉ thiệt hại trong TTL
  • Không cần rotate thủ công — credential luôn ngắn hạn

6. Detect secrets trong CI

gitleaks

# .github/workflows/gitleaks.yml
name: Secret scan
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # scan toàn bộ history
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Chạy local:

# Cài
brew install gitleaks

# Scan toàn bộ lịch sử
gitleaks detect --source . --verbose

# Scan staged changes (pre-commit hook)
gitleaks protect --staged --verbose

truffleHog

# truffleHog v3 — verify thật sự key có hoạt động không
trufflehog git https://github.com/org/repo --only-verified

Khác biệt: truffleHog có thể gọi API thật để kiểm tra key còn valid không — giảm false positive.

GitHub Secret Scanning

Bật miễn phí cho public repo và GitHub Advanced Security cho private. GitHub có partnership với nhà cung cấp (Stripe, AWS, Slack, ...) — khi detect key, có thể tự động revoke key qua provider.

Pre-commit hook local

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
pip install pre-commit
pre-commit install
# Mỗi lần git commit, gitleaks chạy trên staged files

7. 12-factor app: Config

Trích phương pháp 12factor.net:

"Store config in the environment"

Nguyên tắc:

  • Config (gồm secret) không được hardcode trong code
  • Config khác nhau giữa env (dev/staging/prod) không được lưu trong code
  • Strict separation giữa code và config: cùng một codebase deploy ở mọi env
// ❌ BAD — config in code
const dbUrl = "postgres://prod-user:secretpass@db.prod.com/app";

// ❌ BAD — config switch trong code
const dbUrl = env === "prod"
  ? "postgres://prod-user:secret@db.prod.com/app"
  : "postgres://dev-user:dev@localhost/app";

// ✅ GOOD — env var
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is required");

Validate env var lúc khởi động (fail fast):

import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(["development", "staging", "production"]),
});

export const env = envSchema.parse(process.env);
// App crash ngay lúc start nếu thiếu/sai env var

8. Câu hỏi ôn tập

  1. Bạn vô tình git push một file .env chứa AWS access key lên public repo. Thứ tự đúng để xử lý là gì?

    Xem đáp án

    Thứ tự ưu tiên: (1) Rotate/deactivate access key trong AWS IAM ngay lập tức, (2) Kiểm tra CloudTrail xem key đã bị dùng cho hoạt động bất thường nào chưa, (3) Sau đó mới scrub history bằng git filter-repo hoặc BFG, (4) Force push và thông báo team re-clone.

    Lý do: scrub history KHÔNG xoá được secret khỏi clone/fork của attacker đã tồn tại. Chỉ rotate mới chặn được việc dùng secret đó. Đảo ngược thứ tự (scrub trước, rotate sau) là sai lầm phổ biến.

  2. Tại sao đưa .env vào .gitignore sau khi đã commit không giải quyết được vấn đề?

    Xem đáp án

    .gitignore chỉ ngăn Git track file mới. File .env đã commit vẫn tồn tại trong mọi commit cũ của lịch sử — clone bất kỳ commit nào trước đó vẫn có file .env. Muốn xoá hoàn toàn phải rewrite history bằng git filter-repo/BFG và force push. Quan trọng hơn: vẫn phải rotate secret trước, vì attacker có thể đã clone trước khi bạn scrub.

  3. So sánh AWS Parameter Store và AWS Secrets Manager — khi nào nên dùng cái nào?

    Xem đáp án

    Parameter Store: rẻ (standard tier miễn phí), tốt cho config có thể không nhạy cảm (region, feature flag) và secret đơn giản. Không có rotation tự động. Limit 4 KB (standard) hoặc 8 KB (advanced).

    Secrets Manager: ~$0.40/secret/tháng, có automatic rotation qua Lambda (tự đổi DB password và cập nhật secret), có cross-region replication, support resource policy. Dùng cho DB password, API key cần rotate định kỳ.

    Quy tắc thường dùng: config → Parameter Store, secret cần rotate → Secrets Manager.

  4. "Dynamic secret" của Vault khác "static secret rotation" của Secrets Manager ở điểm nào?

    Xem đáp án

    Static rotation: secret tồn tại lâu dài (vài tuần/tháng), được rotate định kỳ. Mọi app cùng share một secret tại một thời điểm. Compromise = thiệt hại cho đến lần rotate tiếp theo.

    Dynamic secret (Vault): secret được tạo on-demand mỗi lần app request, có TTL ngắn (vài phút đến giờ). Mỗi instance có credential riêng. Vault tạo user DB thật sự trong PostgreSQL với GRANT phù hợp, hết TTL Vault tự DROP user. Compromise chỉ thiệt hại trong TTL window. Audit log truy được instance nào đã làm gì.

  5. Vì sao NEXT_PUBLIC_STRIPE_SECRET_KEY trong Next.js là một anti-pattern nguy hiểm?

    Xem đáp án

    Mọi env var có prefix NEXT_PUBLIC_ được Next.js bundle thẳng vào JavaScript của client (browser). Bất kỳ ai mở DevTools đều đọc được giá trị. Đặt secret key (vốn chỉ được dùng server-side) vào prefix này = công khai secret cho mọi người. Stripe secret key cho phép tạo charge, refund, đọc customer data — leak nó = thảm họa.

    Quy tắc: chỉ NEXT_PUBLIC_* cho publishable key (vd: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY bắt đầu bằng pk_). Secret key dùng prefix không có NEXT_PUBLIC_, chỉ truy cập trong API routes/Server Actions.

Bài tập thực hành

# 1. Tạo project demo và setup .env pattern
mkdir secrets-lab && cd secrets-lab
git init

cat > .env.example <<EOF
DATABASE_URL=postgres://user:password@localhost:5432/mydb
API_KEY=your_key_here
EOF

cat > .env <<EOF
DATABASE_URL=postgres://realuser:realpass@db.example.com:5432/prod
API_KEY=sk_live_FAKE_DO_NOT_COMMIT
EOF

cat > .gitignore <<EOF
.env
.env.*.local
EOF

git add .env.example .gitignore
git commit -m "chore: add env template"

# 2. Cài gitleaks và scan
brew install gitleaks   # hoặc: docker run zricethezav/gitleaks
gitleaks detect --source . --verbose
# → phát hiện .env (vì .gitignore không skip working dir)

# 3. Thử commit "nhầm" .env và scan history
git add -f .env
git commit -m "oops: committed secret"
gitleaks detect --source . --log-opts="--all" --verbose
# → tìm thấy secret trong commit history

# 4. Scrub bằng git filter-repo
pip install git-filter-repo
git filter-repo --path .env --invert-paths --force
git log --all   # .env đã biến mất khỏi mọi commit

# 5. Setup pre-commit hook
cat > .pre-commit-config.yaml <<EOF
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
EOF
pip install pre-commit
pre-commit install

Tài liệu tham khảo chính thức


Tiếp theo: Ngày 14 — TLS / HTTPS