DEFAULT_CONFIG ship agent_security.sandbox_pw = "" kể từ commit3827552, và cấu hình đưa tới dialog luôn được deep-merge với defaults đó. Nghĩa là trên mọi bản cài không đặt COWORK_SANDBOX_PASSWORD, mật khẩu đã lưu là chuỗi rỗng — và phép so `pw == self._sandbox_pw` nhận luôn ô nhập trống. Bấm Unlock với ô trống là mở được toàn bộ nhóm Sandbox Security. Commit3827552chỉ sửa config.py nên bỏ sót bản sao thứ hai của literal nằm trong ui/settings_dialog.py, và chính nó tạo ra lỗ hổng rỗng này. Sửa: - gỡ literal credential khỏi mã nguồn (nó vốn là code chết: deep-merge làm tham số mặc định của .get() không bao giờ chạy); - chặn rỗng trước khi so, theo đúng mẫu mà MS365 unlock đã dùng; - so sánh timing-safe trên BYTES, không trên str — secrets.compare_digest ném TypeError với str ngoài ASCII, mà app mặc định tiếng Việt và phục vụ khách Nhật nên mật khẩu có dấu là input bình thường; - chưa đặt mật khẩu thì báo đúng trạng thái đó, không báo "sai mật khẩu" — người dùng sẽ gõ lại mãi một thứ không tồn tại. Root cause: ui/settings_dialog.py:104 (bản cũ) Test: tests/ui/test_sandbox_unlock_security.py LƯU Ý CHO REVIEWER: literal cũ vẫn nằm trong 6 commit của Git history. Gỡ ở đây không gỡ khỏi lịch sử — cần xoay credential trên các máy còn giá trị đó trong config.json. Không rewrite history (SECURITY.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
9.2 KiB
Python
236 lines
9.2 KiB
Python
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
|
|
|
|
``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit
|
|
``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới
|
|
dialog LUÔN được deep-merge với defaults đó
|
|
(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài
|
|
không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so
|
|
sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống.
|
|
|
|
Ba nhóm bài ở đây:
|
|
|
|
* **đường tấn công** — chốt đúng lỗ trên;
|
|
* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu;
|
|
* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao
|
|
thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư
|
|
mục nguồn để lần sau không sót kiểu đó nữa.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Fake ctx dùng chung với đặc tả SettingsDialog: nó đã là "đủ cho SettingsDialog,
|
|
# không hơn". Dựng bản thứ hai ở đây chỉ tạo thêm một chỗ để lệch nhau.
|
|
from .test_settings_dialog_dac_ta import _Ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def shown(monkeypatch):
|
|
"""Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test).
|
|
|
|
Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt
|
|
"chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì
|
|
hai nhánh gộp lại làm một mà test vẫn xanh.
|
|
"""
|
|
from PySide6.QtWidgets import QMessageBox
|
|
calls: list[tuple[str, str, str]] = []
|
|
|
|
def _record(kind):
|
|
def _fn(_parent, title, text, *a, **k):
|
|
calls.append((kind, title, text))
|
|
return staticmethod(_fn)
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", _record("warning"))
|
|
monkeypatch.setattr(QMessageBox, "information", _record("information"))
|
|
return calls
|
|
|
|
|
|
def _dialog(stored_pw: str):
|
|
"""SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt."""
|
|
from cowork_local.ui.settings_dialog import SettingsDialog
|
|
ctx = _Ctx()
|
|
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
|
|
return SettingsDialog(ctx)
|
|
|
|
|
|
# ---- đường tấn công ------------------------------------------------------
|
|
|
|
def test_o_trong_khong_mo_duoc_khoa(qapp, shown):
|
|
"""Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá."""
|
|
dlg = _dialog("")
|
|
dlg.sandbox_pw_edit.setText("")
|
|
|
|
dlg._sandbox_unlock()
|
|
|
|
assert dlg._sandbox_unlocked is False
|
|
dlg.deleteLater()
|
|
|
|
|
|
def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown):
|
|
"""Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng."""
|
|
dlg = _dialog("")
|
|
dlg.sandbox_pw_edit.setText("bat ky")
|
|
|
|
dlg._sandbox_unlock()
|
|
|
|
assert dlg._sandbox_unlocked is False
|
|
dlg.deleteLater()
|
|
|
|
|
|
def test_mat_khau_sai_khong_mo_duoc(qapp, shown):
|
|
"""Đã đặt mật khẩu thì gõ sai vẫn khoá."""
|
|
dlg = _dialog("K7MNP2QRSTVW")
|
|
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX")
|
|
|
|
dlg._sandbox_unlock()
|
|
|
|
assert dlg._sandbox_unlocked is False
|
|
dlg.deleteLater()
|
|
|
|
|
|
# ---- thông báo phải phân biệt được hai tình huống -------------------------
|
|
|
|
def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown):
|
|
"""Hai nhánh phải nói hai chuyện khác nhau.
|
|
|
|
Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một
|
|
thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung
|
|
vẫn xanh hết.
|
|
"""
|
|
from cowork_local.i18n import tr
|
|
|
|
dlg = _dialog("")
|
|
dlg.sandbox_pw_edit.setText("")
|
|
dlg._sandbox_unlock()
|
|
chua_cau_hinh = list(shown)
|
|
dlg.deleteLater()
|
|
|
|
shown.clear()
|
|
dlg2 = _dialog("K7MNP2QRSTVW")
|
|
dlg2.sandbox_pw_edit.setText("sai roi")
|
|
dlg2._sandbox_unlock()
|
|
sai_mat_khau = list(shown)
|
|
dlg2.deleteLater()
|
|
|
|
assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo"
|
|
assert len(sai_mat_khau) == 1
|
|
assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body")
|
|
assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], (
|
|
"chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau")
|
|
|
|
|
|
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
|
|
|
|
def test_mat_khau_dung_van_mo_duoc(qapp, shown):
|
|
"""Bản vá không được phá đường đi hợp lệ."""
|
|
dlg = _dialog("K7MNP2QRSTVW")
|
|
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW")
|
|
|
|
dlg._sandbox_unlock()
|
|
|
|
assert dlg._sandbox_unlocked is True
|
|
dlg.deleteLater()
|
|
|
|
|
|
@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"])
|
|
def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw):
|
|
"""``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII.
|
|
|
|
App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật
|
|
khẩu là input bình thường. Phải so sánh trên bytes.
|
|
"""
|
|
dlg = _dialog(pw)
|
|
dlg.sandbox_pw_edit.setText(pw)
|
|
|
|
dlg._sandbox_unlock() # không được ném TypeError
|
|
|
|
assert dlg._sandbox_unlocked is True
|
|
dlg.deleteLater()
|
|
|
|
|
|
def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown):
|
|
"""Chữ có dấu không được biến thành đường mở khoá dễ dãi."""
|
|
dlg = _dialog("mật khẩu")
|
|
dlg.sandbox_pw_edit.setText("mat khau")
|
|
|
|
dlg._sandbox_unlock()
|
|
|
|
assert dlg._sandbox_unlocked is False
|
|
dlg.deleteLater()
|
|
|
|
|
|
# ---- hàm so khớp, gọi thẳng ----------------------------------------------
|
|
|
|
@pytest.mark.parametrize("entered,stored,expected", [
|
|
("", "", False), # cả hai rỗng
|
|
("", "K7MNP2QRSTVW", False), # ô nhập rỗng
|
|
("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ
|
|
("K7MNP2QRSTVW", "K7MNP2QRSTVW", True),
|
|
("mật khẩu", "mật khẩu", True), # ngoài ASCII
|
|
("mật khẩu", "mat khau", False),
|
|
])
|
|
def test_ham_so_khop(entered, stored, expected):
|
|
"""Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã
|
|
chặn trước bằng return sớm."""
|
|
from cowork_local.ui.settings_dialog import _sandbox_password_matches
|
|
assert _sandbox_password_matches(entered, stored) is expected
|
|
|
|
|
|
# ---- chặn cả lớp lỗi -----------------------------------------------------
|
|
|
|
#: ``.get("<khoá kiểu credential>", "<literal khác rỗng>")`` — mặc định trông có
|
|
#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code
|
|
#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại.
|
|
#:
|
|
#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira
|
|
#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây
|
|
#: chỉ gồm tên đã mang nghĩa bí mật.
|
|
_CREDENTIAL_FALLBACK = re.compile(
|
|
r'\.get\(\s*["\'][a-z_]*'
|
|
r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)'
|
|
r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']'
|
|
)
|
|
|
|
#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit
|
|
#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi
|
|
#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục.
|
|
_SCANNED = (
|
|
"ui", "presentation", "core", "infrastructure", "application", "domain",
|
|
"mcp_servers", "providers", "security", "theme", "config.py", "state.py",
|
|
)
|
|
|
|
|
|
def test_khong_con_fallback_credential_trong_ma_nguon():
|
|
"""Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``."""
|
|
root = Path(__file__).resolve().parents[2]
|
|
offenders = []
|
|
for name in _SCANNED:
|
|
target = root / name
|
|
if target.is_file():
|
|
files = [target]
|
|
elif target.is_dir():
|
|
files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts]
|
|
else: # thư mục bị đổi tên/xoá
|
|
continue
|
|
for path in files:
|
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
if _CREDENTIAL_FALLBACK.search(line):
|
|
offenders.append(
|
|
f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}")
|
|
|
|
assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders)
|
|
|
|
|
|
def test_phep_quet_thuc_su_nhin_thay_file():
|
|
"""Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh."""
|
|
root = Path(__file__).resolve().parents[2]
|
|
seen = sum(
|
|
1 for name in _SCANNED
|
|
for _ in ([root / name] if (root / name).is_file()
|
|
else (root / name).rglob("*.py") if (root / name).is_dir() else [])
|
|
)
|
|
assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng"
|