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:
+111
-6
@@ -4,20 +4,33 @@
|
||||
用法:
|
||||
python scripts/self_update.py --check 检查更新(24 小时缓存,重复检查不拉网络)
|
||||
python scripts/self_update.py --notify 只读缓存打印提示,绝不碰网络(供 cast_chart 调用)
|
||||
python scripts/self_update.py --apply 下载 zipball → 按 manifest 逐文件校验 → 同步本地
|
||||
python scripts/self_update.py --apply 下载 zipball → 验签 → 按 manifest 逐文件校验 → 同步本地
|
||||
|
||||
退出码(--apply):0 成功;2 无需更新或配置缺失;3 失败(本地原样不动)。
|
||||
|
||||
信任模型(三道防线,各防一层):
|
||||
1. 签名信任锚:manifest.json 附带 ed25519 签名 manifest.json.sig,
|
||||
公钥内置于本文件(UPDATE_PUBLIC_KEY_HEX),私钥仅作者持有
|
||||
(~/.zipec/signing.key,不进任何仓库)。仓库(GitHub/Gitea)或
|
||||
下载链路被攻破时,攻击者改 manifest 即验签失败——防「源头投毒」。
|
||||
2. 双源交叉验证:--apply 从 GitHub 与 Gitea 各拉一份 manifest.json,
|
||||
与 zip 内字节逐一比对,任一不一致即中止——防「单一源被篡改/
|
||||
劫持后单独口径」。
|
||||
3. 逐文件 SHA256:manifest 通过前两关后,逐文件核对清单哈希——
|
||||
防「zip 内文件与清单不符」(传输损坏、打包错误、部分篡改)。
|
||||
manifest.json.sig 本身不进 manifest 清单(自指),随仓库提交分发。
|
||||
|
||||
远端双线路 failover:GitHub 优先,Gitea 兜底。HTTPS_PROXY/HTTP_PROXY 等
|
||||
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
|
||||
支持 file:// 形式。
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS(逗号分隔)
|
||||
整体覆盖远端地址,支持 file:// 形式。
|
||||
|
||||
抓取通道:urllib 优先;macOS 框架版 Python 常见的根证书缺失
|
||||
(SSL CERTIFICATE_VERIFY_FAILED)时自动回退到系统 curl(curl 自带
|
||||
系统信任库且自带代理环境变量支持)。
|
||||
系统信任库且自带代理环境变量支持)。manifest 与 .sig 的抓取同走此通道。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -34,12 +47,21 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _update_sig # 纯 Python ed25519(RFC 8032),零依赖验签
|
||||
|
||||
# ---- 远端配置(GitHub 优先,Gitea 兜底)----
|
||||
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
|
||||
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
|
||||
GITHUB_MANIFEST_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/manifest.json"
|
||||
GITEA_VERSION_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/VERSION"
|
||||
GITEA_ZIPBALL_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/archive/main.zip"
|
||||
GITEA_MANIFEST_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/manifest.json"
|
||||
|
||||
# 更新签名信任锚:ed25519 公钥(32 字节,十六进制),作者 2026-10-10 生成。
|
||||
# 指纹 SHA256(公钥) = 3089bc1cc9a453e5be66a36574635da6b2ad9797c57af11164111faca157411c
|
||||
# 用途:--apply 校验 manifest.json.sig。私钥仅作者持有(~/.zipec/signing.key),
|
||||
# 不进任何仓库;公钥更换 = 信任锚更换,须随版本说明明确公告。
|
||||
UPDATE_PUBLIC_KEY_HEX = "49103c13cad0d9c67207945b4e4f6ff81d1261339eb0de4a60c2a1cf949d7852"
|
||||
|
||||
CACHE_REL = "data/.update_check.json"
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
@@ -71,6 +93,14 @@ def _zipball_urls() -> list:
|
||||
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
|
||||
|
||||
|
||||
def _manifest_urls() -> list:
|
||||
"""双源交叉验证用:各源的 manifest.json 直链(.sig 直链 = 本地址 + '.sig')。"""
|
||||
env = os.environ.get("ZIPEC_MANIFEST_URLS")
|
||||
if env:
|
||||
return [u.strip() for u in env.split(",") if u.strip()]
|
||||
return [GITHUB_MANIFEST_URL, GITEA_MANIFEST_URL]
|
||||
|
||||
|
||||
def _fetch_bytes(url: str, timeout: int) -> bytes:
|
||||
"""urllib 优先;SSL 证书缺失等失败时回退系统 curl(macOS 框架版 Python 常见)。"""
|
||||
try:
|
||||
@@ -288,8 +318,58 @@ def _verify_manifest(src: Path, files: dict):
|
||||
return errors
|
||||
|
||||
|
||||
def _cross_check_manifest(manifest_bytes: bytes):
|
||||
"""从全部配置源各拉一份 manifest.json,与 zip 内字节逐一比对。
|
||||
|
||||
返回 None = 各源与 zip 完全一致;否则返回错误信息(任一源不可达也
|
||||
视为失败:无法交叉确认时宁可中止)。
|
||||
"""
|
||||
urls = _manifest_urls()
|
||||
if not urls:
|
||||
return "未配置任何 manifest 源"
|
||||
for url in urls:
|
||||
try:
|
||||
data = _fetch_bytes(url, NETWORK_TIMEOUT)
|
||||
except Exception as e:
|
||||
return f"{url} 不可达({e}),无法完成双源交叉验证"
|
||||
if data != manifest_bytes:
|
||||
return f"{url} 的 manifest.json 与下载包内字节不一致,两源口径分歧"
|
||||
return None
|
||||
|
||||
|
||||
def _load_manifest_sig(src: Path):
|
||||
"""优先取 zip 内 manifest.json.sig;缺失则按 manifest 源单独拉 .sig(curl 回退同路)。
|
||||
|
||||
返回签名文本(base64)或 None。
|
||||
"""
|
||||
p = src / "manifest.json.sig"
|
||||
if p.is_file():
|
||||
try:
|
||||
return p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for url in _manifest_urls():
|
||||
try:
|
||||
return _fetch_text(url + ".sig")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _signature_valid(manifest_bytes: bytes, sig_text: str) -> bool:
|
||||
try:
|
||||
sig = base64.b64decode(sig_text.strip(), validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
pk = bytes.fromhex(UPDATE_PUBLIC_KEY_HEX)
|
||||
except ValueError:
|
||||
return False
|
||||
return _update_sig.verify(pk, manifest_bytes, sig)
|
||||
|
||||
|
||||
def _sync(root: Path, src: Path, files: dict) -> None:
|
||||
managed = set(files) | {"manifest.json", "VERSION"}
|
||||
managed = set(files) | {"manifest.json", "manifest.json.sig", "VERSION"}
|
||||
copied = deleted = 0
|
||||
for rel in sorted(managed - {"VERSION"}): # VERSION 最后写
|
||||
s = src / rel
|
||||
@@ -364,7 +444,32 @@ def cmd_apply(root: Path) -> int:
|
||||
print("失败:远端包内找不到 manifest.json(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
try:
|
||||
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest_bytes = (src / "manifest.json").read_bytes()
|
||||
except OSError as e:
|
||||
print(f"失败:manifest.json 读取异常({e})(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
# 防线 1:双源交叉验证(各源 manifest 与 zip 内字节一致)
|
||||
err = _cross_check_manifest(manifest_bytes)
|
||||
if err:
|
||||
print(f"失败:{err},已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
print(f"双源交叉验证通过({len(_manifest_urls())} 个源口径一致)")
|
||||
|
||||
# 防线 2:内置公钥验签 manifest(缺 sig 视同验签失败)
|
||||
sig_text = _load_manifest_sig(src)
|
||||
if sig_text is None:
|
||||
print("失败:包未带 manifest.json.sig 签名,可能为旧版或被篡改,"
|
||||
"已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
if not _signature_valid(manifest_bytes, sig_text):
|
||||
print("失败:manifest.json 签名校验不符,包可能被篡改,"
|
||||
"已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
print("manifest 签名校验通过")
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
files = manifest["files"]
|
||||
except Exception as e:
|
||||
print(f"失败:manifest.json 无法解析({e})(本地未改动)", file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user