v3.6.1 安全修复同步发版
- 盘面图 XSS 防护:装配层全面转义用户可控字段 + 模板严格 CSP(default-src 'none',无 script-src)+ 6 项注入向量回归测试 - 自更新签名信任锚:--apply 双源 manifest 交叉验证 → ed25519 验签(纯 Python 实现,缺 sig 即拒)→ 逐文件 SHA256,全 fail-closed - sync 工具同步后自动重建 manifest + 签名 + 独立验签,私钥缺失 fail-closed - 测试 170 项全绿
This commit is contained in:
+160
-20
@@ -1,8 +1,11 @@
|
||||
"""自更新:版本比较、24h 缓存、manifest 校验拦截、apply 同步与保留区、退出码。
|
||||
|
||||
远端用本地临时目录伪造:VERSION / zipball 均以 file:// 地址经
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS 注入,不碰真实网络。
|
||||
远端用本地临时目录伪造:VERSION / zipball / manifest 直链均以 file://
|
||||
地址经 ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS
|
||||
注入,不碰真实网络。manifest 一律用测试密钥签名,UPDATE_PUBLIC_KEY_HEX
|
||||
由 autouse fixture 替换为测试公钥。
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
@@ -14,8 +17,19 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import _update_sig
|
||||
import self_update as su
|
||||
|
||||
# 测试专用密钥(与正式私钥无关)
|
||||
TEST_SEED = bytes(range(32))
|
||||
TEST_PK_HEX = _update_sig.publickey(TEST_SEED).hex()
|
||||
OTHER_SEED = bytes(range(32, 64))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_public_key(monkeypatch):
|
||||
monkeypatch.setattr(su, "UPDATE_PUBLIC_KEY_HEX", TEST_PK_HEX)
|
||||
|
||||
|
||||
# ---- 造本地副本与伪造远端 ----
|
||||
|
||||
@@ -38,10 +52,19 @@ def make_local(tmp_path, version="3.5.0"):
|
||||
return root
|
||||
|
||||
|
||||
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
|
||||
"""造远端 zipball(带一层顶层目录,模拟 GitHub codeload 结构)+ 远端 VERSION。
|
||||
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None,
|
||||
sign_seed=TEST_SEED, zip_sig=True, sig_message=None,
|
||||
diverge_source=False):
|
||||
"""造远端:zipball(带一层顶层目录,模拟 GitHub codeload)+ 远端 VERSION
|
||||
+ 两个"源"目录(src_a/src_b,各存 manifest.json 与 manifest.json.sig
|
||||
直链文件,供双源交叉验证)。
|
||||
|
||||
tamper=<相对路径>:先按原内容算清单哈希,再篡改该文件,用于触发校验拦截。
|
||||
tamper=<相对路径>:先按原内容算清单哈希并签名,再篡改该文件 → 触发逐文件
|
||||
SHA256 拦截。sign_seed 控制签名密钥(换 OTHER_SEED 模拟错误私钥)。
|
||||
zip_sig=False:zip 内不放 manifest.json.sig(源目录仍放,可测单独拉取;
|
||||
配合 source_sig=False 则彻底无签名)。sig_message:改签别的内容,
|
||||
模拟 manifest 与签名不符。diverge_source=True:src_b 的 manifest 改一字节,
|
||||
模拟两源不一致。
|
||||
"""
|
||||
src = tmp_path / "zipcontent" / "ZiPingEightChar-main"
|
||||
src.mkdir(parents=True)
|
||||
@@ -54,25 +77,43 @@ def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
|
||||
rel: hashlib.sha256((src / rel).read_bytes()).hexdigest()
|
||||
for rel in all_files
|
||||
}
|
||||
(src / "manifest.json").write_text(
|
||||
json.dumps({"version": version, "files": hashes}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_bytes = (json.dumps({"version": version, "files": hashes}, indent=2)
|
||||
+ "\n").encode("utf-8")
|
||||
(src / "manifest.json").write_bytes(manifest_bytes)
|
||||
sig_b64 = base64.b64encode(
|
||||
_update_sig.sign(sign_seed, sig_message if sig_message is not None
|
||||
else manifest_bytes)).decode("ascii") + "\n"
|
||||
if tamper:
|
||||
(src / tamper).write_text("# tampered\n", encoding="utf-8")
|
||||
if zip_sig:
|
||||
(src / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
|
||||
zip_path = tmp_path / "remote.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in sorted(src.parent.rglob("*")):
|
||||
zf.write(f, f.relative_to(src.parent))
|
||||
# 双源直链文件
|
||||
source_dirs = []
|
||||
for name in ("src_a", "src_b"):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
m = manifest_bytes
|
||||
if diverge_source and name == "src_b":
|
||||
m = m.replace(b'"files"', b'"filez"', 1)
|
||||
(d / "manifest.json").write_bytes(m)
|
||||
(d / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
|
||||
source_dirs.append(d)
|
||||
version_file = tmp_path / "REMOTE_VERSION"
|
||||
version_file.write_text(version + "\n", encoding="utf-8")
|
||||
return version_file, zip_path
|
||||
return version_file, zip_path, source_dirs
|
||||
|
||||
|
||||
def point_at_remote(monkeypatch, version_file, zip_path=None):
|
||||
def point_at_remote(monkeypatch, version_file, zip_path=None, source_dirs=None):
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", version_file.as_uri())
|
||||
if zip_path is not None:
|
||||
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zip_path.as_uri())
|
||||
if source_dirs is not None:
|
||||
monkeypatch.setenv("ZIPEC_MANIFEST_URLS", ",".join(
|
||||
(d / "manifest.json").as_uri() for d in source_dirs))
|
||||
|
||||
|
||||
REMOTE_FILES = {
|
||||
@@ -91,7 +132,7 @@ class TestVersionCompare:
|
||||
|
||||
def test_newer_remote_marks_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path)
|
||||
vf, _, _ = make_remote(tmp_path)
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
@@ -100,7 +141,7 @@ class TestVersionCompare:
|
||||
|
||||
def test_same_version_no_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path, version="3.5.0")
|
||||
vf, _, _ = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
@@ -167,15 +208,17 @@ class TestCacheFreshness:
|
||||
class TestApply:
|
||||
def test_success_syncs_and_preserves(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 0
|
||||
assert "更新完成" in capsys.readouterr().out
|
||||
out = capsys.readouterr().out
|
||||
assert "交叉验证通过" in out and "签名校验通过" in out and "更新完成" in out
|
||||
# 清单内文件被覆盖/新增
|
||||
assert (root / "VERSION").read_text().strip() == "3.6.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# new\n"
|
||||
assert (root / "scripts" / "new_feature.py").exists()
|
||||
assert (root / "manifest.json").exists()
|
||||
assert (root / "manifest.json.sig").exists() # 签名随包同步到本地
|
||||
# 清单不含的本地多余文件被清理
|
||||
assert not (root / "stale_legacy.py").exists()
|
||||
assert not (root / "legacy").exists() # 清空后的空目录一并移除
|
||||
@@ -187,9 +230,9 @@ class TestApply:
|
||||
|
||||
def test_tampered_file_aborts_untouched(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
tamper="scripts/cast_chart.py")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
tamper="scripts/cast_chart.py")
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "不符" in capsys.readouterr().err
|
||||
# 本地原样不动
|
||||
@@ -200,7 +243,7 @@ class TestApply:
|
||||
|
||||
def test_already_latest_exit_2(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, version="3.5.0")
|
||||
vf, zp, _ = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
assert su.cmd_apply(root) == 2
|
||||
assert "无需更新" in capsys.readouterr().out
|
||||
@@ -218,6 +261,103 @@ class TestApply:
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
|
||||
|
||||
class TestApplySigning:
|
||||
"""--apply 的签名信任锚与双源交叉验证。"""
|
||||
|
||||
def _untouched(self, root):
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# old\n"
|
||||
assert (root / "stale_legacy.py").exists()
|
||||
assert not (root / "scripts" / "new_feature.py").exists()
|
||||
assert not (root / "manifest.json.sig").exists()
|
||||
|
||||
def test_sig_fetched_separately_when_not_in_zip(self, tmp_path, monkeypatch):
|
||||
"""zip 内无 sig、但源直链有:单独拉取并验签通过。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 0
|
||||
assert (root / "VERSION").read_text().strip() == "3.6.0"
|
||||
|
||||
def test_missing_sig_everywhere_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""zip 与源直链都没有 sig → 视同为旧版或被篡改,拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
|
||||
for d in dirs:
|
||||
(d / "manifest.json.sig").unlink()
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "未带" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_sig_message_mismatch_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""签名签的是别的内容(manifest 被换)→ 验签拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
sig_message=b"forged manifest")
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_wrong_signing_key_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
sign_seed=OTHER_SEED)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_manifest_one_byte_flip_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""manifest 改一字节(源直链同步改、签名照旧)→ 验签拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
for d in dirs: # 两源与 zip 保持一致地改,隔离出"验签"这一道
|
||||
m = d / "manifest.json"
|
||||
data = bytearray(m.read_bytes())
|
||||
data[20] ^= 0x01
|
||||
m.write_bytes(bytes(data))
|
||||
# zip 内 manifest 也同步改
|
||||
import zipfile as zf_mod
|
||||
extract = tmp_path / "repack"
|
||||
with zf_mod.ZipFile(zp) as z:
|
||||
z.extractall(extract)
|
||||
m = extract / "ZiPingEightChar-main" / "manifest.json"
|
||||
data = bytearray(m.read_bytes())
|
||||
data[20] ^= 0x01
|
||||
m.write_bytes(bytes(data))
|
||||
with zf_mod.ZipFile(zp, "w") as z:
|
||||
for f in sorted(extract.rglob("*")):
|
||||
z.write(f, f.relative_to(extract))
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_divergent_sources_abort(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
diverge_source=True)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "不一致" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_source_unreachable_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""交叉验证时一个源不可达 → 宁可中止。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", vf.as_uri())
|
||||
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zp.as_uri())
|
||||
monkeypatch.setenv("ZIPEC_MANIFEST_URLS",
|
||||
(dirs[0] / "manifest.json").as_uri()
|
||||
+ ",file:///nonexistent/manifest.json")
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "交叉验证" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
|
||||
class TestCastChartIntegration:
|
||||
def test_notice_on_stderr_stdout_clean(self):
|
||||
"""排盘成功后 stderr 出一行提示;stdout 命盘契约不受影响。"""
|
||||
|
||||
Reference in New Issue
Block a user