diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 55246405..8b110460 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -11,7 +11,8 @@ base64 嵌入,浏览器重算 sha256 和 id 比;带遮罩的快照不嵌内容、只给 id。口径卡上记着每个输入的值和代入式, 有输入取自遮罩列(cell() 的格、Agent 字段、按位置取的 vars.res.rows[0][2])的整张卡同样只给 id,认法和证据 面板、推演同一个(recompute.input_origin);服务端文件被改过的卡照样按卡上记的输入认,解析不出一张卡的整张只给 - id。没被报告引用的工件不嵌。 + id。只给 id 的工件,服务端的文件和 id 对不上的另标 mismatch、解析不出来的另标 unparsable,核对页照实说(对不上的 + 判不通过)。没被报告引用的工件不嵌。 - 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。 它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次 @@ -337,6 +338,12 @@ class _Artifacts: elif (kind == "query" and self.store.masked(artifact)) \ or (kind == "metric_set" and self.store.card_masked(artifact, self.events)): item["masked"] = True + # 不嵌内容,浏览器就算不出这份文件对不对得上:服务端自己看到的照实带上,核对页据此判不通过、说清原因。 + # 以前被改过的卡原样嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢 + if not self.store.intact(artifact): + item["mismatch"] = True + if not isinstance(self.store.content(artifact), dict): + item["unparsable"] = True else: # 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上 item["b64"] = base64.b64encode(bytes(raw)).decode("ascii") diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 8184979f..38b8d154 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -106,6 +106,8 @@ artifactUndecodable: '内容无法解码', artifactUnparsable: '哈希一致,但内容无法解析', masked: '含遮罩列,未随文件附带,只能核对到哈希', + maskedMismatch: '导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过;按含遮罩列处理,未随文件附带', + maskedUnparsable: '导出时服务端的这份文件无法解析,认不出是否含遮罩列,按含遮罩列处理,未随文件附带', maskedOdd: '只有查询快照和口径卡会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过', artifactMissing: '导出时服务端已没有这份工件的文件', artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过', @@ -452,7 +454,7 @@ out.manifest.push({ kind: 'shown', shown: shownSeal, result: shownSeal === seal ? PASS : FAIL, reason: shownSeal === seal ? T.shownOk : T.shownMismatch }) - // ③ 工件。服务端给每份工件的是三样之一:原字节、遮罩(只有查询快照)、服务端已没有文件 + // ③ 工件。服务端给每份工件的是三样之一:原字节、遮罩(查询快照、口径卡,可另带 mismatch、unparsable)、服务端已没有文件 const arts = new Map() for (const a of Array.isArray(data.artifacts) ? data.artifacts.filter(isObj) : []) { const item = { a, result: NA, reason: '', json: null, loose: null, parsed: false } @@ -485,12 +487,17 @@ } } } else if (a.masked) { - // 查询快照带遮罩列;口径卡上有输入取自遮罩列(卡上记着输入的值和代入式)。两种都只给 id,报告文档不会 - if (a.kind === 'query' || a.kind === 'metric_set') { - item.reason = T.masked - } else { + // 查询快照带遮罩列;口径卡上有输入取自遮罩列(卡上记着输入的值和代入式)。两种都只给 id,报告文档不会。 + // 内容不随文件附带,浏览器算不出它对不对得上:导出时服务端看到自己的文件和 id 对不上(mismatch)、解析不出来 + // (unparsable)的照实说,对不上的判不通过——不然被改过的文件看起来和正常的遮罩一样,篡改的证据就丢了 + if (a.kind !== 'query' && a.kind !== 'metric_set') { item.result = FAIL item.reason = T.maskedOdd + } else if (a.mismatch) { + item.result = FAIL + item.reason = T.maskedMismatch + } else { + item.reason = a.unparsable ? T.maskedUnparsable : T.masked } } else if (a.missing) { item.reason = T.artifactMissing @@ -617,7 +624,7 @@ if (!art) return { ...info, result: FAIL, reason: T.numArtifactNotListed } if (art.result === FAIL) return { ...info, result: NA, reason: T.numArtifactFailed } if (art.result !== PASS) { - return { ...info, result: NA, reason: art.a.masked ? T.masked : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable } + return { ...info, result: NA, reason: art.a.masked ? art.reason : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable } } if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained } if (cite.kind === 'metric') { diff --git a/backend/tests/test_bundle_masked_cards.py b/backend/tests/test_bundle_masked_cards.py index 5a142e59..0803a655 100644 --- a/backend/tests/test_bundle_masked_cards.py +++ b/backend/tests/test_bundle_masked_cards.py @@ -7,6 +7,10 @@ vars.res.rows[0][1]),在这份要发给别人的文件里照样看得到。 服务端的卡文件被改过(哈希对不上)也照卡上记的输入认:嵌进去的是原字节,号码照样在里面。读得出字节、却解析不出一张卡 (截断的 JSON、不是对象)的,认不出输入,整张按带遮罩算、不嵌:和读不出来的快照按带遮罩算是同一个道理。 +不嵌的工件,服务端把它知道的情况照实带上:自己的文件和工件 id(哈希)对不上的标 mismatch,解析不出来的标 unparsable, +核对页据此判不通过、照实说原因(以前原字节嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢)。带遮罩的 +查询快照同样标。 + 工件、事件都是手搭的(会员、号码都是编的),照 tests/test_bundle_build.py 的样子。 """ from __future__ import annotations @@ -16,6 +20,7 @@ import base64 import pytest from app.bundle.build import build_bundle, render_html +from app.core.artifact_store import content_hash from tests.test_bundle_build import GRAPH, MASKED_ID, RUN, SNAP_MASKED, STORE, doc_of, stored #: SNAP_MASKED 里的号码(phone 设了遮罩) @@ -30,16 +35,19 @@ def card_with(inputs, substituted="len('000-0001')", expression=f"len({CELL})"): "substituted": substituted, "inputs": inputs}]} -def bundle_of(card, *, outputs=None, source_masks=None, tamper=None): +def bundle_of(card, *, outputs=None, source_masks=None, tamper=None, rehash=False, files_changed=None): """一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs 里的产出)→ 口径卡 → 报告。tamper 给了就把服务端的 - 卡文件换成 tamper(原字节),id 照旧(文件被改过、哈希对不上)。""" + 卡文件换成 tamper(原字节),id 照旧(文件被改过、哈希对不上);rehash 为真时 id 按换过的字节算(服务端存的就是这一份, + 哈希对得上)。files_changed 换掉服务端别的文件({工件 id: 新字节},id 照旧)。""" card_id, card_raw = stored(card) if tamper is not None: card_raw = tamper(card_raw) + if rehash: + card_id = content_hash(card_raw) doc = doc_of(extra_catalog={"m:gmv": {"alias": "m:gmv", "kind": "metric", "artifact": card_id, "node_id": "card", "locator": {"metric": "gmv"}, "value": 1280.5}}) doc_id, doc_raw = stored(doc) - files = {**STORE, card_id: card_raw, doc_id: doc_raw} + files = {**STORE, card_id: card_raw, doc_id: doc_raw, **(files_changed or {})} events = [ (1, "run.started", None, {"nodes": 7}), (2, "tool.end", "members", {"tool": "db_query__crm", "query_artifact": MASKED_ID}), @@ -115,11 +123,12 @@ def test_a_card_reading_a_cell_by_position_is_judged_the_same_way(parsed, masked def test_a_card_file_changed_on_the_server_is_still_judged_by_its_inputs(): """服务端的卡文件被改过(这里改了口径版本,哈希对不上):照样按卡上记的输入认遮罩,记着号码的只给 id。以前先看哈希、 - 对不上就当卡上没有输入,原字节照样嵌进页面,号码进了文件。""" + 对不上就当卡上没有输入,原字节照样嵌进页面,号码进了文件。不嵌了,就另标 mismatch:核对页判这一项不通过,不和 + 没被改过的遮罩卡一样说成正常的无法核对。""" card = card_with([cell_input("phone", PHONE)]) bundle, entry = bundle_of(card, tamper=lambda raw: raw.replace(b'"caliber_version":"v1"', b'"caliber_version":"v9"')) assert entry["id"] == stored(card)[0] - assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True} + assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "mismatch": True} assert PHONE not in render_html(bundle) @@ -138,8 +147,33 @@ def test_a_changed_card_without_masked_inputs_is_still_embedded_for_the_browser_ lambda raw: b'["000-0001"]', ]) def test_a_card_file_that_does_not_parse_as_a_card_is_not_embedded(damage): - """服务端的卡文件读得出字节、解析不出一张卡:认不出卡上有哪些输入,整张按带遮罩算、只给 id,不原样嵌进页面。""" + """服务端的卡文件读得出字节、解析不出一张卡:认不出卡上有哪些输入,整张按带遮罩算、只给 id,不原样嵌进页面。字节和 + id 对不上(mismatch),也解析不出来(unparsable):两样都照实标上。""" card = card_with([cell_input("phone", PHONE)]) bundle, entry = bundle_of(card, tamper=damage) - assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True} + assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "mismatch": True, + "unparsable": True} + assert PHONE not in render_html(bundle) + + +def test_a_card_stored_as_something_unparsable_is_marked_so(): + """服务端存的这一份本来就解析不出一张卡(哈希对得上,不是被改过):整张按带遮罩算、不嵌,只标 unparsable——核对页照实 + 说认不出是否含遮罩列,不说成「含遮罩列」,也不说被改过。""" + card = card_with([cell_input("phone", PHONE)]) + bundle, entry = bundle_of(card, tamper=lambda raw: raw[: raw.index(b"000-0001") + len(b"000-0001")], rehash=True) + assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "unparsable": True} + assert PHONE not in render_html(bundle) + + +def test_a_masked_snapshot_changed_on_the_server_is_marked_so(): + """带遮罩的查询快照在服务端被改过(哈希对不上):照旧只给 id,另标 mismatch,核对页判不通过。以前和没被改过的遮罩快照 + 一模一样,总结论还是「可核对的项全部通过」。没被改过的照旧只标遮罩。""" + card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')") + snapshot = lambda bundle: next(a for a in bundle["artifacts"] if a["id"] == MASKED_ID) # noqa: E731 + bundle, _ = bundle_of(card) + assert snapshot(bundle) == {"id": MASKED_ID, "kind": "query", "node_id": "members", "masked": True} + changed = stored(SNAP_MASKED)[1].replace("林一".encode(), "林二".encode()) + bundle, _ = bundle_of(card, files_changed={MASKED_ID: changed}) + assert snapshot(bundle) == {"id": MASKED_ID, "kind": "query", "node_id": "members", "masked": True, + "mismatch": True} assert PHONE not in render_html(bundle) diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 08c0bf22..ef9a589c 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -17,7 +17,9 @@ // 经继续运行跑到成功的早期运行、两条出具 // 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列),逐份断言各项结果和原因文案;在上面篡改 ④ // 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把 -// 报告文档标成遮罩、只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」; +// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、 +// 只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写 +// 「尚未核对」; // - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端; // 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行 // 提示之后继续运行会重新封存。 @@ -675,6 +677,39 @@ await section('样本页:篡改', async () => { doc.masked = true return '报告文档去掉内容、标遮罩' }, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照和口径卡会因遮罩不随文件附带')]]) + // 遮罩卡在服务端被改过时,服务端照样只给 id、另标 mismatch(tests/test_bundle_masked_cards.py 守着服务端这一边): + // 不能因为不嵌内容就和正常的遮罩卡一样「可核对的项全部通过」 + await tamper(samples['masked-card'], '遮罩卡标成服务端的文件与 id 不一致', (data) => { + byKind(data, 'metric_set').mismatch = true + return '口径卡加 mismatch' + }, (s) => { + const card = s.items.find((i) => i.check === 'artifacts' && i.text.includes('口径卡')) + const metricNumber = s.items.find((i) => i.check === 'numbers' && i.text.includes('销售额')) + return [ + ...turnsRed('artifacts', ['events', 'manifest', 'chain'])(s), + ['口径卡写明导出时服务端的文件与 id 不一致、可能被改过、未随文件附带', card?.result === 'fail' + && card.text.includes('导出时服务端的这份文件与工件 id(哈希)不一致,可能被改过') && card.text.includes('未随文件附带'), + card?.text], + ['引用这张卡的数字无法核对,写明引用的工件没有通过核对', metricNumber?.result === 'na' + && metricNumber.text.includes('引用的工件没有通过核对'), metricNumber?.text], + ] + }) + // 服务端标了解析不出来、哈希又对得上的遮罩卡:照实说认不出是否含遮罩列,是正常的无法核对(同哈希一致却解析不了的 + // 嵌入工件),不说成「含遮罩列」 + await tamper(samples['masked-card'], '遮罩卡标成服务端解析不出来', (data) => { + byKind(data, 'metric_set').unparsable = true + return '口径卡加 unparsable' + }, (s) => { + const card = s.items.find((i) => i.check === 'artifacts' && i.text.includes('口径卡')) + const metricNumber = s.items.find((i) => i.check === 'numbers' && i.text.includes('销售额')) + return [ + ['口径卡无法核对,写明服务端的文件无法解析、认不出是否含遮罩列', card?.result === 'na' + && card.text.includes('导出时服务端的这份文件无法解析,认不出是否含遮罩列'), card?.text], + ['引用它的数字原因同上', metricNumber?.result === 'na' && metricNumber.text.includes('认不出是否含遮罩列'), + metricNumber?.text], + ['总览照常通过', s.overall === 'pass', `${s.overall}:${s.text}`], + ] + }) await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [ ...turnsRed('artifacts')(s), ['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],