Files
AgentLab/backend/tests/test_bundle_masked_cards.py
T
ThearchyHelios e7fb8f6c1c 可验证报告复核修复(第 2 轮):SQL 解析不了的快照,口径卡同样整张只给 id;整行引用补全函数记法、按表函数的先后认;拒绝名单补别的会话的 SQL 原文
复核查出三处:口径卡和快照对 SQL 解析不了的结论仍不一致;整行引用的函数记法名单不全,表函数参数里「先按列认」不看先后;
拒绝名单里「别的会话的 SQL 原文」一类不全。逐条修:

- 口径卡和快照按同一个结论判(复核的第一条):SQL 每种读法都解析不了时快照只给哈希,卡这条路以前退回按 SQL 认遮罩列的
  那一套(inquiry._derived_hidden)。那一套解析不了时只看 SQL 里写没写着遮罩列,没写就只认遮罩列本身,卡上取自整行的值
  照样整张嵌入。例如 PG17 起的 JSON_QUERY(to_jsonb(m), '$.*' WITH WRAPPER) AS tel,以及 ORDER BY … USING < 这类 sqlglot
  读不了的写法,号码就这样原样进了文件。现在 _derived_columns 对不是合并查询的快照,没有一种读法读得通时整份的列都算,
  整张卡只给 id。没有 SQL、SQL 是空的也一样处理:这时快照同样只给哈希。合并查询照旧不看 SQL。
- 整行引用:
  - 函数记法的名单补上 record_out、record_send、any_value、json_agg_strict、jsonb_agg_strict。另照 PG 18 的 pg_proc
    逐个对过,补上 max、min:m.max 就是 max(m),交回最大的那一整行,复核没点到,本地实测号码在里面。补了 max、min 之后,
    写了表名的名字同样先按列认:派生来源明写出了叫 max 的列,t.max 就是那一列,按地区取最大值的子查询不受影响。数据源里
    的表有哪些列这里看不到,碰巧叫 max、min 的列宁可多挡。
  - 没写表名的名字写在表函数的参数里时,「先按列认」只看排在这个表函数前面的派生来源(_holder)。
    FROM members m, jsonb_each_text(to_jsonb(m)), (SELECT 1 AS m) s 里,s 对函数不可见,m 是整行,以前按列认,快照照常嵌入。
    没有改成「表函数参数里一律不按列认」:排在前面的派生来源明写出了同名列时,那确实是列,补了对照用例。
- 拒绝名单(不点列名也读得出表里内容的写法):
  - 别的会话的 SQL 原文:Postgres 的 pg_stat_get_activity、pg_stat_get_backend_activity、pg_stat_statements、
    pg_prepared_statements(asyncpg 连接池里的连接留着前几次查询的原文,本地 PG 18 实测);MySQL sys 库的 session、
    processlist、innodb_lock_waits、schema_table_lock_waits 及各自的 x$ 版本(只认这几张,Oracle 常写的 sys.dual 不受
    影响,补了对照用例),企业版的 audit_log_read;Oracle 的 V$OPEN_CURSOR 和审计记录(*_AUDIT_TRAIL、
    UNIFIED_AUDIT_TRAIL、AUD$、FGA_LOG$)。
  - 复核脚本里同样照常嵌入、和已拦的写法读的是同一份数据的,顺手补上:pageinspect 按索引名读页的 bt_page_items、
    pg_walinspect 的 pg_get_wal_block_info、SQLite 的 readfile、Oracle 直方图和列统计的底表 histgrm$、hist_head$、
    MariaDB 的 column_stats。大对象(lo_get、pg_largeobject)里存的不是表里的数据,没有加。

测试:口径卡补 SQL 解析不了(三种 sqlglot 读不了的真实写法)、没有 SQL、SQL 为空的用例,卡上取 tel 和 spend 都只给 id;
另补函数记法 record_out、表函数后面同名派生来源两种整行写法。查询快照补七个函数记法、WITH 里 * 加函数记法、两种表函数
先后的写法,以及二十项拒绝名单。对照组补派生来源明写出 max 列、表函数前面的派生来源明写出同名列、sys.dual,这三种照常
附带。夹具里的 SQL 都在本地 PG 18.6 上跑过,结果和用例说的一致。逐处去掉实现,相关测试都变红:
- 解析不了的退回那一套 4 项,没有 SQL 的 1 项;
- 名单不补 9 项,只去掉 max、min 2 项;
- 写了表名的不先按列认 1 项;
- 表函数参数不看先后 3 项,一律不先按列认 1 项;
- 别的会话的 SQL 不补 14 项,原始数据、统计信息不补 6 项;
- sys 整个拦掉时 sys.dual 对照变红。

后端全量 10238 项通过、11 项跳过;check-all bundle 183 项通过;check-copy 通过。
2026-10-06 19:21:23 +08:00

