Tuần này, một bài trên Hacker News gây chú ý với tiêu đề: tính năng "Don't Ask Again" của Claude Code làm lộ password trong 1.122 file public trên GitHub. Mình không bàn con số chính xác là bao nhiêu, vì vấn đề lớn hơn một tool cụ thể: file config của AI agent đang trở thành nơi chứa secret mà không ai để ý. Chúng ta đã quen review .env, nhưng ít ai đọc kỹ một file JSON do tool tự sinh ra trước khi git add .. Bài này đi qua cơ chế gây lộ, cách kiểm tra repo trong 10 phút, cách xử lý khi đã lộ và cách chặn lại bằng công cụ.
Secret lọt vào file config như thế nào?
Coding agent thường hỏi xin phép trước khi chạy lệnh. Khi bạn chọn "đừng hỏi lại", tool sẽ lưu rule để lần sau tự cho phép. Với Claude Code, rule ở local thường nằm trong .claude/settings.local.json.
Vấn đề xuất hiện khi lệnh được duyệt có chứa credential ngay trong nội dung:
{ "permissions": { "allow": [ "Bash(npm run test *)", "Bash(curl -u admin:S3cretPass https://api.internal.example/health)" ] } } Rule thứ hai vừa là permission, vừa là bản lưu plaintext của password. Bạn không hề chủ động ghi secret vào file, nên cũng không có phản xạ cảnh giác.
flowchart TD A[Dev hoặc agent tạo lệnh có password] --> B[Tool hỏi quyền chạy lệnh] B --> C[Dev chọn Don't Ask Again] C --> D[Lệnh được lưu vào file settings] D --> E{File có bị git ignore không?} E -->|Có| F[Secret vẫn nằm plaintext trên máy] E -->|Không| G[git add và git push] G --> H[Secret nằm trong lịch sử public repo] Để ý nhánh "Có": kể cả khi file không lên GitHub, secret vẫn nằm plaintext trên disk. Backup, dotfiles repo hay một lệnh git add -f đều có thể đưa nó ra ngoài.
Đây cũng không phải chuyện riêng của Claude Code. Những tool lưu command history hoặc chat log, như file .aider.chat.history.md của Aider, cũng có rủi ro tương tự nếu bạn từng dán secret vào đó.
Kiểm tra repo trong 10 phút
Đầu tiên, xem file local có đang bị track hay từng bị commit không:
# File đang được track git ls-files | grep -E '(^|/)\.claude/settings\.local\.json$' # File từng xuất hiện trong lịch sử, kể cả đã xóa git log --all --oneline -- '*.claude/settings.local.json' # Quét toàn bộ lịch sử bằng gitleaks 8.x, --redact để không in secret ra terminal gitleaks git --redact --verbose . # TruffleHog v3: thử xác minh secret có còn dùng được không trufflehog git file://. --results=verified,unknown Các scanner rất tốt với token có format rõ ràng như ghp_... hay AKIA..., nhưng có thể bỏ sót password tự đặt nằm trong curl -u. Vì vậy mình viết thêm một script nhỏ chỉ dành cho permission rule. Script này cố ý không in nội dung rule để secret không rơi vào terminal log hoặc CI log:
#!/usr/bin/env python3 # Dùng: python3 scan_agent_rules.py ~/Projects import json import re import sys from pathlib import Path PATTERNS = { "basic_auth": re.compile(r'''(?:^|\s)(?:-u|--user)[ =]["']?[^\s:"']+:[^\s"']+'''), "url_credential": re.compile(r"[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@", re.I), "auth_header": re.compile(r"authorization:\s*(?:bearer|basic)\s+[\w.~+/=-]{12,}", re.I), "password_arg": re.compile(r"(?:--password[ =]|sshpass -p ?|PGPASSWORD=)\S+"), "known_token": re.compile(r"\b(?:ghp_\w{36}|github_pat_\w{20,}|AKIA[0-9A-Z]{16})\b"), } def scan_file(path): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): print(f"SKIP {path}: không đọc được") return 0 permissions = data.get("permissions") or {} hits = 0 for section in ("allow", "ask", "deny"): for index, rule in enumerate(permissions.get(section) or []): for name, pattern in PATTERNS.items(): match = pattern.search(str(rule)) # Bỏ qua trường hợp dùng biến môi trường, ví dụ $API_PASS if match and "$" not in match.group(0): print(f"FOUND {path} {section}[{index}]: {name}") hits += 1 return hits root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").expanduser() files = [p for p in root.rglob("settings*.json") if p.parent.name == ".claude"] total = sum(scan_file(p) for p in files) print(f"Đã quét {len(files)} file, phát hiện {total} rule đáng ngờ") sys.exit(1 if total else 0) Regex không thể bắt hết mọi trường hợp, nên hãy xem đây là lớp lọc nhanh, không phải bằng chứng rằng repo đã sạch.
Đã lộ rồi thì xử lý theo thứ tự nào?
Sai lầm phổ biến nhất là lao vào xóa commit trước. Một khi secret đã lên public repo, hãy coi như nó đã bị lấy. Fork, cache và bot quét GitHub đều có thể đã có bản sao, nên viết lại history không làm secret an toàn trở lại.
Thứ tự nên là:
- Rotate secret ngay: đổi password, thu hồi token.
- Kiểm tra audit log của service liên quan để tìm truy cập lạ trong khoảng thời gian bị lộ.
- Gỡ file khỏi tracking và thêm vào
.gitignore. - Viết lại history nếu cần, sau khi báo cho cả team.
# Gỡ khỏi git, giữ lại file trên máy git rm --cached .claude/settings.local.json echo '.claude/settings.local.json' >> .gitignore git commit -m "chore: stop tracking local agent settings" # Viết lại history: nên chạy trên một bản clone mới # git-filter-repo sẽ gỡ remote origin, cần add lại trước khi push git filter-repo --invert-paths --path .claude/settings.local.json git remote add origin git@github.com:your-org/your-repo.git git push --force --all origin git push --force --tags origin Sau khi force push, mọi người trong team phải clone lại, nếu không secret có thể quay về qua một lần push từ bản cũ. Với repo nhiều fork, bạn có thể cần liên hệ GitHub Support để dọn dữ liệu còn sót.
Dựng hàng rào để không lặp lại
Không có lớp bảo vệ nào đủ một mình, nên mình dùng nhiều lớp nối tiếp nhau:
flowchart LR A[Secret trong env hoặc vault] --> B[Rule chỉ chứa tên biến] B --> C[Global gitignore] C --> D[Pre-commit gitleaks] D --> E[CI quét toàn bộ history] E --> F[Push protection trên GitHub] Lớp 1: Không đưa secret vào lệnh. Hãy dùng biến môi trường, ví dụ curl -u "$API_USER:$API_PASS" https://api.internal.example/health. Khi đó rule được lưu chỉ chứa tên biến, không chứa giá trị. Trước khi chọn "Don't Ask Again", đọc lại lệnh một lần, đúng theo nguyên tắc lệnh có secret thì chỉ duyệt một lần.
Lớp 2: Global gitignore. Cấu hình một lần là áp dụng cho mọi repo trên máy:
git config --global core.excludesFile ~/.gitignore_global cat >> ~/.gitignore_global <<'EOF' .claude/settings.local.json CLAUDE.local.md .aider* .env .env.* EOF File .claude/settings.json thường được commit để chia sẻ cho team, nên đừng ignore file này. Thay vào đó, hãy review nó như code.
Lớp 3: Rule riêng cho gitleaks. Tạo .gitleaks.toml ở root repo:
[extend] useDefault = true [[rules]] id = "agent-rule-inline-basic-auth" description = "Credential viết inline trong permission rule của agent" path = '''(^|/)\.claude/settings(\.local)?\.json$''' regex = '''(?:-u|--user)[ =]["']?[^\s:"'$]+:[^\s"'$]+''' Sau đó thêm vào .pre-commit-config.yaml rồi chạy pre-commit install:
repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.28.0 hooks: - id: gitleaks Lớp 4: CI và GitHub. Pre-commit hook có thể bị bỏ qua bằng --no-verify hoặc đơn giản là chưa được cài. Vì vậy CI nên chạy lại gitleaks git --redact . với fetch-depth: 0, đồng thời bật secret scanning và push protection của GitHub nếu repo hỗ trợ.
Lớp 5: Giới hạn thứ agent đọc được. Có thể thêm deny rule như Read(./.env) hoặc Read(./secrets/**). Tuy nhiên, rule này không phải sandbox: agent được chạy shell tự do vẫn có thể cat .env nếu rule cho Bash quá rộng. Vì vậy, đừng allow các pattern kiểu Bash(*), và ưu tiên dùng credential ngắn hạn, quyền tối thiểu cho môi trường dev.
Kết luận
AI agent không tạo ra loại rủi ro mới hoàn toàn, nhưng nó tạo thêm những nơi secret có thể nằm mà bạn không biết. Hôm nay bạn có thể làm ngay các việc sau:
- Chạy kiểm tra: dùng
gitleaks git --redact .và script trên cho toàn bộ thư mục project. - Mở file allowlist: đọc từng rule trong
.claude/settings.local.jsonvà xóa những rule chứa credential. - Rotate trước, dọn sau: secret đã lên public repo thì phải đổi, dù commit đã bị xóa.
- Cấu hình global gitignore: chỉ mất hai phút và có tác dụng trên mọi repo.
- Thêm gitleaks vào pre-commit và CI: hook bảo vệ máy của bạn, CI bảo vệ cả team.
- Tạo thói quen mới: lệnh có secret chỉ duyệt một lần, không bao giờ chọn "Don't Ask Again".
Nguyên tắc rút ra khá đơn giản: file nào do tool tự sinh ra cũng là code cần review. Đừng để sự tiện lợi của một nút bấm trở thành bản sao plaintext của production password.