diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 38b8d154..9f6d9a5a 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -12,7 +12,7 @@ * engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版; * ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称 * 绑不进封存,页面上写明它们取自运行记录。 - * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、口径卡标着遮罩) + * 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩) * 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。 * * SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现, @@ -105,6 +105,9 @@ artifactMismatch: '内容与工件 id(哈希)不一致', artifactUndecodable: '内容无法解码', artifactUnparsable: '哈希一致,但内容无法解析', + /** 查询快照只给哈希:服务端证明不了这条查询没碰设了遮罩的列(没碰的照常随文件附带) */ + maskedQuery: '这条查询用到或可能用到设了遮罩的列,快照未随文件附带,只能核对到哈希', + /** 口径卡只给哈希:卡上有输入取自遮罩列(卡上记着输入的值和代入式) */ masked: '含遮罩列,未随文件附带,只能核对到哈希', maskedMismatch: '导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过;按含遮罩列处理,未随文件附带', maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带', @@ -487,7 +490,8 @@ } } } else if (a.masked) { - // 查询快照带遮罩列;口径卡上有输入取自遮罩列(卡上记着输入的值和代入式)。两种都只给 id,报告文档不会。 + // 查询快照:这条查询用到或可能用到设了遮罩的列;口径卡:卡上有输入取自遮罩列(卡上记着输入的值和代入式)。 + // 两种都只给 id,报告文档不会。 // 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来 // (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了 if (a.kind !== 'query' && a.kind !== 'metric_set') { @@ -497,7 +501,7 @@ item.result = FAIL item.reason = T.maskedMismatch } else { - item.reason = a.unparsable ? T.maskedUnparsable : T.masked + item.reason = a.unparsable ? T.maskedUnparsable : a.kind === 'query' ? T.maskedQuery : T.masked } } else if (a.missing) { item.reason = T.artifactMissing @@ -980,7 +984,7 @@ if (snap && typeof snap.sql === 'string' && snap.sql) parts.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, snap.sql)) const record = snap && Number.isInteger(number.row) && Array.isArray(snap.rows) ? snap.rows[number.row] : undefined if (Array.isArray(record) || isObj(record)) { - // 被引用的那一行:快照随文件附带(没有遮罩)才走到这里,整行照原样列出,引用的那一格高亮 + // 被引用的那一行:快照随文件附带(没碰设了遮罩的列)才走到这里,整行照原样列出,引用的那一格高亮 let columns = (Array.isArray(snap.columns) ? snap.columns : []).map(String) if (isObj(record) && !columns.length) columns = Object.keys(record) const cellAt = (c, i) => (isObj(record) ? own(record, c) : record[i]) diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index 2e87f8d8..e3b48b8f 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -19,6 +19,8 @@ - missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件 - masked-card:口径卡上一个指标的输入取自会员消费的号码列(设了遮罩),卡上记着号码原值:整张卡只给 id,引用它的 数字无法核对 +- masked-source:会员库设了号码遮罩(查询当时记下了,数据源现在也设着)。报告引用会员按地区汇总的查询(没碰遮罩列: + 快照随文件附带,数字核对得过)和选出了号码的那份会员消费(只给哈希) - nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用) - stray:报告自查记下的文档是另一次运行写的 """ @@ -39,8 +41,8 @@ RUN = {"id": RUN_ID, "workflow_id": "wf-sample-weekly", "workflow_name": "门店 "started_at": "2026-09-07T01:00:00+00:00", "finished_at": "2026-09-07T01:02:03+00:00"} GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [ ("start", "input", "开始"), ("fetch", "tool", "门店流水"), ("members", "tool", "会员消费"), - ("card", "metrics", "周报口径"), ("write", "report", "写周报"), ("monthly", "report", "写月报"), - ("out", "output", "成果")]]} + ("regions", "tool", "会员地区汇总"), ("card", "metrics", "周报口径"), ("write", "report", "写周报"), + ("monthly", "report", "写月报"), ("out", "output", "成果")]]} EXPORTED_AT = "2026-10-05T08:00:00+00:00" STORES = [["青石店", 1280.5], ["白沙店", "960.00"], ["临川店", 732], ["望江店", 1105.25], ["枫桥店", 418], @@ -48,6 +50,11 @@ STORES = [["青石店", 1280.5], ["白沙店", "960.00"], ["临川店", 732], [" CRM = {"columns": ["name", "phone", "spend"], "column_types": {"name": "text", "phone": "text", "spend": "number"}, "rows": [["林一", "000-0001", 320.0], ["周二", "000-0002", 188.5]], "source": "crm", "sql": "SELECT name, phone, spend FROM members ORDER BY id", "truncated": False, "mask_columns": ["phone"]} +#: 会员按地区汇总:同一个设了号码遮罩的会员库,SQL 没碰号码列 +REGIONS = {"columns": ["region", "spend"], "column_types": {"region": "text", "spend": "number"}, + "rows": [["华东", 320.0], ["华南", 188.5]], "source": "crm", + "sql": "SELECT region, SUM(spend) AS spend FROM members GROUP BY region ORDER BY region", "truncated": False, + "mask_columns": ["phone"]} CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "metrics": [{"id": "gmv", "name": "销售额", "value": 8381.25, "unit": "元", "decimals": 2, "format": "thousands"}]} @@ -55,6 +62,9 @@ WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计* "**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n" "[[table:Q1 cols=store,amount rows=0-7]]") MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]" +#: 会员周报:Q3 是会员按地区汇总(没碰遮罩列),Q2 是选出了号码的会员消费 +MEMBERS = ("## 会员周报\n\n华东会员消费 [[v:Q3.r0.spend]],华南 [[v:Q3.r1.spend]]。[[see:Q3]]\n\n" + "会员林一消费 [[v:Q2.r0.spend]]。") def stored(obj: Any) -> tuple[str, bytes]: @@ -83,21 +93,29 @@ def masked_card(crm_id: str) -> dict[str, Any]: class Sample: - """一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入。""" + """一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入; + regions=True 时会员库另有一次按地区汇总的查询(目录里排在 Q3)。""" - def __init__(self, *, nan: bool = False, masked: bool = False) -> None: + def __init__(self, *, nan: bool = False, masked: bool = False, regions: bool = False) -> None: self.files: dict[str, bytes] = {} self.shop_id = self.put(shop(nan)) self.crm_id = self.put(CRM) self.card = masked_card(self.crm_id) if masked else CARD self.card_id = self.put(self.card) - self.ledger = [ - {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id, "tool": "db_query__shop", - "source": "shop", "columns": ["store", "amount", "ratio"], "rows": len(STORES), "truncated": False}, - {"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id, "tool": "db_query__crm", - "source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), "truncated": False}, - {"kind": "metric_set", "node_id": "card", "artifact": self.card_id}, - ] + self.entries = { + "fetch": {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id, + "tool": "db_query__shop", "source": "shop", "columns": ["store", "amount", "ratio"], + "rows": len(STORES), "truncated": False}, + "members": {"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id, + "tool": "db_query__crm", "source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), + "truncated": False}, + } + if regions: + self.entries["regions"] = {"kind": "query", "node_id": "regions", "exec": 1, + "artifact": self.put(REGIONS), "tool": "db_query__crm", "source": "crm", + "columns": REGIONS["columns"], "rows": len(REGIONS["rows"]), "truncated": False} + self.entries["card"] = {"kind": "metric_set", "node_id": "card", "artifact": self.card_id} + self.ledger = list(self.entries.values()) self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger) def put(self, obj: Any) -> str: @@ -131,12 +149,17 @@ class Sample: "preview": json.dumps({"columns": ["store", "amount"], "rows": STORES[:2]}, ensure_ascii=False)}) add("node.finished", "fetch", {"duration_ms": 14, "attempt": 1, "preview": "{\"rows\": …}", - "evidence": [self.ledger[0]]}) + "evidence": [self.entries["fetch"]]}) add("tool.end", "members", {"tool": "db_query__crm", "duration_ms": 9, "query_artifact": self.crm_id, "preview": json.dumps(CRM["rows"][:1], ensure_ascii=False)}) - add("node.finished", "members", {"duration_ms": 10, "attempt": 1, "evidence": [self.ledger[1]]}) + add("node.finished", "members", {"duration_ms": 10, "attempt": 1, "evidence": [self.entries["members"]]}) + if "regions" in self.entries: + add("tool.end", "regions", {"tool": "db_query__crm", "duration_ms": 7, + "query_artifact": self.entries["regions"]["artifact"], + "preview": json.dumps(REGIONS["rows"], ensure_ascii=False)}) + add("node.finished", "regions", {"duration_ms": 8, "attempt": 1, "evidence": [self.entries["regions"]]}) add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": {"kind": "metric_set"}, - "evidence": [self.ledger[2]]}) + "evidence": [self.entries["card"]]}) for node_id, doc_id in reports.items(): add("llm.end", node_id, {"model": "mock", "output": "(写作草稿)"}) add("report.checked", node_id, {"doc_artifact": doc_id, "ok": True}) @@ -167,12 +190,12 @@ class Sample: def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None, - legacy: bool = False, drop: tuple[str, ...] = ()) -> str: - """真实封存哈希的页面。drop:导出时服务端已没有文件的工件。""" + legacy: bool = False, drop: tuple[str, ...] = (), source_masks: dict[str, list[str]] | None = None) -> str: + """真实封存哈希的页面。drop:导出时服务端已没有文件的工件;source_masks:数据源现在设的遮罩。""" sealed = [e for e in events if e[0] <= bound] files = {k: v for k, v in sample.files.items() if k not in drop} bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get, - exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks={}) + exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks=source_masks or {}) return render_html(bundle) @@ -215,6 +238,10 @@ def pages() -> dict[str, str]: m = Sample(masked=True) events, bound = m.events({"write": m.doc(WEEKLY, "write")}) out["masked-card"] = render(m, events, bound) + + r = Sample(regions=True) + events, bound = r.events({"write": r.doc(MEMBERS, "write")}) + out["masked-source"] = render(r, events, bound, source_masks={"crm": ["phone"]}) return out diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index fff346d7..c8edc293 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -461,9 +461,10 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): 本身:封存哈希是对事件真算的(浏览器的拼法重算得出),各页的形状是 check-bundle 的断言预期的那样。""" from tests.bundle_samples import pages - got = {name: json.loads(data_block(page)) for name, page in pages().items()} + rendered = pages() + got = {name: json.loads(data_block(page)) for name, page in rendered.items()} assert set(got) == {"base", "two-reports", "legacy", "failed", "continued", "degraded", "missing-doc", - "missing-card", "nan", "stray", "masked-card"} + "missing-card", "nan", "stray", "masked-card", "masked-source"} for name, bundle in got.items(): assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name @@ -502,6 +503,13 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): # 口径卡上有输入取自遮罩列:整张卡只给 id,页面里找不到那个号码 card = next(a for a in got["masked-card"]["artifacts"] if a["kind"] == "metric_set") assert card.get("masked") is True and "b64" not in card + # 会员库设了号码遮罩:按地区汇总的那份没碰号码列,随文件附带;选出了号码的那份只给 id,页面里找不到号码 + source = got["masked-source"] + shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")} + for a in source["artifacts"]} + assert shapes == {("report_doc", "write"): {"b64"}, ("query", "regions"): {"b64"}, ("query", "members"): {"masked"}} + assert [n for n in segments(source) if n.get("cite", {}).get("alias") == "Q3"] + assert not any(phone in rendered["masked-source"] for phone in ("000-0001", "000-0002")) nan = got["nan"] assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started") assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch")) diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index ef9a589c..f4b4e70e 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -15,9 +15,11 @@ // - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的; // - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后 // 经继续运行跑到成功的早期运行、两条出具 -// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列),逐份断言各项结果和原因文案;在上面篡改 ④ +// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询),逐份 +// 断言各项结果和原因文案;在上面篡改 ④ // 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把 // 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、 +// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、 // 只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写 // 「尚未核对」; // - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端; @@ -145,6 +147,8 @@ const stateOf = (page) => page.evaluate(() => ({ })) const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).join(' ') +/** 查询快照只给哈希时核对页写的原因(verifier.js 的 T.maskedQuery) */ +const MASKED_QUERY = '这条查询用到或可能用到设了遮罩的列,快照未随文件附带,只能核对到哈希' /** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */ const noFail = (r) => r === 'pass' || r === 'partial' const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result @@ -489,16 +493,15 @@ await section('样本页:各种形状', async () => { check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e)) return } - check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 11, Object.keys(samples).join('、')) + check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 12, Object.keys(samples).join('、')) await sample('base', async (s, page) => { check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`) check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass') && s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s)) - check('base:遮罩快照在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」', - hasItem(s, 'artifacts', 'na', '含遮罩列,未随文件附带,只能核对到哈希')) - check('base:引用遮罩快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', '含遮罩列,未随文件附带,只能核对到哈希')) + check(`base:选出了遮罩列的快照在 ③ 无法核对,写明「${MASKED_QUERY}」`, hasItem(s, 'artifacts', 'na', MASKED_QUERY)) + check('base:引用这份快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', MASKED_QUERY)) check('base:没有出处的数字写明未使用引用标记', hasItem(s, 'numbers', 'na', '该数字未使用引用标记,无法核对其来源')) const outside = s.items.filter((i) => i.check === 'numbers' && !i.folded) check('base:⑤ 超过 20 项收起来时,无法核对的列在外面', outside.length === 2 && outside.every((i) => i.result === 'na') @@ -616,6 +619,31 @@ await section('样本页:各种形状', async () => { const html = await page.content() check('masked-card:页面里找不到卡上那个遮罩列的号码', !html.includes('000-0001') && !html.includes('000-0002')) }) + // 会员库设了号码遮罩(查询当时记下了,数据源现在也设着):按地区汇总的查询没碰号码列,快照随文件附带、数字核对得过; + // 选出了号码的那份只给哈希,原因写明这条查询用到或可能用到设了遮罩的列 + await sample('masked-source', async (s, page) => { + const regions = s.items.find((i) => i.check === 'artifacts' && i.text.includes('会员地区汇总')) + check('masked-source:按地区汇总的快照随文件附带,③ 通过', regions?.result === 'pass', regions?.text) + const cited = s.items.filter((i) => i.check === 'numbers' && i.text.includes('查询 Q3')) + check('masked-source:引用地区汇总的 2 个数字都核对通过', cited.length === 2 && cited.every((i) => i.result === 'pass'), + cited.map((i) => `${i.result}:${i.text}`).join(' | ')) + const members = s.items.find((i) => i.check === 'artifacts' && i.text.includes('会员消费')) + check('masked-source:选出了号码的快照只给哈希,写明这条查询用到或可能用到设了遮罩的列', + members?.result === 'na' && members.text.includes(MASKED_QUERY), members?.text) + const phoneCited = s.items.find((i) => i.check === 'numbers' && i.text.includes('查询 Q2')) + check('masked-source:引用它的数字无法核对,原因同上', phoneCited?.result === 'na' && phoneCited.text.includes(MASKED_QUERY), + phoneCited?.text) + check('masked-source:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' + && s.text === '可核对的项全部通过' && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`) + // 点开地区汇总的第一个数字:出处面板里有这条查询的 SQL 和被引用的那一行 + await page.locator('button.num[data-result="pass"]').first().click() + await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 }) + const detail = await page.locator('#detail').innerText() + check('masked-source:点开数字看得到汇总查询的 SQL 和被引用的那一行', detail.includes('SUM(spend)') + && detail.includes('快照第 1 行') && detail.includes('华东'), detail.replace(/\s+/g, ' ').slice(0, 120)) + const html = await page.content() + check('masked-source:页面里找不到号码', !html.includes('000-0001') && !html.includes('000-0002')) + }) await sample('nan', async (s) => { check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass' && !s.items.some((i) => i.result === 'fail'), describe(s)) @@ -694,6 +722,13 @@ await section('样本页:篡改', async () => { && metricNumber.text.includes('引用的工件没有通过核对'), metricNumber?.text], ] }) + // 遮罩源上随文件附带的汇总快照照样逐字节核对:改一个字节 ③ 变红;改它的数字的引用值、哈希全部重算,⑤ 拿快照对出来变红 + await tamper(samples['masked-source'], '遮罩源上汇总快照的一个字节', (data) => { + const art = data.artifacts.find((a) => a.kind === 'query' && typeof a.b64 === 'string') + return art ? flipByte(art) : null + }, turnsRed('artifacts', ['events', 'manifest'])) + await tamper(samples['masked-source'], '遮罩源上汇总查询的数字的引用值并重算全部哈希', forge, + turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain']), { seal: true }) // 服务端标了解析不出来、哈希又对得上的遮罩卡:照实说认不出是否含遮罩列,是正常的无法核对(同哈希一致却解析不了的 // 嵌入工件),不说成「含遮罩列」 await tamper(samples['masked-card'], '遮罩卡标成服务端解析不出来', (data) => {