Your AI Agent's Allowlist Is a Secret Store: Find and Fix Credentials Leaked Through Agent Config Files
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
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to