281 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""可验证报告里的口径卡和遮罩(期 4 I2a 复核):口径卡上记着每个输入的值和代入式,有输入取自遮罩列时整张卡只给 id。
以前只有查询快照会因遮罩不随文件附带,口径卡一律原样嵌入:证据面板上已经遮住的号码(cell() 取的遮罩格、按位置取的
vars.res.rows[0][1]),在这份要发给别人的文件里照样看得到。认哪个输入取自哪份查询结果的哪几列,和证据面板、推演同一个
认法(recompute.input_origin);核对不了的宁可多挡。那几列算不算遮罩列,除了列名对上的,SQL 里给遮罩列起了别名、套了函数、
经子查询或 WITH 转了一道的也算(同质询、双盲复算的 inquiry._derived_hidden):卡上取的 tel 可能就是 phone AS tel。
服务端的卡文件被改过(哈希对不上)也照卡上记的输入认:嵌进去的是原字节,号码照样在里面。读得出字节、却解析不出一张卡
(截断的 JSON、不是对象)的,认不出输入,整张按带遮罩算、不嵌:和读不出来的快照按带遮罩算是同一个道理。
不嵌的工件,服务端把它知道的情况照实带上:自己的文件和工件 id(哈希)对不上的标 mismatch,解析不出来的标 unparsable,
核对页据此判不通过、照实说原因(以前原字节嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢)。带遮罩的
查询快照同样标。
工件、事件都是手搭的(会员、号码都是编的),照 tests/test_bundle_build.py 的样子。
"""
from __future__ import annotations
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 设了遮罩)
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, tamper=None, rehash=False, files_changed=None):
"""一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs 里的产出)→ 口径卡 → 报告。tamper 给了就把服务端的
卡文件换成 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_changed or {})}
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]
@pytest.mark.parametrize("sql", [
"SELECT name, phone AS tel, spend FROM members",
"SELECT name, tel, spend FROM (SELECT name, phone AS tel, spend FROM members) t",
"WITH c AS (SELECT name, SUBSTR(phone, 1) AS tel, spend FROM members) SELECT name, tel, spend FROM c",
])
def test_a_card_reading_a_renamed_masked_column_ships_only_its_id(sql):
"""查询给号码起了别名、经子查询或 WITH 转了一道:卡上取的 tel 那一格就是号码。改了名字的遮罩列按 SQL 认(同质询、
双盲复算的 inquiry._derived_hidden),整张卡只给 id。以前只按列名认,号码原样进了文件。"""
snap = {**SNAP_MASKED, "columns": ["name", "tel", "spend"], "sql": sql}
snap_id, raw = stored(snap)
item = {**cell_input("tel", PHONE), "artifact": snap_id, "path": "cell(nodes.members, 0, 'tel')"}
bundle, entry = bundle_of(card_with([item], expression="len(cell(nodes.members, 0, 'tel'))"),
files_changed={snap_id: raw})
assert entry.get("masked") is True and "b64" not in entry
assert PHONE not in render_html(bundle)
@pytest.mark.parametrize("sql", [
# 列名表按位置改了名
"SELECT name, c AS tel, spend FROM members AS m(a, b, c)",
# 整行引用:FROM 里的表函数不写 LATERAL 也看得见前面的来源,整行一列列摊开;直接把整行转成文字
"SELECT name, j.value AS tel, spend FROM members m, jsonb_each_text(to_jsonb(m)) j",
"SELECT name, to_json(m)::text AS tel, spend FROM members m",
# UNION 一支里的 * 按位置对上第一支的列名
"SELECT name, tel, spend FROM (SELECT 'x' AS name, 'y' AS tel, 0 AS spend UNION ALL SELECT * FROM members) t",
# FROM 里的表函数吃进遮罩列,交出的列叫别的名字
"SELECT name, value AS tel, spend FROM members m, json_each_text(json_build_object('p', phone)) j",
# 不点列名也读得出表里内容;语法树认不准数据库实际执行什么
"SELECT name, table_to_xml('members', true, false, '')::text AS tel, spend FROM members",
"SELECT name, /*!50000 phone AS */ tel, spend FROM members",
# 函数记法交出整行的文字写法;表函数看不见排在它后面的派生来源里叫 m 的列,m 还是整行
"SELECT name, m.record_out AS tel, spend FROM members m",
"SELECT name, value AS tel, spend FROM members m, jsonb_each_text(to_jsonb(m)), (SELECT 1 AS m) s",
])
def test_a_card_reading_a_column_whose_origin_the_sql_hides_ships_only_its_id(sql):
"""快照的 SQL 认不出结果里哪一列取自哪一列(快照本身因此只给哈希):卡上取的 tel 可能就是号码,整份快照的列都按
遮罩列算,整张卡只给 id。以前这条路只按列名和改了名字的遮罩列认,快照只给哈希,卡上记着的号码却原样进了文件。"""
snap = {**SNAP_MASKED, "columns": ["name", "tel", "spend"], "sql": sql}
snap_id, raw = stored(snap)
item = {**cell_input("tel", PHONE), "artifact": snap_id, "path": "cell(nodes.members, 0, 'tel')"}
bundle, entry = bundle_of(card_with([item], expression="len(cell(nodes.members, 0, 'tel'))"),
files_changed={snap_id: raw})
assert entry.get("masked") is True and "b64" not in entry, sql
assert PHONE not in render_html(bundle)
@pytest.mark.parametrize("sql", [
# 每种读法都解析不了:PG17 起的 JSON_QUERY … WITH WRAPPER(这里把整行装进了 tel)、ORDER BY … USING
"SELECT name, JSON_QUERY(to_jsonb(m), '$.*' WITH WRAPPER) AS tel, spend FROM members m",
"SELECT name, value AS tel, spend FROM members m, jsonb_each_text(to_jsonb(m)) ORDER BY value USING <",
"SELECT name, tel, spend FROM (SELECT * FROM members) AS x(name, tel, spend) ORDER BY spend USING <",
# 没有 SQL(数据源查询工具落的快照都带着)、SQL 是空的
None,
"",
])
def test_a_card_reading_a_snapshot_whose_sql_does_not_parse_ships_only_its_id(sql):
"""快照的 SQL 每种读法都解析不了(或者根本没有):快照本身只给哈希(_untouched),认不出结果里哪一列取自哪一列,
卡上取自这份快照任一列的输入都按遮罩列算,整张卡只给 id——和快照同一个结论。以前这条路退回按 SQL 认遮罩列的那一套,
解析不了时它只看 SQL 里写没写着 phone:没写就只认 phone 本身,卡上取的 tel 是整行、号码原样进了文件。"""
snap = {**SNAP_MASKED, "columns": ["name", "tel", "spend"], "sql": sql}
if sql is None:
del snap["sql"]
snap_id, raw = stored(snap)
for column, value in (("tel", PHONE), ("spend", 320.0)):
item = {**cell_input(column, value), "artifact": snap_id, "path": f"cell(nodes.members, 0, '{column}')"}
bundle, entry = bundle_of(card_with([item], substituted=repr(value),
expression=f"len(cell(nodes.members, 0, '{column}'))"),
files_changed={snap_id: raw})
assert entry.get("masked") is True and "b64" not in entry, (sql, column)
assert PHONE not in render_html(bundle)
@pytest.mark.parametrize("sql, columns", [
("SELECT name, phone AS tel, spend FROM members", ["name", "tel", "spend"]),
# 最外层的 * 照原来的名字展开:号码还在 phone 那一列
("SELECT * FROM members", ["name", "phone", "spend"]),
])
def test_a_card_reading_an_unrenamed_column_of_that_query_is_embedded(sql, columns):
"""同一条查询,卡上取的是消费额(不取自号码):照常嵌入。"""
snap = {**SNAP_MASKED, "columns": columns, "sql": sql}
snap_id, raw = stored(snap)
item = {**cell_input("spend", 320.0), "artifact": snap_id}
card = card_with([item], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card, files_changed={snap_id: raw})
assert "masked" not in entry and base64.b64decode(entry["b64"]) == stored(card)[1]
def test_an_error_while_finding_renamed_columns_masks_the_whole_snapshot(monkeypatch):
"""按 SQL 认改了名字的遮罩列时出了意外:认不出哪几列取自遮罩列,整份快照的列都按遮罩算,卡上取消费额也只给 id。"""
from app.engine import inquiry
def broken(*_args, **_kwargs):
raise RuntimeError("解析器坏了")
monkeypatch.setattr(inquiry, "_derived_hidden", broken)
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card)
assert entry.get("masked") is True and "b64" not in entry
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()
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, "mismatch": True}
assert PHONE not in render_html(bundle)
def test_a_changed_card_without_masked_inputs_is_still_embedded_for_the_browser_to_judge():
"""改过的卡上没有遮罩列的值:照常原样嵌进去,浏览器自己算出哈希对不上(只是遮罩的判断不再看哈希)。"""
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
changed = stored(card)[1].replace(b'"caliber_version":"v1"', b'"caliber_version":"v9"')
_, entry = bundle_of(card, tamper=lambda raw: changed)
assert "masked" not in entry and base64.b64decode(entry["b64"]) == changed
@pytest.mark.parametrize("damage", [
# 截断的 JSON:号码还在已经写下的那半截里
lambda raw: raw[: raw.index(b"000-0001") + len(b"000-0001")],
# 解析得出、却不是一张卡(对象)
lambda raw: b'["000-0001"]',
])
def test_a_card_file_that_does_not_parse_as_a_card_is_not_embedded(damage):
"""服务端的卡文件读得出字节、解析不出一张卡:认不出卡上有哪些输入,整张按带遮罩算、只给 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, "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)