From b6b4c7b4c705885f6a25ab011cccd78099aab853 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 11:16:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(security):=20wheel=20=E6=BC=8F=E6=89=93?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E6=B8=85=E5=8D=95=E4=B8=89=E4=BB=B6?= =?UTF-8?q?=E5=A5=97=EF=BC=8C=E4=B8=94=20enforce=20=E5=BC=80=E7=9D=80?= =?UTF-8?q?=E6=B2=A1=E6=B8=85=E5=8D=95=E6=97=B6=E9=9D=99=E9=BB=98=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发完 v2.2.2 后按"产物也要回读"解开 wheel 核对,发现 integrated_app/security/ 里只有 .py: integrity_manifest.json、清单的 .sig.ed25519、验签公钥 manifest_signing_public_key.pem 三件都没进包。而 config.yaml 默认就是 security.integrity_selfcheck.enforce: true, `run_startup_selfcheck()` 在"清单不存在"分支只 logger.info("跳过自检") 就返回 skipped=16 / manifest_signed=False,enforce 也不拦 —— 于是**纯 pip 安装那条部署路径上 P0 核心模块完整性保护一条都没执行,而配置声称它在强制运行**。 Docker 与便携包不受影响(两者另外拷了源码树,容器启动探测因此一直绿,把这个缺口遮住了)。 两处一起改,缺一半都不对: - pyproject 的 [tool.setuptools.package-data] 逐条点名三件套(只改下面那条会把 pip 路径变成起不来); - integrity_selfcheck:enforce 开着却没清单 → RuntimeError 拒绝启动,报错给三条出路 (源码检出生成清单 / 装出来的包是 wheel 漏打 / 确要关保护就显式写 enforce=false)。 非强制模式保持原语义(返回 skipped、不抛)。 原注释"清单缺失仍跳过不阻断(避免误伤首次部署)"的判断被推翻:enforce=true 是默认值, 所以"没有清单"从来不是首次部署的状态,而是分发产物坏了。 验收不靠"配置看起来对"(setuptools 行为一变,声明对了产物也可能没有): - 本机 python -m build 前后对比:包内条目 1062 → 1065,三件逐条 OK; - 把 wheel 解到临时目录当安装环境真跑:有清单时 enforce=True 返回 16/16/0/signed=True; 挪走清单则拒绝启动,enforce=False 仍 skipped=16; - CI 的 Build (sdist/wheel) 作业加一步产物核对(缺失即 exit 1); - tests/test_integrity_selfcheck_packaging.py 共 8 条,含两条反空验证: ①仓库自带三件套 → enforce=True 在源码检出下必须真跑完 16 个模块且 0 失败; ②CI 若不再核对 wheel 就红(防止只查声明的假安全感)。 清单:integrity_selfcheck.py 属 16 个被签模块,已重算 + Ed25519 重签 (--verify PASS、check_integrity_manifest_sync 16/16)。 本地门禁:全量 2112 passed / 111 skipped / 0 failed(首轮那 1 条是 test_circuit_breaker::test_half_open_failure_reopens 的计时 flake:reset_timeout=0.01 + sleep(0.02) 靠挂钟,隔离重跑 3/3 通过、整轮复跑 0 failed),mypy 棘轮 103 不变, verify_cloud_native 全绿,check_spec_refs new=0。 Signed-off-by: ReSerendipity --- .github/workflows/ci.yml | 25 +++++ .../security/integrity_manifest.json | 2 +- .../integrity_manifest.json.sig.ed25519 | 3 +- .../security/integrity_selfcheck.py | 17 +++- pyproject.toml | 14 ++- tests/test_integrity_selfcheck_packaging.py | 97 +++++++++++++++++++ 6 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/test_integrity_selfcheck_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e52cbce..44efba10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,31 @@ jobs: # 2026-08-31 治理修复(P1-1):移除 `|| true`,元数据校验失败应真实阻断 run: twine check dist/* + - name: 核对 wheel 里有完整性自检三件套 + # 为什么单独查产物:`config.yaml` 默认 enforce=true,而 wheel 若漏打清单/签名/公钥, + # 装出来的环境里 P0 完整性保护会**一声不响地不运行**(v2.2.2 的 wheel 实测就是这样, + # 详见 app/integrated_app/security/integrity_selfcheck.py 的分支注释)。 + # 光有 pyproject 的 package-data 声明不算数 —— setuptools 行为一变就谎报了。 + run: | + set -eu + python - <<'PY' + import glob, sys, zipfile + whl = glob.glob("dist/*.whl")[0] + names = set(zipfile.ZipFile(whl).namelist()) + need = [ + "integrated_app/security/integrity_manifest.json", + "integrated_app/security/integrity_manifest.json.sig.ed25519", + "integrated_app/security/manifest_signing_public_key.pem", + ] + miss = [n for n in need if n not in names] + for n in need: + print(("OK " if n in names else "MISS ") + n) + if miss: + print("::error::wheel 缺以上文件 → 安装后 enforce 模式的完整性自检不会运行") + sys.exit(1) + print(f"[wheel-integrity] {whl} 含完整性三件套(包内 {len(names)} 项)") + PY + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/app/integrated_app/security/integrity_manifest.json b/app/integrated_app/security/integrity_manifest.json index 89ddc809..1a23de3c 100644 --- a/app/integrated_app/security/integrity_manifest.json +++ b/app/integrated_app/security/integrity_manifest.json @@ -16,7 +16,7 @@ "middleware/rate_limit.py": "9fe4beb2e8cada5d6b5404e0d028b2155013dde55fe03463324235d07824fe60", "middleware/request_id.py": "b0196972a30e77cc80497e12ef0e668964a62191835981d2f77ac4c92590d335", "security/integrity_check.py": "df788a55f11cab03919d434a31d1e5709c03bd4ce74fc6e58999d4f342e668c3", - "security/integrity_selfcheck.py": "bcb565f2cb4c2ab9a295ebbfbbb4d12bd65348995736a456edeb9e0c4f8a18a9", + "security/integrity_selfcheck.py": "c1cfb634430d36e733d3896d2121794283dd5c70a36ff338f53dd35e6ee04246", "security/secret_key.py": "eabd5a6f730cd47d7faa498d958e3a10fa14d6400b0d1123378814c0e1792eb1" } } diff --git a/app/integrated_app/security/integrity_manifest.json.sig.ed25519 b/app/integrated_app/security/integrity_manifest.json.sig.ed25519 index d36cc6fb..9835a772 100644 --- a/app/integrated_app/security/integrity_manifest.json.sig.ed25519 +++ b/app/integrated_app/security/integrity_manifest.json.sig.ed25519 @@ -1,2 +1 @@ -g`MÔÙ{‚Wh#³µAÍù/әXŽ%²$°‰ͨУ -f 8N ½G%U(v�“Œûêú`ËêÑR‹bêÑXã \ No newline at end of file +‘E>*¥ß ŸVÀ‰Ų##ÊÚÉf¨wìj¨�]d6Nó"z“áL£9ßPŠÀV;� Œ%Øþ!ñuí ´ü \ No newline at end of file diff --git a/app/integrated_app/security/integrity_selfcheck.py b/app/integrated_app/security/integrity_selfcheck.py index d4f9cab0..d45441ac 100644 --- a/app/integrated_app/security/integrity_selfcheck.py +++ b/app/integrated_app/security/integrity_selfcheck.py @@ -151,19 +151,32 @@ def run_startup_selfcheck(enforce: bool = False) -> dict: Args: enforce: True 时校验失败抛出 RuntimeError 阻断启动(fail-fast); - 清单缺失仍跳过不阻断(避免误伤首次部署)。 + **清单缺失在 enforce=True 下同样是失败**(见下)。 Returns: dict: 包含 total/passed/failed/skipped/failed_files/manifest_signed 字段。 Raises: - RuntimeError: enforce=True 且存在校验失败的文件或清单签名无效。 + RuntimeError: enforce=True 且存在校验失败的文件、清单签名无效,或清单根本不存在。 """ manifest_path = _get_manifest_path() app_dir = Path(__file__).parent.parent # app/integrated_app/ # 读取清单 if not manifest_path.exists(): + if enforce: + # 口径变更(2026-09-22):原先这里"清单缺失也照旧跳过",注释写的是"避免误伤首次部署"。 + # 但 config.yaml 默认就是 enforce=true,而 v2.2.2 的 wheel 实测**没把清单打进包** + # (security/ 里只有 .py)—— 结果配置声明"强制校验",实际一条都没跑,日志里只有 + # 一行 info。"enforce 开着却没有清单"不是首次部署,是分发产物坏了,必须响。 + raise RuntimeError( + f"完整性清单不存在({manifest_path}),但 enforce 已开启 —— 拒绝以「无校验模式」启动。" + " 若这是源码检出:运行 `python scripts/generate_integrity_manifest.py` 生成清单" + "(有私钥时再跑 `python scripts/sign_integrity_manifest.py` 签名);" + " 若这是 pip 安装出来的包:说明 wheel 漏打了清单/签名/公钥," + " 见 pyproject 的 [tool.setuptools.package-data] 与 tests/test_integrity_selfcheck_packaging.py;" + " 确要关闭这道保护:显式设 security.integrity_selfcheck.enforce=false,别让它静默降级。" + ) logger.info( "[SELF-CHECK] 完整性清单不存在,跳过自检。" " 运行 `python scripts/generate_integrity_manifest.py` 生成清单以启用启动自检。" diff --git a/pyproject.toml b/pyproject.toml index b482daa2..82e5518d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,7 +155,19 @@ where = ["app"] include = ["integrated_app*", "integrated_app.vendor*"] [tool.setuptools.package-data] -"integrated_app" = ["templates/**/*", "static/**/*", "locales/*.json"] +"integrated_app" = [ + "templates/**/*", + "static/**/*", + "locales/*.json", + # 完整性自检的三件套必须随包走:清单、清单的 Ed25519 签名、验签公钥。 + # 少任何一件,装出来的环境里 `integrity_selfcheck.py` 就走"清单不存在 → 跳过自检"分支, + # 而 config.yaml 默认 `security.integrity_selfcheck.enforce: true` —— 于是配置说自己 + # 在强制校验,实际一条都没校验。2026-09-22 核对 v2.2.2 的 wheel 时实测到此项缺失 + # (security/ 目录里只打进 .py)。现在缺清单会在 enforce 下直接拒绝启动,见该文件的自证。 + "security/integrity_manifest.json", + "security/integrity_manifest.json.sig.ed25519", + "security/manifest_signing_public_key.pem", +] [tool.ruff] target-version = "py310" diff --git a/tests/test_integrity_selfcheck_packaging.py b/tests/test_integrity_selfcheck_packaging.py new file mode 100644 index 00000000..0f163b89 --- /dev/null +++ b/tests/test_integrity_selfcheck_packaging.py @@ -0,0 +1,97 @@ +"""完整性清单在**打包**与 **enforce 语义**上的两条守卫(2026-09-22 由 v2.2.2 的产物核对引出)。 + +背景:`config.yaml` 默认 `security.integrity_selfcheck.enforce: true`,而 v2.2.2 的 wheel 里 +`integrated_app/security/` 只有 `.py` —— 清单、Ed25519 签名、验签公钥三件都没打进包。 +当时 `run_startup_selfcheck()` 在"清单不存在"分支只 `logger.info("跳过自检")` 就返回, +于是**纯 pip 安装的那条部署路径上 P0 完整性保护一条都没执行,而配置声称它在强制运行**。 + +本文件守住修法两侧: +- 行为侧:enforce 开着却没清单 → 拒绝启动(不许静默降级);enforce 关着仍按跳过处理; +- 产物侧:`[tool.setuptools.package-data]` 必须逐条点名那三件,且 CI 的构建作业必须真的 + 去解开 wheel 核对(只查配置不查产物,setuptools 行为变了也不会红)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from integrated_app.security import integrity_selfcheck as isc +from integrated_app.security.integrity_selfcheck import _CORE_MODULES, run_startup_selfcheck + +_ROOT = Path(__file__).resolve().parent.parent +_PYPROJECT = _ROOT / "pyproject.toml" +_CI_WF = _ROOT / ".github" / "workflows" / "ci.yml" + +#: 必须随包分发的完整性三件套(相对 integrated_app/) +INTEGRITY_FILES = ( + "security/integrity_manifest.json", + "security/integrity_manifest.json.sig.ed25519", + "security/manifest_signing_public_key.pem", +) + + +def _package_data_patterns() -> list[str]: + """取 [tool.setuptools.package-data] 里 integrated_app 那一串的条目(不依赖 tomllib,CI 有 3.10)。""" + text = _PYPROJECT.read_text(encoding="utf-8") + m = re.search(r"^\[tool\.setuptools\.package-data\]\s*$(.*?)(?=^\[|\Z)", text, re.M | re.S) + assert m, "pyproject.toml 里没有 [tool.setuptools.package-data] 段" + block = m.group(1) + key = re.search(r'"integrated_app"\s*=\s*\[(.*?)\]', block, re.S) + assert key, "package-data 里没有 integrated_app 这个键(templates/static 会一起丢)" + return re.findall(r'"([^"]+)"', key.group(1)) + + +class TestMissingManifestUnderEnforce: + def test_enforce_refuses_to_start_without_the_manifest(self, tmp_path, monkeypatch) -> None: + missing = tmp_path / "integrity_manifest.json" + monkeypatch.setattr(isc, "_get_manifest_path", lambda: missing) + with pytest.raises(RuntimeError) as exc: + run_startup_selfcheck(enforce=True) + msg = str(exc.value) + assert "拒绝以「无校验模式」启动" in msg, msg + # 报错必须给得出路,而不只是"失败了" + assert "generate_integrity_manifest.py" in msg, "没告诉源码检出的人怎么补清单" + assert "package-data" in msg, "没告诉装包的人这是 wheel 漏打文件" + assert "enforce=false" in msg, "没说明要关闭保护必须显式改配置" + + def test_without_enforce_it_still_skips(self, tmp_path, monkeypatch) -> None: + """非强制模式保持原语义:跳过并返回 skipped,不抛。""" + monkeypatch.setattr(isc, "_get_manifest_path", lambda: tmp_path / "nope.json") + out = run_startup_selfcheck(enforce=False) + assert out["skipped"] == len(_CORE_MODULES) + assert out["manifest_signed"] is False + + def test_repository_checkout_has_the_trio_so_enforce_passes(self) -> None: + """防空转:上面那条"没清单就拒启动"必须只在**真没清单**时触发。 + + 仓库自带三件套,所以 enforce=True 在源码检出下应正常跑完 16 个模块且 0 失败。 + 若这条红了,说明清单/签名与代码不同步(GOTCHAS #77/#142 那一族),不是本测试太严。 + """ + out = run_startup_selfcheck(enforce=True) + assert out["total"] == len(_CORE_MODULES), out + assert out["failed"] == 0, f"清单与当前代码不同步:{out['failed_files']}" + + +class TestIntegrityFilesArePackaged: + @pytest.mark.parametrize("rel", INTEGRITY_FILES) + def test_named_explicitly_in_package_data(self, rel: str) -> None: + pats = _package_data_patterns() + assert rel in pats, f"package-data 没点名 {rel}(当前条目:{pats})→ 装出来的包里没有它" + + @pytest.mark.parametrize("rel", INTEGRITY_FILES) + def test_the_file_actually_exists_in_tree(self, rel: str) -> None: + path = _ROOT / "app" / "integrated_app" / rel + assert path.is_file(), f"{rel} 在 package-data 里被点名,但仓库里没有这个文件" + + def test_ci_builds_and_inspects_the_wheel(self) -> None: + """只查配置不够:setuptools 的打包行为变了,配置看着对、产物里却没有(本次就是这么发现的)。""" + text = _CI_WF.read_text(encoding="utf-8") + assert "python -m build" in text, "CI 不再构建 wheel,这条守卫就失去了对象" + for rel in INTEGRITY_FILES: + assert rel in text, f"CI 的构建作业没有核对 wheel 里的 {rel}" + # 反空验证:判据不是恒真 —— 把清单文件名从 workflow 里去掉后必须不再匹配 + mutated = text.replace(INTEGRITY_FILES[0], "") + assert INTEGRITY_FILES[0] not in mutated From 22f9471a681862470038a9d646ab72fc3033ed2b Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 11:19:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(DOD):=20=E8=AE=B0=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E5=8F=91=E7=89=88=E5=90=8E=E6=A0=B8=E5=AF=B9=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E6=89=8D=E6=9A=B4=E9=9C=B2=E7=9A=84=E7=BC=BA=E5=8F=A3=E2=80=94?= =?UTF-8?q?=E2=80=94wheel=20=E6=9C=AA=E6=89=93=E5=8C=85=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=80=A7=E6=B8=85=E5=8D=95=EF=BC=8Cpip=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=9A=84=20enforce=20=E4=BB=8E=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写清三件事:缺口的真实影响面(只有纯 pip 安装路径中招,Docker/便携因另拷源码树而被遮住)、 两处必须一起改的原因(只改 enforce 会让 pip 路径起不来、只改 package-data 则下次打包行为一变又静默回到不校验)、 以及验收层次(1062→1065 的产物对比、把 wheel 解到临时目录真跑 enforce 的正负两向、CI 常驻核对)。 Signed-off-by: ReSerendipity --- docs/DOD.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/DOD.md b/docs/DOD.md index f5c41cde..cd6db9aa 100644 --- a/docs/DOD.md +++ b/docs/DOD.md @@ -202,6 +202,21 @@ 绿色 run 同一行是 `Get:7 ... Packages [44.3 kB]`。 性质上正是"警告后照旧继续":报错文案(包不存在)与真原因(索引没抓下来)完全对不上, 下一次抖动随时会再红一遍。修法与验收(含不等抖动的破坏态复现)见 #111 与 `Dockerfile` 注释。 + * **wheel 漏打完整性清单三件套已修(发版后核对产物发现,PR #117)**:v2.2.2 的 wheel 里 + `integrated_app/security/` 只有 `.py`,`integrity_manifest.json` / 其 `.sig.ed25519` / + 验签公钥 `.pem` **三件都没进包**;而 `config.yaml` 默认 `security.integrity_selfcheck.enforce: true`, + 自检在"清单不存在"分支只 `logger.info("跳过自检")` 就返回 → **纯 `pip install` 那条部署路径上 + P0 完整性保护一条都没跑,配置却声称它在强制运行**(Docker/便携另拷源码树,所以不受影响, + 这也是容器启动探测一直绿着的原因)。两处一起改:`package-data` 逐条点名三件; + enforce 开着却没清单 → `RuntimeError` 并给出三条出路(生成 / 重打 wheel / 显式关 enforce), + 非强制模式保持原"跳过"语义。 + 验收不靠"配置看起来对":本机 `python -m build` 前后对比(包内条目 1062 → 1065,三件均 `OK`), + 再把 wheel 解到临时目录当成安装环境真跑一遍 —— 有清单时 `enforce=True` 返回 + `total/passed/failed/signed = 16/16/0/True`;把清单挪走则拒绝启动、`enforce=False` 仍返回 `skipped=16`。 + CI 侧在 `Build (sdist/wheel)` 作业里加了产物核对(只查 pyproject 不算数:setuptools 行为一变就谎报)。 + 守卫 `tests/test_integrity_selfcheck_packaging.py`(8 条,含"仓库自带三件套所以 enforce 该通过"的 + 反空验证,与"CI 不再核对 wheel 就红"的自证);`integrity_selfcheck.py` 属 16 个被签模块, + 清单已重算并重签(`--verify` PASS、sync 16/16)。 * **仍未覆盖**:桌面安装包链路(staging → data 7z → NSIS)**无任何 workflow 调用**、本机也无从安装 (`scripts/installer/` 只有一个 4.3 MB `Setup.exe`、无同目录分卷),所以 `unpack_desktop.ps1` 新加的许可/字体落地核对只过了语法层,`release_gate.ps1` 的第 ⑥ 步也只在发版/dispatch 时跑;