Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion app/integrated_app/security/integrity_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
g`M��{�Wh#��A��/әX�%�$��ͨУ
f 8N �G%U(v������`���R�b��X�
�E>*�� �V��Ų##���f�w�j��]d6N�"z��L�9�P��V;��%��!�u� ��
Expand Down
17 changes: 15 additions & 2 deletions app/integrated_app/security/integrity_selfcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -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` 生成清单以启用启动自检。"
Expand Down
15 changes: 15 additions & 0 deletions docs/DOD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 时跑;
Expand Down
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
97 changes: 97 additions & 0 deletions tests/test_integrity_selfcheck_packaging.py
Original file line number Diff line number Diff line change
@@ -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
Loading