diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index 5886bd22..90fca722 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -8,7 +8,9 @@ 浏览器先核它的哈希,再从里面读出报告文档、口径卡、查询快照的 id。其余事件只给哈希:工具返回、节点产出的 预览里可能有遮罩列的原值和提示词。 - 工件:每份报告文档(各报告节点最后一次自查记下的 doc_artifact)、它引用到的口径卡和查询快照,按原字节 - base64 嵌入,浏览器重算 sha256 和 id 比;带遮罩的快照不嵌内容、只给 id。没被报告引用的工件不嵌。 + base64 嵌入,浏览器重算 sha256 和 id 比;带遮罩的快照不嵌内容、只给 id。口径卡上记着每个输入的值和代入式, + 有输入取自遮罩列(cell() 的格、Agent 字段、按位置取的 vars.res.rows[0][2])的整张卡同样只给 id,认法和证据 + 面板、推演同一个(recompute.input_origin)。没被报告引用的工件不嵌。 - 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。 它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次 @@ -34,6 +36,7 @@ from app.core.artifact_store import canonical_json, content_hash from app.core.events import EventType from app.engine.evidence import iter_units, query_entry_fields from app.engine.merge_query import MERGE_SOURCE +from app.engine.recompute import input_origin from app.engine.schema import NodeType from app.engine.toolcalls import QUERY_PREFIX @@ -106,6 +109,44 @@ class _Store: self._masked[artifact] = _snapshot_masked(self.content(artifact), self._source_masks) return self._masked[artifact] + def card_masked(self, artifact: str, events: Sequence[tuple[int, str, str | None, Any]]) -> bool: + """这张口径卡算不算带遮罩:卡上有一个输入取自遮罩列,或者遮没遮核对不了。卡上记着每个输入的值(cell() 取的格、 + Agent 字段、按位置取的 vars.res.rows[0][2])和代入式,整张卡不随文件附带——没法只拿掉几个值,拿掉了哈希就对不上。 + + 认哪个输入取自哪份查询结果的哪几列,和证据面板、推演同一个认法(recompute.input_origin);按位置取的一格顺着 + 取值链从写变量的节点在口径卡算这一次之前的产出起走。核对不了的宁可多挡:快照读不出来、取值链上的产出读不出来、 + 那份结果没存快照只带着数据源名、定位里没写列。认得出、可结果既没带快照的 id 也没带数据源名的(数据整形改过形状) + 认不出出处,不算。读不出来的卡本来就不嵌,这里不管。""" + key = f"card:{artifact}" + if key not in self._masked: + card = self.content(artifact) if self.intact(artifact) else None + outputs = _card_outputs(events, artifact) if isinstance(card, dict) else {} + + def output(node_id: Any) -> Any: + found = outputs.get(str(node_id or "")) + return self.content(found) if found and self.intact(found) else None + + items = [item for metric in (card or {}).get("metrics") or [] if isinstance(metric, dict) + for item in metric.get("inputs") or [] if isinstance(item, dict)] + self._masked[key] = any(self._input_masked(item, output) for item in items) + return self._masked[key] + + def _input_masked(self, item: dict[str, Any], output: Callable[[Any], Any]) -> bool: + origin = input_origin(item, output) + if origin is None: + return False + if origin.unverified is not None: + return True + if origin.artifact is None: + return origin.source is not None # 没存快照:查询当时记下的遮罩无从核对 + content = self.content(origin.artifact) + if not self.intact(origin.artifact) or not isinstance(content, dict): + return True + if not origin.columns: + return self.masked(origin.artifact) + hidden = _snapshot_hidden(content, self._source_masks) + return any(c.lower() in hidden for c in origin.columns) + def _snapshot_masked(content: Any, source_masks: Mapping[str, Any]) -> bool: if not isinstance(content, dict): @@ -117,6 +158,37 @@ def _snapshot_masked(content: Any, source_masks: Mapping[str, Any]) -> bool: return any(source_masks.get(str(name or "")) for name in sources or []) +def _snapshot_hidden(content: dict[str, Any], source_masks: Mapping[str, Any]) -> set[str]: + """一份读得出来的快照要遮的列(小写):查询当时记下的,加上它的数据源(合并查询看它合并的那几个)现在设的。""" + recorded = content.get("mask_columns") + hidden = {c.lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set() + sources = content.get("sources") if content.get("source") == MERGE_SOURCE else [content.get("source")] + for name in sources if isinstance(sources, list) else []: + now = source_masks.get(str(name or "")) + hidden |= {str(c).lower() for c in now} if isinstance(now, (list, tuple, set, frozenset)) else set() + return hidden + + +def _card_outputs(events: Sequence[tuple[int, str, str | None, Any]], card: str) -> dict[str, str]: + """算出这份指标集的那一次口径卡执行开始之前,每个节点最后一次的产出工件:卡上 vars.*、nodes.* 取到的就是它们 + (和证据接口 api/evidence._card_outputs 同一个认法)。认不出是哪一次执行时取封存范围内每个节点最后一次的产出。""" + found = next(((seq, node_id) for seq, etype, node_id, data in events + if etype == EventType.NODE_FINISHED and isinstance(data, dict) + and any(isinstance(e, dict) and e.get("kind") == "metric_set" and e.get("artifact") == card + for e in data.get("evidence") or [])), None) + bound = None + if found is not None: + bound = max((seq for seq, etype, node_id, _ in events + if etype == EventType.NODE_STARTED and node_id == found[1] and seq < found[0]), default=found[0]) + out: dict[str, str] = {} + for seq, etype, node_id, data in events: + if bound is not None and seq >= bound: + break + if etype == EventType.NODE_FINISHED and node_id and isinstance(data, dict) and data.get("artifact"): + out[node_id] = str(data["artifact"]) + return out + + def _query_artifacts(events: Iterable[tuple[int, str, str | None, Any]]) -> list[str]: """封存范围内交回过的查询快照:tool.end / merge.end 的 query_artifact,节点台账里的 query 条目。""" out: list[str] = [] @@ -234,10 +306,12 @@ def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]: class _Artifacts: - """嵌进页面的工件清单,按先后:报告文档在前,接着是它引用到的。同一份只列一次。""" + """嵌进页面的工件清单,按先后:报告文档在前,接着是它引用到的。同一份只列一次。events 是封存范围内的事件 + (认口径卡带不带遮罩时找写变量的节点当时的产出)。""" - def __init__(self, store: _Store) -> None: + def __init__(self, store: _Store, events: Sequence[tuple[int, str, str | None, Any]] = ()) -> None: self.store = store + self.events = events self.items: list[dict[str, Any]] = [] self._seen: set[str] = set() @@ -252,7 +326,8 @@ class _Artifacts: if raw is None: # 服务端的文件不在了:只给 id,浏览器照实说核对不了 item["missing"] = True - elif kind == "query" and self.store.masked(artifact): + elif (kind == "query" and self.store.masked(artifact)) \ + or (kind == "metric_set" and self.store.card_masked(artifact, self.events)): item["masked"] = True else: # 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上 @@ -380,7 +455,7 @@ def build_bundle( later = [e for e in events if e[0] > manifest_seq] store = _Store(read, source_masks or {}) - artifacts = _Artifacts(store) + artifacts = _Artifacts(store, sealed) reports = _report_events(sealed) cited_queries: list[str] = [] for report in reports: diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 6aa76dd7..8184979f 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -106,7 +106,7 @@ artifactUndecodable: '内容无法解码', artifactUnparsable: '哈希一致,但内容无法解析', masked: '含遮罩列,未随文件附带,只能核对到哈希', - maskedOdd: '只有查询快照会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过', + maskedOdd: '只有查询快照和口径卡会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过', artifactMissing: '导出时服务端已没有这份工件的文件', artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过', chainDoc: (label) => `报告「${label}」的文档`, @@ -485,7 +485,8 @@ } } } else if (a.masked) { - if (a.kind === 'query') { + // 查询快照带遮罩列;口径卡上有输入取自遮罩列(卡上记着输入的值和代入式)。两种都只给 id,报告文档不会 + if (a.kind === 'query' || a.kind === 'metric_set') { item.reason = T.masked } else { item.result = FAIL diff --git a/backend/tests/bundle_samples.py b/backend/tests/bundle_samples.py index 722c9b17..2e87f8d8 100644 --- a/backend/tests/bundle_samples.py +++ b/backend/tests/bundle_samples.py @@ -17,6 +17,8 @@ - continued:早期运行先失败、经继续运行跑到成功:范围里有那条运行失败,之后运行又继续了,成功结束的那条在范围外 - degraded:两条出具判定,先完整出具、后降档出具,认最后一条 - missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件 +- masked-card:口径卡上一个指标的输入取自会员消费的号码列(设了遮罩),卡上记着号码原值:整张卡只给 id,引用它的 + 数字无法核对 - nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用) - stray:报告自查记下的文档是另一次运行写的 """ @@ -70,14 +72,25 @@ def shop(nan: bool = False) -> dict[str, Any]: "sql": "SELECT store, amount, ratio FROM orders ORDER BY id", "truncated": False} -class Sample: - """一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。""" +def masked_card(crm_id: str) -> dict[str, Any]: + """口径卡多一个指标:联系电话位数,输入是会员消费第 0 行的号码(phone 设了遮罩),卡上记着号码原值和代入式。""" + cell = "cell(nodes.members, 0, 'phone')" + contact = {"id": "contact", "name": "联系电话位数", "value": 8, "unit": "位", "decimals": 0, "format": "thousands", + "expression": f"len({cell})", "substituted": f"len('{CRM['rows'][0][1]}')", + "inputs": [{"path": cell, "value": CRM["rows"][0][1], "node_id": "members", "via": "tool_cell", + "artifact": crm_id, "locator": {"row": 0, "column": "phone"}, "status": "verified"}]} + return {**CARD, "metrics": [*CARD["metrics"], contact]} - def __init__(self, *, nan: bool = False) -> None: + +class Sample: + """一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。masked=True 时口径卡带着取自遮罩列的输入。""" + + def __init__(self, *, nan: bool = False, masked: bool = False) -> None: self.files: dict[str, bytes] = {} self.shop_id = self.put(shop(nan)) self.crm_id = self.put(CRM) - self.card_id = self.put(CARD) + 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}, @@ -85,7 +98,7 @@ class Sample: "source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), "truncated": False}, {"kind": "metric_set", "node_id": "card", "artifact": self.card_id}, ] - self.catalog = build_catalog(nodes={"card": CARD}, ledger=self.ledger) + self.catalog = build_catalog(nodes={"card": self.card}, ledger=self.ledger) def put(self, obj: Any) -> str: artifact, raw = stored(obj) @@ -198,6 +211,10 @@ def pages() -> dict[str, str]: n = Sample(nan=True) events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True) out["nan"] = render(n, events, bound) + + m = Sample(masked=True) + events, bound = m.events({"write": m.doc(WEEKLY, "write")}) + out["masked-card"] = render(m, events, bound) return out diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index 3ecfc334..fff346d7 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -463,7 +463,7 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): got = {name: json.loads(data_block(page)) for name, page in pages().items()} assert set(got) == {"base", "two-reports", "legacy", "failed", "continued", "degraded", "missing-doc", - "missing-card", "nan", "stray"} + "missing-card", "nan", "stray", "masked-card"} for name, bundle in got.items(): assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name @@ -499,6 +499,9 @@ def test_the_sample_pages_for_the_browser_check_are_sealed_for_real(): issuances = [e["seq"] for e in got["degraded"]["events"] if e["type"] == "issuance"] assert len(issuances) == 2 and got["degraded"]["meta"]["tier"] == {"value": "degraded", "seq": issuances[-1]} assert [a["kind"] for a in got["stray"]["artifacts"]] == ["report_doc"] + # 口径卡上有输入取自遮罩列:整张卡只给 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 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/backend/tests/test_bundle_masked_cards.py b/backend/tests/test_bundle_masked_cards.py new file mode 100644 index 00000000..11f8d917 --- /dev/null +++ b/backend/tests/test_bundle_masked_cards.py @@ -0,0 +1,107 @@ +"""可验证报告里的口径卡和遮罩(期 4 I2a 复核):口径卡上记着每个输入的值和代入式,有输入取自遮罩列时整张卡只给 id。 + +以前只有查询快照会因遮罩不随文件附带,口径卡一律原样嵌入:证据面板上已经遮住的号码(cell() 取的遮罩格、按位置取的 +vars.res.rows[0][1]),在这份要发给别人的文件里照样看得到。认哪个输入取自哪份查询结果的哪几列,和证据面板、推演同一个 +认法(recompute.input_origin);核对不了的宁可多挡。 + +工件、事件都是手搭的(会员、号码都是编的),照 tests/test_bundle_build.py 的样子。 +""" +from __future__ import annotations + +import base64 + +import pytest + +from app.bundle.build import build_bundle, render_html +from tests.test_bundle_build import GRAPH, MASKED_ID, RUN, SNAP_MASKED, STORE, doc_of, stored + +#: SNAP_MASKED 里的号码(phone 设了遮罩) +PHONE = SNAP_MASKED["rows"][0][1] +CELL = "cell(nodes.members, 0, 'phone')" + + +def card_with(inputs, substituted="len('000-0001')", expression=f"len({CELL})"): + """会员号码位数一张卡:报告引用的是它(m:gmv 指过来)。""" + return {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "metrics": [ + {"id": "gmv", "name": "号码位数", "value": 1280.5, "unit": "元", "decimals": 1, "expression": expression, + "substituted": substituted, "inputs": inputs}]} + + +def bundle_of(card, *, outputs=None, source_masks=None): + """一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs 里的产出)→ 口径卡 → 报告。""" + card_id, card_raw = stored(card) + 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} + events = [ + (1, "run.started", None, {"nodes": 7}), + (2, "tool.end", "members", {"tool": "db_query__crm", "query_artifact": MASKED_ID}), + (3, "node.finished", "members", {"evidence": [{"kind": "query", "node_id": "members", "artifact": MASKED_ID}]}), + ] + for node_id, content in (outputs or {}).items(): + artifact, raw = stored(content) if content is not None else ("ee" * 32, None) + if raw is not None: + files[artifact] = raw + events.append((len(events) + 1, "node.finished", node_id, {"artifact": artifact})) + events += [ + (len(events) + 1, "node.started", "card", {}), + (len(events) + 2, "node.finished", "card", {"evidence": [{"kind": "metric_set", "node_id": "card", + "artifact": card_id}]}), + (len(events) + 3, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}), + (len(events) + 4, "run.finished", None, {"output": {}}), + ] + bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash="0" * 64, graph=GRAPH, + source_masks=source_masks or {}, read=files.get, exported_at="2026-10-06T08:00:00+00:00") + entry = next(a for a in bundle["artifacts"] if a["id"] == card_id) + return bundle, entry + + +def cell_input(column, value): + return {"path": f"cell(nodes.members, 0, '{column}')", "value": value, "node_id": "members", "via": "tool_cell", + "artifact": MASKED_ID, "locator": {"row": 0, "column": column}, "status": "verified"} + + +def test_a_card_reading_a_masked_cell_ships_only_its_id(): + bundle, entry = bundle_of(card_with([cell_input("phone", PHONE)])) + assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True} + assert PHONE not in render_html(bundle) + + +def test_a_card_reading_only_unmasked_columns_is_embedded(): + """同一份快照里没遮的列(消费额):卡上没有遮罩列的值,照常嵌入、照常核对。""" + card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')") + _, entry = bundle_of(card) + assert "masked" not in entry and base64.b64decode(entry["b64"]) == stored(card)[1] + + +def test_a_mask_set_on_the_source_now_counts_too(): + card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')") + _, entry = bundle_of(card, source_masks={"crm": ["spend"]}) + assert entry.get("masked") is True and "b64" not in entry + + +PARSED = {"columns": SNAP_MASKED["columns"], "rows": SNAP_MASKED["rows"]} +POSITIONAL = "vars.res.rows[0][1]" + + +@pytest.mark.parametrize("parsed, masked", [ + # 数据整形原样解析的查询结果(带着快照的 id):认得出取自 phone 列 + ({**PARSED, "artifact": MASKED_ID, "source": "crm"}, True), + # 写变量的节点的产出读不出来:核对不了,宁可多挡 + (None, True), + # 结果没存快照、只带着数据源名:查询当时记下的遮罩无从核对,宁可多挡 + ({**PARSED, "source": "crm"}, True), + # 改过形状、既没带快照的 id 也没带数据源名:认不出出处,照常嵌入(和证据面板、推演一样,是已知的缺口) + (PARSED, False), +]) +def test_a_card_reading_a_cell_by_position_is_judged_the_same_way(parsed, masked): + item = {"path": POSITIONAL, "value": PHONE, "node_id": "parse", "via": "transform", "field": "rows[0][1]", + "status": "ok"} + card = card_with([item], substituted=f"len('{PHONE}')", expression=f"len({POSITIONAL})") + bundle, entry = bundle_of(card, outputs={"parse": parsed}) + assert (entry.get("masked") is True) is masked and ("b64" in entry) is not masked + if masked: + assert PHONE not in render_html(bundle) + else: + assert PHONE in base64.b64decode(entry["b64"]).decode() diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs index 193c6d25..08c0bf22 100644 --- a/scripts/check-bundle.mjs +++ b/scripts/check-bundle.mjs @@ -15,9 +15,9 @@ // - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的; // - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、先失败后 // 经继续运行跑到成功的早期运行、两条出具 -// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、 -// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部 -// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」; +// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列),逐份断言各项结果和原因文案;在上面篡改 ④ +// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把 +// 报告文档标成遮罩、只改页面顶部的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」; // - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端; // 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行 // 提示之后继续运行会重新封存。 @@ -487,7 +487,7 @@ await section('样本页:各种形状', async () => { check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e)) return } - check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 10, Object.keys(samples).join('、')) + check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 11, Object.keys(samples).join('、')) await sample('base', async (s, page) => { check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', @@ -601,6 +601,19 @@ await section('样本页:各种形状', async () => { check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件')) check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`) }) + // 口径卡上有输入取自遮罩列(会员号码):卡上记着号码原值和代入式,整张卡只给 id。这是正常的无法核对,不是被改过 + await sample('masked-card', async (s, page) => { + const card = s.items.find((i) => i.check === 'artifacts' && i.text.includes('口径卡')) + check('masked-card:口径卡在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」,不判不通过', + card?.result === 'na' && card.text.includes('含遮罩列,未随文件附带,只能核对到哈希'), card?.text) + const metricNumber = s.items.find((i) => i.check === 'numbers' && i.text.includes('销售额')) + check('masked-card:引用这张卡的数字无法核对,原因同上', metricNumber?.result === 'na' + && metricNumber.text.includes('含遮罩列'), metricNumber?.text) + check('masked-card:总览通过(可核对的项全部通过),没有一项不通过', s.overall === 'pass' && s.text === '可核对的项全部通过' + && !s.items.some((i) => i.result === 'fail'), `${s.overall}:${s.text}`) + const html = await page.content() + check('masked-card:页面里找不到卡上那个遮罩列的号码', !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)) @@ -655,12 +668,13 @@ await section('样本页:篡改', async () => { data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set') return '删掉口径卡条目' }, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]]) - await tamper(base, '口径卡标成遮罩', (data) => { - const card = byKind(data, 'metric_set') - delete card.b64 - card.masked = true - return '口径卡去掉内容、标遮罩' - }, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]]) + // 口径卡有输入取自遮罩列时服务端照样只给 id(见样本页 masked-card),报告文档从来不会:把它标成遮罩只能是改过 + await tamper(base, '报告文档标成遮罩', (data) => { + const doc = byKind(data, 'report_doc') + delete doc.b64 + doc.masked = true + return '报告文档去掉内容、标遮罩' + }, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照和口径卡会因遮罩不随文件附带')]]) await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [ ...turnsRed('artifacts')(s), ['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],