Sáu việc trong "mục chung" của bản phân công, làm trước khi ba nhánh tính năng tách ra. 1. Khung 5 tầng theo đúng đường dẫn plan.md: domain/ application/ infrastructure/ presentation/ platform/ + tests/fakes/ — 38 __init__.py. Trước đó là 0 file, mà mọi task của cả ba người đều ghi vào đây. Đã kiểm platform/ không che khuất module platform của stdlib. 2. Hợp đồng SecretStore và ConfigRepository (Protocol, chưa cài đặt) + fake chạy trong bộ nhớ. Danh sách thuộc tính không bịa: đếm 156 lời gọi ctx.config.* trong 29 file rồi lấy những cái dùng thật, xếp theo số lần. Cố ý bỏ config.data (36 lời gọi, nhiều nhất) — bê dict thô sang kiến trúc mới là bê nguyên vấn đề cũ. 3. tests/test_contracts.py — bài nghiệm thu, không phải test cho vui. Bài chính chạy tiến trình riêng và khẳng định dùng fake KHÔNG kéo theo cowork_local.config lẫn PySide6; đó là điều kiện để N2 và N3 code ngay hôm nay thay vì đợi bản thật ngày 23 và 26/08. 4. scripts/audit_security.py — CASAN Check 1, Gamma chủ trì (hạn 30/08). Viết sớm để kiểm liên tục trong lúc chuyển API key, không đợi tới ngày cổng. Lần chạy đầu ra 3 báo động giả (secret_in_output là tên quy tắc, api_key="x" là dữ liệu test) nên đã siết: ngưỡng độ dài, hằng liệt kê, hình dạng khoá i18n, và dấu "# casan: allow" làm lối thoát chuẩn. --self-test cắm 4 credential thật + 5 mẫu vô hại để chứng minh nó còn cắn được — một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó biết tìm. 5. Ba check CASAN vào CI, chạy mọi PR thay vì dồn tới 30/08. Check 2 và 3 thuộc Team Hoa và Team Duy, chưa có script — bước CI bỏ qua nếu file chưa tồn tại, để thêm cổng không làm đỏ CI của hai team kia. 6. docs/refactor/GammaTeam_decisions.md — hai quyết định chờ nhóm trưởng chốt: provider_conf() còn trả api_key hay không (ảnh hưởng 5 nơi, 3 nằm ngoài team), và số phận 24 checker UI sẽ vỡ khi file bị dời. 96 test xanh (90 cũ + 6 mới). CASAN Check 1: 0 credential lộ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
8.2 KiB
Python
205 lines
8.2 KiB
Python
"""CASAN Check 1 — không được có credential nào nằm phơi trong repo.
|
|
|
|
Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên
|
|
tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới
|
|
chạy lần đầu rồi mới biết còn sót.
|
|
|
|
Quét gì:
|
|
* file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env``
|
|
* mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret
|
|
|
|
Tìm hai loại:
|
|
1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…)
|
|
2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder
|
|
|
|
Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng
|
|
không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung).
|
|
|
|
Chạy: python scripts/audit_security.py [--json]
|
|
Mã thoát: 0 = sạch, 1 = có phát hiện.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build",
|
|
"dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"}
|
|
CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"}
|
|
|
|
# tên trường coi là nhạy cảm
|
|
SENSITIVE = re.compile(
|
|
r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|"
|
|
r"access[_-]?key|private[_-]?key|credential)", re.I)
|
|
|
|
# hình dạng credential thật — bắt được kể cả khi tên trường vô hại
|
|
SHAPES = [
|
|
("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")),
|
|
("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
|
("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")),
|
|
("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")),
|
|
("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
|
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")),
|
|
("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
]
|
|
|
|
#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng
|
|
#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm.
|
|
ALLOW_MARK = re.compile(r"#\s*casan:\s*allow")
|
|
|
|
#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng
|
|
#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ.
|
|
LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
|
|
|
#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test
|
|
#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo.
|
|
MIN_SECRET_LEN = 12
|
|
|
|
#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt…
|
|
ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off",
|
|
"true", "false", "yes", "no", "allow", "deny", "block", "ask",
|
|
"always", "never", "auto", "default", "disabled", "enabled"}
|
|
|
|
# giá trị vô hại — không tính là phát hiện
|
|
PLACEHOLDER = re.compile(
|
|
r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|"
|
|
r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|"
|
|
r"sk-\.\.\.|\.\.\.)$", re.I)
|
|
|
|
# gán chuỗi trong Python: api_key = "..."
|
|
PY_ASSIGN = re.compile(
|
|
r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*"""
|
|
r"""["']([^"']*)["']""", re.I)
|
|
|
|
|
|
def _is_placeholder(value: str) -> bool:
|
|
v = value.strip()
|
|
if PLACEHOLDER.match(v) or v.lower() in ENUMISH:
|
|
return True
|
|
if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output"
|
|
return True
|
|
# quá ngắn để là credential thật
|
|
return len(v) < MIN_SECRET_LEN
|
|
|
|
|
|
def _walk():
|
|
for path in REPO.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
if any(part in SKIP_DIRS for part in path.parts):
|
|
continue
|
|
if path.suffix in CONFIG_SUFFIX or path.suffix == ".py":
|
|
yield path
|
|
|
|
|
|
def scan() -> list[dict]:
|
|
findings: list[dict] = []
|
|
for path in _walk():
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
continue
|
|
rel = path.relative_to(REPO).as_posix()
|
|
|
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
if ALLOW_MARK.search(line):
|
|
continue
|
|
# 1. hình dạng credential thật
|
|
for label, pattern in SHAPES:
|
|
m = pattern.search(line)
|
|
if m:
|
|
findings.append({
|
|
"file": rel, "line": lineno, "kind": f"{label} credential",
|
|
"evidence": m.group(0)[:12] + "…",
|
|
})
|
|
|
|
# 2. trường nhạy cảm có giá trị
|
|
for m in PY_ASSIGN.finditer(line):
|
|
field, value = m.group(1), m.group(2)
|
|
if not SENSITIVE.search(field) or _is_placeholder(value):
|
|
continue
|
|
findings.append({
|
|
"file": rel, "line": lineno,
|
|
"kind": f"trường '{field}' có giá trị",
|
|
"evidence": value[:6] + "…" if len(value) > 6 else value,
|
|
})
|
|
return findings
|
|
|
|
|
|
def _self_test() -> int:
|
|
"""Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó
|
|
biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không."""
|
|
import tempfile
|
|
|
|
bad = {
|
|
"OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script
|
|
"GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script
|
|
"AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script
|
|
"Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script
|
|
}
|
|
ok = {
|
|
"rỗng": '"api_key": ""',
|
|
"placeholder": '"api_key": "your-key-here"',
|
|
"ollama": '"api_key": "ollama"',
|
|
"test ngắn": 'api_key = "x"',
|
|
"hằng liệt kê": '"secret_in_output": "warning"',
|
|
}
|
|
global REPO
|
|
keep = REPO
|
|
passed = True
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
REPO = Path(tmp)
|
|
for label, line in {**bad, **ok}.items():
|
|
(REPO / "probe.py").write_text(line + "\n", encoding="utf-8")
|
|
found = bool(scan())
|
|
want = label in bad
|
|
mark = "OK " if found == want else "SAI"
|
|
if found != want:
|
|
passed = False
|
|
verb = "bắt được" if found else "bỏ qua"
|
|
print(f" [{mark}] {label:14} -> {verb}")
|
|
REPO = keep
|
|
print()
|
|
print("Tự kiểm: " + ("script phân biệt đúng." if passed
|
|
else "*** script phân biệt SAI ***"))
|
|
return 0 if passed else 1
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ")
|
|
ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON")
|
|
ap.add_argument("--self-test", action="store_true",
|
|
help="cắm credential giả vào file tạm, kiểm script có bắt được")
|
|
args = ap.parse_args()
|
|
|
|
if args.self_test:
|
|
return _self_test()
|
|
|
|
findings = scan()
|
|
if args.json:
|
|
print(json.dumps(findings, ensure_ascii=False, indent=2))
|
|
else:
|
|
n_files = sum(1 for _ in _walk())
|
|
print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/")
|
|
if not findings:
|
|
print("\n0 credential lưu plaintext. PASS.")
|
|
else:
|
|
print(f"\n*** {len(findings)} phát hiện ***\n")
|
|
for f in findings:
|
|
print(f" {f['file']}:{f['line']}")
|
|
print(f" {f['kind']} — {f['evidence']}")
|
|
return 1 if findings else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|