From 0772b42bfd85c99ba6648c1628593b787cd0c9cd Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 16:55:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?K3=20=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E6=8A=8A=E8=AE=A4=E9=81=AE=E7=BD=A9=E5=88=97=E3=80=81=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E5=80=BC=E7=9A=84=E5=85=B1=E7=94=A8=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=8E=9F=E6=A0=B7=E6=8C=AA=E5=88=B0=20engine/masking.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 SQL 认出取自遮罩列的结果列(_derived_hidden 一套,含分档标签列 _label_columns)、按值认遮罩值(_Secrets)、 快照遮好的样子(_masked_view),连同它们用到的写死值的算法,从 engine/inquiry.py 原样挪到新的 engine/masking.py, 一个字没改。接下来核查 agent 的查询工具要在交给模型之前先遮,工具这一层不该反过来依赖审批质询的模块。 inquiry 照旧按原名导出这些名字(nodes/dual_check 按 inquiry._derived_hidden 引用,测试按 inquiry 的名字引用), 行为不变:质询、双盲复算、反方审稿的相关测试全过。 --- backend/app/engine/inquiry.py | 921 +------------------------------- backend/app/engine/masking.py | 954 ++++++++++++++++++++++++++++++++++ 2 files changed, 966 insertions(+), 909 deletions(-) create mode 100644 backend/app/engine/masking.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 969c52de..67ad717e 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -77,7 +77,6 @@ from __future__ import annotations import asyncio import contextlib import itertools -import json import logging import operator import re @@ -99,9 +98,18 @@ from app.data.provenance_types import DOC_PROVENANCE from app.db.base import SessionLocal from app.db.models import Approval, Artifact, DataSource, Run, RunEvent from app.engine import judge as judging -from app.engine.evidence import MARKER_RE, RenderError, compose_doc, iter_segments, iter_units, render_cell -from app.engine.expressions import numeric_text +from app.engine.evidence import MARKER_RE, compose_doc, iter_segments, iter_units from app.engine.issuance import extract_numbers +# 认遮罩列、遮罩值的共用部分在 engine/masking.py。名字照旧从这里导出:nodes/dual_check、测试按 inquiry 的名字引用 +from app.engine.masking import ( # noqa: F401 + SECRETS_MAX, SECRET_DIGITS, SECRET_MIN, _ABSENT, _DIGITS, _EXPONENT_MAX, _LabelMemo, _NUMERIC, _PLAIN_NUMBER, + _PREFIX, _Secrets, _WORD, _WORDS, _all_labels, _arithmetic, _band_only, _block_label, _block_label_of, _blocks, + _branch_results, _branches, _canonical, _constant, _constants, _decimal, _derived_block, _derived_hidden, + _digits_of, _fixed, _forms, _from_sources, _is_star, _item_label, _label_columns, _leaves, _literal_types, + _masked_view, _masked_word, _mentions, _merging, _numeric, _output_name, _pattern, _places, _plain_text, + _point_text, _query_shaped, _result_label, _snapshot_labels, _source_label, _source_names, _star_label, _strong, + _tainted, _value_columns, _worked_out, +) from app.engine.merge_query import MERGE_SOURCE from app.engine.nodes.challenge import mark_original, with_note from app.engine.run_append import append_events @@ -142,13 +150,6 @@ REPORT_CHARS = 4000 REFS_MAX = 40 #: 被问的是表格里的一格时,交给 agent 的表格最多几行(不含表头) TABLE_ROWS = 20 -#: 按值认遮罩值的门槛:文字至少这么多字(一个字的「是」「男」换掉,会把不相干的词也换得面目全非);纯数字至少这么多位 -#: (年龄、等级、小额金额这类短的数和阈值、天数、日期、LIMIT 里的数太容易撞上,按值换会把实际执行的 SQL、提问人自己 -#: 的条件改得面目全非。它们只在 SQL 里和遮罩列做等值一类比较时才换,见 _scrub_sql;引用到的格子照旧按列遮) -SECRET_MIN = 2 -SECRET_DIGITS = 5 -#: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换 -SECRETS_MAX = 20_000 #: 没写进运行记录的回答在进程里留多久、最多留几条:这段时间里质询工件接口照样认它引用的工件 HELD_TTL_S = 3600 HELD_MAX = 200 @@ -158,8 +159,6 @@ _TERMINAL = ("succeeded", "failed", "cancelled") #: 比较;找两个条件的公共祖先,一步是往上走一层):几百个分档边界连成一长串时,挨个细看要算上好几秒。超出之后两边 #: 都隔开的一律按夹住算(宁可多换),分档很多时会成批多遮 PIN_WORK = 100_000 -#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截) -_WORD = re.compile(r"[0-9A-Za-z_]") INQUIRY_SYSTEM = ( "你在帮人核对一份报告:提问人针对报告里的一句话提了问题,你查库作答。你不写报告,也不改报告。\n" @@ -648,14 +647,10 @@ def _task(op: _Opening, ask: Ask, target: _Target | None, excerpts: list[str], r # -------------------------------------------------------------------------- -# 遮罩:哪些列 +# 遮罩(哪些列、哪些值的认法在 engine/masking.py) # -------------------------------------------------------------------------- -def _query_shaped(content: Any) -> bool: - return isinstance(content, dict) and isinstance(content.get("rows"), list) - - async def _hidden_columns(snapshot: dict[str, Any], masks: Any) -> tuple[set[str], list[str]]: """一份查询快照要遮的列(小写)和已经不在的数据源,照证据面板的规则(api/evidence.py 的 _query_step):查询当时 记下的,加上数据源现在设的;合并查询按它合并的那几个数据源算。""" @@ -666,729 +661,6 @@ async def _hidden_columns(snapshot: dict[str, Any], masks: Any) -> tuple[set[str cols, found = await masks.of(snapshot.get("source")) return cols | _recorded_masks(snapshot), ([] if found or not snapshot.get("source") else [str(snapshot["source"])]) - -def _masked_word() -> str: - from app.api.evidence import MASKED - - return MASKED - - -def _value_columns(node: Any) -> set[str]: - """一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的 - 分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询 - 只看它选出来的列(它的条件、连接里写的列带不出值)。""" - from sqlglot import exp - - out: set[str] = set() - stack = [node] - while stack: - item = stack.pop() - if isinstance(item, exp.Column): - if not isinstance(item.this, exp.Star) and item.name: - out.add(item.name.lower()) - elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)): - continue - elif isinstance(item, exp.Window): - stack.append(item.this) - elif isinstance(item, exp.Select): - stack.extend(item.expressions) - else: - stack.extend(item.iter_expressions()) - return out - - -def _branch_results(node: Any) -> list[Any] | None: - """CASE 的各个结果(每个 THEN,加上 ELSE;没写 ELSE 记 None,就是 NULL)、IIF / IF 的真假两支;别的写法为 None。 - CASE 里的每个 WHEN … THEN 在语法树里也是 If,不单算。""" - from sqlglot import exp - - if isinstance(node, exp.Case): - return [branch.args.get("true") for branch in node.args.get("ifs") or []] + [node.args.get("default")] - if isinstance(node, exp.If) and not isinstance(node.parent, exp.Case): - return [node.args.get("true"), node.args.get("false")] - return None - - -def _fixed(node: Any) -> bool: - """写死的结果:字面量(几个数算出来的也算)、NULL、TRUE / FALSE、没写的 ELSE。""" - from sqlglot import exp - - return node is None or isinstance(node, (exp.Null, exp.Boolean)) or _constant(node) is not None - - -def _band_only(node: Any) -> bool: - """按遮罩列分档、只带出档位或个数的 CASE / IIF:结果全是写死的常量(标签、档位编号、1 和 0),而且 - - 它本身就是一个选出列(起不起别名都行),又是这个 SELECT 的分组键(GROUP BY 按位置、按别名、照原样再写一遍都算): - 每一档一行,这一列只说这一行是哪一档; - - 或者套在汇总里(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END)、AVG(IIF(…, 1.0, 0))):按条件数个数、算占比, - 同 COUNT。 - 都带不出遮罩列的值,同按阈值数个数(col >= v 的 COUNT)。不分组、不汇总的逐行标签照旧算派生值,那是每一笔各落在 - 哪一档;汇总遮罩列的值本身(SUM(amount)、CASE 里有一支取 amount 的)也照旧算。""" - from sqlglot import exp - - results = _branch_results(node) - if results is None or not all(_fixed(r) for r in results): - return False - parent = node.parent - while parent is not None and not isinstance(parent, (exp.Select, exp.Subquery)): - if isinstance(parent, exp.AggFunc): - return True - parent = parent.parent - item = node - while isinstance(item.parent, (exp.Paren, exp.Alias)) and item.arg_key == "this": - item = item.parent - select = item.parent - if not isinstance(select, exp.Select) or item.arg_key != "expressions" or select.args.get("group") is None: - return False - place = next((str(i + 1) for i, e in enumerate(select.expressions) if e is item), None) - alias = item.alias.lower() if isinstance(item, exp.Alias) and item.alias else None - for key in select.args["group"].expressions: - while isinstance(key, exp.Paren): - key = key.this - if (isinstance(key, exp.Literal) and not key.is_string and key.this == place) or key == node or ( - alias and isinstance(key, exp.Column) and not key.table and key.name.lower() == alias): - return True - return False - - -def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, set[str]]: - """结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号) - 是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的 - 标签、回答正文里的档名都会被换成「已遮罩」。 - 只装着标签:结果的这一列在 UNION 这类的每一支里,都是结果全为写死常量的 CASE / IIF,或者取自派生来源(子查询、 - CTE、LATERAL)里只装着标签的一列(_item_label)。一项取自哪个来源按作用域认,不按名字猜:按名字认时,遮罩列原样 - 选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出, - 等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、 - 递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都 - 不算。""" - from app.data.sql_semantics import _parse, _read - - try: - root, _ = _parse(sql, _read(kind)) - except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理(标签照旧当遮罩值,宁可多遮) - root = None - if root is None: - return {} - try: - result = next((top for top, role, _listed in _blocks(root) if role == "result"), None) - if result is None: - return {} - memo: _LabelMemo = {} - found: dict[str, list[set[str] | None]] = {} - for place, column in enumerate(columns): - name = column.lower() - found.setdefault(name, []).append(_result_label(result, place, name, len(columns), memo)) - return {name: texts for name, parts in found.items() if (texts := _all_labels(parts)) is not None} - except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:一个都不算(宁可多遮) - logger.warning("质询的 SQL 标签列分析失败", exc_info=True) - return {} - - -#: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开 -_ABSENT: Any = object() -#: 标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT -_LabelMemo = dict[tuple[int, str], Any] - - -def _result_label(top: Any, place: int, name: str, width: int, memo: _LabelMemo) -> set[str] | None: - """结果(width 列)的第 place 列(列名 name)只装着标签时标签的写法,不然为 None:UNION 这类的每一支,那一项都要 - 只装着标签。落在 * 里的(认不出是哪一项)按列名经 * 往下找,只有一支时才找(几支的 * 各展开成什么说不清)。""" - from sqlglot import exp - - leaves = _leaves(top) - if not all(isinstance(leaf, exp.Select) for leaf in leaves): - return None - out: set[str] = set() - for select in leaves: - items = list(select.expressions) - item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) - if item is not None: - texts = _item_label(item, select, memo) - elif len(leaves) == 1: - texts = _star_label(select, name, memo) - else: - return None - if texts is None or texts is _ABSENT: - return None - out |= texts - return out - - -def _item_label(item: Any, select: Any, memo: _LabelMemo) -> set[str] | None: - """select 的一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种 - 写法:25、25.0);单独一列取它在 FROM 里取自的那一列的(_source_label);别的为 None。""" - from sqlglot import exp - - node = item - while isinstance(node, (exp.Alias, exp.Paren)): - node = node.this - results = _branch_results(node) - if results is not None: - if not all(_fixed(r) for r in results): - return None - out: set[str] = set() - for result in results: - value = _constant(result) if result is not None else None - if isinstance(value, Decimal): - out |= {_point_text(value), format(value, "f")} - elif value is not None: - out.add(_plain_text(value)) - return out - if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name: - texts = _source_label(select, node.table.lower() or None, node.name.lower(), memo) - return None if texts is _ABSENT else texts - return None - - -def _star_label(select: Any, name: str, memo: _LabelMemo) -> Any: - """select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的 - 装着别的、认不准为 None。""" - from sqlglot import exp - - out: set[str] = set() - seen = False - for item in select.expressions: - if not _is_star(item): - continue - qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None - texts = _source_label(select, qualifier, name, memo) - if texts is None: - return None - if texts is not _ABSENT: - out |= texts - seen = True - return out if seen else _ABSENT - - -def _source_label(select: Any, qualifier: str | None, name: str, memo: _LabelMemo) -> Any: - """select 的 FROM、JOIN 里读到的那一列(qualifier 是写的表名或别名,没写为 None)只装着标签时标签的写法;没有哪个 - 来源出这一列为 _ABSENT,出这一列但装着别的、认不准为 None。 - - 写了表名的只看那个来源(对不上的是外层的表,认不准); - - 没写的看所有来源:派生来源出这一列的就是它——SQL 执行过,别的来源就没有同名的列,不然会报列名有歧义。按这一列 - 连起来的(USING 里写着它、NATURAL)例外:几个来源的这一列合成一列,外连接时取的可能是另一边的值; - - 数据源里的表一律不算(遮罩列本身、别的列原样选出来,哪怕和标签同名),VALUES、函数这类来源也认不准。""" - sources = _from_sources(select) - if qualifier is not None: - sources = [(alias, source) for alias, source in sources if alias == qualifier] - if len(sources) != 1: - return None - found: list[set[str]] = [] - opaque = False - for _alias, source in sources: - derived = _derived_block(source) - if derived is None: - opaque = True - continue - texts = _block_label(derived[0], derived[1], name, memo) - if texts is None: - return None - if texts is not _ABSENT: - found.append(texts) - if not found: - return None if opaque else _ABSENT - if opaque and _merging(select, name): - return None - return set().union(*found) - - -def _block_label(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: - """派生来源的那块查询(listed 是写的列名表)出的 name 这一列只装着标签时标签的写法;不出这一列为 _ABSENT,出这一列 - 但装着别的、认不准为 None。一条 SELECT 按选出项的名字找,没有的经 * 往下找;UNION 这类按位置看每一支,列名取写的 - 列名表,没写的取第一支的。""" - key = (id(block), name) - if key not in memo: - # 先记成 None:递归的 CTE 绕回自己时按认不准算 - memo[key] = None - memo[key] = _block_label_of(block, listed, name, memo) - return memo[key] - - -def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: - """_block_label 真去找的那一步(不看记下的结果)。""" - from sqlglot import exp - - leaves = _leaves(block) - if not all(isinstance(leaf, exp.Select) for leaf in leaves): - return None - if listed is None and len(leaves) == 1: - named = [item for item in leaves[0].expressions if _output_name(item) == name] - if not named: - return _star_label(leaves[0], name, memo) - return _all_labels([_item_label(item, leaves[0], memo) for item in named]) - width: int | None = None - if listed is not None: - if name not in listed: - return _ABSENT - place, width = (listed.index(name) if listed.count(name) == 1 else None), len(listed) - else: - first = list(leaves[0].expressions) - places = [p for item, p in zip(first, _places(first, None)) if _output_name(item) == name] - if not places: - # 第一支没明写这一列:有 * 时说不清它在不在、在哪个位置 - return None if any(_is_star(item) for item in first) else _ABSENT - place = places[0] if len(places) == 1 else None - if place is None: - return None - texts: list[set[str] | None] = [] - for select in leaves: - items = list(select.expressions) - item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) - texts.append(_item_label(item, select, memo) if item is not None else None) - return _all_labels(texts) - - -def _all_labels(texts: list[set[str] | None]) -> set[str] | None: - """几项都只装着标签时,标签的写法合起来;有一项不是为 None。""" - return set().union(*texts) if all(t is not None for t in texts) else None - - -def _from_sources(select: Any) -> list[tuple[str, Any]]: - """一条 SELECT 的 FROM、JOIN 里的来源:(名字, 来源节点),名字是别名,没起别名的是表名(小写)。""" - from_ = select.args.get("from_") - nodes = [from_.this] if from_ is not None and from_.this is not None else [] - nodes += [join.this for join in select.args.get("joins") or [] if join.this is not None] - return [((node.alias_or_name or "").lower(), node) for node in nodes] - - -def _derived_block(source: Any) -> tuple[Any, list[str] | None] | None: - """FROM、JOIN 里的一个来源 → (它的那块查询, 写的列名表):FROM 里的子查询、LATERAL、指向 WITH 里一段的表名。数据源里 - 的表、VALUES、函数这类为 None(出哪些列、装着什么,SQL 里看不出)。""" - from sqlglot import exp - - from app.data.sql_semantics import _visible_ctes - - node, alias = source, None - if isinstance(node, exp.Lateral): - node, alias = node.this, node.args.get("alias") - if isinstance(node, exp.Table): - if node.args.get("db") or node.args.get("catalog") or not isinstance(node.this, exp.Identifier): - return None - cte = _visible_ctes(node).get(node.name.lower()) - if cte is None: - return None - node, alias = cte.this, cte.args.get("alias") - while isinstance(node, exp.Subquery): - alias = alias or node.args.get("alias") - node = node.this - if not isinstance(node, (exp.Select, exp.SetOperation)): - return None - return node, ([c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None) - - -def _merging(select: Any, name: str) -> bool: - """FROM 里按同名的 name 这一列连起来(USING 里写着它,或者 NATURAL):几个来源的这一列合成一列。""" - return any(str(join.args.get("method") or "").upper() == "NATURAL" - or any(str(getattr(u, "name", "")).lower() == name for u in join.args.get("using") or []) - for join in select.args.get("joins") or []) - - -def _is_star(item: Any) -> bool: - from sqlglot import exp - - return isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)) - - -def _leaves(root: Any) -> list[Any]: - """一块查询的每一支:UNION 这类连起来的按先后摊开,套在外面的括号去掉。一般是 SELECT,也可能是 VALUES 这类。""" - from sqlglot import exp - - out: list[Any] = [] - stack = [root] - while stack: - node = stack.pop() - if isinstance(node, exp.Subquery): - stack.append(node.this) - elif isinstance(node, exp.SetOperation): - stack.extend((node.expression, node.this)) - else: - out.append(node) - return out - - -def _branches(root: Any) -> list[list[Any]]: - """一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" - from sqlglot import exp - - return [list(node.expressions) for node in _leaves(root) if isinstance(node, exp.Select)] - - -def _output_name(item: Any) -> str | None: - """一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、 - 子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?), - 认不准,返回 None。""" - from sqlglot import exp - - if isinstance(item, exp.Alias): - return item.alias.lower() or None - if isinstance(item, exp.Column) and not isinstance(item.this, exp.Star): - return item.name.lower() or None - return None - - -def _places(branch: list[Any], width: int | None) -> list[int | None]: - """一支里每一项落在结果的第几列。没有 * 的一一对上;有 * 的,第一个 * 前面的从头数,最后一个 * 后面的从结果末尾 - 倒着数(width 是结果的列数,不知道就数不了);两个 * 之间的、* 本身认不出位置(None)。""" - stars = [i for i, item in enumerate(branch) if _is_star(item)] - if not stars: - return list(range(len(branch))) - first, last = stars[0], stars[-1] - tail = len(branch) - 1 - last - # * 至少展开成 0 列:倒着数要是数到了 * 前面那几项的位置上,列数对不上,后面的一项都不认 - countable = width is not None and width - tail >= first - return [i if i < first else width - (len(branch) - i) if i > last and countable else None - for i in range(len(branch))] - - -def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: - """SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪: - - "result":最外层,结果就是快照的列; - - "source":当成一张表用的——WITH 里的一段、FROM / JOIN 里的子查询、LATERAL,外层按列名取它的列,* 会把它的列 - 全带出去。写了列名表的(t(p))带上列名表; - - "other":标量子查询、IN / EXISTS 里的,值经外层的表达式带出(_value_columns 已经算上),列名无关紧要。 - 套在外面的括号(可能好几层)不改变结果流到哪:先越过它们再看。UNION 里的一支不另算,整串按一块。""" - from sqlglot import exp - - out: list[tuple[Any, str, list[str] | None]] = [] - for node in root.find_all(exp.Select, exp.SetOperation): - wrappers: list[Any] = [] - outer = node - while isinstance(outer.parent, exp.Subquery): - outer = outer.parent - wrappers.append(outer) - parent = outer.parent - if isinstance(parent, exp.SetOperation): - continue - if parent is None: - out.append((node, "result", None)) - elif isinstance(parent, (exp.CTE, exp.Lateral)) or (wrappers and isinstance(parent, (exp.From, exp.Join))): - # 别名(和列名表)记在 WITH 的那一段、LATERAL 上,FROM 里的子查询记在带别名的那层括号上 - alias = next((h.args["alias"] for h in [parent, *reversed(wrappers)] if h.args.get("alias")), None) - listed = [c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None - out.append((node, "source", listed)) - else: - out.append((node, "other", None)) - return out - - -def _source_names(top: Any, listed: list[str] | None, names: set[str]) -> tuple[set[str], bool]: - """派生来源的结果里值取自遮罩列的那几列的名字,以及有没有认不出名字的(没起别名的表达式、两个 * 之间的项)。 - 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。""" - branches = _branches(top) - found: set[str] = set() - unknown = False - - def take(name: str | None) -> None: - nonlocal unknown - if name is None: - unknown = True - else: - found.add(name) - - if listed is not None: - for branch in branches: - for item, place in zip(branch, _places(branch, len(listed))): - if names & _value_columns(item): - take(listed[place] if place is not None and place < len(listed) else None) - return found, unknown - first = branches[0] if branches else [] - for item in first: - if names & _value_columns(item): - take(_output_name(item)) - if len(branches) > 1: - # 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置 - width = next((len(b) for b in branches if not any(_is_star(i) for i in b)), None) - heads = {place: _output_name(item) for item, place in zip(first, _places(first, width)) if place is not None} - for branch in branches[1:]: - for item, place in zip(branch, _places(branch, width)): - if names & _value_columns(item): - take(heads.get(place) if place is not None else None) - return found, unknown - - -def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[set[str], set[int]] | None: - """SQL 里指代遮罩列的名字(小写),和结果(width 列)里值取自遮罩列的那几列的位置。解析不了返回 None。 - - 名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的 - 名字也算;派生来源(子查询、WITH 里的一段)按位置对上列名表、UNION 第一支的列名,也算,直到不再多出名字。同名 - 不同层的也一并算上(宁可多遮)。 - 位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、 - 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 - 又认不准)——整份快照的列都算(同解析不了)。""" - from sqlglot import exp - - from app.data.sql_semantics import _parse, _read - - try: - root, _ = _parse(sql, _read(kind)) - except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理 - root = None - if root is None: - return None - try: - names = set(masked) - selects = list(root.find_all(exp.Select)) - blocks = _blocks(root) - sources = [(top, listed) for top, role, listed in blocks if role == "source"] - for _ in range(len(selects) + len(sources) + 1): - before = len(names) - for select_ in selects: - for item in select_.expressions: - name = item.alias_or_name.lower() - if name and name not in names and names & _value_columns(item): - names.add(name) - for top, listed in sources: - names |= _source_names(top, listed, names)[0] - if len(names) == before: - break - everything = any(_source_names(top, listed, names)[1] for top, listed in sources) - positions: set[int] = set() - for top, role, _listed in blocks: - if role != "result": - continue - for branch in _branches(top): - for item, place in zip(branch, _places(branch, width)): - if names & _value_columns(item): - if place is None or place >= width: - everything = True - else: - positions.add(place) - return names, set(range(width)) if everything else positions - except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮) - logger.warning("质询的 SQL 遮罩分析失败", exc_info=True) - return None - - -def _mentions(sql: str, names: Iterable[str]) -> bool: - return any(re.search(rf"(? tuple[set[str], set[str]]: - """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。 - - 证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名 - (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的,整份快照的 - 列都遮掉;合并查询的快照不看 SQL,它记着自己的来历(_hidden_columns 已按 _merge_masks 遮好)。""" - base = set(hidden) - sql = snapshot.get("sql") - if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: - return base, base - columns = [str(c) for c in snapshot.get("columns") or []] - found = _tainted(sql, kind, base, len(columns)) - if found is None: - return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) - names, positions = found - return names | {columns[i].lower() for i in positions if i < len(columns)}, names - - -# -------------------------------------------------------------------------- -# 遮罩:哪些值 -# -------------------------------------------------------------------------- - -#: 一个数的写法:可带正负号、千分位、小数、百分号 -_NUMERIC = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?") -#: 正文里一个个「词」:字母数字下划线连成的一串。头尾都是这种字的遮罩值,只能整个对上这样一串 -_WORDS = re.compile(r"[0-9A-Za-z_]+") -#: 正文里的一串数(可带千分位、小数):带千分位的遮罩值按去掉逗号的样子比 -_DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?") -#: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition) -_PREFIX = 8 - - -def _plain_text(value: Any) -> str: - text = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value) - return re.sub(r"\s+", " ", text).strip() - - -def _numeric(value: Any) -> bool: - if isinstance(value, bool): - return False - return isinstance(value, (int, float)) or (isinstance(value, str) and numeric_text(value) is not None) - - -def _forms(value: Any) -> set[str]: - """一格的值在正文里可能的写法:原样(压成一行),是数的再加上千分位的写法(13,900,000,001)。""" - text = _plain_text(value) - forms = {text} if text else set() - if _numeric(value): - with contextlib.suppress(RenderError, ValueError, ArithmeticError): - forms.add(render_cell(value)) - return forms - - -def _strong(form: str) -> bool: - """按值认得出的写法(见 SECRET_MIN、SECRET_DIGITS)。""" - if _NUMERIC.fullmatch(form): - return sum(c.isdigit() for c in form) >= SECRET_DIGITS - return len(form) >= SECRET_MIN - - -def _digits_of(form: str) -> str: - return form.replace(",", "").lstrip("+-").rstrip("%") - - -def _pattern(value: str) -> str: - """一个值的正则:头尾是字母数字的按词边界认;数字还不能是更长的数、日期、时间的一截(12.5 里的 2.5、2026-01-25 里的 - 25、12:30 里的 30),也不能是千分位写法里的一组(1,390 里的 390)。逗号隔开的一串长号码(13900000001,13900000002) - 各自照认:千分位一组只有三位。""" - head = tail = "" - if _WORD.match(value[0]): - head = r"(? None: - """收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的 - 格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。""" - if not _query_shaped(snapshot) or not hidden: - return - own = set(base) - written = {name: texts for name, texts in (labels or {}).items() if name not in own} - columns = [str(c) for c in snapshot.get("columns") or []] - index = [i for i, c in enumerate(columns) if c.lower() in hidden] - for row in snapshot["rows"]: - if len(self.weak) >= SECRETS_MAX: - break - if isinstance(row, list): - cells = [(columns[i].lower(), row[i]) for i in index if i < len(row)] - elif isinstance(row, dict): - cells = [(str(k).lower(), v) for k, v in row.items() if str(k).lower() in hidden] - else: - continue - for name, value in cells: - if value is None or isinstance(value, bool): - continue - forms = _forms(value) - if forms & written.get(name, set()): - continue - for form in forms: - self.weak.add(form) - if _strong(form): - self.strong.add(form) - self._index = None - - def protect(self, texts: Iterable[str | None]) -> None: - """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), - 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。""" - given = " ".join(t for t in texts if t) - if not given: - return - numbers = {_digits_of(n) for n in _DIGITS.findall(given)} - - def asked(form: str) -> bool: - return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given - - self.strong = {f for f in self.strong if not asked(f)} - self.weak = {f for f in self.weak if not asked(f)} - self._index, self._compiled = None, {} - - def show(self, numbers: Iterable[str]) -> None: - """SQL 里照原样留着的阈值、行数(_shown_literals):回答正文里复述它们不换(「金额不低于 30000 元」),换了只会 - 让回答和 SQL 对不上。只免正文:格子、列名、SQL 里照旧按值换——格子里的值正好是这个数,可能就是那个人 - (col >= v 再按 col 排序取第一行,查出来的就是 v)。""" - self.shown = set(numbers) - - def is_shown(self, form: str) -> bool: - return bool(_NUMERIC.fullmatch(form)) and _digits_of(form) in self.shown - - def hit(self, value: Any) -> bool: - """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。""" - if value is None or isinstance(value, bool) or not self.strong: - return False - text = _plain_text(value) - if text in self.strong: - return True - if _numeric(value) and sum(c.isdigit() for c in text) >= SECRET_DIGITS: - with contextlib.suppress(RenderError, ValueError, ArithmeticError): - return render_cell(value) in self.strong - return False - - def weak_hit(self, text: str) -> bool: - """SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。""" - return _plain_text(text) in self.weak - - def scrub(self, text: str, *, prose: bool = False) -> str: - """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。""" - if not text or not self.strong: - return text - pattern = self.pattern(text, prose=prose) - return pattern.sub(_masked_word(), text) if pattern is not None else text - - def pattern(self, text: str, *, prose: bool = False) -> re.Pattern[str] | None: - """认 text 里出现的遮罩值的正则;一个都没有时为 None。prose 同 scrub。""" - found = self._found(text) if text and self.strong else set() - if prose and self.shown: - found = {v for v in found if not self.is_shown(v)} - if not found: - return None - key = frozenset(found) - if key not in self._compiled: - if len(self._compiled) > 64: - self._compiled = {} - # 长的在前:13900000001 先于它的一截被认出来 - self._compiled[key] = re.compile("|".join(_pattern(v) for v in sorted(found, key=lambda v: (-len(v), v)))) - return self._compiled[key] - - def _found(self, text: str) -> set[str]: - words, numbers, others = self._partition() - found = {w for w in _WORDS.findall(text) if w in words} - if numbers: - for run in _DIGITS.findall(text): - found |= numbers.get(_digits_of(run), set()) - for i in range(len(text) - 1) if others else (): - for size, buckets in others.items(): - for length, values in buckets.get(text[i:i + size], {}).items(): - if (piece := text[i:i + length]) in values: - found.add(piece) - return found - - def prepare(self) -> None: - """先把索引建好(收完值、去掉提问人写的之后):上万个值建一次要几十毫秒,放在线程里建,别等到事件循环里第一次换。""" - self._partition() - - def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]]]: - """strong 分成三类:只有字母数字的(按词比)、带千分位小数的数(按去掉逗号的数比)、其余的(中文、日期、 - 邮箱)。其余的按开头至多 _PREFIX 个字分桶、桶里再按长短分:同一个市区的上万个地址开头七八个字都一样,只按开头 - 分的话,正文里每碰到一次那几个字都要逐个比;再按长短分,碰到一次只是按几种长短各查一下。三类各自只在正文里 - 真出现时才进正则。""" - if self._index is None: - words: set[str] = set() - numbers: dict[str, set[str]] = {} - others: dict[int, dict[str, dict[int, set[str]]]] = {} - for value in self.strong: - if _WORDS.fullmatch(value): - words.add(value) - elif _NUMERIC.fullmatch(value): - numbers.setdefault(_digits_of(value), set()).add(value) - else: - size = min(len(value), _PREFIX) - others.setdefault(size, {}).setdefault(value[:size], {}).setdefault(len(value), set()).add(value) - self._index = (words, numbers, others) - return self._index - - #: 单元格引用的前一截(Q1.r0.):后面才是列名 _CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.") @@ -1417,66 +689,6 @@ def _scrubbed_source(text: str, secrets: _Secrets) -> str: return "".join(out) -def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False - ) -> tuple[dict[str, Any], list[str]]: - """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); - 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 - 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 - 原快照不动;没有要遮的返回原快照。 - - embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号),把那一截换成「已遮罩」,这一格别的字 - 照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 - 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。""" - columns = [str(c) for c in snapshot.get("columns") or []] - index = {i for i, c in enumerate(columns) if c.lower() in hidden} - by_value = bool(secrets.strong) - if not index and not by_value: - return snapshot, [] - word = _masked_word() - touched = set(index) - edited = False - position = {c: i for i, c in enumerate(columns)} - names = [secrets.scrub(c) for c in columns] if by_value else columns - - def cell(i: int | None, value: Any) -> Any: - nonlocal edited - if i in index: - return word - if by_value and secrets.hit(value): - if i is not None: - touched.add(i) - return word - if embedded and by_value and isinstance(value, (str, dict, list)): - # JSON 列(对象、数组)按界面画出来的样子换,换过的照文字给 - text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) - if (scrubbed := secrets.scrub(text)) != text: - edited = True - return scrubbed - return value - - def key(k: Any) -> Any: - i = position.get(str(k)) - return names[i] if i is not None else k - - rows: list[Any] = [] - for row in snapshot["rows"]: - if isinstance(row, list): - rows.append([cell(i, v) for i, v in enumerate(row)]) - elif isinstance(row, dict): - rows.append({key(k): word if str(k).lower() in hidden else cell(position.get(str(k)), v) - for k, v in row.items()}) - else: - rows.append(row) - renamed = names != columns - if not touched and not renamed and not edited: - return snapshot, [] - types = {key(k): v for k, v in snapshot["column_types"].items()} \ - if isinstance(snapshot.get("column_types"), dict) else {} - types.update({names[i]: "text" for i in index}) - view = {**snapshot, "rows": rows, "column_types": types, **({"columns": names} if renamed else {})} - return view, [names[i] for i in sorted(touched)] - - #: 按词认在找某一个值的比较(SQL 解析不了时的退路,见 _context):col = 'x'、col <> 'x'、col LIKE 'x'、SQLite 的 #: col IS 'x'(IN 列表另外认) _EQUAL_TOKENS = frozenset({"EQ", "NEQ", "NULLSAFE_EQ", "LIKE", "ILIKE", "GLOB", "RLIKE", "IS"}) @@ -1491,8 +703,6 @@ _VALUE_ENDS = frozenset({"NUMBER", "VAR", "IDENTIFIER", "R_PAREN", "R_BRACKET", _KEPT = frozenset({"range", "limit"}) #: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25(等值一类翻过来还是它自己) _FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">=", "=": "=", "<>": "<>"} -#: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样 -_PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?") def _is_literal(kind: str) -> bool: @@ -1657,91 +867,6 @@ def _exact_ops() -> dict[Any, str]: return {exp.EQ: "=", exp.NullSafeEQ: "=", exp.Is: "=", exp.NEQ: "<>", exp.NullSafeNEQ: "<>"} -def _arithmetic() -> tuple[Any, ...]: - """加减乘除、取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //):几个写死的数算出来的整个算式按算出来的那一个值认 - (13900000000 + 1 就是 13900000001,125 % 100 就是 25)。""" - from sqlglot import exp - - return exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.IntDiv - - -#: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位) -_EXPONENT_MAX = 40 - - -def _decimal(text: str) -> Decimal | None: - """一段字按数读(025、2.5e1、13900000001.0 都认);读不了、不是有限的数、指数太大为 None。""" - try: - value = Decimal(text.strip()) - except (ArithmeticError, ValueError): - return None - return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None - - -def _canonical(text: str) -> str | None: - """一个数的规范写法(_point_text):25.0、025、2.5e1 都是 25,13900000001.0 是 13900000001。SQL 里换个写法写同一个 - 数,照样认得出是不是遮罩值(_swap_literal)。不是数为 None。""" - value = _decimal(text) - return _point_text(value) if value is not None else None - - -def _constant(node: Any) -> Any: - """写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1;025、2.5e1 这样的写法也按数),字符串按原文 - (纯粹的数照样按数);别的为 None。""" - return _constants(node).get(id(node)) if node is not None else None - - -def _constants(root: Any) -> dict[int, Any]: - """一棵语法树里每个写死的值(_constant):id(节点) → 值。从下往上一层层算(广度优先的倒序,孩子总在父亲前面), - 不递归:上千项的连加(1+1+…)会超过递归深度,以前整条 SQL 因此退回按词认,同一句里夹住的值就认不出了。""" - from sqlglot import exp - - out: dict[int, Any] = {} - for node in reversed(list(root.walk())): - value: Any = None - if isinstance(node, exp.Neg): - inner = out.get(id(node.this)) - value = -inner if isinstance(inner, Decimal) else None - elif isinstance(node, (exp.Paren, exp.Cast, exp.Collate)): - value = out.get(id(node.this)) - elif isinstance(node, exp.Introducer): - value = out.get(id(node.expression)) - elif isinstance(node, _literal_types()): - text = str(node.this).strip() - number = Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else ( - _decimal(text) if isinstance(node, exp.Literal) and not node.is_string else None) - value = number if number is not None else text - elif isinstance(node, _arithmetic()): - value = _worked_out(node, out.get(id(node.this)), out.get(id(node.expression))) - if value is not None: - out[id(node)] = value - return out - - -def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None: - """一步加减乘除、取模、整除的结果;不是两个数、除以零、结果太大为 None。取模、整除照 SQL 往零取整(-7 % 2 是 -1, - -7 DIV 2 是 -3,同 Decimal 的 % 和 //)。/ 在 SQLite、Postgres 这类方言里两个整数相除只留整数部分(解析时记在 - typed 上:53 / 2 是 26),写成小数的(53.0 / 2)、别的方言照小数算。以前一律按小数算,SQLite 里 age >= 25 AND - age < 53 / 2 夹住了 25 却认不出。""" - from sqlglot import exp - - if not (isinstance(a, Decimal) and isinstance(b, Decimal)): - return None - try: - if isinstance(node, (exp.Div, exp.IntDiv, exp.Mod)): - if not b: - return None - # 写成整数的数(和它们加减乘出来的)指数正好是 0;53.0、1e1 这样写的在 SQL 里不是整数 - whole = isinstance(node, exp.IntDiv) or ( - node.args.get("typed") and a.as_tuple().exponent == 0 and b.as_tuple().exponent == 0) - value = a % b if isinstance(node, exp.Mod) else a // b if whole else a / b - else: - value = a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b - except ArithmeticError: - return None - return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None - - def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]: """写死的值往上能越过的外壳和算式:id(节点) → 越过之后走到的那一层(13900000000 + 1 里的两个数都走到整个算式, (25) 里的 25 走到括号)。先父亲后孩子(广度优先)地记,每个字面量直接跳到那一层,上千项的连加不必每个数都一层层 @@ -1816,13 +941,6 @@ def _key_of(node: Any) -> str: return "\0" + (node.sql() if node is not None else "") -def _literal_types() -> tuple[Any, ...]: - """语法树里的字面量:数字、字符串,以及字符串带前缀的几种变体(N'…'、$$…$$、U&'…'、E'…'、X'…'、B'…')。""" - from sqlglot import exp - - return (exp.Literal, exp.National, exp.RawString, exp.UnicodeString, exp.ByteString, exp.HexString, exp.BitString) - - def _logic() -> tuple[Any, ...]: """把几个条件连成一个条件的:且、或、非、异或(MySQL 的 XOR)、括号。""" from sqlglot import exp @@ -2144,13 +1262,6 @@ def _sign(a: Any, b: Any) -> int: return (a > b) - (a < b) -def _point_text(point: Any) -> str: - """夹住的值写成格子里的样子(25、13900000001、12.5),拿去和遮罩列里的值比(_swap_literal)。""" - if isinstance(point, Decimal): - return format(point.to_integral_value() if point == point.to_integral_value() else point.normalize(), "f") - return str(point) - - #: SQL 里一个字面量用在哪:(用法, 被比较的一边取自哪些列, 认它时一并看的值),见 _literal_uses _Use = tuple[str, frozenset[str], tuple[str, ...]] @@ -2548,14 +1659,6 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] gone={artifact: named[artifact][1] for artifact in own}) -def _snapshot_labels(snapshot: dict[str, Any], kind: str | None) -> dict[str, set[str]]: - """一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。""" - sql = snapshot.get("sql") - if not isinstance(sql, str) or not sql.strip(): - return {} - return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []]) - - def _compared(tainted: dict[str, set[str]], artifact: str, live: dict[str, list[str]], name: str) -> set[str]: """一条查询的 SQL 里认哪些名字是遮罩列(和它们做等值比较的,短的数也换):快照认出的(_derived_hidden 的名字, 含改了名字的);快照没取到的(存不下、被删了),按这个源现在设的遮罩列认。""" diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py new file mode 100644 index 00000000..4b4fc02e --- /dev/null +++ b/backend/app/engine/masking.py @@ -0,0 +1,954 @@ +"""遮罩:一份查询结果里哪些列要遮(按 SQL 认出改了名字的遮罩列)、哪些值是遮罩值(按值认)。 + +数据源在 options.mask_columns 里设了遮罩的列(手机号、姓名这类),证据面板按列名遮(api/evidence.py)。核查 agent +(engine/verify_agent.py:双盲复算、反方审稿、审批质询共用)自己写 SQL,起别名、套函数、经子查询转一道,结果列名就 +对不上遮罩列;按值认又有门槛和写法的问题。认法集中在这里,审批质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py, +经 inquiry 的同名导出)共用: + +- 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的 + 另一支、和 * 写在同一支里没起别名的表达式);COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在 + 汇总里数个数的不算(_value_columns、_band_only)。认不出落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算 +- 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值 +- 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出 + 正文里真有的那几个值,不现编上万个分支的正则 +- 快照遮好的样子(_masked_view) + +这里的函数原来都在 engine/inquiry.py 里,名字不变,inquiry 照旧导出(别处按 inquiry 的名字引用)。 +""" +from __future__ import annotations + +import contextlib +import json +import logging +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from app.engine.evidence import RenderError, render_cell +from app.engine.expressions import numeric_text +from app.engine.merge_query import MERGE_SOURCE + +logger = logging.getLogger(__name__) + + +#: 按值认遮罩值的门槛:文字至少这么多字(一个字的「是」「男」换掉,会把不相干的词也换得面目全非);纯数字至少这么多位 +#: (年龄、等级、小额金额这类短的数和阈值、天数、日期、LIMIT 里的数太容易撞上,按值换会把实际执行的 SQL、提问人自己 +#: 的条件改得面目全非。它们只在 SQL 里和遮罩列做等值一类比较时才换,见 _scrub_sql;引用到的格子照旧按列遮) +SECRET_MIN = 2 +SECRET_DIGITS = 5 +#: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换 +SECRETS_MAX = 20_000 + + +#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截) +_WORD = re.compile(r"[0-9A-Za-z_]") + + +def _query_shaped(content: Any) -> bool: + return isinstance(content, dict) and isinstance(content.get("rows"), list) + + +def _masked_word() -> str: + from app.api.evidence import MASKED + + return MASKED + + +# -------------------------------------------------------------------------- +# 遮罩:哪些列 +# -------------------------------------------------------------------------- + + +def _value_columns(node: Any) -> set[str]: + """一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的 + 分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询 + 只看它选出来的列(它的条件、连接里写的列带不出值)。""" + from sqlglot import exp + + out: set[str] = set() + stack = [node] + while stack: + item = stack.pop() + if isinstance(item, exp.Column): + if not isinstance(item.this, exp.Star) and item.name: + out.add(item.name.lower()) + elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)): + continue + elif isinstance(item, exp.Window): + stack.append(item.this) + elif isinstance(item, exp.Select): + stack.extend(item.expressions) + else: + stack.extend(item.iter_expressions()) + return out + + +def _branch_results(node: Any) -> list[Any] | None: + """CASE 的各个结果(每个 THEN,加上 ELSE;没写 ELSE 记 None,就是 NULL)、IIF / IF 的真假两支;别的写法为 None。 + CASE 里的每个 WHEN … THEN 在语法树里也是 If,不单算。""" + from sqlglot import exp + + if isinstance(node, exp.Case): + return [branch.args.get("true") for branch in node.args.get("ifs") or []] + [node.args.get("default")] + if isinstance(node, exp.If) and not isinstance(node.parent, exp.Case): + return [node.args.get("true"), node.args.get("false")] + return None + + +def _fixed(node: Any) -> bool: + """写死的结果:字面量(几个数算出来的也算)、NULL、TRUE / FALSE、没写的 ELSE。""" + from sqlglot import exp + + return node is None or isinstance(node, (exp.Null, exp.Boolean)) or _constant(node) is not None + + +def _band_only(node: Any) -> bool: + """按遮罩列分档、只带出档位或个数的 CASE / IIF:结果全是写死的常量(标签、档位编号、1 和 0),而且 + - 它本身就是一个选出列(起不起别名都行),又是这个 SELECT 的分组键(GROUP BY 按位置、按别名、照原样再写一遍都算): + 每一档一行,这一列只说这一行是哪一档; + - 或者套在汇总里(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END)、AVG(IIF(…, 1.0, 0))):按条件数个数、算占比, + 同 COUNT。 + 都带不出遮罩列的值,同按阈值数个数(col >= v 的 COUNT)。不分组、不汇总的逐行标签照旧算派生值,那是每一笔各落在 + 哪一档;汇总遮罩列的值本身(SUM(amount)、CASE 里有一支取 amount 的)也照旧算。""" + from sqlglot import exp + + results = _branch_results(node) + if results is None or not all(_fixed(r) for r in results): + return False + parent = node.parent + while parent is not None and not isinstance(parent, (exp.Select, exp.Subquery)): + if isinstance(parent, exp.AggFunc): + return True + parent = parent.parent + item = node + while isinstance(item.parent, (exp.Paren, exp.Alias)) and item.arg_key == "this": + item = item.parent + select = item.parent + if not isinstance(select, exp.Select) or item.arg_key != "expressions" or select.args.get("group") is None: + return False + place = next((str(i + 1) for i, e in enumerate(select.expressions) if e is item), None) + alias = item.alias.lower() if isinstance(item, exp.Alias) and item.alias else None + for key in select.args["group"].expressions: + while isinstance(key, exp.Paren): + key = key.this + if (isinstance(key, exp.Literal) and not key.is_string and key.this == place) or key == node or ( + alias and isinstance(key, exp.Column) and not key.table and key.name.lower() == alias): + return True + return False + + +def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, set[str]]: + """结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号) + 是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的 + 标签、回答正文里的档名都会被换成「已遮罩」。 + 只装着标签:结果的这一列在 UNION 这类的每一支里,都是结果全为写死常量的 CASE / IIF,或者取自派生来源(子查询、 + CTE、LATERAL)里只装着标签的一列(_item_label)。一项取自哪个来源按作用域认,不按名字猜:按名字认时,遮罩列原样 + 选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出, + 等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、 + 递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都 + 不算。""" + from app.data.sql_semantics import _parse, _read + + try: + root, _ = _parse(sql, _read(kind)) + except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理(标签照旧当遮罩值,宁可多遮) + root = None + if root is None: + return {} + try: + result = next((top for top, role, _listed in _blocks(root) if role == "result"), None) + if result is None: + return {} + memo: _LabelMemo = {} + found: dict[str, list[set[str] | None]] = {} + for place, column in enumerate(columns): + name = column.lower() + found.setdefault(name, []).append(_result_label(result, place, name, len(columns), memo)) + return {name: texts for name, parts in found.items() if (texts := _all_labels(parts)) is not None} + except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:一个都不算(宁可多遮) + logger.warning("质询的 SQL 标签列分析失败", exc_info=True) + return {} + + +#: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开 +_ABSENT: Any = object() +#: 标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT +_LabelMemo = dict[tuple[int, str], Any] + + +def _result_label(top: Any, place: int, name: str, width: int, memo: _LabelMemo) -> set[str] | None: + """结果(width 列)的第 place 列(列名 name)只装着标签时标签的写法,不然为 None:UNION 这类的每一支,那一项都要 + 只装着标签。落在 * 里的(认不出是哪一项)按列名经 * 往下找,只有一支时才找(几支的 * 各展开成什么说不清)。""" + from sqlglot import exp + + leaves = _leaves(top) + if not all(isinstance(leaf, exp.Select) for leaf in leaves): + return None + out: set[str] = set() + for select in leaves: + items = list(select.expressions) + item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) + if item is not None: + texts = _item_label(item, select, memo) + elif len(leaves) == 1: + texts = _star_label(select, name, memo) + else: + return None + if texts is None or texts is _ABSENT: + return None + out |= texts + return out + + +def _item_label(item: Any, select: Any, memo: _LabelMemo) -> set[str] | None: + """select 的一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种 + 写法:25、25.0);单独一列取它在 FROM 里取自的那一列的(_source_label);别的为 None。""" + from sqlglot import exp + + node = item + while isinstance(node, (exp.Alias, exp.Paren)): + node = node.this + results = _branch_results(node) + if results is not None: + if not all(_fixed(r) for r in results): + return None + out: set[str] = set() + for result in results: + value = _constant(result) if result is not None else None + if isinstance(value, Decimal): + out |= {_point_text(value), format(value, "f")} + elif value is not None: + out.add(_plain_text(value)) + return out + if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name: + texts = _source_label(select, node.table.lower() or None, node.name.lower(), memo) + return None if texts is _ABSENT else texts + return None + + +def _star_label(select: Any, name: str, memo: _LabelMemo) -> Any: + """select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的 + 装着别的、认不准为 None。""" + from sqlglot import exp + + out: set[str] = set() + seen = False + for item in select.expressions: + if not _is_star(item): + continue + qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None + texts = _source_label(select, qualifier, name, memo) + if texts is None: + return None + if texts is not _ABSENT: + out |= texts + seen = True + return out if seen else _ABSENT + + +def _source_label(select: Any, qualifier: str | None, name: str, memo: _LabelMemo) -> Any: + """select 的 FROM、JOIN 里读到的那一列(qualifier 是写的表名或别名,没写为 None)只装着标签时标签的写法;没有哪个 + 来源出这一列为 _ABSENT,出这一列但装着别的、认不准为 None。 + - 写了表名的只看那个来源(对不上的是外层的表,认不准); + - 没写的看所有来源:派生来源出这一列的就是它——SQL 执行过,别的来源就没有同名的列,不然会报列名有歧义。按这一列 + 连起来的(USING 里写着它、NATURAL)例外:几个来源的这一列合成一列,外连接时取的可能是另一边的值; + - 数据源里的表一律不算(遮罩列本身、别的列原样选出来,哪怕和标签同名),VALUES、函数这类来源也认不准。""" + sources = _from_sources(select) + if qualifier is not None: + sources = [(alias, source) for alias, source in sources if alias == qualifier] + if len(sources) != 1: + return None + found: list[set[str]] = [] + opaque = False + for _alias, source in sources: + derived = _derived_block(source) + if derived is None: + opaque = True + continue + texts = _block_label(derived[0], derived[1], name, memo) + if texts is None: + return None + if texts is not _ABSENT: + found.append(texts) + if not found: + return None if opaque else _ABSENT + if opaque and _merging(select, name): + return None + return set().union(*found) + + +def _block_label(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: + """派生来源的那块查询(listed 是写的列名表)出的 name 这一列只装着标签时标签的写法;不出这一列为 _ABSENT,出这一列 + 但装着别的、认不准为 None。一条 SELECT 按选出项的名字找,没有的经 * 往下找;UNION 这类按位置看每一支,列名取写的 + 列名表,没写的取第一支的。""" + key = (id(block), name) + if key not in memo: + # 先记成 None:递归的 CTE 绕回自己时按认不准算 + memo[key] = None + memo[key] = _block_label_of(block, listed, name, memo) + return memo[key] + + +def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any: + """_block_label 真去找的那一步(不看记下的结果)。""" + from sqlglot import exp + + leaves = _leaves(block) + if not all(isinstance(leaf, exp.Select) for leaf in leaves): + return None + if listed is None and len(leaves) == 1: + named = [item for item in leaves[0].expressions if _output_name(item) == name] + if not named: + return _star_label(leaves[0], name, memo) + return _all_labels([_item_label(item, leaves[0], memo) for item in named]) + width: int | None = None + if listed is not None: + if name not in listed: + return _ABSENT + place, width = (listed.index(name) if listed.count(name) == 1 else None), len(listed) + else: + first = list(leaves[0].expressions) + places = [p for item, p in zip(first, _places(first, None)) if _output_name(item) == name] + if not places: + # 第一支没明写这一列:有 * 时说不清它在不在、在哪个位置 + return None if any(_is_star(item) for item in first) else _ABSENT + place = places[0] if len(places) == 1 else None + if place is None: + return None + texts: list[set[str] | None] = [] + for select in leaves: + items = list(select.expressions) + item = next((i for i, p in zip(items, _places(items, width)) if p == place), None) + texts.append(_item_label(item, select, memo) if item is not None else None) + return _all_labels(texts) + + +def _all_labels(texts: list[set[str] | None]) -> set[str] | None: + """几项都只装着标签时,标签的写法合起来;有一项不是为 None。""" + return set().union(*texts) if all(t is not None for t in texts) else None + + +def _from_sources(select: Any) -> list[tuple[str, Any]]: + """一条 SELECT 的 FROM、JOIN 里的来源:(名字, 来源节点),名字是别名,没起别名的是表名(小写)。""" + from_ = select.args.get("from_") + nodes = [from_.this] if from_ is not None and from_.this is not None else [] + nodes += [join.this for join in select.args.get("joins") or [] if join.this is not None] + return [((node.alias_or_name or "").lower(), node) for node in nodes] + + +def _derived_block(source: Any) -> tuple[Any, list[str] | None] | None: + """FROM、JOIN 里的一个来源 → (它的那块查询, 写的列名表):FROM 里的子查询、LATERAL、指向 WITH 里一段的表名。数据源里 + 的表、VALUES、函数这类为 None(出哪些列、装着什么,SQL 里看不出)。""" + from sqlglot import exp + + from app.data.sql_semantics import _visible_ctes + + node, alias = source, None + if isinstance(node, exp.Lateral): + node, alias = node.this, node.args.get("alias") + if isinstance(node, exp.Table): + if node.args.get("db") or node.args.get("catalog") or not isinstance(node.this, exp.Identifier): + return None + cte = _visible_ctes(node).get(node.name.lower()) + if cte is None: + return None + node, alias = cte.this, cte.args.get("alias") + while isinstance(node, exp.Subquery): + alias = alias or node.args.get("alias") + node = node.this + if not isinstance(node, (exp.Select, exp.SetOperation)): + return None + return node, ([c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None) + + +def _merging(select: Any, name: str) -> bool: + """FROM 里按同名的 name 这一列连起来(USING 里写着它,或者 NATURAL):几个来源的这一列合成一列。""" + return any(str(join.args.get("method") or "").upper() == "NATURAL" + or any(str(getattr(u, "name", "")).lower() == name for u in join.args.get("using") or []) + for join in select.args.get("joins") or []) + + +def _is_star(item: Any) -> bool: + from sqlglot import exp + + return isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)) + + +def _leaves(root: Any) -> list[Any]: + """一块查询的每一支:UNION 这类连起来的按先后摊开,套在外面的括号去掉。一般是 SELECT,也可能是 VALUES 这类。""" + from sqlglot import exp + + out: list[Any] = [] + stack = [root] + while stack: + node = stack.pop() + if isinstance(node, exp.Subquery): + stack.append(node.this) + elif isinstance(node, exp.SetOperation): + stack.extend((node.expression, node.this)) + else: + out.append(node) + return out + + +def _branches(root: Any) -> list[list[Any]]: + """一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" + from sqlglot import exp + + return [list(node.expressions) for node in _leaves(root) if isinstance(node, exp.Select)] + + +def _output_name(item: Any) -> str | None: + """一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、 + 子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?), + 认不准,返回 None。""" + from sqlglot import exp + + if isinstance(item, exp.Alias): + return item.alias.lower() or None + if isinstance(item, exp.Column) and not isinstance(item.this, exp.Star): + return item.name.lower() or None + return None + + +def _places(branch: list[Any], width: int | None) -> list[int | None]: + """一支里每一项落在结果的第几列。没有 * 的一一对上;有 * 的,第一个 * 前面的从头数,最后一个 * 后面的从结果末尾 + 倒着数(width 是结果的列数,不知道就数不了);两个 * 之间的、* 本身认不出位置(None)。""" + stars = [i for i, item in enumerate(branch) if _is_star(item)] + if not stars: + return list(range(len(branch))) + first, last = stars[0], stars[-1] + tail = len(branch) - 1 - last + # * 至少展开成 0 列:倒着数要是数到了 * 前面那几项的位置上,列数对不上,后面的一项都不认 + countable = width is not None and width - tail >= first + return [i if i < first else width - (len(branch) - i) if i > last and countable else None + for i in range(len(branch))] + + +def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: + """SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪: + - "result":最外层,结果就是快照的列; + - "source":当成一张表用的——WITH 里的一段、FROM / JOIN 里的子查询、LATERAL,外层按列名取它的列,* 会把它的列 + 全带出去。写了列名表的(t(p))带上列名表; + - "other":标量子查询、IN / EXISTS 里的,值经外层的表达式带出(_value_columns 已经算上),列名无关紧要。 + 套在外面的括号(可能好几层)不改变结果流到哪:先越过它们再看。UNION 里的一支不另算,整串按一块。""" + from sqlglot import exp + + out: list[tuple[Any, str, list[str] | None]] = [] + for node in root.find_all(exp.Select, exp.SetOperation): + wrappers: list[Any] = [] + outer = node + while isinstance(outer.parent, exp.Subquery): + outer = outer.parent + wrappers.append(outer) + parent = outer.parent + if isinstance(parent, exp.SetOperation): + continue + if parent is None: + out.append((node, "result", None)) + elif isinstance(parent, (exp.CTE, exp.Lateral)) or (wrappers and isinstance(parent, (exp.From, exp.Join))): + # 别名(和列名表)记在 WITH 的那一段、LATERAL 上,FROM 里的子查询记在带别名的那层括号上 + alias = next((h.args["alias"] for h in [parent, *reversed(wrappers)] if h.args.get("alias")), None) + listed = [c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None + out.append((node, "source", listed)) + else: + out.append((node, "other", None)) + return out + + +def _source_names(top: Any, listed: list[str] | None, names: set[str]) -> tuple[set[str], bool]: + """派生来源的结果里值取自遮罩列的那几列的名字,以及有没有认不出名字的(没起别名的表达式、两个 * 之间的项)。 + 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。""" + branches = _branches(top) + found: set[str] = set() + unknown = False + + def take(name: str | None) -> None: + nonlocal unknown + if name is None: + unknown = True + else: + found.add(name) + + if listed is not None: + for branch in branches: + for item, place in zip(branch, _places(branch, len(listed))): + if names & _value_columns(item): + take(listed[place] if place is not None and place < len(listed) else None) + return found, unknown + first = branches[0] if branches else [] + for item in first: + if names & _value_columns(item): + take(_output_name(item)) + if len(branches) > 1: + # 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置 + width = next((len(b) for b in branches if not any(_is_star(i) for i in b)), None) + heads = {place: _output_name(item) for item, place in zip(first, _places(first, width)) if place is not None} + for branch in branches[1:]: + for item, place in zip(branch, _places(branch, width)): + if names & _value_columns(item): + take(heads.get(place) if place is not None else None) + return found, unknown + + +def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[set[str], set[int]] | None: + """SQL 里指代遮罩列的名字(小写),和结果(width 列)里值取自遮罩列的那几列的位置。解析不了返回 None。 + + 名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的 + 名字也算;派生来源(子查询、WITH 里的一段)按位置对上列名表、UNION 第一支的列名,也算,直到不再多出名字。同名 + 不同层的也一并算上(宁可多遮)。 + 位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、 + 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 + 又认不准)——整份快照的列都算(同解析不了)。""" + from sqlglot import exp + + from app.data.sql_semantics import _parse, _read + + try: + root, _ = _parse(sql, _read(kind)) + except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理 + root = None + if root is None: + return None + try: + names = set(masked) + selects = list(root.find_all(exp.Select)) + blocks = _blocks(root) + sources = [(top, listed) for top, role, listed in blocks if role == "source"] + for _ in range(len(selects) + len(sources) + 1): + before = len(names) + for select_ in selects: + for item in select_.expressions: + name = item.alias_or_name.lower() + if name and name not in names and names & _value_columns(item): + names.add(name) + for top, listed in sources: + names |= _source_names(top, listed, names)[0] + if len(names) == before: + break + everything = any(_source_names(top, listed, names)[1] for top, listed in sources) + positions: set[int] = set() + for top, role, _listed in blocks: + if role != "result": + continue + for branch in _branches(top): + for item, place in zip(branch, _places(branch, width)): + if names & _value_columns(item): + if place is None or place >= width: + everything = True + else: + positions.add(place) + return names, set(range(width)) if everything else positions + except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮) + logger.warning("质询的 SQL 遮罩分析失败", exc_info=True) + return None + + +def _mentions(sql: str, names: Iterable[str]) -> bool: + return any(re.search(rf"(? tuple[set[str], set[str]]: + """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。 + + 证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名 + (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的,整份快照的 + 列都遮掉;合并查询的快照不看 SQL,它记着自己的来历(_hidden_columns 已按 _merge_masks 遮好)。""" + base = set(hidden) + sql = snapshot.get("sql") + if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: + return base, base + columns = [str(c) for c in snapshot.get("columns") or []] + found = _tainted(sql, kind, base, len(columns)) + if found is None: + return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) + names, positions = found + return names | {columns[i].lower() for i in positions if i < len(columns)}, names + + +def _snapshot_labels(snapshot: dict[str, Any], kind: str | None) -> dict[str, set[str]]: + """一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。""" + sql = snapshot.get("sql") + if not isinstance(sql, str) or not sql.strip(): + return {} + return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []]) + + +# -------------------------------------------------------------------------- +# SQL 里写死的值 +# -------------------------------------------------------------------------- + + +#: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样 +_PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?") + + +def _arithmetic() -> tuple[Any, ...]: + """加减乘除、取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //):几个写死的数算出来的整个算式按算出来的那一个值认 + (13900000000 + 1 就是 13900000001,125 % 100 就是 25)。""" + from sqlglot import exp + + return exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.IntDiv + + +#: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位) +_EXPONENT_MAX = 40 + + +def _decimal(text: str) -> Decimal | None: + """一段字按数读(025、2.5e1、13900000001.0 都认);读不了、不是有限的数、指数太大为 None。""" + try: + value = Decimal(text.strip()) + except (ArithmeticError, ValueError): + return None + return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None + + +def _canonical(text: str) -> str | None: + """一个数的规范写法(_point_text):25.0、025、2.5e1 都是 25,13900000001.0 是 13900000001。SQL 里换个写法写同一个 + 数,照样认得出是不是遮罩值(_swap_literal)。不是数为 None。""" + value = _decimal(text) + return _point_text(value) if value is not None else None + + +def _constant(node: Any) -> Any: + """写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1;025、2.5e1 这样的写法也按数),字符串按原文 + (纯粹的数照样按数);别的为 None。""" + return _constants(node).get(id(node)) if node is not None else None + + +def _constants(root: Any) -> dict[int, Any]: + """一棵语法树里每个写死的值(_constant):id(节点) → 值。从下往上一层层算(广度优先的倒序,孩子总在父亲前面), + 不递归:上千项的连加(1+1+…)会超过递归深度,以前整条 SQL 因此退回按词认,同一句里夹住的值就认不出了。""" + from sqlglot import exp + + out: dict[int, Any] = {} + for node in reversed(list(root.walk())): + value: Any = None + if isinstance(node, exp.Neg): + inner = out.get(id(node.this)) + value = -inner if isinstance(inner, Decimal) else None + elif isinstance(node, (exp.Paren, exp.Cast, exp.Collate)): + value = out.get(id(node.this)) + elif isinstance(node, exp.Introducer): + value = out.get(id(node.expression)) + elif isinstance(node, _literal_types()): + text = str(node.this).strip() + number = Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else ( + _decimal(text) if isinstance(node, exp.Literal) and not node.is_string else None) + value = number if number is not None else text + elif isinstance(node, _arithmetic()): + value = _worked_out(node, out.get(id(node.this)), out.get(id(node.expression))) + if value is not None: + out[id(node)] = value + return out + + +def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None: + """一步加减乘除、取模、整除的结果;不是两个数、除以零、结果太大为 None。取模、整除照 SQL 往零取整(-7 % 2 是 -1, + -7 DIV 2 是 -3,同 Decimal 的 % 和 //)。/ 在 SQLite、Postgres 这类方言里两个整数相除只留整数部分(解析时记在 + typed 上:53 / 2 是 26),写成小数的(53.0 / 2)、别的方言照小数算。以前一律按小数算,SQLite 里 age >= 25 AND + age < 53 / 2 夹住了 25 却认不出。""" + from sqlglot import exp + + if not (isinstance(a, Decimal) and isinstance(b, Decimal)): + return None + try: + if isinstance(node, (exp.Div, exp.IntDiv, exp.Mod)): + if not b: + return None + # 写成整数的数(和它们加减乘出来的)指数正好是 0;53.0、1e1 这样写的在 SQL 里不是整数 + whole = isinstance(node, exp.IntDiv) or ( + node.args.get("typed") and a.as_tuple().exponent == 0 and b.as_tuple().exponent == 0) + value = a % b if isinstance(node, exp.Mod) else a // b if whole else a / b + else: + value = a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b + except ArithmeticError: + return None + return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None + + +def _literal_types() -> tuple[Any, ...]: + """语法树里的字面量:数字、字符串,以及字符串带前缀的几种变体(N'…'、$$…$$、U&'…'、E'…'、X'…'、B'…')。""" + from sqlglot import exp + + return (exp.Literal, exp.National, exp.RawString, exp.UnicodeString, exp.ByteString, exp.HexString, exp.BitString) + + +def _point_text(point: Any) -> str: + """夹住的值写成格子里的样子(25、13900000001、12.5),拿去和遮罩列里的值比(_swap_literal)。""" + if isinstance(point, Decimal): + return format(point.to_integral_value() if point == point.to_integral_value() else point.normalize(), "f") + return str(point) + + +# -------------------------------------------------------------------------- +# 遮罩:哪些值 +# -------------------------------------------------------------------------- + + +#: 一个数的写法:可带正负号、千分位、小数、百分号 +_NUMERIC = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?") +#: 正文里一个个「词」:字母数字下划线连成的一串。头尾都是这种字的遮罩值,只能整个对上这样一串 +_WORDS = re.compile(r"[0-9A-Za-z_]+") +#: 正文里的一串数(可带千分位、小数):带千分位的遮罩值按去掉逗号的样子比 +_DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?") +#: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition) +_PREFIX = 8 + + +def _plain_text(value: Any) -> str: + text = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value) + return re.sub(r"\s+", " ", text).strip() + + +def _numeric(value: Any) -> bool: + if isinstance(value, bool): + return False + return isinstance(value, (int, float)) or (isinstance(value, str) and numeric_text(value) is not None) + + +def _forms(value: Any) -> set[str]: + """一格的值在正文里可能的写法:原样(压成一行),是数的再加上千分位的写法(13,900,000,001)。""" + text = _plain_text(value) + forms = {text} if text else set() + if _numeric(value): + with contextlib.suppress(RenderError, ValueError, ArithmeticError): + forms.add(render_cell(value)) + return forms + + +def _strong(form: str) -> bool: + """按值认得出的写法(见 SECRET_MIN、SECRET_DIGITS)。""" + if _NUMERIC.fullmatch(form): + return sum(c.isdigit() for c in form) >= SECRET_DIGITS + return len(form) >= SECRET_MIN + + +def _digits_of(form: str) -> str: + return form.replace(",", "").lstrip("+-").rstrip("%") + + +def _pattern(value: str) -> str: + """一个值的正则:头尾是字母数字的按词边界认;数字还不能是更长的数、日期、时间的一截(12.5 里的 2.5、2026-01-25 里的 + 25、12:30 里的 30),也不能是千分位写法里的一组(1,390 里的 390)。逗号隔开的一串长号码(13900000001,13900000002) + 各自照认:千分位一组只有三位。""" + head = tail = "" + if _WORD.match(value[0]): + head = r"(? None: + """收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的 + 格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。""" + if not _query_shaped(snapshot) or not hidden: + return + own = set(base) + written = {name: texts for name, texts in (labels or {}).items() if name not in own} + columns = [str(c) for c in snapshot.get("columns") or []] + index = [i for i, c in enumerate(columns) if c.lower() in hidden] + for row in snapshot["rows"]: + if len(self.weak) >= SECRETS_MAX: + break + if isinstance(row, list): + cells = [(columns[i].lower(), row[i]) for i in index if i < len(row)] + elif isinstance(row, dict): + cells = [(str(k).lower(), v) for k, v in row.items() if str(k).lower() in hidden] + else: + continue + for name, value in cells: + if value is None or isinstance(value, bool): + continue + forms = _forms(value) + if forms & written.get(name, set()): + continue + for form in forms: + self.weak.add(form) + if _strong(form): + self.strong.add(form) + self._index = None + + def protect(self, texts: Iterable[str | None]) -> None: + """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), + 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。""" + given = " ".join(t for t in texts if t) + if not given: + return + numbers = {_digits_of(n) for n in _DIGITS.findall(given)} + + def asked(form: str) -> bool: + return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given + + self.strong = {f for f in self.strong if not asked(f)} + self.weak = {f for f in self.weak if not asked(f)} + self._index, self._compiled = None, {} + + def show(self, numbers: Iterable[str]) -> None: + """SQL 里照原样留着的阈值、行数(_shown_literals):回答正文里复述它们不换(「金额不低于 30000 元」),换了只会 + 让回答和 SQL 对不上。只免正文:格子、列名、SQL 里照旧按值换——格子里的值正好是这个数,可能就是那个人 + (col >= v 再按 col 排序取第一行,查出来的就是 v)。""" + self.shown = set(numbers) + + def is_shown(self, form: str) -> bool: + return bool(_NUMERIC.fullmatch(form)) and _digits_of(form) in self.shown + + def hit(self, value: Any) -> bool: + """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。""" + if value is None or isinstance(value, bool) or not self.strong: + return False + text = _plain_text(value) + if text in self.strong: + return True + if _numeric(value) and sum(c.isdigit() for c in text) >= SECRET_DIGITS: + with contextlib.suppress(RenderError, ValueError, ArithmeticError): + return render_cell(value) in self.strong + return False + + def weak_hit(self, text: str) -> bool: + """SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。""" + return _plain_text(text) in self.weak + + def scrub(self, text: str, *, prose: bool = False) -> str: + """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。""" + if not text or not self.strong: + return text + pattern = self.pattern(text, prose=prose) + return pattern.sub(_masked_word(), text) if pattern is not None else text + + def pattern(self, text: str, *, prose: bool = False) -> re.Pattern[str] | None: + """认 text 里出现的遮罩值的正则;一个都没有时为 None。prose 同 scrub。""" + found = self._found(text) if text and self.strong else set() + if prose and self.shown: + found = {v for v in found if not self.is_shown(v)} + if not found: + return None + key = frozenset(found) + if key not in self._compiled: + if len(self._compiled) > 64: + self._compiled = {} + # 长的在前:13900000001 先于它的一截被认出来 + self._compiled[key] = re.compile("|".join(_pattern(v) for v in sorted(found, key=lambda v: (-len(v), v)))) + return self._compiled[key] + + def _found(self, text: str) -> set[str]: + words, numbers, others = self._partition() + found = {w for w in _WORDS.findall(text) if w in words} + if numbers: + for run in _DIGITS.findall(text): + found |= numbers.get(_digits_of(run), set()) + for i in range(len(text) - 1) if others else (): + for size, buckets in others.items(): + for length, values in buckets.get(text[i:i + size], {}).items(): + if (piece := text[i:i + length]) in values: + found.add(piece) + return found + + def prepare(self) -> None: + """先把索引建好(收完值、去掉提问人写的之后):上万个值建一次要几十毫秒,放在线程里建,别等到事件循环里第一次换。""" + self._partition() + + def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]]]: + """strong 分成三类:只有字母数字的(按词比)、带千分位小数的数(按去掉逗号的数比)、其余的(中文、日期、 + 邮箱)。其余的按开头至多 _PREFIX 个字分桶、桶里再按长短分:同一个市区的上万个地址开头七八个字都一样,只按开头 + 分的话,正文里每碰到一次那几个字都要逐个比;再按长短分,碰到一次只是按几种长短各查一下。三类各自只在正文里 + 真出现时才进正则。""" + if self._index is None: + words: set[str] = set() + numbers: dict[str, set[str]] = {} + others: dict[int, dict[str, dict[int, set[str]]]] = {} + for value in self.strong: + if _WORDS.fullmatch(value): + words.add(value) + elif _NUMERIC.fullmatch(value): + numbers.setdefault(_digits_of(value), set()).add(value) + else: + size = min(len(value), _PREFIX) + others.setdefault(size, {}).setdefault(value[:size], {}).setdefault(len(value), set()).add(value) + self._index = (words, numbers, others) + return self._index + + +def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False + ) -> tuple[dict[str, Any], list[str]]: + """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); + 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 + 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 + 原快照不动;没有要遮的返回原快照。 + + embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号),把那一截换成「已遮罩」,这一格别的字 + 照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 + 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。""" + columns = [str(c) for c in snapshot.get("columns") or []] + index = {i for i, c in enumerate(columns) if c.lower() in hidden} + by_value = bool(secrets.strong) + if not index and not by_value: + return snapshot, [] + word = _masked_word() + touched = set(index) + edited = False + position = {c: i for i, c in enumerate(columns)} + names = [secrets.scrub(c) for c in columns] if by_value else columns + + def cell(i: int | None, value: Any) -> Any: + nonlocal edited + if i in index: + return word + if by_value and secrets.hit(value): + if i is not None: + touched.add(i) + return word + if embedded and by_value and isinstance(value, (str, dict, list)): + # JSON 列(对象、数组)按界面画出来的样子换,换过的照文字给 + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + if (scrubbed := secrets.scrub(text)) != text: + edited = True + return scrubbed + return value + + def key(k: Any) -> Any: + i = position.get(str(k)) + return names[i] if i is not None else k + + rows: list[Any] = [] + for row in snapshot["rows"]: + if isinstance(row, list): + rows.append([cell(i, v) for i, v in enumerate(row)]) + elif isinstance(row, dict): + rows.append({key(k): word if str(k).lower() in hidden else cell(position.get(str(k)), v) + for k, v in row.items()}) + else: + rows.append(row) + renamed = names != columns + if not touched and not renamed and not edited: + return snapshot, [] + types = {key(k): v for k, v in snapshot["column_types"].items()} \ + if isinstance(snapshot.get("column_types"), dict) else {} + types.update({names[i]: "text" for i in index}) + view = {**snapshot, "rows": rows, "column_types": types, **({"columns": names} if renamed else {})} + return view, [names[i] for i in sorted(touched)] From 8c76f91ca9f5140b1c6b874b75b1f340cc514cd0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:00:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?K3=EF=BC=9A=E5=88=86=E6=A1=A3=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=88=97=E5=9C=A8=E6=B4=BE=E7=94=9F=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E9=87=8C=E8=A2=AB=E5=90=8C=E5=90=8D=E7=9A=84=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=E5=88=97=E6=8C=A1=E4=BD=8F=E6=97=B6=E4=B8=8D=E5=86=8D=E6=8C=89?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E8=AE=A4=EF=BC=88J2=20=E7=AC=AC=203=20?= =?UTF-8?q?=E8=BD=AE=E5=A4=8D=E9=AA=8C=E7=9A=84=E7=BC=BA=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite 让派生来源里同名的几列都留着,外层按名字取的是排在最前面的那一列。以前认标签列只看起了这个名字的选出项, SELECT vip_level AS 档 FROM (SELECT *, CASE WHEN amount > 1000 THEN 1 ELSE 0 END AS vip_level FROM sales) t 交回的是 真实等级,却被当成只装着标签:等级 1、2、3 撞上 THEN 1 ELSE 0 不收进遮罩值,之后 WHERE vip_level = 1 在查询清单、 事件、质询工件里都照原样封存。 按复核建议保守处理(engine/masking.py): - _block_label_of(单支、多支的第一支):第一个同名项前面有 * 带得出这一列(_star_label 不是 _ABSENT),或者有没起 别名、去掉括号和 COLLATE 后同名的项((vip_level)、vip_level COLLATE NOCASE,SQLite 给子查询的结果列起名时去掉 它们),都不算只装着标签; - _star_label:没写表名的 * 展开了数据源里的表(或 VALUES、函数这类说不清出哪些列的来源)为 None——* 把表里的同名列 照样带出来,不像直接写列名那样会报列名有歧义。 补单元和端到端测试:* 在前、t.* 在前、CTE 里 * 在前、JOIN 子查询 * 在前、(col) / ((col)) / COLLATE 在前、多支的 第一支,以及标签排在 * 前面、* 只带出派生来源里的标签时照旧算标签列。三条规则各自去掉,对应的用例都会变红。 另把「SQLite 里带小数的取模先取整」写进已知局限。 --- backend/app/engine/inquiry.py | 10 +++-- backend/app/engine/masking.py | 39 ++++++++++++++++---- backend/tests/test_inquiry_api.py | 42 +++++++++++++++++++++ backend/tests/test_inquiry_masking.py | 53 +++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 10 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 67ad717e..e835311d 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -46,8 +46,11 @@ 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收: 结果的这一列在 UNION 的每一支里都是这样的 CASE / IIF,或者按作用域认得出取自子查询、CTE 里只装着标签的一列;有一支 取自数据源里的表(遮罩列原样选出、经子查询按原名传出,哪怕起了标签的名字)就照收。认不准的也照收(多遮),比如 - 按这一列 USING、NATURAL 合成的同名列,递归的 CTE,UNION 里落在某一支 * 里的列,写死的字符串、和列一起算的值。 - 标签恰好和别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮) + 按这一列 USING、NATURAL 合成的同名列,递归的 CTE,UNION 里落在某一支 * 里的列,写死的字符串、和列一起算的值; + 派生来源里同名的几列(SQLite 都留着,外层按名字取排在最前面的那一列):标签前面有 * 带得出这一列(没写表名的 * + 展开了数据源里的表就算带得出),或者有没起别名、套了括号或 COLLATE 的同名列((vip_level)、vip_level COLLATE + NOCASE),都不算只装着标签(_block_label_of、_star_label)。标签恰好和别的列里的遮罩值相同时,正文里的档名、SQL + 里写成字符串的标签也换(多遮) - 「夹住某一个值」(_pinned):范围比较在整条 SQL 里配对,只有 CASE 的不同分支、SELECT 里不筛行的不同项(选出列、 GROUP BY、ORDER BY)、UNION 的不同支各算各的,两头误差都有:把找值拆进这几处(CASE WHEN age > 25 THEN … ELSE CASE WHEN age >= 25 …,两个选出列 SUM(age >= 25)、SUM(age > 25) 再自己相减)认不出,边界照原样留着,正文里复述也不遮; @@ -64,7 +67,8 @@ 两个整数相除照数据源的算法,SQLite、Postgres 里 53 / 2 是 26,MySQL 里是 26.5)。更绕的写法认不出:值藏进和列 一起算的算式(age - 25 = 0、buyer_phone - 1 = 13900000000)、位运算(24 | 1)、拼接('1390' || '0000001')、 函数拼出来的值(REPLACE、SUBSTR、POWER)、CAST 改了值的(CAST(25.6 AS INTEGER) 按 25.6 认,CAST(53 AS REAL) / 2 - 按整数相除算),短的值照原样封存,长号码只在整个写出来时按值换 + 按整数相除算),SQLite 里带小数的取模(它先把两边取整再算:25.9 % 26 是 25,这里照小数算成 25.9,认不出在找 + 25),短的值照原样封存,长号码只在整个写出来时按值换 - 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在 片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的, 从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮) diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index 4b4fc02e..b572d80f 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -228,17 +228,21 @@ def _item_label(item: Any, select: Any, memo: _LabelMemo) -> set[str] | None: return None -def _star_label(select: Any, name: str, memo: _LabelMemo) -> Any: +def _star_label(select: Any, name: str, memo: _LabelMemo, *, before: int | None = None) -> Any: """select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的 - 装着别的、认不准为 None。""" + 装着别的、认不准为 None。before 给了只看排在它前面的那几项(_block_label_of 看同名项前面有没有 *)。 + 没写表名的 * 展开了数据源里的表(或 VALUES、函数这类说不清出哪些列的来源)为 None:表里可能就有这一列,* 照样把它 + 带出来,不像直接写列名那样会报列名有歧义,_source_label 那条「派生来源出这一列的就是它」在这里不成立。""" from sqlglot import exp out: set[str] = set() seen = False - for item in select.expressions: + for item in select.expressions[:before]: if not _is_star(item): continue qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None + if qualifier is None and any(_derived_block(source) is None for _, source in _from_sources(select)): + return None texts = _source_label(select, qualifier, name, memo) if texts is None: return None @@ -299,10 +303,15 @@ def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _Labe if not all(isinstance(leaf, exp.Select) for leaf in leaves): return None if listed is None and len(leaves) == 1: - named = [item for item in leaves[0].expressions if _output_name(item) == name] + items = list(leaves[0].expressions) + named = [i for i, item in enumerate(items) if _same_name(item, name)] if not named: return _star_label(leaves[0], name, memo) - return _all_labels([_item_label(item, leaves[0], memo) for item in named]) + # SQLite 让同名的几列都留着,外层按名字取的是排在最前面的那一列:第一个同名项前面的 * 带得出这一列(或说不清带 + # 不带),取到的就是 * 里的那一列(SELECT *, CASE … END AS vip_level 交回的是真实等级) + if _star_label(leaves[0], name, memo, before=named[0]) is not _ABSENT: + return None + return _all_labels([_item_label(items[i], leaves[0], memo) for i in named]) width: int | None = None if listed is not None: if name not in listed: @@ -310,7 +319,7 @@ def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _Labe place, width = (listed.index(name) if listed.count(name) == 1 else None), len(listed) else: first = list(leaves[0].expressions) - places = [p for item, p in zip(first, _places(first, None)) if _output_name(item) == name] + places = [p for item, p in zip(first, _places(first, None)) if _same_name(item, name)] if not places: # 第一支没明写这一列:有 * 时说不清它在不在、在哪个位置 return None if any(_is_star(item) for item in first) else _ABSENT @@ -400,6 +409,21 @@ def _branches(root: Any) -> list[list[Any]]: return [list(node.expressions) for node in _leaves(root) if isinstance(node, exp.Select)] +def _same_name(item: Any, name: str) -> bool: + """一个选出项在派生来源的结果里是不是叫 name(小写):起了别名的、直接选一列的按 _output_name;没起别名、套了括号 + 或 COLLATE 的一列((vip_level)、vip_level COLLATE NOCASE)也算——SQLite 给子查询的结果列起名时去掉括号和 COLLATE, + 外层按 vip_level 取得到它。只在认标签列时用:_output_name 别处照旧认不准这种写法(那边认不准就整份遮)。""" + from sqlglot import exp + + if _output_name(item) == name: + return True + node = item + while isinstance(node, (exp.Paren, exp.Collate)): + node = node.this + return node is not item and isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) \ + and node.name.lower() == name + + def _output_name(item: Any) -> str | None: """一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、 子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?), @@ -650,7 +674,8 @@ def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None: """一步加减乘除、取模、整除的结果;不是两个数、除以零、结果太大为 None。取模、整除照 SQL 往零取整(-7 % 2 是 -1, -7 DIV 2 是 -3,同 Decimal 的 % 和 //)。/ 在 SQLite、Postgres 这类方言里两个整数相除只留整数部分(解析时记在 typed 上:53 / 2 是 26),写成小数的(53.0 / 2)、别的方言照小数算。以前一律按小数算,SQLite 里 age >= 25 AND - age < 53 / 2 夹住了 25 却认不出。""" + age < 53 / 2 夹住了 25 却认不出。已知局限:SQLite 的取模先把两边取整再算(25.9 % 26 是 25),这里照小数算 + (25.9),这样写的值认不出。""" from sqlglot import exp if not (isinstance(a, Decimal) and isinstance(b, Decimal)): diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index f657bf7f..a39fd68f 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -1168,6 +1168,48 @@ async def test_a_masked_column_after_a_label_in_a_union_keeps_its_values(monkeyp await engines.invalidate(source_id) +_BIG_ONE = "CASE WHEN amount > 1000 THEN 1 ELSE 0 END" + + +@pytest.mark.parametrize("shadowed", [ + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, {_BIG_ONE} AS vip_level FROM sales) t", id="* 在前"), + pytest.param(f"SELECT x.vip_level AS 档 FROM (SELECT * FROM sales s JOIN (SELECT id AS bid, {_BIG_ONE} AS vip_level " + "FROM sales) b ON s.id = b.bid) x", id="JOIN 子查询 * 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {_BIG_ONE} AS vip_level FROM sales) t", + id="(col) 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {_BIG_ONE} AS vip_level FROM sales) t", + id="COLLATE 在前"), +]) +async def test_a_masked_column_shadowing_a_label_keeps_its_values(monkeypatch, tmp_path, client, shadowed): + """第 3 轮复验(J2 留下的缺口):派生来源里 * 带出的等级、套了括号或 COLLATE 的等级排在同名的标签前面,SQLite 交回 + 的是真实等级。以前这一列被当成只装着标签,等级 1 撞上标签 1 不收,之后 WHERE vip_level = 1 在查询清单、事件、质询 + 工件里都照原样。现在不算只装着标签的列,两条都遮。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "vip_level"}, script=LEVELS) + first, second = (f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}" for level in (1, 2)) + plays = {"各等级": Play([shadowed, first, second], + "问到的两个等级各有 [[v:Q2.r0.人数]] 人、[[v:Q3.r0.人数]] 人。[[see:Q2]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, AMOUNTS)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="各等级的客户有几位?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "问到的两个等级各有 2 人、1 人。") + sealed = [shadowed, *["SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩"] * 2] + assert [q["sql"] for q in item["queries"]] == sealed + [event] = await events(paused.id, "approval.inquiry") + assert [q["sql"] for q in event.data["queries"]] == sealed + for query, sql in zip(item["queries"], sealed): + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200 and got.json()["content"]["sql"] == sql, "质询工件的 SQL 和事件里的一字不差" + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][0]['artifact']}") + assert {cell for [cell] in got.json()["content"]["rows"]} == {"已遮罩"} and got.json()["masked"] == ["档"] + finally: + await engines.invalidate(source_id) + + STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1" diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 0ecd52f1..513a375c 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -349,6 +349,59 @@ def test_label_columns_are_traced_to_where_they_are_written(sql, columns, texts) assert inquiry_mod._label_columns(sql, "sqlite", columns) == {"档位": texts} +#: 派生来源里排在标签前面、和标签同名的列:SQLite 让同名的几列都留着,外层按名字取的是排在前面的那一列(真实等级) +SHADOWED = [ + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, {BIG_ONE} AS vip_level FROM sales) t", id="* 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT s.*, {BIG_ONE} AS vip_level FROM sales s) t", id="t.* 在前"), + pytest.param(f"WITH t AS (SELECT *, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM t", + id="CTE 里 * 在前"), + pytest.param(f"SELECT x.vip_level AS 档 FROM (SELECT * FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level " + "FROM sales) b ON s.id = b.bid) x", id="JOIN 子查询 * 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, b.lbl AS vip_level FROM sales s JOIN (SELECT id AS bid, " + f"{BIG_ONE} AS lbl FROM sales) b ON s.id = b.bid) t", id="JOIN 之后 * 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales) t", + id="(col) 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT ((vip_level)), {BIG_ONE} AS vip_level FROM sales) t", + id="((col)) 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales) t", + id="COLLATE 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level) COLLATE BINARY, {BIG_ONE} AS vip_level " + "FROM sales) t", id="(col) COLLATE 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales UNION ALL " + f"SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 (col) 在前"), + pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales " + f"UNION ALL SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 COLLATE 在前"), +] + + +@pytest.mark.parametrize("sql", SHADOWED) +def test_a_masked_column_shadowing_a_label_in_a_derived_source_is_not_a_label_column(sql): + """第 3 轮复验(J2 留下的缺口):派生来源里 * 带出的遮罩列、没起别名但套了括号或 COLLATE 的遮罩列排在同名的标签 + 前面,SQLite 交回的是真实等级(1、2、3),以前却按标签认、短码值不收,之后 WHERE vip_level = 1 照原样封存。现在 + 第一个同名项前面有 * 带出这一列(或说不清带不带)、有去掉括号和 COLLATE 后同名的项,都不算只装着标签的列。""" + plan = plan_of({"source": "shop", "sql": sql, "columns": ["档"], "rows": [[1], [2], [3]]}, {"vip_level"}) + assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {} + assert {"1", "2", "3"} <= plan.secrets.weak + for level in (1, 2, 3): + assert inquiry_mod._scrub_sql(f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}", plan.secrets, + plan.tainted["a1"], "sqlite") == ( + "SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩") + + +@pytest.mark.parametrize("sql", [ + # 标签排在 * 前面:SQLite 取的是排在前面的标签 + f"SELECT vip_level AS 档 FROM (SELECT {BIG_ONE} AS vip_level, * FROM sales) t", + # * 只带出另一个派生来源(写了表名):那里只有标签 + f"SELECT x.vip_level AS 档 FROM (SELECT b.* FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level " + "FROM sales) b ON s.id = b.bid) x", + # * 只展开 CTE 里的标签 + f"WITH b AS (SELECT {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM (SELECT * FROM b) t", +]) +def test_a_label_ahead_of_a_star_or_alone_behind_one_is_still_a_label_column(sql): + """收紧之后,标签排在前面、* 只带出派生来源里的标签的,照旧算只装着标签的列。""" + assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {"档": {"0", "1"}} + + # -------------------------------------------------------------------------- # 按值认:门槛、边界 # -------------------------------------------------------------------------- From 1136b2d09d8d4468a781536ee81b7b92f7d6b018 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:11:00 +0800 Subject: [PATCH 3/7] =?UTF-8?q?K3=EF=BC=9A=E6=8D=A2=E4=BA=86=E5=86=99?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E5=8F=B7=E7=A0=81=E5=9C=A8=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E3=80=81=E8=B4=A8=E8=AF=A2=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E6=96=87=E5=AD=97=E6=A0=BC=E5=92=8C=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E9=81=93=E5=85=B3=E9=87=8C=E4=B9=9F=E8=AE=A4=E5=BE=97?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收准备时复现过:模型把号码写成 199-1474-2785、199 1474 2785、199.1474.2785、(199)1474-2785、+8619914742785、 全角数字,回答正文、事件、回答文档原样带着,最后一道关 _leaked 也拦不住,正式运行里永久封存。 engine/masking.py 的 _Secrets 新加 loose_spans:在正文里找允许夹分隔符的数字串(数字含全角,之间夹一到三个空格、 连字符、点、括号,含全角;开头可带 + 和括号),从每一截起往后拼,规范化(全角转半角、去分隔符、去 0086 / 86 国家码) 之后在「遮罩值的纯数字写法」集合里查——集合查找,不现编正则,两万个遮罩值照样不卡。拼出来的要是完整的号码:和别的 数字只隔着一个连字符或点连着的(13900000001-2、日期)、只有两截中间一个点的(小数)不算,同原来按词认的边界。 括号配成对:(199)1474-2785 整个换掉,「(电话 199-1474-2785)」两个括号留着。存的值本身带分隔符或国家码的,也按 纯数字写法认。 - scrub:正文(prose)缺省这样认,文字格(质询工件里夹着的、整格就是号码的)也认;SQL 里不认,199-1474-2785 在 SQL 里是一个算式。 - protect:提问人换着写法写进问题的号码照样免遮;SQL 里照原样留着的阈值换了写法也只在正文里不遮。 - inquiry._leaked:组装之后还认得出换了写法的号码,整份不交。 反方审稿的理由正文目前没有按值遮这一步(理由里只引用反方自己的查询格子,查询结果在核查 agent 的工具边界先遮,见 下一个提交),这里不另加。补单元测试(18 种写法 × 三种存法、不误伤日期小数编号、文字格、最后一道关、提问人写的、 阈值、两万个值的耗时)和端到端测试(11 种写法写进回答都换成「已遮罩」,事件、回答文档里也没有;备注里夹着换了 写法的号码时回答整份不交、质询工件只换那一截)。去掉实现后这些用例都会变红。 --- backend/app/engine/inquiry.py | 20 ++- backend/app/engine/masking.py | 148 ++++++++++++++++-- backend/tests/test_inquiry_api.py | 70 +++++++++ .../tests/test_masking_reformatted_numbers.py | 128 +++++++++++++++ 4 files changed, 345 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_masking_reformatted_numbers.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index e835311d..ef8d021f 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -20,8 +20,9 @@ 认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且 同一个数在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的 范围……,值换个写法、写成几个数的算式也认)和别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、 - 报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的, - 这份回答不交出去 + 报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。回答正文、质询工件的文字格里, + 换了写法的长号码(199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字)按纯数字写法认(masking 的 + _Secrets.loose_spans)。组装之后还认得出遮罩值的(换了写法的也算),这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -75,6 +76,9 @@ - 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照记在这次运行名下(同报告自己的查询快照),从 那里打开不经这里的遮罩 - 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) +- 换了写法的号码只认数字之间夹着一到三个空格、连字符、点、括号(含全角),带 +86 / 0086 / 86 前缀、全角数字:写成 + 汉字(一三九……)、中间夹着别的字、拆成几处写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、 + 19914.74)不算号码;SQL 里不这样认(199-1474-2785 在 SQL 里是一个算式) """ from __future__ import annotations @@ -1834,15 +1838,17 @@ def _compose(source: str, catalog: dict[str, Any], *, run_id: str, views: dict[s def _leaked(doc: dict[str, Any], secrets: _Secrets) -> bool: - """组装出来的正文里还认得出遮罩值。SQL 里照原样留着的阈值写在正文里不算(_Secrets.show),渲染进单元格引用里的 - 算:格子已经按值遮过,遮完还认得出,就是一格文字里夹着这个数,渲染出来才露。""" + """组装出来的正文里还认得出遮罩值,换了写法的长号码也算(199-1474-2785、+86 …、全角数字,_Secrets.loose_spans)。 + SQL 里照原样留着的阈值写在正文里不算(_Secrets.show),渲染进单元格引用里的算:格子已经按值遮过,遮完还认得出, + 就是一格文字里夹着这个数,渲染出来才露。""" markdown = str(doc.get("markdown") or "") pattern = secrets.pattern(markdown) - if pattern is None: + hits = [(m.start(), m.end(), secrets.is_shown(m.group())) for m in pattern.finditer(markdown)] if pattern else [] + hits += [(start, end, key in secrets.shown) for start, end, key in secrets.loose_spans(markdown)] + if not hits: return False cells = [seg["span"] for seg in iter_segments(doc) if str(seg.get("ref") or "").startswith("v:")] - return any(not secrets.is_shown(m.group()) or any(a < m.end() and m.start() < b for a, b in cells) - for m in pattern.finditer(markdown)) + return any(not shown or any(a < end and start < b for a, b in cells) for start, end, shown in hits) def _renamed_entry(entry: Any, secrets: _Secrets) -> Any: diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index b572d80f..39d68420 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -722,6 +722,18 @@ _WORDS = re.compile(r"[0-9A-Za-z_]+") _DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?") #: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition) _PREFIX = 8 +#: 正文里一串允许夹分隔符的数字(换了写法的号码,见 _Secrets.loose_spans):数字(含全角)之间夹着一到三个空格、连字符、 +#: 点、括号(含全角),开头可以带 + 和括号。逗号不算分隔符(千分位按 _DIGITS 认),冒号、斜杠也不算(时间、日期),不跨行 +_LOOSE_RUN = re.compile(r"[((]?[++]?[((]?[0-90-9]+(?:[ \u3000\-‐‑‒–—-..·・()()]{1,3}[0-90-9]+)*[))]?") +_LOOSE_DIGITS = re.compile(r"[0-90-9]+") +#: 两截数字之间只隔着这样一个字(不带空格):是更长的编号、小数、日期的两截(13900000001-2、13900000001.5),号码到这里 +#: 不算完——同 _pattern 里「前后不能是 [.:/-] 加数字」 +_JOINERS = frozenset("-‐‑‒–—-..") +_POINTS = frozenset("..") +#: 去掉国家码(0086、86)之后至少还要有这么多位才算号码(固话号码最短 7 位):86 开头的短数不拆 +_LOCAL_MIN = 7 +_OPENS, _CLOSES = frozenset("(("), frozenset("))") +_HALF_DIGITS = str.maketrans("0123456789", "0123456789") def _plain_text(value: Any) -> str: @@ -756,6 +768,64 @@ def _digits_of(form: str) -> str: return form.replace(",", "").lstrip("+-").rstrip("%") +def _loose_candidates(digits: str) -> list[str]: + """一串数字(半角)可能是哪个号码的纯数字写法:原样;以国家码(0086、86)开头、去掉之后还有 _LOCAL_MIN 位以上的, + 去掉国家码的也算。""" + out = [digits] + for code in ("0086", "86"): + if digits.startswith(code) and len(digits) - len(code) >= _LOCAL_MIN: + out.append(digits[len(code):]) + return out + + +def _loose_keys(form: str) -> list[str]: + """一个按值认得出的遮罩值的纯数字写法(换了写法的号码按它认,见 _Secrets.loose_spans):整个是一个整数(可带千分位、 + 正负号、全角数字)的取它的数字;整个是夹着分隔符的数字串(存的就是 199-1474-2785、+86 199 1474 2785)的取规范化 + 之后的数字;以国家码开头的另加去掉国家码的。带小数的、不到 SECRET_DIGITS 位的、别的写法没有。""" + if _NUMERIC.fullmatch(form): + digits = _digits_of(form).translate(_HALF_DIGITS) + elif _LOOSE_RUN.fullmatch(form): + digits = "".join(_LOOSE_DIGITS.findall(form)).translate(_HALF_DIGITS) + else: + return [] + if not (digits.isascii() and digits.isdigit()) or len(digits) < SECRET_DIGITS: + return [] + return _loose_candidates(digits) + + +def _loose_whole(text: str, run: re.Match[str], groups: list[tuple[int, int, str]], i: int, j: int) -> bool: + """一串数字里第 i 到第 j 截拼起来是一个完整的号码,不是更长的编号、小数、日期的一截:两头不和别的数字只隔着一个 + 连字符或点(13900000001-2、13900000001.5、2026-10-06),只有两截、中间是一个点的按小数算(19914.74);这一串 + 前后不紧挨着字母数字(ID13900000001),同 _pattern 的词边界。""" + def gap(a: int, b: int) -> str: + return text[groups[a][1]:groups[b][0]] + + if (i > 0 and gap(i - 1, i) in _JOINERS) or (j < len(groups) - 1 and gap(j, j + 1) in _JOINERS): + return False + if j == i + 1 and gap(i, j) in _POINTS: + return False + before = text[run.start() - 1] if i == 0 and run.start() > 0 else "" + after = text[run.end()] if j == len(groups) - 1 and run.end() < len(text) else "" + return not (_WORD.match(before) or _WORD.match(after)) + + +def _loose_span(text: str, run: re.Match[str], groups: list[tuple[int, int, str]], i: int, j: int) -> tuple[int, int]: + """第 i 到第 j 截这个号码在 text 里换掉的那一段:从第一截到最后一截,从这一串开头起的连同前面的 + 和括号。括号配成 + 对:号码里有落单的左括号,紧跟着的右括号一起换掉;配不上的落单左括号留着不换(「(电话 199-1474-2785)」两个括号 + 都留着);落单的右括号,紧挨在前面的左括号一起换掉((199)1474-2785 从第二截起认出来时)。""" + start, end = groups[i][0], groups[j][1] + if i == 0: + start = run.start() + depth = sum((c in _OPENS) - (c in _CLOSES) for c in text[start:end]) + while depth > 0 and end < len(text) and text[end] in _CLOSES: + end, depth = end + 1, depth - 1 + while depth > 0 and text[start] in _OPENS: + start, depth = start + 1, depth - 1 + while depth < 0 and start > 0 and text[start - 1] in _OPENS: + start, depth = start - 1, depth + 1 + return start, end + + def _pattern(value: str) -> str: """一个值的正则:头尾是字母数字的按词边界认;数字还不能是更长的数、日期、时间的一截(12.5 里的 2.5、2026-01-25 里的 25、12:30 里的 30),也不能是千分位写法里的一组(1,390 里的 390)。逗号隔开的一串长号码(13900000001,13900000002) @@ -780,7 +850,9 @@ class _Secrets: strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字面量、名字和注释、别的列里的格子、列名都按 它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上;shown 是 SQL 里照原样 留着的阈值、行数(去掉千分位、正负号的数字),只在回答正文里不换(scrub 的 prose)。换的时候先按词、按开头两个 - 字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。""" + 字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。 + 正文、文字格里另认换了写法的长号码(199-1474-2785、+86 199 1474 2785、全角数字,见 loose_spans):按纯数字写法 + 在集合里查,不进正则。""" strong: set[str] = field(default_factory=set) weak: set[str] = field(default_factory=set) @@ -826,9 +898,13 @@ class _Secrets: if not given: return numbers = {_digits_of(n) for n in _DIGITS.findall(given)} + # 换了写法写进来的号码(「199-1474-2785 这位客户」)按纯数字写法认 + reformatted = {key for _, _, key in self.loose_spans(given)} def asked(form: str) -> bool: - return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given + if _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given: + return True + return bool(reformatted) and any(key in reformatted for key in _loose_keys(form)) self.strong = {f for f in self.strong if not asked(f)} self.weak = {f for f in self.weak if not asked(f)} @@ -844,7 +920,8 @@ class _Secrets: return bool(_NUMERIC.fullmatch(form)) and _digits_of(form) in self.shown def hit(self, value: Any) -> bool: - """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。""" + """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。整格就是一个换了 + 写法的号码的(没设遮罩的联系方式列里写着 199-1474-2785)也算。""" if value is None or isinstance(value, bool) or not self.strong: return False text = _plain_text(value) @@ -852,20 +929,60 @@ class _Secrets: return True if _numeric(value) and sum(c.isdigit() for c in text) >= SECRET_DIGITS: with contextlib.suppress(RenderError, ValueError, ArithmeticError): - return render_cell(value) in self.strong - return False + if render_cell(value) in self.strong: + return True + return isinstance(value, str) and any(start == 0 and end == len(text) for start, end, _ in self.loose_spans(text)) def weak_hit(self, text: str) -> bool: """SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。""" return _plain_text(text) in self.weak - def scrub(self, text: str, *, prose: bool = False) -> str: - """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。""" + def scrub(self, text: str, *, prose: bool = False, loose: bool | None = None) -> str: + """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。loose:换了写法 + 的长号码也换(loose_spans),缺省同 prose——只在正文、文字格里这样认,SQL 里的 199-1474-2785 是一个算式。""" if not text or not self.strong: return text + if prose if loose is None else loose: + # 先换换了写法的:+86 19914742785 整个换掉,不留下一截「+86」 + for start, end, _ in reversed(self.loose_spans(text, prose=prose)): + text = text[:start] + _masked_word() + text[end:] pattern = self.pattern(text, prose=prose) return pattern.sub(_masked_word(), text) if pattern is not None else text + def loose_spans(self, text: str, *, prose: bool = False) -> list[tuple[int, int, str]]: + """text 里换了写法的长号码(199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字……):(起, 止, 纯数字 + 写法),按先后、不重叠。在正文里找允许夹分隔符的数字串(_LOOSE_RUN),从每一截起往后拼,规范化(全角转半角、去 + 分隔符、去国家码)之后在遮罩值的纯数字写法里查(集合查找,不现编正则),取拼得最长的那个;拼出来的不是完整的 + 号码(更长的编号、小数、日期的一截,见 _loose_whole)不算。prose 同 scrub:SQL 里照原样留着的阈值不算。""" + if not text or not self.strong: + return [] + keys, longest = self._partition()[3] + if not keys: + return [] + out: list[tuple[int, int, str]] = [] + for run in _LOOSE_RUN.finditer(text): + groups = [(run.start() + g.start(), run.start() + g.end(), g.group().translate(_HALF_DIGITS)) + for g in _LOOSE_DIGITS.finditer(run.group())] + i = 0 + while i < len(groups): + best: tuple[int, str] | None = None + digits = "" + for j in range(i, len(groups)): + digits += groups[j][2] + if len(digits) > longest + 4: # 再拼也对不上任何一个了(4 是国家码 0086 的长度) + break + key = next((c for c in _loose_candidates(digits) if c in keys), None) + if key is not None and _loose_whole(text, run, groups, i, j): + best = (j, key) + if best is None: + i += 1 + continue + j, key = best + if not (prose and key in self.shown): + out.append((*_loose_span(text, run, groups, i, j), key)) + i = j + 1 + return out + def pattern(self, text: str, *, prose: bool = False) -> re.Pattern[str] | None: """认 text 里出现的遮罩值的正则;一个都没有时为 None。prose 同 scrub。""" found = self._found(text) if text and self.strong else set() @@ -882,7 +999,7 @@ class _Secrets: return self._compiled[key] def _found(self, text: str) -> set[str]: - words, numbers, others = self._partition() + words, numbers, others, _ = self._partition() found = {w for w in _WORDS.findall(text) if w in words} if numbers: for run in _DIGITS.findall(text): @@ -898,15 +1015,17 @@ class _Secrets: """先把索引建好(收完值、去掉提问人写的之后):上万个值建一次要几十毫秒,放在线程里建,别等到事件循环里第一次换。""" self._partition() - def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]]]: + def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]], + tuple[frozenset[str], int]]: """strong 分成三类:只有字母数字的(按词比)、带千分位小数的数(按去掉逗号的数比)、其余的(中文、日期、 邮箱)。其余的按开头至多 _PREFIX 个字分桶、桶里再按长短分:同一个市区的上万个地址开头七八个字都一样,只按开头 分的话,正文里每碰到一次那几个字都要逐个比;再按长短分,碰到一次只是按几种长短各查一下。三类各自只在正文里 - 真出现时才进正则。""" + 真出现时才进正则。另记长号码的纯数字写法和其中最长的有几位(_loose_keys,loose_spans 按它查)。""" if self._index is None: words: set[str] = set() numbers: dict[str, set[str]] = {} others: dict[int, dict[str, dict[int, set[str]]]] = {} + loose: set[str] = set() for value in self.strong: if _WORDS.fullmatch(value): words.add(value) @@ -915,7 +1034,8 @@ class _Secrets: else: size = min(len(value), _PREFIX) others.setdefault(size, {}).setdefault(value[:size], {}).setdefault(len(value), set()).add(value) - self._index = (words, numbers, others) + loose.update(_loose_keys(value)) + self._index = (words, numbers, others, (frozenset(loose), max(map(len, loose), default=0))) return self._index @@ -926,8 +1046,8 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 原快照不动;没有要遮的返回原快照。 - embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号),把那一截换成「已遮罩」,这一格别的字 - 照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 + embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号,换了写法的也算),把那一截换成「已遮罩」, + 这一格别的字照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。""" columns = [str(c) for c in snapshot.get("columns") or []] index = {i for i, c in enumerate(columns) if c.lower() in hidden} @@ -951,7 +1071,7 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, if embedded and by_value and isinstance(value, (str, dict, list)): # JSON 列(对象、数组)按界面画出来的样子换,换过的照文字给 text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) - if (scrubbed := secrets.scrub(text)) != text: + if (scrubbed := secrets.scrub(text, loose=True)) != text: edited = True return scrubbed return value diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index a39fd68f..3ce61dd7 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -1651,6 +1651,76 @@ async def test_a_customer_looked_up_in_any_shape_never_shows(monkeypatch, tmp_pa await engines.invalidate(source_id) +#: 同一个号码(13900000001)换了写法:带空格、连字符、点、括号、全角数字或全角标点、+86 / 0086 / 86 前缀 +REWRITTEN = [ + pytest.param("139-0000-0001", id="连字符"), pytest.param("139 0000 0001", id="空格"), + pytest.param("139.0000.0001", id="点"), pytest.param("(139)0000-0001", id="括号"), + pytest.param("+8613900000001", id="+86"), pytest.param("+86 139 0000 0001", id="+86 加空格"), + pytest.param("0086 139 0000 0001", id="0086"), pytest.param("86-139-0000-0001", id="86"), + pytest.param("13900000001", id="全角数字"), pytest.param("139-0000-0001", id="全角连字符"), + pytest.param("(139)0000 0001", id="全角括号和空格"), +] + + +def _shown_anywhere(*texts: str) -> str: + return "\n".join(texts) + + +@pytest.mark.parametrize("written", REWRITTEN) +async def test_a_masked_number_written_in_another_shape_never_shows(monkeypatch, tmp_path, client, written): + """验收准备时复现过:模型把号码换个写法写进回答(139-0000-0001、+86 …、全角数字),回答正文、事件、回答文档原样 + 带着,最后一道关也拦不住,正式运行里永久封存。现在正文里按纯数字写法认出来,换成「已遮罩」。报告取数选了手机号, + 号码是从报告自己的查询快照里认出来的。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=SALES) + asked = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'" + plays = {"这位客户": Play([asked], f"客户 {written} 共下了 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, WITH_PHONE)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="这位客户下了几笔?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "客户 已遮罩 共下了 1 笔。") + [event] = await events(paused.id, "approval.inquiry") + doc = artifact_store.load(item["answer"]["doc_artifact"]) + shown = _shown_anywhere(json.dumps(r.json(), ensure_ascii=False), json.dumps(event.data, ensure_ascii=False), + json.dumps(doc, ensure_ascii=False)) + assert written not in shown and "1390000000" not in shown + finally: + await engines.invalidate(source_id) + + +#: 回访记录的备注里写着换了写法的号码(备注没设遮罩) +REWRITTEN_CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEXT, note TEXT);" + "INSERT INTO callbacks VALUES (1, '华东', '客户 (139)0000-0001 来电改址'), " + "(2, '华南', '例行回访');") + + +async def test_a_reformatted_number_inside_a_text_cell_stops_the_answer(monkeypatch, tmp_path, client): + """备注里夹着换了写法的号码,agent 引用这一格:渲染出来才露,最后一道关认得出,回答整份不交;质询工件里只换掉那一截。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=REWRITTEN_CALLBACKS) + notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id" + plays = {"回访": Play([notes], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, WITH_PHONE)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="华东的回访说了什么?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["error"]) == (False, LEAKED) + [query] = item["queries"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", "客户 已遮罩 来电改址"], ["华南", "例行回访"]] + assert "0000-0001" not in got.text + finally: + await engines.invalidate(source_id) + + # -------------------------------------------------------------------------- # 接线:两道审批的竞态、上传表格的版本、设置、开放之后状态又变了、张冠李戴的报告文档 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_masking_reformatted_numbers.py b/backend/tests/test_masking_reformatted_numbers.py new file mode 100644 index 00000000..44d9076b --- /dev/null +++ b/backend/tests/test_masking_reformatted_numbers.py @@ -0,0 +1,128 @@ +"""换了写法的号码(engine/masking.py 的 _Secrets):按值认得出的长数字(SECRET_DIGITS 位以上)被写成带空格、连字符、 +点、括号、全角数字或全角标点、带 +86 / 0086 / 86 前缀的样子,模型写的正文(审批质询的回答)里照样认得出,换成 +「已遮罩」;最后一道关(inquiry._leaked)、质询工件里文字格夹着的号码同样认得出。 + +验收准备时复现过:模型把号码写成 199-1474-2785、199 1474 2785、199.1474.2785、(199)1474-2785、+8619914742785、 +全角数字,回答正文、事件、回答文档原样带着,最后一道关也拦不住,正式运行里永久封存。 + +认法是在正文里找允许夹分隔符的数字串,规范化(全角转半角、去分隔符、去国家码)后在「遮罩值的纯数字写法」集合里 +查——集合查找,不现编正则。日期、小数、更长的数里的一截、连字符连着的编号照旧不动。 + +数据都是虚构的:手机号、金额。 +""" +from __future__ import annotations + +import time + +import pytest + +from app.engine import inquiry as inquiry_mod +from app.engine.masking import _masked_view, _Secrets + +MASKED = "已遮罩" +PHONE = "19914742785" + +#: 同一个号码的各种写法(换了写法的号码) +REFORMATTED = [ + pytest.param("199-1474-2785", id="连字符"), + pytest.param("199 1474 2785", id="空格"), + pytest.param("199.1474.2785", id="点"), + pytest.param("(199)1474-2785", id="括号"), + pytest.param("(199) 1474 2785", id="括号加空格"), + pytest.param("+8619914742785", id="+86"), + pytest.param("+86 199 1474 2785", id="+86 加空格"), + pytest.param("(+86)19914742785", id="(+86)"), + pytest.param("0086 199 1474 2785", id="0086"), + pytest.param("0086-19914742785", id="0086 连字符"), + pytest.param("86-199-1474-2785", id="86"), + pytest.param("8619914742785", id="86 连写"), + pytest.param("19914742785", id="全角数字"), + pytest.param("199-1474-2785", id="全角数字和连字符"), + pytest.param("199 1474 2785", id="全角空格"), + pytest.param("199.1474.2785", id="全角点"), + pytest.param("(199)1474-2785", id="全角括号"), + pytest.param("+86 199 1474 2785", id="全角加号"), +] + + +def secrets_of(values: list, *, hidden: str = "phone") -> _Secrets: + secrets = _Secrets() + secrets.add({"columns": [hidden], "rows": [[v] for v in values]}, {hidden}) + return secrets + + +@pytest.mark.parametrize("written", REFORMATTED) +@pytest.mark.parametrize("stored", [PHONE, int(PHONE), "199-1474-2785"], ids=["文本", "整数", "存的就带连字符"]) +def test_a_long_number_in_another_shape_is_masked_in_prose(stored, written): + secrets = secrets_of([stored]) + assert secrets.scrub(f"金额最高的一笔来自 {written},共 3 笔", prose=True) == f"金额最高的一笔来自 {MASKED},共 3 笔" + assert secrets.scrub(f"客户{written}来电", prose=True) == f"客户{MASKED}来电" + + +@pytest.mark.parametrize("text", [ + # 日期、时间、小数、千分位、更长的数里的一截、连字符连着的编号、别的号码 + "2026-10-06 19:14 下单", "金额 19914.74 元", "共 1,991,474 笔", "号码 1991474278512", "批次 19914742785-2", + "199-1474-2786", "单号 2026199-1474-2785", "共 199 笔 1474 元 2785 次", +]) +def test_other_numbers_and_number_like_text_are_left_alone(text): + secrets = secrets_of([PHONE]) + assert secrets.scrub(text, prose=True) == text + + +def test_reformatted_numbers_are_only_read_in_prose_and_text_cells(): + """SQL 里 199-1474-2785 是一个算式,不当成号码换(SQL 里的号码按字面量的用法换,见 inquiry._scrub_sql)。""" + secrets = secrets_of([PHONE]) + assert secrets.scrub("SELECT 199-1474-2785 AS x") == "SELECT 199-1474-2785 AS x" + assert secrets.scrub("备注 199-1474-2785", loose=True) == f"备注 {MASKED}" + + +def test_a_text_cell_holding_a_reformatted_number_is_masked_whole_or_in_part(): + """快照里别的列(没设遮罩)写着换了写法的号码:整格就是号码的整格遮;质询工件里夹在一段文字里的只换那一截。""" + secrets = secrets_of([PHONE]) + snapshot = {"columns": ["联系方式", "备注"], "rows": [["199-1474-2785", "客户 (199)1474-2785 来电改址"], + ["138-0000-0000", "例行回访"]]} + view, masked = _masked_view(snapshot, set(), secrets) + assert view["rows"] == [[MASKED, "客户 (199)1474-2785 来电改址"], ["138-0000-0000", "例行回访"]] + assert masked == ["联系方式"] + view, masked = _masked_view(snapshot, set(), secrets, embedded=True) + assert view["rows"] == [[MASKED, f"客户 {MASKED} 来电改址"], ["138-0000-0000", "例行回访"]] + assert masked == ["联系方式"] + + +@pytest.mark.parametrize("written", REFORMATTED) +def test_the_last_gate_stops_an_answer_still_showing_a_reformatted_number(written): + """组装之后还认得出(渲染进单元格引用的一格文字里夹着换了写法的号码):不交出去。正文里的在组装之前就换掉了。""" + secrets = secrets_of([PHONE]) + entry = {"alias": "Q1", "kind": "query", "artifact": "a1", "source": "shop", "columns": ["备注"], "rows": 1, + "truncated": False} + views = {"a1": _masked_view({"columns": ["备注"], "rows": [[f"客户{written}来电改址"]]}, set(), secrets)[0]} + with pytest.raises(inquiry_mod._Leak): + inquiry_mod._compose("回访备注是 [[v:Q1.r0.备注]]。", {"Q1": entry}, run_id="r1", views=views, secrets=secrets, + allow=[]) + doc = inquiry_mod._compose(f"来电的是 {written}。", {}, run_id="r1", views={}, secrets=secrets, allow=[]) + assert doc["markdown"] == f"来电的是 {MASKED}。" + assert inquiry_mod._leaked({"markdown": f"来电的是 {written}。", "blocks": []}, secrets) + + +def test_a_number_the_asker_wrote_in_another_shape_is_left_alone(): + """提问人自己写的号码(换了写法也一样)已经摆在读者眼前:回答里照原样,别的号码照遮。""" + secrets = secrets_of([PHONE, "13800000000"]) + secrets.protect(["199-1474-2785 这位客户还买过别的吗?"]) + assert secrets.scrub("199 1474 2785 另有 138-0000-0000 的单", prose=True) == f"199 1474 2785 另有 {MASKED} 的单" + + +def test_a_threshold_left_in_the_sql_is_shown_in_the_prose_whatever_the_shape(): + """SQL 里照原样留着的阈值,回答正文里复述不遮,换了写法也一样(只免正文)。""" + secrets = secrets_of([20000, 35000], hidden="amount") + secrets.show({"20000"}) + assert secrets.scrub("金额不低于 20 000 元,最高一笔 35 000 元", prose=True) == f"金额不低于 20 000 元,最高一笔 {MASKED} 元" + + +def test_many_masked_numbers_are_matched_without_stalling(): + """遮罩值上万个:集合查找,不现编上万个分支的正则。""" + secrets = secrets_of([str(19900000000 + i) for i in range(20_000)]) + text = ";".join(f"客户 199-0000-{i:04d} 来电" for i in range(0, 4000, 7)) * 3 + began = time.perf_counter() + out = secrets.scrub(text, prose=True) + assert time.perf_counter() - began < 2.0 + assert "199-0000" not in out and out.count(MASKED) == text.count("199-0000") From 450758185d5cd5e47ed10db679ea0caf9b2f1dc0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 17:36:55 +0800 Subject: [PATCH 4/7] =?UTF-8?q?K3=EF=BC=9A=E6=A0=B8=E6=9F=A5=20agent=20?= =?UTF-8?q?=E7=9C=8B=E5=88=B0=E7=9A=84=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=85=88=E9=81=AE=E3=80=81=E5=AD=98=E4=B8=8B=E7=9A=84=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E4=B9=9F=E9=81=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双盲复算、反方审稿、审批质询共用的 engine/verify_agent.py 以前把查询结果原样交给模型(db_query 自己落快照,ToolMessage 里是原样的结果),遮罩全靠审批质询事后按值认,期 4 起六轮复核每轮都找到新的漏法;运行详情「工件」页签走的通用接口 GET /api/artifacts/{id} 还把核查的查询快照原样给出。根本的修法是不让模型看到遮罩值。 - tools/datasource:查询工具加遮罩开关 mask_results(缺省关,Agent 节点、调用工具节点照旧)。打开时数据源设了遮罩的, 先按 engine/masking.py 的 mask_query_result 遮好再交给模型、再落快照:列名直接对上的、按 SQL 认出取自遮罩列的 (别名、函数、子查询、WITH、SELECT *,复用 _derived_hidden;SQL 解析不了又写着遮罩列的,整份结果的列都遮)整列 换成「已遮罩」、类型记成文本;同一份结果里别的格子整格是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码 也认);列名不换。结果里一列都没取自遮罩列的(数值的汇总、只数个数、只在条件里用到)原样。快照里 mask_columns 另记下查询时遮了的、改了名字的结果列(证据面板、质询工件据此画成遮罩列),masked_at_query 记下查询时就整列遮掉的列。 这一次遮掉的值并进 ToolContext.extra[MASKED_VALUES_KEY],只在内存里。 - verify_agent:建查询工具时和 force_readonly 一样打开遮罩开关;格子引用指到查询时就遮了的列,照实说是遮罩列、不采信; 遮掉的值经 VerifyOutcome.masked_values 交回调用方(不进 as_dict、不往外交);系统提示补一句「已遮罩」是什么。 - 审批质询:按值遮降为第二道防线。agent 自己的快照查询时就遮好了,收不到遮罩值,改由 masked_values 并进来(_plan 的 seen),回答、查询清单照旧按值遮;「已遮罩」本身不收成遮罩值。事后取质询工件时内存里的值已经不在,SQL 检查结果的 片段、说明照事件里记下的那条 SQL 换(_recorded_swaps:逐词对出回答时换掉的字面量和 LIKE 里换掉的那一截)。 - 双盲复算取盲算值、反方审稿的引用、证据面板、可验证报告(带遮罩的快照只给哈希,照旧)、推演、运行对比(不收核查的 查询)都按快照里已经是「已遮罩」照常工作,文件不用改。 测试:核查 agent 选出遮罩列(直接、别名、函数、子查询、WITH、SELECT *)时交给假模型的 ToolMessage、落库的快照、通用 工件接口、tool.end 预览、收尾交给模型的查询结果里都是「已遮罩」;没碰遮罩列的聚合查询原样、照常取值;文字格夹着的 号码换掉那一截;引用遮罩格子不采信;SQL 认不出时整份遮;开关缺省关、没设遮罩的源快照一字不差;列名不改;审批质询、 反方审稿、双盲复算各补一条端到端;质询工件的检查片段照记下的 SQL 换。逐处去掉实现(开关、并进内存的值、照记下的 SQL 换、跳过「已遮罩」、遮罩格子的说法、记下改了名字的列、文字格按子串换、不改列名)对应的用例都会变红。 --- backend/app/engine/inquiry.py | 72 ++++- backend/app/engine/masking.py | 110 ++++++-- backend/app/engine/verify_agent.py | 45 +++- backend/app/tools/datasource.py | 40 ++- backend/tests/test_challenge_node.py | 26 ++ backend/tests/test_dual_check_node.py | 21 ++ backend/tests/test_inquiry_api.py | 29 ++ backend/tests/test_inquiry_masking.py | 21 ++ .../tests/test_masking_reformatted_numbers.py | 11 + backend/tests/test_verify_agent_masking.py | 248 ++++++++++++++++++ 10 files changed, 576 insertions(+), 47 deletions(-) create mode 100644 backend/tests/test_verify_agent_masking.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index ef8d021f..9e652f68 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -12,7 +12,11 @@ 上传表格不算,见 _usable_sources);上限按设置(每次的查询次数、时长,全局每日金额)。回答先给结论,数字只能引用 agent 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。 封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明 -3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 +3. 遮罩(_plan):agent 的查询结果在核查 agent 的工具边界就遮好了(engine/masking.py 的 mask_query_result):取自 + 遮罩列的格子交给模型、落进快照之前就是「已遮罩」,模型看不到遮罩值。这里是第二道防线:查询时遮掉的值由 verify_agent + 交回(masked_values,只在内存里),连同报告自己快照里的遮罩值,照下面的办法按值遮;事后取质询工件时内存里的值已经 + 不在,SQL 检查结果照事件里记下的 SQL 换(_recorded_swaps)。 + 哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉;按遮罩列 分档、当分组键或套在汇总里数个数的不算,_band_only);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询 工件里,文字格里夹着的遮罩值另按子串换)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql): @@ -73,8 +77,10 @@ - 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在 片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的, 从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮) -- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照记在这次运行名下(同报告自己的查询快照),从 - 那里打开不经这里的遮罩 +- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照落库时就遮好了(工具边界先遮),从那里打开也 + 看不到取自遮罩列的值;但工具边界只认这份结果里的遮罩值,别的查询、报告快照里遮罩列的值写在这份结果没设遮罩的列里时 + (没设遮罩的联系方式列、备注),从那里打开照原样(质询工件接口按回答时的办法换)。报告自己的查询快照(写作时的查询) + 仍原样存着,从那里打开不经这里的遮罩 - 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) - 换了写法的号码只认数字之间夹着一到三个空格、连字符、点、括号(含全角),带 +86 / 0086 / 86 前缀、全角数字:写成 汉字(一三九……)、中间夹着别的字、拆成几处写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、 @@ -84,6 +90,7 @@ from __future__ import annotations import asyncio import contextlib +import difflib import itertools import logging import operator @@ -91,7 +98,7 @@ import re import time from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timezone from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from typing import Any @@ -1618,13 +1625,15 @@ async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None], shown: list[tuple[str, str, str]] | None = None, live: dict[str, list[str]] | None = None, *, - embedded: frozenset[str] = frozenset()) -> _Plan: + embedded: frozenset[str] = frozenset(), seen: _Secrets | None = None) -> _Plan: """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, agent 会照着复述、写进查询条件);given:问题、选中的原文、报告里列出的指标和运行输入的值(都已经摆在读者眼前, 里面的值哪里都不换);shown:agent 执行的查询,(SQL, 数据源名, 快照工件 id)(照原样留在 SQL 里的阈值、LIMIT, 回答正文里不换,见 _Secrets.show;取质询工件不用:快照里的格子照旧按值遮);live:用得上的数据源现在设的遮罩列 (快照没取到的查询按它认 SQL 里的比较,见 _compared);embedded:这几份快照的文字格里夹着的遮罩值也按子串换 - (只给要取的那份质询工件,见 _masked_view)。 + (只给要取的那份质询工件,见 _masked_view);seen:agent 查询时在工具边界遮掉的值(verify_agent 交回的 + masked_values,只在内存里)。agent 自己的快照查询时就遮好了,遮罩列里收不到值;模型照着别处看到的号码写进回答、 + 查询条件时,按值遮要靠它认出来。 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 里别的运行(同 tools/datasource 的 _sql_checks)。""" @@ -1636,13 +1645,13 @@ async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], gi based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] kinds = await _source_kinds([*(str(s.get("source") or "") for s in [*own.values(), *others]), *(name for _, name, _ in shown)]) - return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown, live or {}, embedded) + return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown, live or {}, embedded, seen) def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str], list[str]]], based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None], given: list[str | None], shown: list[tuple[str, str, str]], live: dict[str, list[str]], - embedded: frozenset[str]) -> _Plan: + embedded: frozenset[str], seen: _Secrets | None = None) -> _Plan: secrets = _Secrets() hidden: dict[str, set[str]] = {} tainted: dict[str, set[str]] = {} @@ -1654,6 +1663,7 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] kind = kinds.get(str(snapshot.get("source") or "")) secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0], labels=_snapshot_labels(snapshot, kind), base=base) + secrets.merge(seen) secrets.protect(given) secrets.show(_shown_literals([(sql, kinds.get(name), _compared(tainted, artifact, live, name)) for sql, name, artifact in shown], secrets)) @@ -1755,7 +1765,8 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict output_schema=INQUIRY_SCHEMA, max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"), daily_max_usd=settings.get("daily_max_usd"), run_id=op.run_id, data_versions=op.versions) - # 遮罩:agent 自己的查询快照、报告自己的查询快照(被问的那句可能直接写着遮罩列的值,agent 会照着复述) + # 遮罩:agent 自己的查询快照(查询时就遮好了,遮掉的值在 outcome.masked_values 里)、报告自己的查询快照(被问的 + # 那句可能直接写着遮罩列的值,agent 会照着复述) own: dict[str, dict[str, Any]] = {} for query in outcome.queries: snapshot = _quiet_load(artifact_store.load, query.get("artifact")) @@ -1765,7 +1776,7 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] plan = await _plan(own, others, [ask.question, ask.quote, *_ref_values(refs)], [(str(q.get("sql") or ""), str(q.get("source") or ""), str(q.get("artifact") or "")) - for q in outcome.queries], live) + for q in outcome.queries], live, seen=outcome.masked_values) text, doc_id, failed = "", None, None source = (outcome.rationale or {}).get("source") if outcome.result is not None else None @@ -2059,22 +2070,57 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: elif isinstance(shown.get("sql"), str): shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, compared, kind)} if "checks" in shown: - shown = {**shown, "checks": _masked_checks(shown["checks"], content.get("sql"), plan.secrets, compared, kind)} + whole, pieces = _recorded_swaps(content.get("sql"), recorded, kind) if recorded is not None else (set(), set()) + shown = {**shown, "checks": _masked_checks(shown["checks"], content.get("sql"), plan.secrets, compared, kind, + swapped=whole, pieces=pieces)} out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": plan.masked[artifact_id]} if gone := plan.gone.get(artifact_id): out["mask_note"] = SOURCE_GONE.format(source="、".join(gone)) return out -def _masked_checks(checks: Any, sql: Any, secrets: _Secrets, compared: set[str], kind: str | None +def _recorded_swaps(sql: Any, recorded: str, kind: str | None) -> tuple[set[str], set[str]]: + """回答时这条 SQL 里换掉的值:快照里原样的那条和事件里记下的那条(_scrub_sql 换过的)逐词对,原样那条里的字面量 + 到了记下的那条里整个换成了「已遮罩」的(第一项,字面量的字),或者其中一截换成了「已遮罩」的(第二项,那一截, + LIKE '%13900000001%' 这类)。_scrub_sql 只换字面量、名字和注释里的值,一个词换成一个词,两条分出来的词一一对得上; + 对不上(分词出错这类)、没有记下的那条时,什么都不认。""" + if not isinstance(sql, str) or not sql.strip() or not recorded or sql == recorded: + return set(), set() + before, after = _sql_tokens(sql, kind), _sql_tokens(recorded, kind) + if before is None or after is None or len(before[0]) != len(after[0]): + return set(), set() + word = _masked_word() + whole: set[str] = set() + pieces: set[str] = set() + for old, kind_of, new in zip(before[0], before[1], after[0]): + if not _is_literal(kind_of) or old.text == new.text or word not in new.text: + continue + if new.text == word: + whole.add(old.text) + continue + for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old.text, new.text, autojunk=False).get_opcodes(): + if op == "replace" and new.text[j1:j2] == word and old.text[i1:i2].strip(): + pieces.add(old.text[i1:i2]) + return whole, pieces + + +def _masked_checks(checks: Any, sql: Any, secrets: _Secrets, compared: set[str], kind: str | None, *, + swapped: set[str] | frozenset[str] = frozenset(), pieces: set[str] | frozenset[str] = frozenset() ) -> list[dict[str, Any]]: """快照里记下的 SQL 检查结果(数据源查询工具对照数据目录查出的问题)交给质询工件的样子:字段照证据面板 (_query_checks),给模型的那句改法不交出去——它照抄了 SQL 片段。片段(sql_excerpt)、说明(message)摘自、照引 同一条 SQL(快照里原样的那条),照它在查询清单里的换法换掉遮罩值(_literal_swaps):SQL 换了、片段和说明没换, - 等于把换掉的值从旁边露出来;片段单拿出来认不出在找哪一个值,不能自己另认一遍。""" + 等于把换掉的值从旁边露出来;片段单拿出来认不出在找哪一个值,不能自己另认一遍。 + swapped / pieces:回答时这条 SQL 里整个换掉的字面量、换掉的一截(_recorded_swaps,照事件里记下的那条认)。回答时 + agent 自己查询里遮掉的值还在内存里,事后取工件时已经不在了(快照查询时就遮好了,收不到值),照记下的 SQL 补上。""" from app.api.evidence import _query_checks whole = _literal_swaps(sql, secrets, compared, kind) + if swapped: + whole = (whole or set()) | set(swapped) + if pieces: + secrets = replace(secrets, strong=secrets.strong | set(pieces), weak=secrets.weak | set(pieces), _index=None, + _compiled={}) out: list[dict[str, Any]] = [] for check in _query_checks({"checks": checks}): if isinstance(check.get("sql_excerpt"), str): diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index 39d68420..082d8000 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -2,18 +2,24 @@ 数据源在 options.mask_columns 里设了遮罩的列(手机号、姓名这类),证据面板按列名遮(api/evidence.py)。核查 agent (engine/verify_agent.py:双盲复算、反方审稿、审批质询共用)自己写 SQL,起别名、套函数、经子查询转一道,结果列名就 -对不上遮罩列;按值认又有门槛和写法的问题。认法集中在这里,审批质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py, -经 inquiry 的同名导出)共用: +对不上遮罩列;按值认又有门槛和写法的问题。认法集中在这里,核查 agent 的查询工具(tools/datasource 的遮罩开关)、审批 +质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py,经 inquiry 的同名导出)共用: + +- 工具边界先遮(mask_query_result):核查 agent 的查询结果交给模型、落进快照之前,取自遮罩列的格子换成「已遮罩」, + 同一份结果里别的文字格夹着的遮罩值也换掉。模型看不到遮罩值,就不会把它写进回答、理由、SQL——事后按值认每一轮 + 复核都找到新的漏法,根治靠这一步;审批质询回答里的按值遮降为第二道防线 - 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的 另一支、和 * 写在同一支里没起别名的表达式);COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在 汇总里数个数的不算(_value_columns、_band_only)。认不出落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算 - 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值 - 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出 - 正文里真有的那几个值,不现编上万个分支的正则 + 正文里真有的那几个值,不现编上万个分支的正则;正文、文字格里换了写法的长号码(199-1474-2785、+86 …、全角数字)按 + 纯数字写法在集合里查(loose_spans) - 快照遮好的样子(_masked_view) -这里的函数原来都在 engine/inquiry.py 里,名字不变,inquiry 照旧导出(别处按 inquiry 的名字引用)。 +除 mask_query_result、换了写法的号码之外,这里的函数原来都在 engine/inquiry.py 里,名字不变,inquiry 照旧导出(别处按 +inquiry 的名字引用)。 """ from __future__ import annotations @@ -810,19 +816,17 @@ def _loose_whole(text: str, run: re.Match[str], groups: list[tuple[int, int, str def _loose_span(text: str, run: re.Match[str], groups: list[tuple[int, int, str]], i: int, j: int) -> tuple[int, int]: - """第 i 到第 j 截这个号码在 text 里换掉的那一段:从第一截到最后一截,从这一串开头起的连同前面的 + 和括号。括号配成 - 对:号码里有落单的左括号,紧跟着的右括号一起换掉;配不上的落单左括号留着不换(「(电话 199-1474-2785)」两个括号 - 都留着);落单的右括号,紧挨在前面的左括号一起换掉((199)1474-2785 从第二截起认出来时)。""" + """第 i 到第 j 截这个号码在 text 里换掉的那一段:从第一截到最后一截,紧挨在前面的 + 一起换。括号只换号码里的:号码 + 里有落单的右括号((199)1474-2785、(+86)199…),把前面配对的左括号一起换掉;落单的左括号,把后面紧跟着的右括号一起 + 换掉。整个号码外面套着的一对括号是标点(「来自(13900000001)」),留着不换。""" start, end = groups[i][0], groups[j][1] - if i == 0: - start = run.start() + if i == 0 and start > run.start() and text[start - 1] in "++": + start -= 1 depth = sum((c in _OPENS) - (c in _CLOSES) for c in text[start:end]) - while depth > 0 and end < len(text) and text[end] in _CLOSES: - end, depth = end + 1, depth - 1 - while depth > 0 and text[start] in _OPENS: - start, depth = start + 1, depth - 1 while depth < 0 and start > 0 and text[start - 1] in _OPENS: start, depth = start - 1, depth + 1 + while depth > 0 and end < len(text) and text[end] in _CLOSES: + end, depth = end + 1, depth - 1 return start, end @@ -844,8 +848,9 @@ def _pattern(value: str) -> str: @dataclass class _Secrets: - """遮罩列里出现过的值。模型看得到原始的查询结果(工具交回的是原样),回答里照着复述、或者把值写进查询条件, - 都会把它带进回答正文和查询清单;按值找出来换成「已遮罩」。这只减少暴露,不是安全边界(同证据面板的遮罩)。 + """遮罩列里出现过的值。模型照着报告正文、提问、别处的文字格里看到的值复述、或者把值写进查询条件,都会把它带进 + 回答正文和查询清单;按值找出来换成「已遮罩」。核查 agent 自己的查询结果在工具边界就遮好了(mask_query_result), + 这里是第二道防线。这只减少暴露,不是安全边界(同证据面板的遮罩)。 strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字面量、名字和注释、别的列里的格子、列名都按 它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上;shown 是 SQL 里照原样 @@ -870,6 +875,8 @@ class _Secrets: written = {name: texts for name, texts in (labels or {}).items() if name not in own} columns = [str(c) for c in snapshot.get("columns") or []] index = [i for i, c in enumerate(columns) if c.lower() in hidden] + # 核查 agent 的查询快照在工具边界就遮好了(mask_query_result):遮罩列的格子是「已遮罩」,不是遮罩值 + word = _masked_word() for row in snapshot["rows"]: if len(self.weak) >= SECRETS_MAX: break @@ -880,7 +887,7 @@ class _Secrets: else: continue for name, value in cells: - if value is None or isinstance(value, bool): + if value is None or isinstance(value, bool) or value == word: continue forms = _forms(value) if forms & written.get(name, set()): @@ -891,6 +898,18 @@ class _Secrets: self.strong.add(form) self._index = None + def merge(self, other: _Secrets | None) -> None: + """并进另一份收好的遮罩值(核查 agent 查询时在工具边界遮掉的,见 mask_query_result)。合起来最多 SECRETS_MAX 种。""" + if other is None or not other.weak: + return + for form in other.weak: + if len(self.weak) >= SECRETS_MAX: + break + self.weak.add(form) + if form in other.strong: + self.strong.add(form) + self._index, self._compiled = None, {} + def protect(self, texts: Iterable[str | None]) -> None: """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。""" @@ -1039,8 +1058,8 @@ class _Secrets: return self._index -def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False - ) -> tuple[dict[str, Any], list[str]]: +def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False, + rename: bool = True) -> tuple[dict[str, Any], list[str]]: """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 @@ -1048,7 +1067,8 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号,换了写法的也算),把那一截换成「已遮罩」, 这一格别的字照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 - 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。""" + 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。 + rename 为假时列名不换(工具边界先遮时:列名是模型自己写的,换了它引用的列名就对不上)。""" columns = [str(c) for c in snapshot.get("columns") or []] index = {i for i, c in enumerate(columns) if c.lower() in hidden} by_value = bool(secrets.strong) @@ -1058,7 +1078,7 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, touched = set(index) edited = False position = {c: i for i, c in enumerate(columns)} - names = [secrets.scrub(c) for c in columns] if by_value else columns + names = [secrets.scrub(c) for c in columns] if by_value and rename else columns def cell(i: int | None, value: Any) -> Any: nonlocal edited @@ -1097,3 +1117,53 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, types.update({names[i]: "text" for i in index}) view = {**snapshot, "rows": rows, "column_types": types, **({"columns": names} if renamed else {})} return view, [names[i] for i in sorted(touched)] + + +# -------------------------------------------------------------------------- +# 工具边界:查询结果交给核查 agent 之前先遮 +# -------------------------------------------------------------------------- + + +@dataclass +class QueryMask: + """一份查询结果在工具边界遮好的样子(mask_query_result)。 + + payload:遮好的结果,交给模型的、落进快照的都是它。columns:查询时就整列遮掉的结果列(结果里的列名,按先后)。 + secrets:这份结果里遮掉的值,只在内存里交给调用方(审批质询按值遮的第二道防线:模型照着别处看到的号码写进回答、 + 查询条件时认得出),不进快照、不交给模型。""" + + payload: dict[str, Any] + columns: list[str] + secrets: _Secrets + + +def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str | None) -> QueryMask: + """一份查询结果(tools/datasource 的查询工具交回的样子:columns / rows / column_types / sql)在交给核查 agent、落进 + 快照之前先遮。masked 是数据源设了遮罩的列,kind 是数据源的类型(SQL 按它的方言解析)。 + + - 整列遮:列名直接对上遮罩列的,加上按 SQL 认出值取自遮罩列的(_derived_hidden:别名、函数、子查询、WITH、 + SELECT *;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着遮罩列的,整份结果的列都遮。这几列的 + 格子换成「已遮罩」,类型记成文本 + - 别的列:整格就是这份结果里某个遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans); + 门槛同按值遮(SECRET_DIGITS、SECRET_MIN),只装着分档标签的列里的标签不算遮罩值 + - 列名不换(模型自己写的,换了它引用的列名就对不上);结果里一列都没取自遮罩列的原样交回:数值的汇总、只数个数的 + 查询不受影响 + + 分析本身出了意外,整份结果的列都遮:这一步挡在模型前面,分析的缺陷不能把原值放过去(宁可多遮)。""" + base = {str(c).strip().lower() for c in masked if str(c).strip()} + columns = [str(c) for c in payload.get("columns") or []] + if not base or not columns or not _query_shaped(payload): + return QueryMask(payload, [], _Secrets()) + try: + hidden, _ = _derived_hidden(payload, base, kind) + if not any(c.lower() in hidden for c in columns): + return QueryMask(payload, [], _Secrets()) + secrets = _Secrets() + secrets.add(payload, hidden, labels=_snapshot_labels(payload, kind), base=base) + secrets.prepare() + view, _ = _masked_view(payload, hidden, secrets, embedded=True, rename=False) + except Exception: # noqa: BLE001 - 认不准就整份遮掉,原值不交给模型 + logger.warning("核查查询的遮罩分析出错,整份结果按遮罩处理", exc_info=True) + hidden, secrets = {c.lower() for c in columns}, _Secrets() + view, _ = _masked_view(payload, hidden, secrets, rename=False) + return QueryMask(view, [c for c in columns if c.lower() in hidden], secrets) diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index b127eaef..55397916 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -6,6 +6,13 @@ **只读。** 工具只建给定数据源的 db_schema / db_query,建的时候 force_readonly:可写源上的写语句也被守卫拒绝 (tools/datasource.py)。这一路没有审批关卡,不能指望人在半路拦下一条 DELETE。 +**先遮再交。** 建查询工具时还打开遮罩开关(tools/datasource 的 mask_results):数据源设了遮罩时,取自遮罩列的格子 +(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT * 都算)在交给模型、落进查询快照之前就换成「已遮罩」, +同一份结果里别的文字格夹着的遮罩值也换掉(engine/masking.py 的 mask_query_result)。模型看不到遮罩值,就不会把它 +写进回答、理由、SQL;以前是事后按值认,每一轮复核都找到新的漏法。快照里的 masked_at_query 记下查询时就遮了的列, +格子引用指到这几列的不采信。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询按值遮的第二道防线 +用),不进快照、不交给模型、不进 as_dict。 + **数从快照取。** 每次成功的查询是一份查询快照(工具自己落的 put_json),按这个 agent 自己的顺序编 Q1、Q2…。 最后交结果时,调用方结构里的数值字段一律改成格子引用 {alias, row, column},由代码按引用从快照里取值:模型自己 写的数字不采信,取不到就是 None,不兜底成 0。数值字段的各种写法都认(type 列表、anyOf 里的数、$ref 引用进来的、 @@ -45,7 +52,7 @@ import json import logging import time from collections.abc import Callable, Iterable -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from enum import Enum from typing import Any @@ -59,13 +66,14 @@ from app.engine import spend from app.engine.evidence import build_catalog, cell_eid, compose_doc, query_entry_fields, schema_ledger_entry from app.engine.expressions import CellError, column_kind, locate_cell, numeric_text from app.engine.judge import FALLBACK_MAX_TOKENS, MAX_TOKENS, _json_in, _tokens, _usd +from app.engine.masking import _Secrets from app.engine.nodes.llm import _DIGEST_BUDGET, _digest, _split_structured, _typed, explain_model_error from app.engine.state import message_text from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded from app.providers import catalog as pricing from app.providers.budget import BudgetFallback, budget_refused, cut_off, cut_words, error_usage, truncated_error from app.providers.factory import ModelSpec, ProviderNotConfigured, bind_tools_safely, get_chat_model -from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX, build_datasource_tools +from app.tools.datasource import MASKED_VALUES_KEY, QUERY_PREFIX, SCHEMA_PREFIX, build_datasource_tools from app.tools.registry import ToolArgsError, ToolContext, Versions, args_model_of, prepare_args logger = logging.getLogger(__name__) @@ -103,6 +111,8 @@ VERIFY_SYSTEM = ( "- 先用 db_schema 工具看表结构,确认表名、字段名,再用 db_query 工具写只读 SQL。不能改数据,写语句会被拒绝\n" "- 每次成功的查询前面都标着编号【证据 Qn】,最后交结果时用它说明每个数取自哪次查询的哪一格\n" "- {limit}先想清楚要查什么,不要重复同样的查询\n" + "- 数据源设了遮罩的列(手机号、姓名这类),查询结果里显示为「已遮罩」:不要设法绕过,交结果、写理由时也不要引用" + "这些格子\n" "- 不要编造数字,查不到就照实说" ) VERIFY_FINAL_PROMPT = ( @@ -149,6 +159,8 @@ class VerifyOutcome: output_tokens, total_tokens, cost_usd, calls, unpriced_calls}。limit:触顶原因(LIMITS 之一),没触顶为 None。 answered:模型是不是自己停下来的(没被上限掐断,也没因为输出到了上限被截断)。被截断的一律没有结果,error 是 TRUNCATED_ERROR。 + masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets)。只在内存里交给调用方按值遮(审批质询的回答), + 不进 as_dict、不往外交。 """ result: dict[str, Any] | None @@ -162,9 +174,12 @@ class VerifyOutcome: answered: bool = False notes: list[str] = field(default_factory=list) duration_ms: int = 0 + masked_values: Any = field(default=None, repr=False, compare=False) def as_dict(self) -> dict[str, Any]: - return asdict(self) + out = asdict(replace(self, masked_values=None)) + out.pop("masked_values", None) + return out # -------------------------------------------------------------------------- @@ -407,6 +422,12 @@ class _Cells: self.cells.append({"path": path, "status": "unresolved", "alias": alias, "artifact": query["artifact"], "reason": f"{alias} {e}"}) return None + if str(name).lower() in _masked_at_query(content): + # 查询时就遮了的列:格子里是「已遮罩」,不是数据源里的值。照实说是遮罩列,不说成「不是数值」 + self.cells.append({"path": path, "status": "unresolved", "ref": f"{alias}.r{row}.{name}", "alias": alias, + "row": row, "column": name, "artifact": query["artifact"], + "reason": f"{alias} 的「{name}」取自设置了遮罩的列,查询时已遮罩,不采信"}) + return None truth = _typed(raw, {"type": sorted(types)}, column_kind(content, name)) cited = {"ref": f"{alias}.r{row}.{name}", "alias": alias, "row": row, "column": name, "artifact": query["artifact"], "eid": cell_eid(query["artifact"], row, name), "value": truth} @@ -502,6 +523,12 @@ class _Cells: return None +def _masked_at_query(content: Any) -> set[str]: + """查询快照里查询时就整列遮掉的列(小写,tools/datasource 记下的 masked_at_query)。""" + listed = content.get("masked_at_query") if isinstance(content, dict) else None + return {c.lower() for c in listed if isinstance(c, str)} if isinstance(listed, list) else set() + + # -------------------------------------------------------------------------- # 花费 # -------------------------------------------------------------------------- @@ -636,6 +663,8 @@ class _State: queries: list[dict[str, Any]] = field(default_factory=list) ledger: list[dict[str, Any]] = field(default_factory=list) model: str | None = None + #: 查询工具在工具边界遮掉的值(经 ToolContext.extra 并进来,只在内存里) + seen: _Secrets = field(default_factory=_Secrets) def outcome(self, **fields: Any) -> VerifyOutcome: usage = self.usage @@ -645,7 +674,8 @@ class _State: for q in self.queries] base: dict[str, Any] = {"result": None, "cells": [], "queries": shown, "rationale": None, "usage": usage, "notes": self.notes, "model": self.model, - "duration_ms": int((time.monotonic() - self.started) * 1000)} + "duration_ms": int((time.monotonic() - self.started) * 1000), + "masked_values": self.seen} return VerifyOutcome(**{**base, **fields}) @@ -728,12 +758,13 @@ async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sou notes.append(note) await _announce(emit, EventType.LOG, level="warn", code="verify_unpriced", message=note) - # ---- 工具:只读的 db_schema / db_query + # ---- 工具:只读的 db_schema / db_query,查询结果先遮再交给模型(遮掉的值并进 state.seen) wanted = list(dict.fromkeys(s for s in sources if isinstance(s, str) and s)) - ctx = ToolContext(run_id=run_id, node_id=node_id, data_versions=data_versions, emit=emit) + ctx = ToolContext(run_id=run_id, node_id=node_id, data_versions=data_versions, emit=emit, + extra={MASKED_VALUES_KEY: state.seen}) async with SessionLocal() as session: tools = await build_datasource_tools([f"{p}{s}" for s in wanted for p in (SCHEMA_PREFIX, QUERY_PREFIX)], ctx, - session, force_readonly=True) + session, force_readonly=True, mask_results=True) tool_map = {t.name: t for t in tools} if lost := [s for s in wanted if f"{QUERY_PREFIX}{s}" not in tool_map]: notes.append("数据源" + "、".join(f"「{s}」" for s in lost) + "不存在或已停用,核查没有查它") diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 88aa8b03..6374c322 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -53,6 +53,9 @@ RUN_VERSIONS_KEY = "data_versions" DATA_UNAVAILABLE = "查询失败(数据不可用):" #: 运行中途补固定一个源时发的那条日志事件的 code(_pin_late) LATE_PIN_CODE = "data_version_pinned" +#: ToolContext.extra 里放「查询时遮掉的值」的键(engine/masking 的 _Secrets):核查 agent(engine/verify_agent.py)放进去, +#: 打开了遮罩开关的查询工具把每次遮掉的值并进去。只在内存里给调用方按值遮用,不进快照、不交给模型 +MASKED_VALUES_KEY = "masked_values" #: 每次查询之后基于数据目录的 SQL 检查最多等这么久。正常一条 SQL 几毫秒;等不到就放弃这一次检查, #: 查询结果照常交回——检查是附加的,不能拖慢查询 @@ -199,7 +202,8 @@ def _query_description(source: Any, *, readonly: bool | None = None) -> str: async def build_datasource_tools( - names: list[str], ctx: ToolContext, session: AsyncSession, *, force_readonly: bool = False + names: list[str], ctx: ToolContext, session: AsyncSession, *, force_readonly: bool = False, + mask_results: bool = False, ) -> list[StructuredTool]: """按工具名反查数据源并构造工具。build_tools 的第四个来源。 @@ -209,6 +213,9 @@ async def build_datasource_tools( force_readonly:查询工具一律按只读守卫检查,可写源上的写语句也拒绝(见 _make_query_tool)。 核查(engine/verify_agent.py)建工具时传它:那一路没有审批关卡,绝不能写。 + + mask_results:查询结果交给模型、落进快照之前先遮(见 _make_query_tool)。核查建工具时也传它:模型看不到遮罩列的 + 值,就不会把它们写进回答、理由。缺省关着:Agent 节点、调用工具节点的查询照旧原样。 """ wanted: dict[str, list[str]] = {} for name in names: @@ -238,7 +245,8 @@ async def build_datasource_tools( entries = await _catalog_of(row) for prefix in prefixes: if prefix == QUERY_PREFIX: - tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries, force_readonly=force_readonly)) + tools.append(_make_query_tool(source, ctx, fixed=fixed, catalog=entries, force_readonly=force_readonly, + mask_results=mask_results)) else: tools.append(_make_schema_tool(source, fixed=fixed, catalog=entries)) return tools @@ -450,7 +458,7 @@ def _unavailable_tool(row: DataSource, prefix: str, reason: str) -> StructuredTo def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, catalog: Mapping[str, data_catalog.CatalogEntry] | None = None, - force_readonly: bool = False) -> StructuredTool: + force_readonly: bool = False, mask_results: bool = False) -> StructuredTool: """source 是手工源的 DataSource,或上传源绑定快照的 SourceView(字段同名)。 fixed:上传源的版本是这次运行固定的(找不到时的说法不同)。 @@ -458,6 +466,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, force_readonly:不管源可不可写,一律按只读守卫检查,写语句交回 FORCED_READONLY、不执行。只靠守卫这一道: 连接池按源缓存、不分只读与否(engine.cache_key),可写源借不到连接层只读的连接——别为此另起一个只读池, 缓存键相同,会把这个源平时用的可写连接也换成只读的。 + mask_results:数据源设了遮罩时,取自遮罩列的格子(按 SQL 认,改了名字的也算)在交给模型、落进快照之前就换成 + 「已遮罩」,见 engine/masking.py 的 mask_query_result;这一次遮掉的值并进 ctx.extra[MASKED_VALUES_KEY](有的话)。 """ # 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句, # 引擎按它告诉界面上限是多少(metadata["timeout_s"]) @@ -505,6 +515,17 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, payload = result.to_payload() payload["source"] = source.name + mask = masked_columns(source.options) + # 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩时为 None(快照和原来一字不差) + masked_now: list[str] | None = None + if mask_results and mask: + from app.engine.masking import mask_query_result + + # 按 SQL 认列、逐格比对是纯计算,放进线程(同 _sql_checks) + done = await asyncio.to_thread(mask_query_result, payload, mask, source.kind) + payload, masked_now = done.payload, done.columns + if (sink := ctx.extra.get(MASKED_VALUES_KEY)) is not None: + sink.merge(done.secrets) # 对照数据目录检查这条 SQL(一对多关联后重复计算、存量跨期求和……)。结果交给模型(Agent 据此改写), # 也随查询快照存下:证据面板看得到,口径卡据此给指标标「存疑」、出具降档。没查出问题不加这个键, # 快照和以前一字不差 @@ -518,14 +539,19 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, try: from app.core.artifact_store import put_json - # 只进快照、不进交给模型的结果的两样(模型看到多余的字段只会误会:以为数据被遮了, + # 只进快照、不进交给模型的结果的几样(模型看到多余的字段只会误会:以为数据被遮了, # 或者把一串十六进制抄进报告): - # - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮; + # - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮。查询时就遮了的结果列(改了名字的 + # 也算)一并记进去,证据面板、质询工件按这个键把它们画成遮罩列; + # - masked_at_query:开了遮罩开关时,查询时就整列遮掉的结果列(这几列的格子是「已遮罩」,不是数据源里的值); # - data_version:上传表格查的是哪个快照,事后据此认得出这个数出自哪一版数据。 # 手工源、没设遮罩的,快照和原来一字不差 extra: dict[str, Any] = {} - if mask := masked_columns(source.options): - extra["mask_columns"] = mask + if mask: + own = {m.lower() for m in mask} + extra["mask_columns"] = [*mask, *(c for c in masked_now or [] if c.lower() not in own)] + if masked_now is not None: + extra["masked_at_query"] = masked_now if version := getattr(source, "snapshot_id", None): extra["data_version"] = version # 工件引用的 meta 记下源和 SQL 里用到的表:数据目录按它数每张表被运行查询过几次 diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 33ab28c4..597d15f7 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -1102,6 +1102,32 @@ async def test_the_task_gives_the_claim_its_evidence_and_only_its_sources(monkey await engines.invalidate(source_id) +async def test_the_challenge_agent_never_sees_masked_values(monkeypatch, tmp_path): + """反查 agent 自己的查询选出了遮罩列(起了别名):交给它的工具结果、它的查询快照里都是「已遮罩」,理由里引用到那一格 + 渲染出来也是「已遮罩」;审稿记录、运行事件里都没有原值。以前查询结果原样交给模型、原样存下,理由也原样渲染。""" + name, source_id = await _source(tmp_path, options={"mask_columns": ["buyer_phone"]}) + try: + top = "SELECT buyer_phone AS 电话, amount AS 金额 FROM sales WHERE region = '华东' ORDER BY amount DESC" + plays = {"居各区域之首": Play([top], "stands", "华东最大一笔来自 [[v:Q1.r0.电话]],金额 [[v:Q1.r0.金额]]。[[see:Q1]]")} + models = Models(monkeypatch, name, plays=plays) + row = await finish(graph(name), run_class="exploratory") + assert row.status == "succeeded", row.error + seen = [str(m.content) for _, key, messages in models.calls if key == "居各区域之首" + for m in messages if isinstance(m, ToolMessage)] + assert seen and any("已遮罩" in s for s in seen) and all("1390000000" not in s for s in seen) + record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"]) + [claim] = [c for c in record["claims"] if "居各区域之首" in c["text"]] + assert claim["verdict"] == "stands" and claim["rationale"] == "华东最大一笔来自 已遮罩,金额 1,200。" + snapshot = artifact_store.load(claim["queries"][0]["artifact"]) + assert {r[0] for r in snapshot["rows"]} == {"已遮罩"} and snapshot["masked_at_query"] == ["电话"] + assert "1390000000" not in json.dumps(record, ensure_ascii=False) + async with SessionLocal() as session: + evs = (await session.execute(select(RunEvent).where(RunEvent.run_id == row.id))).scalars().all() + assert evs and all("1390000000" not in json.dumps(e.data, ensure_ascii=False) for e in evs) + finally: + await engines.invalidate(source_id) + + async def test_a_metric_excerpt_marks_the_report_query_numbers_too(monkeypatch, shop): """句子挂的是口径卡指标(正式报告的主路径「取数 → 口径卡 → 报告」):摘录正文里「取自查询 Q1」「查询 Q1 的 SQL」 也标成原报告的编号,和反查 agent 自己的 Q1 分开;工具按指标的输入取自的查询找源。""" diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 8c5587c2..b20aea80 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -1034,6 +1034,27 @@ async def test_a_blind_cell_on_a_masked_column_is_not_used(monkeypatch, masked_s assert "30.25" not in json.dumps(out, ensure_ascii=False) +async def test_a_renamed_masked_column_in_the_blind_query_is_masked_before_the_blind_agent_sees_it( + monkeypatch, masked_shop): + """盲算的查询选出了改了名字的遮罩列(region AS 区域):交给盲算模型、落进盲算快照之前就是「已遮罩」,快照记下查询 + 时遮了哪一列;没碰遮罩列的那一列(销售额)照常取值、照常比对。""" + name, _ = masked_shop + east = {**GMV, "id": "east", "name": "华东销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} + renamed = (f"SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status <> 'refunded' AND paid_date {WEEK} " + "GROUP BY region ORDER BY region") + Blind(monkeypatch, name, {"华东销售额": (renamed, "gmv")}) + run = await start(graph(name, BY_REGION, metrics=[east], dual={"definitions": {}})) + assert run.status == "succeeded", run.error + evs = await events(run.id) + out = dual_output(run, evs) + assert out["status"] == "agreed" and out["metrics"][0]["status"] == "agreed" + [end] = [e.data for e in evs if e.type == "tool.end" and e.node_id == "dual" and e.data.get("query_artifact")] + snapshot = artifact_store.load(end["query_artifact"]) + assert {row[0] for row in snapshot["rows"]} == {"已遮罩"} and snapshot["masked_at_query"] == ["区域"] + shown = json.loads(end["preview"].split("\n", 1)[1]) + assert {row[0] for row in shown["rows"]} == {"已遮罩"}, "交给盲算模型的结果里也是「已遮罩」" + + async def test_masked_group_columns_are_not_shown_to_the_blind_agent(monkeypatch, masked_shop): name, _ = masked_shop first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 3ce61dd7..e638ec8c 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -904,6 +904,35 @@ async def test_resuming_after_an_inquiry_does_not_repeat_the_nodes(monkeypatch, ALIASED = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY amount DESC LIMIT 1" +async def test_the_inquiry_agent_sees_and_stores_its_queries_masked(monkeypatch, masked_shop, client): + """质询 agent 的查询结果在交给模型之前就遮好了(核查 agent 建查询工具时打开遮罩开关):模型看到的工具结果、落库的 + 查询快照、运行详情「工件」页签走的通用工件接口里,遮罩列(起了别名的也算)都是「已遮罩」。以前快照原样存着,模型 + 看得到原值,通用接口也原样给出。回答照常按值遮(查询时遮掉的值只在内存里交回来)。""" + plays = {"买得最多": Play([ALIASED], "这一笔来自 [[v:Q1.r0.手机号]](13900000001),金额 [[v:Q1.r0.金额]]。[[see:Q1]]")} + Models(monkeypatch, masked_shop, plays=plays) + seen: list[str] = [] + answering = MockChatModel._decide + + def spy(model, messages): + seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage)) + return answering(model, messages) + + monkeypatch.setattr(MockChatModel, "_decide", spy) + paused = await pause(masked_shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="买得最多的是哪位客户?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert r.json()["inquiry"]["answer"]["text"] == "这一笔来自 已遮罩(已遮罩),金额 1,200。" + [query] = r.json()["inquiry"]["queries"] + assert seen and any("已遮罩" in s for s in seen) and all("1390000000" not in s for s in seen) + got = await client.get(f"/api/artifacts/{query['artifact']}") + assert got.status_code == 200 and "1390000000" not in got.text + content = got.json()["content"] + assert content["rows"] == [["已遮罩", 1200]] and content["masked_at_query"] == ["手机号"] + assert content == artifact_store.load(query["artifact"]) + + async def test_a_masked_column_under_another_name_is_masked_everywhere(monkeypatch, masked_shop, client): """遮罩列起了中文别名(本仓库查询的惯例):回答里引用到的格子、事件、回答文档、质询工件里都不露出原值。 报告自己的查询没选手机号,原值哪里都认不出来,只能按 SQL 认出「手机号」就是 buyer_phone。""" diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 513a375c..323c0548 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -1197,6 +1197,27 @@ def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql(): "sql_excerpt": "SUM(balance)"}] +def test_check_excerpts_follow_the_sql_recorded_at_answer_time(): + """agent 自己的查询快照查询时就遮好了,事后取质询工件时收不到遮罩列里的值:片段、说明照事件里记下的那条 SQL(回答时 + 按内存里的遮罩值换过)换——那条里整个换成「已遮罩」的字面量、换掉的那一截(LIKE 的 %…%),片段、说明里一样换。""" + sql = ("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001' AND age = 25 " + "AND note LIKE '%13900000002%'") + recorded = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩' AND age = 已遮罩 AND note LIKE '%已遮罩%'" + assert inquiry_mod._recorded_swaps(sql, recorded, "sqlite") == ({"13900000001", "25"}, {"13900000002"}) + assert inquiry_mod._recorded_swaps(sql, sql, "sqlite") == (set(), set()) + assert inquiry_mod._recorded_swaps(sql, recorded + " LIMIT 5", "sqlite") == (set(), set()), "对不上就不认" + checks = [{"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「25」", + "sql_excerpt": "buyer_phone = '13900000001' AND age = 25"}, + {"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选", + "sql_excerpt": "note LIKE '%13900000002%'"}] + whole, pieces = inquiry_mod._recorded_swaps(sql, recorded, "sqlite") + assert inquiry_mod._masked_checks(checks, sql, inquiry_mod._Secrets(), set(), "sqlite", swapped=whole, + pieces=pieces) == [ + {"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「已遮罩」", + "sql_excerpt": "buyer_phone = '已遮罩' AND age = 已遮罩"}, + {"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选", "sql_excerpt": "note LIKE '%已遮罩%'"}] + + @pytest.mark.parametrize("sql, excerpt, shown", [ # 片段只是一段表达式,单独拿出来认不出在找哪一个值:按整条 SQL 里换掉的字面量换 ("SELECT SUM(CASE age WHEN 25 THEN balance END) AS 余额 FROM accounts", diff --git a/backend/tests/test_masking_reformatted_numbers.py b/backend/tests/test_masking_reformatted_numbers.py index 44d9076b..cb3c0306 100644 --- a/backend/tests/test_masking_reformatted_numbers.py +++ b/backend/tests/test_masking_reformatted_numbers.py @@ -59,6 +59,17 @@ def test_a_long_number_in_another_shape_is_masked_in_prose(stored, written): assert secrets.scrub(f"客户{written}来电", prose=True) == f"客户{MASKED}来电" +@pytest.mark.parametrize("text, shown", [ + ("来自(19914742785),共 3 笔", f"来自({MASKED}),共 3 笔"), + ("来自(+86 199 1474 2785),共 3 笔", f"来自({MASKED}),共 3 笔"), + ("电话(+86)199 1474 2785。", f"电话{MASKED}。"), + ("(电话 199-1474-2785)", f"(电话 {MASKED})"), +]) +def test_brackets_around_a_number_are_kept_and_brackets_inside_it_are_replaced(text, shown): + """整个号码外面套着的一对括号是标点,留着;号码里的括号(区号、国家码)和号码一起换掉。""" + assert secrets_of([PHONE]).scrub(text, prose=True) == shown + + @pytest.mark.parametrize("text", [ # 日期、时间、小数、千分位、更长的数里的一截、连字符连着的编号、别的号码 "2026-10-06 19:14 下单", "金额 19914.74 元", "共 1,991,474 笔", "号码 1991474278512", "批次 19914742785-2", diff --git a/backend/tests/test_verify_agent_masking.py b/backend/tests/test_verify_agent_masking.py new file mode 100644 index 00000000..d2f93c66 --- /dev/null +++ b/backend/tests/test_verify_agent_masking.py @@ -0,0 +1,248 @@ +"""核查 agent 看到的查询结果先遮、存下的快照也遮(engine/verify_agent.py 建查询工具时打开 tools/datasource 的遮罩开关)。 + +双盲复算、反方审稿、审批质询共用的核查 agent 以前把查询结果原样交给模型,遮罩是事后按值做的(审批质询的 _Secrets), +每一轮复核都找到新的漏法。现在在工具边界先遮:数据源设了遮罩时,按 SQL 认出取自遮罩列的结果列(别名、函数、子查询、 +WITH、SELECT *),连同列名直接对上的,这些格子在交给模型的结果、落库的快照里都是「已遮罩」,快照记下哪些列在查询时 +就遮了;别的文字格里夹着这份结果里遮罩值的那一截同样换掉(换了写法的号码也认)。运行详情「工件」页签走的通用接口 +GET /api/artifacts/{id} 给的也就是遮好的。没碰遮罩列的聚合查询原样。遮掉的值只在内存里交回调用方(审批质询按值遮的 +第二道防线用),不进快照、不交给模型。遮罩开关缺省关着,别的调用方(Agent 节点、调用工具节点)照旧。 + +模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,手机号、姓名、金额都是虚构的。 +""" +from __future__ import annotations + +import json +import sqlite3 +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete + +from app.core import artifact_store +from app.core.events import EventType +from app.data import introspect +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Setting +from app.engine import masking, spend +from app.engine.verify_agent import run_verify_agent +from app.main import app +from app.tools.datasource import build_datasource_tools +from app.tools.registry import ToolContext, Versions +from tests.test_verify_agent import SPEC, Script, q + +MASKED = "已遮罩" +#: 遮罩列里的值(和备注里换了写法的号码):交给模型的、存下的、接口给的都不能出现 +SECRETS = ("1390000000", "张伟", "李娜", "王芳", "刘洋", "139-0000-0003") +SALES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER," + " note TEXT);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '客户13900000001来电改址')," + " (2, '华东', '13900000002', '李娜', 300, '例行回访'), (3, '华南', '13900000003', '王芳', 900, '回访:联系 139-0000-0003')," + " (4, '华北', '13900000004', '刘洋', 400, '');") +VALUE_SCHEMA = {"type": "object", "properties": {"value": {"type": "number", "description": "要核对的数"}}, + "required": ["value"]} + + +@pytest.fixture(autouse=True) +async def clean_spend(): + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_([spend.SPEND_KEY, "verify"]))) + await session.commit() + yield + + +async def _source(tmp_path, options: dict | None) -> tuple[str, int]: + path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" + db = sqlite3.connect(path) + db.executescript(SALES) + db.commit() + db.close() + name = f"shop_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options=options or {}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + return name, row.id + + +@pytest.fixture +async def masked_shop(tmp_path): + """四笔订单,买家手机号、姓名两列设了遮罩;备注没设遮罩,其中两条写着手机号(一条换了写法)。返回源名。""" + name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone, buyer_name"}) + yield name + await engines.invalidate(source_id) + + +@pytest.fixture +async def plain_shop(tmp_path): + name, source_id = await _source(tmp_path, None) + yield name + await engines.invalidate(source_id) + + +async def ask(name: str, **kwargs): + """按剧本跑一次核查 agent(剧本由各个测试先用 Script 装好),交回结果和发出的事件。""" + events: list[tuple] = [] + out = await run_verify_agent(spec=SPEC, system="核对一个数。", task="查一下。", sources=[name], + output_schema=VALUE_SCHEMA, data_versions=Versions.CURRENT, run_id="run-mask", + node_id="check", emit=lambda kind, **data: events.append((kind, data)), **kwargs) + return out, events + + +def _clean(text: str) -> bool: + return not any(s in text for s in SECRETS) + + +def _cells(snapshot: dict, column: str) -> list: + i = snapshot["columns"].index(column) + return [row[i] for row in snapshot["rows"]] + + +@pytest.mark.parametrize("sql, masked", [ + pytest.param("SELECT buyer_phone, amount FROM sales ORDER BY id", ["buyer_phone"], id="直接选出"), + pytest.param("SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id", ["手机号"], id="起别名"), + pytest.param("SELECT UPPER(buyer_name) AS 姓名, amount FROM sales ORDER BY id", ["姓名"], id="套函数"), + pytest.param("SELECT t.p AS 号码, t.amount FROM (SELECT id, buyer_phone AS p, amount FROM sales) t ORDER BY t.id", + ["号码"], id="子查询"), + pytest.param("WITH t AS (SELECT id, buyer_phone AS p, amount FROM sales) SELECT p, amount FROM t ORDER BY id", + ["p"], id="WITH"), + pytest.param("SELECT * FROM sales ORDER BY id", ["buyer_phone", "buyer_name"], id="SELECT *"), +]) +async def test_masked_columns_are_masked_before_the_model_sees_them(monkeypatch, masked_shop, sql, masked): + name = masked_shop + amount = "amount" if "金额" not in sql else "金额" + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": amount}, "rationale": "见 [[see:Q1]]。"}) + out, events = await ask(name) + assert out.error is None and out.result["value"] == 1200, "没遮的列照常取值" + + # 交给模型的工具结果:遮罩列是「已遮罩」,别的列照原样 + seen = script.tool_results()["c1"] + assert seen.startswith("【证据 Q1】") and MASKED in seen and _clean(seen), seen + payload = json.loads(seen.split("\n", 1)[1]) + for column in masked: + assert set(_cells(payload, column)) == {MASKED} + assert _cells(payload, amount) == [1200, 300, 900, 400] + assert "mask_columns" not in payload and "masked_at_query" not in payload, "交给模型的结果不多出字段" + + # 落库的快照:同样遮好,记下查询时就遮了的列;数据源设的遮罩列照记,换了名字的结果列也记进去(证据面板据此画成遮罩) + [query] = out.queries + snapshot = artifact_store.load(query["artifact"]) + assert _clean(json.dumps(snapshot, ensure_ascii=False)) + for column in masked: + assert set(_cells(snapshot, column)) == {MASKED} + assert snapshot.get("column_types", {}).get(column, "text") == "text" + assert _cells(snapshot, amount) == [1200, 300, 900, 400] + assert snapshot["masked_at_query"] == masked + assert {c.lower() for c in snapshot["mask_columns"]} == {"buyer_phone", "buyer_name", *(c.lower() for c in masked)} + + # 运行详情「工件」页签走的通用接口:给的就是遮好的 + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + got = await client.get(f"/api/artifacts/{query['artifact']}") + assert got.status_code == 200 and got.json()["content"] == snapshot and _clean(got.text) + + # 事件(tool.end 的预览)、收尾时交给模型的查询结果里也没有 + [end] = [d for k, d in events if k == EventType.TOOL_END] + assert MASKED in end["preview"] and _clean(end["preview"]) + final = "\n".join(str(m.content) for m in script.calls[-1][1]) + assert MASKED in final and _clean(final) + + # 遮掉的值只在内存里交回调用方,不跟着结果往外交 + assert out.masked_values is not None and out.masked_values.strong + assert "masked_values" not in out.as_dict() + + +@pytest.mark.parametrize("sql, value", [ + ("SELECT SUM(amount) AS total FROM sales", 2800), + ("SELECT COUNT(DISTINCT buyer_phone) AS total FROM sales", 4), + ("SELECT SUM(amount) AS total FROM sales WHERE buyer_phone = '13900000001'", 1200), + ("SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC", 1500), + ("SELECT region, note, amount AS total FROM sales ORDER BY id", 1200), +]) +async def test_queries_that_do_not_read_masked_values_are_untouched(monkeypatch, masked_shop, sql, value): + """没碰遮罩列的值(汇总、只数个数、只在条件里用到):结果原样,取值照常,快照记下查询时一列都没遮。只遮这份结果 + 里的遮罩值:备注里写着手机号、这份结果却没有选出手机号的,按值认不出(那是数据源的遮罩没设全)。""" + name = masked_shop + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "见 [[see:Q1]]。"}) + out, _ = await ask(name) + assert out.error is None and out.result["value"] == value + seen = script.tool_results()["c1"] + assert MASKED not in seen + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == [] and MASKED not in json.dumps(snapshot, ensure_ascii=False) + assert [c.lower() for c in snapshot["mask_columns"]] == ["buyer_phone", "buyer_name"] + + +async def test_masked_values_inside_other_text_cells_are_cut_out(monkeypatch, masked_shop): + """同一份结果里选出了手机号:备注里夹着的同一个号码(换了写法的也算)换成「已遮罩」,备注别的字照原样。""" + name = masked_shop + sql = "SELECT buyer_phone, note, amount AS total FROM sales ORDER BY id" + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "见 [[see:Q1]]。"}) + out, _ = await ask(name) + seen = script.tool_results()["c1"] + assert _clean(seen) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert _cells(snapshot, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""] + assert snapshot["masked_at_query"] == ["buyer_phone"], "备注只换了一截,不算整列遮" + + +async def test_a_cell_reference_into_a_masked_column_is_not_taken(monkeypatch, masked_shop): + """模型交的格子引用指到查询时就遮了的那一列:不采信,照实说是遮罩列,不说成「不是数值」。""" + name = masked_shop + sql = "SELECT buyer_phone AS 手机号, amount FROM sales ORDER BY id" + Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "手机号"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.result["value"] is None + [cell] = out.cells + assert cell["status"] == "unresolved" and "1390000000" not in json.dumps(cell) + assert cell["reason"] == "Q1 的「手机号」取自设置了遮罩的列,查询时已遮罩,不采信" + + +async def test_sql_that_cannot_be_read_but_names_a_masked_column_masks_the_whole_result(monkeypatch, masked_shop): + """SQL 认不出哪一列取自遮罩列、里面又写着遮罩列:整份结果的列都遮(和双盲复算、审批质询同一个保守规则)。""" + name = masked_shop + monkeypatch.setattr(masking, "_tainted", lambda *args, **kwargs: None) + sql = "SELECT buyer_phone, amount FROM sales ORDER BY id" + Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "amount"}, "rationale": "x"}) + out, _ = await ask(name) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == ["buyer_phone", "amount"] + assert set(_cells(snapshot, "amount")) == {MASKED} and out.result["value"] is None + + +async def test_the_switch_is_off_by_default_and_a_source_without_masks_is_untouched(masked_shop, plain_shop): + """遮罩开关缺省关着:Agent 节点、调用工具节点建的查询工具照旧交回原样的结果。没设遮罩的数据源开着开关也一字不差 + (快照里不多出键)。""" + sql = "SELECT buyer_phone, amount FROM sales ORDER BY id" + ctx = ToolContext(run_id="run-switch", node_id="n1", data_versions=Versions.CURRENT) + async with SessionLocal() as session: + [plain_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session) + [masking_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session, mask_results=True) + [unmasked_tool] = await build_datasource_tools([f"db_query__{plain_shop}"], ctx, session, mask_results=True) + raw = json.loads(await plain_tool.ainvoke({"sql": sql})) + assert _cells(raw, "buyer_phone")[0] == "13900000001" + stored = artifact_store.load(raw["artifact"]) + assert _cells(stored, "buyer_phone")[0] == "13900000001" and "masked_at_query" not in stored + shown = json.loads(await masking_tool.ainvoke({"sql": sql})) + assert set(_cells(shown, "buyer_phone")) == {MASKED} + untouched = json.loads(await unmasked_tool.ainvoke({"sql": sql})) + stored = artifact_store.load(untouched["artifact"]) + assert _cells(stored, "buyer_phone")[0] == "13900000001" + assert "masked_at_query" not in stored and "mask_columns" not in stored + + +async def test_column_names_the_model_wrote_are_kept(monkeypatch, masked_shop): + """列名是模型自己写在 SQL 里的(哪怕写着一个号码):工具边界只遮格子、不改列名,它引用的列名才对得上。""" + name = masked_shop + sql = 'SELECT buyer_phone, amount AS "13900000001 的金额" FROM sales ORDER BY id' + Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "13900000001 的金额"}, "rationale": "x"}) + out, _ = await ask(name) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["columns"] == ["buyer_phone", "13900000001 的金额"] + assert set(_cells(snapshot, "buyer_phone")) == {MASKED} and out.result["value"] == 1200 From 099473cd6d9dab5d0d8f42c6d702aea661c6ff13 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 19:11:14 +0800 Subject: [PATCH 5/7] =?UTF-8?q?K3=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=90=8E?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E6=9F=A5=E8=AF=A2=E6=8C=89=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E9=81=AE=E6=8E=89=E7=9A=84=E5=80=BC=E9=81=AE=E3=80=81=E8=B4=A8?= =?UTF-8?q?=E8=AF=A2=E5=B7=A5=E4=BB=B6=E7=85=A7=E5=9B=9E=E7=AD=94=E6=97=B6?= =?UTF-8?q?=E8=AE=B0=E4=B8=8B=E7=9A=84=E4=BD=8D=E7=BD=AE=E9=87=8D=E6=94=BE?= =?UTF-8?q?=EF=BC=9B=E8=A1=A8=E5=80=BC=E5=87=BD=E6=95=B0=E6=8B=86=E5=BC=80?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E5=88=97=E4=B9=9F=E8=AE=A4=EF=BC=9B=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E9=81=AE=E7=BD=A9=E5=88=97=E7=9A=84=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E4=B8=8D=E6=B4=BE=E7=9B=B2=E7=AE=97=EF=BC=9B=E5=8F=8D=E6=96=B9?= =?UTF-8?q?=E7=90=86=E7=94=B1=E6=8C=89=E5=80=BC=E9=81=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核发现质询工件接口相对基线回退:agent 自己的查询快照在工具边界遮好之后,事后取质询工件收不到它另一次查询里的号码, 没设遮罩的备注、联系方式列原样给出。另有几处模型照样看得到遮罩值、或者遮多了的地方。 - 工具边界(tools/datasource、engine/masking.mask_query_result):此前已经认出的遮罩值(这个 agent 前面的查询遮掉的、 调用方交进来的)也用来按值遮后面的结果,数据源没设遮罩的也一样。审批质询、反方审稿在派 agent 之前把报告快照里的 遮罩值交进来(inquiry.snapshot_secrets,run_verify_agent 的 masked_values),模型从第一条查询起就看不到。已经摆在 读者眼前的值(提问人写的、报告指标、被审的结论原文)跟着并进来的值一起免遮(_Secrets.protected);这份结果自己写 的分档标签不按前面收的值换。 - 先查备注、后查出号码的:回答时只靠内存里的值才换掉的地方按快照记成位置(不含值,masking.view_edits),写进质询 事件的 masked_cells,质询工件接口先照着重放(replay_edits)再按原来的办法遮。工具边界按值换掉的整格照样画成遮罩列。 agent 查到的值先收、报告快照的后收:上限收满时不丢 agent 自己查到的。 - _derived_hidden 认 FROM、JOIN、LATERAL 里拆开遮罩列的表值函数(json_each、json_tree、unnest、jsonb_each_text、 regexp_split_to_table、JSON_TABLE、LATERAL VALUES):按出列名认,不认得的函数又没写列名表的整份遮。 - 汇总里的 CASE 只在条件里用到遮罩列的不算取自遮罩列(同写在 WHERE 里)。双盲复算按同一套认法认出指标取自遮罩列 (SUM(amount)、amount 设了遮罩)时,派盲算之前就记为无法复算并说清原因,不白花一次盲算(nodes/dual_check 的 _query_input,改动在文件所有权之外,只加这一处判断)。 - 反方审稿:理由正文和理由文档按值换掉遮罩值(换了写法的也认),这句结论原文里写着的不换;重新组装时引用到的格子按 遮好的快照渲染。 - 换了写法的号码另认不换行空格、窄空格、细空格、制表符、减号,最多四个分隔符;SQL 的字符串、带引号的名字、注释里 也认(不带引号的数照旧是算式),字符串里换掉的号码同一条 SQL 里写成阈值的也不留;列名同样认。 - 查询失败时报错原话里的值换掉(mask_error_text:引号里、不带引号的长数,SQL 里写着的照留)。 - 快照的 mask_columns 只记数据源设的列名,改了名字的结果列只在 masked_at_query:证据面板并各份快照的这一项从表的 字段清单里去列,混进别名会误藏同源别的表里同名的真实字段。 - * 展开数据源里的表时按冻结的表结构认它带不带同名列,没有的照常认分档标签(J2 收紧之后的多遮)。 - 格子引用指到按值换掉的格子时照实说;上万个值的合并、免遮放进线程。 测试:质询 agent 先查号码后查备注、先查备注后查号码、另一张表的号码列两种先后,质询工件、通用工件接口、事件都没有 原值;报告快照里的值在第一条查询就遮;表值函数(SQLite、Postgres、MySQL 的写法)、汇总里 CASE 的条件、* 连着的表按 表结构认标签、新的分隔符、SQL 字符串和注释和列名、报错原话、mask_columns 不混别名、遮罩列汇总的指标不派盲算、反方 理由按值遮且免遮结论原文、上限收满时 agent 的值先收。逐处去掉实现(24 处)对应的用例都会变红。 --- backend/app/engine/inquiry.py | 207 ++++--- backend/app/engine/masking.py | 534 +++++++++++++++--- backend/app/engine/nodes/challenge.py | 70 ++- backend/app/engine/nodes/dual_check.py | 13 +- backend/app/engine/verify_agent.py | 33 +- backend/app/tools/datasource.py | 40 +- backend/tests/test_challenge_node.py | 82 +++ backend/tests/test_dual_check_node.py | 32 ++ backend/tests/test_inquiry_api.py | 132 ++++- backend/tests/test_inquiry_masking.py | 113 ++++ .../tests/test_masking_reformatted_numbers.py | 70 +++ backend/tests/test_masking_table_functions.py | 109 ++++ backend/tests/test_verify_agent_masking.py | 145 ++++- 13 files changed, 1387 insertions(+), 193 deletions(-) create mode 100644 backend/tests/test_masking_table_functions.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 9e652f68..acb75d6c 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -13,9 +13,12 @@ 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。 封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明 3. 遮罩(_plan):agent 的查询结果在核查 agent 的工具边界就遮好了(engine/masking.py 的 mask_query_result):取自 - 遮罩列的格子交给模型、落进快照之前就是「已遮罩」,模型看不到遮罩值。这里是第二道防线:查询时遮掉的值由 verify_agent - 交回(masked_values,只在内存里),连同报告自己快照里的遮罩值,照下面的办法按值遮;事后取质询工件时内存里的值已经 - 不在,SQL 检查结果照事件里记下的 SQL 换(_recorded_swaps)。 + 遮罩列的格子交给模型、落进快照之前就是「已遮罩」,模型看不到遮罩值。报告自己快照里的遮罩值在派 agent 之前就交给 + 查询工具(snapshot_secrets → run_verify_agent 的 masked_values),agent 前面的查询遮掉的值也一样:后面查到的备注、 + 别的表里写着同一个号码的,交给模型、落库之前就按值换掉。这里是第二道防线:查询时遮掉的值由 verify_agent 交回 + (masked_values,只在内存里),连同报告自己快照里的遮罩值,照下面的办法按值遮。事后取质询工件时内存里的值已经 + 不在:回答时只靠它们才换掉的地方(先查备注、后查出号码的那份备注快照)记成位置写进事件(REPLAY_KEY,不含值, + masking.view_edits),取工件时照着再换一遍;SQL 检查结果照事件里记下的 SQL 换(_recorded_swaps)。 哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉;按遮罩列 分档、当分组键或套在汇总里数个数的不算,_band_only);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询 @@ -24,8 +27,9 @@ 认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且 同一个数在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的 范围……,值换个写法、写成几个数的算式也认)和别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、 - 报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。回答正文、质询工件的文字格里, - 换了写法的长号码(199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字)按纯数字写法认(masking 的 + 报告列出的指标和运行输入的值(agent 后面的查询遮掉的值照样免遮,_Secrets.protected);SQL 里照原样留着的阈值只在 + 回答正文里不换,格子照旧换。回答正文、质询工件的文字格、SQL 的字符串、带引号的名字和注释里,换了写法的长号码 + (199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字、不换行空格或减号隔开的)按纯数字写法认(masking 的 _Secrets.loose_spans)。组装之后还认得出遮罩值的(换了写法的也算),这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 @@ -33,7 +37,8 @@ append_events 不写,回答照样交回给提问的人,说明没有写进运行记录(这样的回答在进程里留一阵,_HELD)。触顶、失败 照样记一条 5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询引用过的(查询快照、回答文档、回答里 - 引用的报告口径卡),遮罩照回答时的办法,快照里 SQL 检查结果的片段、说明照整条 SQL 的换法换(_masked_checks) + 引用的报告口径卡),先照事件里记下的位置重放回答时换掉的地方,再照回答时的办法遮,快照里 SQL 检查结果的片段、说明 + 照整条 SQL 的换法换(_masked_checks) 不发 tool.start、llm.start 这类事件(run_verify_agent 不传 emit):界面从接口的返回取回答;runner 的 _stale_replay 只看 tool.start,质询不影响恢复后的重放。 @@ -45,17 +50,24 @@ 正好等于某一笔金额)也遮掉。这是产品取舍,不按上下文去猜 - 遮罩数值列的 SUM / AVG / MIN / MAX 算派生自遮罩列,整列遮(COUNT 不算,见 _value_columns),CASE 里有一支取的是 遮罩列的也算:MIN / MAX 就是某一行的原值,只有一行的分组 SUM / AVG 也是,按列认不出哪些汇总值安全,宁可多遮。 - 汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT + 核查 agent 的查询在工具边界照这个办法遮,所以这样的指标双盲复算不了(原 SQL 里认得出的,派盲算之前就记为无法 + 复算),反方审稿核对这类汇总数的结论也拿不到数。汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 + THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT;汇总里的 CASE 只在条件里用到遮罩列的(SUM(CASE WHEN buyer_phone + IS NOT NULL THEN amount ELSE 0 END))同写在 WHERE 里,不算 +- FROM、JOIN、LATERAL 里的表值函数(json_each、json_tree、unnest、jsonb_each_text、JSON_TABLE、LATERAL VALUES)拆开 + 遮罩列的,按出列名认它出的列(常见函数的出列名、写的列名表、别名、函数名,_function_columns),同名的别的列一并遮 + (多遮);不认得的函数又没写列名表的,整份快照都遮 - 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里 照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收: 结果的这一列在 UNION 的每一支里都是这样的 CASE / IIF,或者按作用域认得出取自子查询、CTE 里只装着标签的一列;有一支 取自数据源里的表(遮罩列原样选出、经子查询按原名传出,哪怕起了标签的名字)就照收。认不准的也照收(多遮),比如 按这一列 USING、NATURAL 合成的同名列,递归的 CTE,UNION 里落在某一支 * 里的列,写死的字符串、和列一起算的值; - 派生来源里同名的几列(SQLite 都留着,外层按名字取排在最前面的那一列):标签前面有 * 带得出这一列(没写表名的 * - 展开了数据源里的表就算带得出),或者有没起别名、套了括号或 COLLATE 的同名列((vip_level)、vip_level COLLATE - NOCASE),都不算只装着标签(_block_label_of、_star_label)。标签恰好和别的列里的遮罩值相同时,正文里的档名、SQL - 里写成字符串的标签也换(多遮) + 派生来源里同名的几列(SQLite 都留着,外层按名字取排在最前面的那一列):标签前面有 * 带得出这一列(* 展开了数据源里 + 的表、冻结的表结构里查不到这张表或者它有同名的列,就算带得出),或者有没起别名、套了括号或 COLLATE 的同名列 + ((vip_level)、vip_level COLLATE NOCASE),都不算只装着标签(_block_label_of、_star_label)。* 展开的表在表结构里 + 查得到、又没有同名的列时,照常按派生来源认标签(_table_lacks)。标签恰好和别的列里的遮罩值相同时,正文里的档名、 + SQL 里写成字符串的标签也换(多遮) - 「夹住某一个值」(_pinned):范围比较在整条 SQL 里配对,只有 CASE 的不同分支、SELECT 里不筛行的不同项(选出列、 GROUP BY、ORDER BY)、UNION 的不同支各算各的,两头误差都有:把找值拆进这几处(CASE WHEN age > 25 THEN … ELSE CASE WHEN age >= 25 …,两个选出列 SUM(age >= 25)、SUM(age > 25) 再自己相减)认不出,边界照原样留着,正文里复述也不遮; @@ -77,14 +89,18 @@ - 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在 片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的, 从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮) -- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照落库时就遮好了(工具边界先遮),从那里打开也 - 看不到取自遮罩列的值;但工具边界只认这份结果里的遮罩值,别的查询、报告快照里遮罩列的值写在这份结果没设遮罩的列里时 - (没设遮罩的联系方式列、备注),从那里打开照原样(质询工件接口按回答时的办法换)。报告自己的查询快照(写作时的查询) - 仍原样存着,从那里打开不经这里的遮罩 -- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧) -- 换了写法的号码只认数字之间夹着一到三个空格、连字符、点、括号(含全角),带 +86 / 0086 / 86 前缀、全角数字:写成 - 汉字(一三九……)、中间夹着别的字、拆成几处写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、 - 19914.74)不算号码;SQL 里不这样认(199-1474-2785 在 SQL 里是一个算式) +- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照落库时就遮好了(工具边界先遮),取自遮罩列的值、 + 报告快照里的遮罩值、agent 前面的查询里遮掉的值都看不到。只有一种照原样:先查了写着号码的备注(那时还不知道这个号码 + 是遮罩值),后来才查出号码——备注快照落库时原样,从通用接口打开照原样,质询工件接口照事件里记下的位置换。报告自己 + 的查询快照(写作时的查询)仍原样存着,从那里打开不经这里的遮罩 +- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)。agent 自己查到的值先收,报告快照里的后收 +- 换了写法的号码只认数字之间夹着一到四个分隔符(空格、制表符、全角空格、不换行空格、窄空格这类空白,连字符、减号、 + 点、括号,含全角),带 +86 / 0086 / 86 前缀、全角数字:写成汉字(一三九……)、中间夹着别的字、拆成几处写的、跨行 + 写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、19914.74)不算号码;SQL 里不带引号的数不这样认 + (199-1474-2785 在 SQL 里是三个数相减),字符串、带引号的名字、注释里照样认 +- 质询 agent 的任务里的证据摘录照 judge.prepare 按列名遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号)时, + 摘录里有原值,agent 看得到;它写进回答、SQL 的,按值认得出的照样换掉(报告快照里的值都收了),封存的是换过的。摘录 + 按 SQL 认改了名字的遮罩列要改 judge.py,另行处理 """ from __future__ import annotations @@ -113,7 +129,7 @@ from app.data.provenance_types import DOC_PROVENANCE from app.db.base import SessionLocal from app.db.models import Approval, Artifact, DataSource, Run, RunEvent from app.engine import judge as judging -from app.engine.evidence import MARKER_RE, compose_doc, iter_segments, iter_units +from app.engine.evidence import compose_doc, iter_segments, iter_units from app.engine.issuance import extract_numbers # 认遮罩列、遮罩值的共用部分在 engine/masking.py。名字照旧从这里导出:nodes/dual_check、测试按 inquiry 的名字引用 from app.engine.masking import ( # noqa: F401 @@ -122,8 +138,8 @@ from app.engine.masking import ( # noqa: F401 _branch_results, _branches, _canonical, _constant, _constants, _decimal, _derived_block, _derived_hidden, _digits_of, _fixed, _forms, _from_sources, _is_star, _item_label, _label_columns, _leaves, _literal_types, _masked_view, _masked_word, _mentions, _merging, _numeric, _output_name, _pattern, _places, _plain_text, - _point_text, _query_shaped, _result_label, _snapshot_labels, _source_label, _source_names, _star_label, _strong, - _tainted, _value_columns, _worked_out, + _point_text, _query_shaped, _result_label, _scrubbed_source, _snapshot_labels, _source_label, _source_names, + _star_label, _strong, _tainted, _value_columns, _worked_out, replay_edits, view_edits, ) from app.engine.merge_query import MERGE_SOURCE from app.engine.nodes.challenge import mark_original, with_note @@ -156,6 +172,9 @@ LIMITS = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd") USAGE_KEYS = ("input_tokens", "output_tokens", "cost_usd", "calls", "unpriced_calls") #: 查询清单每条给的几项:没有行,查询结果只在快照里(取的时候按遮罩遮好) QUERY_KEYS = ("alias", "artifact", "sql", "row_count", "truncated", "source") +#: 质询事件数据里记「回答时只靠 agent 查询时遮掉的值才换掉的位置」的键:{快照工件: masking.view_edits 的形状}。 +#: 不含值;事后取质询工件时内存里的值已经不在,照着位置再换一遍(inquiry_artifact) +REPLAY_KEY = "masked_cells" #: 回答文档存成工件时的种类;工件引用的 meta 里记下问题的前这么多字,运行的工件列表据此认得出是哪个问题的回答 ANSWER_KIND = "inquiry_answer" QUESTION_BRIEF = 40 @@ -676,33 +695,6 @@ async def _hidden_columns(snapshot: dict[str, Any], masks: Any) -> tuple[set[str cols, found = await masks.of(snapshot.get("source")) return cols | _recorded_masks(snapshot), ([] if found or not snapshot.get("source") else [str(snapshot["source"])]) -#: 单元格引用的前一截(Q1.r0.):后面才是列名 -_CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.") - - -def _scrubbed_source(text: str, secrets: _Secrets) -> str: - """回答原文里的遮罩值换成「已遮罩」:标记之外按正文的办法换(SQL 里照原样留着的阈值不换);标记里的按段换(| 隔开 - 的几段各自换),单元格引用只换列名那一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数 - 的一截,反而认不出来。列名照快照、SQL 里的名字那样换(阈值也换),换得一样才对得上。 - 标记里写着遮罩值的引用换完多半解析不了(列名里写着遮罩值的除外:快照的列名也换过了,正好对上),宁可弄坏一个 - 引用,也不让值留在存下的原文、片段里,或者从解析不了的占位里露出来。""" - if not text or not secrets.strong: - return text - out: list[str] = [] - last = 0 - for m in MARKER_RE.finditer(text): - out.append(secrets.scrub(text[last:m.start()], prose=True)) - start, end = m.span("body") - parts = text[start:end].split("|") - head = _CELL_HEAD.match(parts[0]) if m.group("kind").strip() == "v" else None - first = parts[0][:head.end()] + secrets.scrub(parts[0][head.end():]) if head \ - else secrets.scrub(parts[0], prose=True) - parts = [first, *(secrets.scrub(p, prose=True) for p in parts[1:])] - out.append(text[m.start():start] + "|".join(parts) + text[end:m.end()]) - last = m.end() - out.append(secrets.scrub(text[last:], prose=True)) - return "".join(out) - #: 按词认在找某一个值的比较(SQL 解析不了时的退路,见 _context):col = 'x'、col <> 'x'、col LIKE 'x'、SQLite 的 #: col IS 'x'(IN 列表另外认) @@ -1404,9 +1396,12 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets, 手机号)。数换个写法照样认(25.0、025、2.5e1 按 25 认,_canonical);夹住某一个值的范围按夹住的值认(age > 24 AND age < 26 的两头都按 25 认),算式里的数按算出来的值认,同一个数在别处换掉了的阈值、行数也按等值认(见 _literals); - - 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人); + - 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人),换了写法的号码 + 也认(LIKE '%199-1474-2785%':字符串里的 199-1474-2785 不是算式);换掉的号码按纯数字写法记,同一个数写成阈值的 + 据此不留; - 别的数:范围比较的边界、LIMIT / OFFSET 的行数照原样(agent 自己定的阈值,换了封存的就不是实际执行的语句); - 别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换(算出来的值是也换)。""" + 别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换(算出来的值是也换)。不带引号的数 + 不按换了写法认:199-1474-2785 在 SQL 里是三个数相减。""" how, columns, points = use word = _masked_word() canonical = _canonical(text) @@ -1418,11 +1413,9 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets, found: list[str] = [] def scrub(inner: str) -> str: - pattern = secrets.pattern(inner) - if pattern is None: - return inner - found.extend(m.group() for m in pattern.finditer(inner)) - return pattern.sub(word, inner) + out, swapped = secrets.swap(inner, loose=True) + found.extend(swapped) + return out return _requote(raw, scrub), _number_forms(found) if how not in _KEPT and any(secrets.hit(v) for v in looked): @@ -1453,7 +1446,9 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None def _rebuild(sql: str, tokens: list[Any], kinds: list[str], secrets: _Secrets, literal: Callable[[int, str], str] ) -> str: - """按词重写一段 SQL:字面量交给 literal(第几个词、原文)定换成什么,名字(带不带引号)、注释和别的字按值换。""" + """按词重写一段 SQL:字面量交给 literal(第几个词、原文)定换成什么,名字(带不带引号)、注释和别的字按值换。 + 词与词之间只有空白和注释,名字里的数也不是算式:都按文字的办法换(换了写法的号码也认,/* 客户 199-1474-2785 */、 + AS "199-1474-2785 的备注")。""" out: list[str] = [] last = 0 for i, token in enumerate(tokens): @@ -1461,16 +1456,16 @@ def _rebuild(sql: str, tokens: list[Any], kinds: list[str], secrets: _Secrets, l if start < last or end > len(sql): continue gap = sql[last:start] - out.append(secrets.scrub(gap) if gap.strip() else gap) + out.append(secrets.scrub(gap, loose=True) if gap.strip() else gap) raw = sql[start:end] if _is_literal(kinds[i]): raw = literal(i, raw) elif kinds[i] in _NAME_TOKENS: - raw = _requote(raw, secrets.scrub) + raw = _requote(raw, lambda inner: secrets.scrub(inner, loose=True)) out.append(raw) last = end rest = sql[last:] - out.append(secrets.scrub(rest) if rest.strip() else rest) + out.append(secrets.scrub(rest, loose=True) if rest.strip() else rest) return "".join(out) @@ -1515,7 +1510,7 @@ def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: s def literal(i: int, raw: str) -> str: if tokens[i].text in whole: return word if kinds[i] == "NUMBER" else _requote(raw, lambda _: word) - return raw if kinds[i] == "NUMBER" else _requote(raw, secrets.scrub) + return raw if kinds[i] == "NUMBER" else _requote(raw, lambda inner: secrets.scrub(inner, loose=True)) out = _rebuild(body, tokens, kinds, secrets, literal) return out + "…" if cut else out @@ -1603,7 +1598,8 @@ def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _ class _Plan: """一次回答(或一次取质询工件)的遮罩:agent 每份查询快照遮好的样子(views)和遮掉的列(masked)、SQL 里指代 遮罩列的名字(tainted)、数据源的类型(kinds,SQL 按它的方言分词)、遮罩列里出现过的值(secrets),以及已经不在的 - 数据源(gone,按查询当时记下的遮罩处理)。""" + 数据源(gone,按查询当时记下的遮罩处理)。replay:回答时只靠 agent 查询时遮掉的值(seen,只在内存里)才换掉的 + 地方,按快照记成位置(masking.view_edits),写进质询事件,事后取质询工件照着再换一遍;取工件时为空。""" views: dict[str, dict[str, Any]] masked: dict[str, list[str]] @@ -1611,6 +1607,7 @@ class _Plan: kinds: dict[str, str | None] secrets: _Secrets gone: dict[str, list[str]] + replay: dict[str, dict[str, Any]] = field(default_factory=dict) async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: @@ -1659,22 +1656,57 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] kind, base = kinds.get(str(snapshot.get("source") or "")), named[artifact][0] hidden[artifact], tainted[artifact] = _derived_hidden(snapshot, base, kind) secrets.add(snapshot, hidden[artifact], labels=_snapshot_labels(snapshot, kind), base=base) - for snapshot, base in based: - kind = kinds.get(str(snapshot.get("source") or "")) - secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0], labels=_snapshot_labels(snapshot, kind), - base=base) + # agent 查询时遮掉的值先收(报告快照里的值收满了上限 SECRETS_MAX,也不丢 agent 自己查到的);不带它们的一份另算, + # 比出事后取质询工件时认不出、要照位置重放的地方 + baseline = secrets.copy() if seen is not None and seen.strong else None secrets.merge(seen) - secrets.protect(given) + for target in (secrets, baseline): + if target is not None: + _add_based(target, based, kinds) + target.protect(given) secrets.show(_shown_literals([(sql, kinds.get(name), _compared(tainted, artifact, live, name)) for sql, name, artifact in shown], secrets)) secrets.prepare() views: dict[str, dict[str, Any]] = {} masked: dict[str, list[str]] = {} + replay: dict[str, dict[str, Any]] = {} for artifact, snapshot in own.items(): views[artifact], masked[artifact] = _masked_view(snapshot, hidden[artifact], secrets, embedded=artifact in embedded) + if baseline is not None and (edits := view_edits(snapshot, hidden[artifact], secrets, baseline)): + replay[artifact] = edits return _Plan(views=views, masked=masked, tainted=tainted, kinds=kinds, secrets=secrets, - gone={artifact: named[artifact][1] for artifact in own}) + gone={artifact: named[artifact][1] for artifact in own}, replay=replay) + + +def _add_based(secrets: _Secrets, based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None]) -> None: + """收报告自己的查询快照里遮罩列的值(based:快照和它要遮的列),按 SQL 认出改了名字的遮罩列(_derived_hidden)。""" + for snapshot, base in based: + kind = kinds.get(str(snapshot.get("source") or "")) + secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0], labels=_snapshot_labels(snapshot, kind), + base=base) + + +async def snapshot_secrets(snapshots: Iterable[Any], given: Iterable[str | None] = ()) -> _Secrets: + """几份查询快照(报告自己的)里遮罩列的值,收法同回答时收报告快照(_plan),去掉已经摆在读者眼前的(given)。 + 审批质询、反方审稿在派核查 agent 之前交给它的查询工具(run_verify_agent 的 masked_values):agent 查到的别的表、 + 备注里写着同一个号码时,交给模型之前就换掉,模型看不到。""" + from app.api.evidence import _Masks + + masks = _Masks() + based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in snapshots + if _query_shaped(snapshot)] + kinds = await _source_kinds(str(snapshot.get("source") or "") for snapshot, _ in based) + asked = list(given) + + def collect() -> _Secrets: + secrets = _Secrets() + _add_based(secrets, based, kinds) + secrets.protect(asked) + secrets.prepare() + return secrets + + return await asyncio.to_thread(collect) def _compared(tainted: dict[str, set[str]], artifact: str, live: dict[str, list[str]], name: str) -> set[str]: @@ -1760,21 +1792,24 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict part = settings.get("inquiry") if isinstance(settings.get("inquiry"), dict) else {} async with SessionLocal() as session: spec = await verify_model_spec(session, None, settings=settings) + # 报告自己的查询快照:被问的那句可能直接写着遮罩列的值,agent 会照着复述;里面的遮罩值在派 agent 之前就交给它的 + # 查询工具(masked_values),agent 查到写着同一个号码的备注、别的表时,交给模型之前就换掉 + given = [ask.question, ask.quote, *_ref_values(refs)] + others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query" + and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] outcome = await run_verify_agent( spec=spec, system=INQUIRY_SYSTEM, task=_task(op, ask, target, excerpts, refs, live, lost), sources=list(live), output_schema=INQUIRY_SCHEMA, max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"), - daily_max_usd=settings.get("daily_max_usd"), run_id=op.run_id, data_versions=op.versions) + daily_max_usd=settings.get("daily_max_usd"), run_id=op.run_id, data_versions=op.versions, + masked_values=await snapshot_secrets(others, given)) - # 遮罩:agent 自己的查询快照(查询时就遮好了,遮掉的值在 outcome.masked_values 里)、报告自己的查询快照(被问的 - # 那句可能直接写着遮罩列的值,agent 会照着复述) + # 遮罩:agent 自己的查询快照(查询时就遮好了,遮掉的值在 outcome.masked_values 里)、报告自己的查询快照 own: dict[str, dict[str, Any]] = {} for query in outcome.queries: snapshot = _quiet_load(artifact_store.load, query.get("artifact")) if _query_shaped(snapshot): own[str(query["artifact"])] = snapshot - others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query" - and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] - plan = await _plan(own, others, [ask.question, ask.quote, *_ref_values(refs)], + plan = await _plan(own, others, given, [(str(q.get("sql") or ""), str(q.get("source") or ""), str(q.get("artifact") or "")) for q in outcome.queries], live, seen=outcome.masked_values) @@ -1808,6 +1843,8 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict "inquiry_artifacts": list(dict.fromkeys(q["artifact"] for q in queries if q.get("artifact"))), "queries": queries, "model": outcome.model, "usage": {k: outcome.usage.get(k, 0) for k in USAGE_KEYS}, + # 只靠内存里的遮罩值才换掉的位置(不含值),质询工件照着再换一遍;没有就不加这个键,事件和以前一字不差 + **({REPLAY_KEY: plan.replay} if plan.replay else {}), } @@ -1865,7 +1902,8 @@ def _leaked(doc: dict[str, Any], secrets: _Secrets) -> bool: def _renamed_entry(entry: Any, secrets: _Secrets) -> Any: if not (isinstance(entry, dict) and entry.get("kind") == "query" and isinstance(entry.get("columns"), list)): return entry - names = [secrets.scrub(str(c)) for c in entry["columns"]] + # 同快照的列名一样换(_masked_view):换了写法的号码也认 + names = [secrets.scrub(str(c), loose=True) for c in entry["columns"]] return {**entry, "columns": names} if names != [str(c) for c in entry["columns"]] else entry @@ -2048,11 +2086,15 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: raise InquiryRefused(404, "无法读取这件工件,可能已被删除", code=ARTIFACT_NOT_FOUND) if not _query_shaped(content): return {"id": artifact_id, "content": content, "masked": []} - # 遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份的结果里),加上报告 - # 自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值。SQL 里照原样留着的阈值只在 - # 回答正文里不遮,格子照旧按值遮,这里不用。文字格里夹着的遮罩值按子串换(embedded):回答那边渲染出来露了就整份 - # 不交,这份快照却照样记在事件里、点得开,不换就等于在「不予展示」旁边把原值摆出来 - own: dict[str, dict[str, Any]] = {artifact_id: content} + # agent 自己查询里遮掉的值事后已经不在(查询时就遮好了,快照里收不到):回答时只靠它们才换掉的地方记在事件里 + # (REPLAY_KEY,只记位置),先照着再换一遍。先查号码、后查备注的,备注查询时就遮好了,用不着这一步 + edits = [e for d in referenced if isinstance(d.get(REPLAY_KEY), dict) and (e := d[REPLAY_KEY].get(artifact_id))] + replayed = replay_edits(content, edits) if edits else content + # 再照回答时的办法遮。遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份 + # 的结果里),加上报告自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值。SQL 里照原样 + # 留着的阈值只在回答正文里不遮,格子照旧按值遮,这里不用。文字格里夹着的遮罩值按子串换(embedded):回答那边渲染 + # 出来露了就整份不交,这份快照却照样记在事件里、点得开,不换就等于在「不予展示」旁边把原值摆出来 + own: dict[str, dict[str, Any]] = {artifact_id: replayed} for data in referenced: for other in dict.fromkeys(str(a) for a in data.get("inquiry_artifacts") or []): if other not in own and _query_shaped(snapshot := _quiet_load(artifact_store.load, other)): @@ -2061,6 +2103,9 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: asked = [t for d in referenced for t in (d.get("question"), d.get("quote")) if isinstance(t, str)] plan = await _plan(own, others, [*asked, *values], embedded=frozenset({artifact_id})) shown = plan.views[artifact_id] + masked = list(dict.fromkeys([*plan.masked[artifact_id], + *(m for e in edits if isinstance(e, dict) for m in e.get("masked") or [] + if isinstance(m, str))])) recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), None) @@ -2073,7 +2118,7 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: whole, pieces = _recorded_swaps(content.get("sql"), recorded, kind) if recorded is not None else (set(), set()) shown = {**shown, "checks": _masked_checks(shown["checks"], content.get("sql"), plan.secrets, compared, kind, swapped=whole, pieces=pieces)} - out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": plan.masked[artifact_id]} + out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": masked} if gone := plan.gone.get(artifact_id): out["mask_note"] = SOURCE_GONE.format(source="、".join(gone)) return out diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index 082d8000..597d24fc 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -6,24 +6,29 @@ 质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py,经 inquiry 的同名导出)共用: - 工具边界先遮(mask_query_result):核查 agent 的查询结果交给模型、落进快照之前,取自遮罩列的格子换成「已遮罩」, - 同一份结果里别的文字格夹着的遮罩值也换掉。模型看不到遮罩值,就不会把它写进回答、理由、SQL——事后按值认每一轮 - 复核都找到新的漏法,根治靠这一步;审批质询回答里的按值遮降为第二道防线 + 别的格子按值换:这份结果里的遮罩值,加上这个 agent 前面的查询遮掉的、调用方交进来的(报告快照里的)。模型看不到 + 遮罩值,就不会把它写进回答、理由、SQL——事后按值认每一轮复核都找到新的漏法,根治靠这一步;审批质询回答、反方 + 审稿理由里的按值遮降为第二道防线。查询失败时报错原话里的值也换(mask_error_text) - 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的 - 另一支、和 * 写在同一支里没起别名的表达式);COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在 - 汇总里数个数的不算(_value_columns、_band_only)。认不出落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算 -- 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值 + 另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数);COUNT 只数个数、窗口的分区键只决定次序、 + 按遮罩列分档又当分组键或套在汇总里数个数的、汇总里的 CASE 只在条件里用到的不算(_value_columns、_band_only)。认不出 + 落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算。双盲复算按同一套认法在派盲算之前挡下取自遮罩列的指标 + (derived_columns) +- 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值;* 展开数据源里的表时按冻结的表结构 + 认它带不带同名的列(_schema_tables) - 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出 - 正文里真有的那几个值,不现编上万个分支的正则;正文、文字格里换了写法的长号码(199-1474-2785、+86 …、全角数字)按 - 纯数字写法在集合里查(loose_spans) -- 快照遮好的样子(_masked_view) + 正文里真有的那几个值,不现编上万个分支的正则;正文、文字格、SQL 的字符串和注释里换了写法的长号码(199-1474-2785、 + +86 …、全角数字)按纯数字写法在集合里查(loose_spans);已经摆在读者眼前的值免遮,之后并进来的照样免(protected) +- 快照遮好的样子(_masked_view),回答时才认得出、事后要照位置重放的地方(view_edits、replay_edits) -除 mask_query_result、换了写法的号码之外,这里的函数原来都在 engine/inquiry.py 里,名字不变,inquiry 照旧导出(别处按 -inquiry 的名字引用)。 +除 mask_query_result、换了写法的号码和这一轮新加的几样之外,这里的函数原来都在 engine/inquiry.py 里,名字不变, +inquiry 照旧导出(别处按 inquiry 的名字引用)。 """ from __future__ import annotations import contextlib +import functools import json import logging import re @@ -32,7 +37,7 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from app.engine.evidence import RenderError, render_cell +from app.engine.evidence import MARKER_RE, RenderError, render_cell from app.engine.expressions import numeric_text from app.engine.merge_query import MERGE_SOURCE @@ -70,7 +75,9 @@ def _masked_word() -> str: def _value_columns(node: Any) -> set[str]: """一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的 分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询 - 只看它选出来的列(它的条件、连接里写的列带不出值)。""" + 只看它选出来的列(它的条件、连接里写的列带不出值)。汇总里的 CASE / IIF 只看各个结果:条件只决定这一行算不算进来, + 同写在 WHERE 里(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) 的值全部来自金额);不汇总的逐行 + CASE 照旧连条件一起算(每一行落在哪一支本身就是这一行的信息)。""" from sqlglot import exp out: set[str] = set() @@ -82,6 +89,9 @@ def _value_columns(node: Any) -> set[str]: out.add(item.name.lower()) elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)): continue + elif isinstance(item, (exp.Case, exp.If)) and (results := _branch_results(item)) is not None \ + and _aggregated(item): + stack.extend(r for r in results if r is not None) elif isinstance(item, exp.Window): stack.append(item.this) elif isinstance(item, exp.Select): @@ -91,6 +101,18 @@ def _value_columns(node: Any) -> set[str]: return out +def _aggregated(node: Any) -> bool: + """node 套在同一层 SELECT 的汇总函数里(SUM(…)、AVG(…) OVER (…) 也算)。""" + from sqlglot import exp + + parent = node.parent + while parent is not None and not isinstance(parent, (exp.Select, exp.Subquery)): + if isinstance(parent, exp.AggFunc): + return True + parent = parent.parent + return False + + def _branch_results(node: Any) -> list[Any] | None: """CASE 的各个结果(每个 THEN,加上 ELSE;没写 ELSE 记 None,就是 NULL)、IIF / IF 的真假两支;别的写法为 None。 CASE 里的每个 WHEN … THEN 在语法树里也是 If,不单算。""" @@ -123,11 +145,8 @@ def _band_only(node: Any) -> bool: results = _branch_results(node) if results is None or not all(_fixed(r) for r in results): return False - parent = node.parent - while parent is not None and not isinstance(parent, (exp.Select, exp.Subquery)): - if isinstance(parent, exp.AggFunc): - return True - parent = parent.parent + if _aggregated(node): + return True item = node while isinstance(item.parent, (exp.Paren, exp.Alias)) and item.arg_key == "this": item = item.parent @@ -145,7 +164,8 @@ def _band_only(node: Any) -> bool: return False -def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, set[str]]: +def _label_columns(sql: Any, kind: str | None, columns: list[str], tables: dict[str, set[str]] | None = None + ) -> dict[str, set[str]]: """结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号) 是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的 标签、回答正文里的档名都会被换成「已遮罩」。 @@ -154,7 +174,8 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, 选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出, 等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、 递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都 - 不算。""" + 不算。tables 是查询当时冻结的表结构里每张表的列名(_schema_tables):没写表名的 * 展开的表查得到、又没有同名的列 + 时,这张表带不出这一列(_table_lacks),不再因为它不算。""" from app.data.sql_semantics import _parse, _read try: @@ -167,7 +188,8 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, result = next((top for top, role, _listed in _blocks(root) if role == "result"), None) if result is None: return {} - memo: _LabelMemo = {} + memo = _LabelMemo() + memo.tables = tables found: dict[str, list[set[str] | None]] = {} for place, column in enumerate(columns): name = column.lower() @@ -180,8 +202,42 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, #: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开 _ABSENT: Any = object() -#: 标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT -_LabelMemo = dict[tuple[int, str], Any] + + +class _LabelMemo(dict): # type: ignore[type-arg] + """标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT。tables 是冻结的表结构里每张表的列名 + (小写,_schema_tables),没有为 None。""" + + tables: dict[str, set[str]] | None = None + + +def _schema_tables(schema: Any) -> dict[str, set[str]] | None: + """冻结的表结构(数据源的 schema_cache、表结构快照的内容:{"tables": {表名: {"columns": [{"name": …}]}}})→ + {表名(小写): 列名(小写)},按登记的名字和不带库名的表名各记一份(几个库里同名的表合起来算,宁可多遮)。表结构 + 里没列出的表不记(截断了的表结构也一样),查不到就当说不清;整份拿不到的为 None。""" + tables = schema.get("tables") if isinstance(schema, dict) else None + if not isinstance(tables, dict): + return None + out: dict[str, set[str]] = {} + for key, meta in tables.items(): + columns = meta.get("columns") if isinstance(meta, dict) else None + if not isinstance(columns, list): + continue + names = {str(c["name"]).lower() for c in columns if isinstance(c, dict) and c.get("name") is not None} + for name in {str(key).lower(), str(key).lower().rsplit(".", 1)[-1], + str(meta.get("qualified") or key).lower().rsplit(".", 1)[-1]}: + out.setdefault(name, set()).update(names) + return out + + +def _table_lacks(source: Any, name: str, memo: _LabelMemo) -> bool: + """FROM 里的这个来源是数据源里的一张表,冻结的表结构里查得到它、又没有 name 这一列:* 展开它带不出 name。""" + from sqlglot import exp + + if not memo.tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier): + return False + columns = memo.tables.get(source.name.lower()) + return columns is not None and name not in columns def _result_label(top: Any, place: int, name: str, width: int, memo: _LabelMemo) -> set[str] | None: @@ -247,7 +303,8 @@ def _star_label(select: Any, name: str, memo: _LabelMemo, *, before: int | None if not _is_star(item): continue qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None - if qualifier is None and any(_derived_block(source) is None for _, source in _from_sources(select)): + if qualifier is None and any(_derived_block(source) is None and not _table_lacks(source, name, memo) + for _, source in _from_sources(select)): return None texts = _source_label(select, qualifier, name, memo) if texts is None: @@ -275,7 +332,8 @@ def _source_label(select: Any, qualifier: str | None, name: str, memo: _LabelMem for _alias, source in sources: derived = _derived_block(source) if derived is None: - opaque = True + # 冻结的表结构里这张表没有这一列:它不出这一列,不算认不准 + opaque = opaque or not _table_lacks(source, name, memo) continue texts = _block_label(derived[0], derived[1], name, memo) if texts is None: @@ -378,6 +436,68 @@ def _derived_block(source: Any) -> tuple[Any, list[str] | None] | None: return node, ([c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None) +#: 表值函数常见的出列名:SQLite 的 json_each / json_tree(key、value、atom、fullkey、path),Postgres 的 jsonb_each +#: (key、value),Spark 的 explode / posexplode(col、pos),Snowflake 的 FLATTEN(key、value、path、index、this)。 +#: 结构性的几列(id、parent、type)带不出值,不算——算上的话,同一条 SQL 里别的 id 列都会被遮 +_FUNCTION_COLUMNS = frozenset({"key", "value", "atom", "fullkey", "path", "col", "pos", "index", "this"}) +#: 出列名认得全的表值函数:出的是上面那几列,或者一列、名字就是别名(没起别名的是函数名) +_TABLE_FUNCTIONS = frozenset({ + "json_each", "json_tree", "jsonb_each", "jsonb_each_text", "json_each_text", "json_array_elements", + "json_array_elements_text", "jsonb_array_elements", "jsonb_array_elements_text", "json_object_keys", + "jsonb_object_keys", "unnest", "explode", "explode_outer", "posexplode", "regexp_split_to_table", + "string_to_table", "string_split", "openjson", "flatten", +}) + + +def _function_sources(select: Any) -> list[Any]: + """一条 SELECT 的 FROM、JOIN、LATERAL VIEW 里说不清出哪些列、装着什么的来源:不是数据源里的表(含指向 WITH 的名字), + 也不是子查询这类派生来源——表值函数(json_each、unnest、JSON_TABLE)、LATERAL 函数、VALUES。""" + from sqlglot import exp + + sources = [source for _, source in _from_sources(select)] + list(select.args.get("laterals") or []) + out: list[Any] = [] + for source in sources: + if _derived_block(source) is not None: + continue + node = source.this if isinstance(source, exp.Lateral) else source + if isinstance(node, exp.Table) and isinstance(node.this, exp.Identifier): + continue + out.append(source) + return out + + +def _function_columns(source: Any) -> set[str] | None: + """表值函数这类来源出的列名(小写):写的列名表、别名(Postgres 里一列的函数出的列就叫别名,没起别名的叫函数名)、 + 函数名、常见的出列名(_FUNCTION_COLUMNS);JSON_TABLE 按 COLUMNS 里写的,VALUES 按 column1、column2…。不认得的函数 + 又没写列名表的,说不清出哪几列,为 None。""" + from sqlglot import exp + + node = source.this if isinstance(source, exp.Lateral) else source + alias = source.args.get("alias") or (node.args.get("alias") if node is not source else None) + names: set[str] = set(_FUNCTION_COLUMNS) + listed: list[str] = [] + if alias is not None: + if alias.name: + names.add(alias.name.lower()) + listed = [c.name.lower() for c in alias.args.get("columns") or [] if c.name] + names |= set(listed) + func = node.this if isinstance(node, exp.Table) else node + while isinstance(func, exp.Subquery): + func = func.this + if isinstance(func, exp.JSONTable): + schema = func.args.get("schema") + defined = [str(c.name).lower() for c in (schema.expressions if schema is not None else []) if c.name] + return names | set(defined) if defined or listed else None + if isinstance(func, exp.Values): + width = max((len(row.expressions) for row in func.expressions if isinstance(row, exp.Tuple)), default=1) + return names | {f"column{i}" for i in range(1, width + 1)} + name = (func.name if isinstance(func, exp.Anonymous) else func.sql_name() if isinstance(func, exp.Func) + else "").lower() + if name: + names.add(name) + return names if listed or name in _TABLE_FUNCTIONS else None + + def _merging(select: Any, name: str) -> bool: """FROM 里按同名的 name 这一列连起来(USING 里写着它,或者 NATURAL):几个来源的这一列合成一列。""" return any(str(join.args.get("method") or "").upper() == "NATURAL" @@ -531,7 +651,9 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[ 不同层的也一并算上(宁可多遮)。 位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 - 又认不准)——整份快照的列都算(同解析不了)。""" + 又认不准)——整份快照的列都算(同解析不了)。 + FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE、LATERAL VALUES)参数里写着这些名字的,它出的列 + 都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算。""" from sqlglot import exp from app.data.sql_semantics import _parse, _read @@ -547,7 +669,9 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[ selects = list(root.find_all(exp.Select)) blocks = _blocks(root) sources = [(top, listed) for top, role, listed in blocks if role == "source"] - for _ in range(len(selects) + len(sources) + 1): + functions = [source for select_ in selects for source in _function_sources(select_)] + unreadable = False + for _ in range(len(selects) + len(sources) + len(functions) + 1): before = len(names) for select_ in selects: for item in select_.expressions: @@ -556,9 +680,14 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[ names.add(name) for top, listed in sources: names |= _source_names(top, listed, names)[0] + for source in functions: + if names & _value_columns(source): + columns = _function_columns(source) + unreadable = unreadable or columns is None + names |= columns or set() if len(names) == before: break - everything = any(_source_names(top, listed, names)[1] for top, listed in sources) + everything = unreadable or any(_source_names(top, listed, names)[1] for top, listed in sources) positions: set[int] = set() for top, role, _listed in blocks: if role != "result": @@ -598,12 +727,41 @@ def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None return names | {columns[i].lower() for i in positions if i < len(columns)}, names -def _snapshot_labels(snapshot: dict[str, Any], kind: str | None) -> dict[str, set[str]]: - """一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。""" +def derived_columns(snapshot: dict[str, Any], hidden: set[str], kind: str | None) -> set[str] | None: + """按 SQL 认出值取自遮罩列(hidden,小写)的结果列(小写,列名直接对上的也算)。SQL 解析不了、合并查询的快照(不看 + SQL)为 None:认不准,宽严由调用方定(_derived_hidden 整份遮;双盲复算照常派盲算,由盲算自己的查询去认)。""" + sql = snapshot.get("sql") + if not hidden or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: + return None + columns = [str(c) for c in snapshot.get("columns") or []] + found = _tainted(sql, kind, set(hidden), len(columns)) + if found is None: + return None + names, positions = found + return {c.lower() for i, c in enumerate(columns) if c.lower() in names or i in positions} + + +def _snapshot_labels(snapshot: dict[str, Any], kind: str | None, tables: dict[str, set[str]] | None = None + ) -> dict[str, set[str]]: + """一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。tables 没给的,按快照记着 + 的表结构快照(schema_artifact,查询当时冻结的)认。""" sql = snapshot.get("sql") if not isinstance(sql, str) or not sql.strip(): return {} - return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []]) + if tables is None and isinstance(snapshot.get("schema_artifact"), str): + tables = _frozen_tables(snapshot["schema_artifact"]) + return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []], tables) + + +@functools.lru_cache(maxsize=64) +def _frozen_tables(artifact: str) -> dict[str, set[str]] | None: + """表结构快照工件里每张表的列名(_schema_tables)。工件按内容寻址、不会变,记下来;取不到、被改过的为 None。""" + from app.core import artifact_store + + try: + return _schema_tables(artifact_store.load(artifact)) + except Exception: # noqa: BLE001 - 取不到就当不知道表结构(标签照旧当遮罩值,宁可多遮) + return None # -------------------------------------------------------------------------- @@ -728,13 +886,17 @@ _WORDS = re.compile(r"[0-9A-Za-z_]+") _DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?") #: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition) _PREFIX = 8 -#: 正文里一串允许夹分隔符的数字(换了写法的号码,见 _Secrets.loose_spans):数字(含全角)之间夹着一到三个空格、连字符、 -#: 点、括号(含全角),开头可以带 + 和括号。逗号不算分隔符(千分位按 _DIGITS 认),冒号、斜杠也不算(时间、日期),不跨行 -_LOOSE_RUN = re.compile(r"[((]?[++]?[((]?[0-90-9]+(?:[ \u3000\-‐‑‒–—-..·・()()]{1,3}[0-90-9]+)*[))]?") +#: 正文里一串允许夹分隔符的数字(换了写法的号码,见 _Secrets.loose_spans):数字(含全角)之间夹着一到四个分隔符,开头 +#: 可以带 + 和括号。分隔符是同一行里的各种空白(空格、制表符、全角空格、不换行空格,以及窄空格、细空格这类 U+2000 至 +#: U+200B、U+202F、U+205F)、连字符和减号(含全角、U+2010 至 U+2015、U+2212)、点(含全角、间隔号)、括号(含全角)。 +#: 逗号不算分隔符(千分位按 _DIGITS 认),冒号、斜杠也不算(时间、日期),不跨行 +_LOOSE_SEPARATORS = (" \t\u00a0\u2000-\u200b\u202f\u205f\u3000" + "\\-\u2010-\u2015\u2212\uff0d.\uff0e\u00b7\u30fb()\uff08\uff09") +_LOOSE_RUN = re.compile(rf"[((]?[++]?[((]?[0-90-9]+(?:[{_LOOSE_SEPARATORS}]{{1,4}}[0-90-9]+)*[))]?") _LOOSE_DIGITS = re.compile(r"[0-90-9]+") #: 两截数字之间只隔着这样一个字(不带空格):是更长的编号、小数、日期的两截(13900000001-2、13900000001.5),号码到这里 -#: 不算完——同 _pattern 里「前后不能是 [.:/-] 加数字」 -_JOINERS = frozenset("-‐‑‒–—-..") +#: 不算完——同 _pattern 里「前后不能是 [.:/-] 加数字」。减号(U+2212)、全角连字符同连字符 +_JOINERS = frozenset("-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uff0d.\uff0e") _POINTS = frozenset("..") #: 去掉国家码(0086、86)之后至少还要有这么多位才算号码(固话号码最短 7 位):86 开头的短数不拆 _LOCAL_MIN = 7 @@ -857,11 +1019,13 @@ class _Secrets: 留着的阈值、行数(去掉千分位、正负号的数字),只在回答正文里不换(scrub 的 prose)。换的时候先按词、按开头两个 字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。 正文、文字格里另认换了写法的长号码(199-1474-2785、+86 199 1474 2785、全角数字,见 loose_spans):按纯数字写法 - 在集合里查,不进正则。""" + 在集合里查,不进正则。protected 是 protect 收下的、已经摆在读者眼前的文字:之后并进来的值(核查 agent 后面的 + 查询遮掉的)照样按它们免遮。""" strong: set[str] = field(default_factory=set) weak: set[str] = field(default_factory=set) shown: set[str] = field(default_factory=set) + protected: tuple[str, ...] = () _index: Any = field(default=None, repr=False, compare=False) _compiled: dict[frozenset[str], re.Pattern[str]] = field(default_factory=dict, repr=False, compare=False) @@ -899,8 +1063,10 @@ class _Secrets: self._index = None def merge(self, other: _Secrets | None) -> None: - """并进另一份收好的遮罩值(核查 agent 查询时在工具边界遮掉的,见 mask_query_result)。合起来最多 SECRETS_MAX 种。""" - if other is None or not other.weak: + """并进另一份收好的遮罩值(核查 agent 查询时在工具边界遮掉的,见 mask_query_result)。合起来最多 SECRETS_MAX 种。 + 两边免遮的文字合起来,并进来的值照样按它们免遮:报告的指标正好等于某一笔金额时,agent 查出了那一笔,别的查询里 + 正好是这个数的格子也不遮。""" + if other is None: return for form in other.weak: if len(self.weak) >= SECRETS_MAX: @@ -908,14 +1074,23 @@ class _Secrets: self.weak.add(form) if form in other.strong: self.strong.add(form) + self.protected = tuple(dict.fromkeys((*self.protected, *other.protected))) self._index, self._compiled = None, {} + if self.protected: + self._spare(" ".join(self.protected)) def protect(self, texts: Iterable[str | None]) -> None: """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), - 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。""" + 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。文字记下来, + 之后并进来的值照样免遮(merge)。""" given = " ".join(t for t in texts if t) if not given: return + self.protected = tuple(dict.fromkeys((*self.protected, given))) + self._spare(given) + + def _spare(self, given: str) -> None: + """去掉 given(几段免遮的文字)里写着的值。""" numbers = {_digits_of(n) for n in _DIGITS.findall(given)} # 换了写法写进来的号码(「199-1474-2785 这位客户」)按纯数字写法认 reformatted = {key for _, _, key in self.loose_spans(given)} @@ -958,15 +1133,40 @@ class _Secrets: def scrub(self, text: str, *, prose: bool = False, loose: bool | None = None) -> str: """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。loose:换了写法 - 的长号码也换(loose_spans),缺省同 prose——只在正文、文字格里这样认,SQL 里的 199-1474-2785 是一个算式。""" + 的长号码也换(loose_spans),缺省同 prose——正文、文字格、SQL 的字符串和注释里这样认,SQL 里不带引号的 + 199-1474-2785 是一个算式。""" + return self.swap(text, prose=prose, loose=loose)[0] + + def swap(self, text: str, *, prose: bool = False, loose: bool | None = None) -> tuple[str, list[str]]: + """scrub,另交回换掉了哪些值:按值认出的照原文的写法,换了写法的号码交它的纯数字写法(SQL 里同一个数写成阈值的 + 据此不留,见 inquiry._swap_literal)。""" + hits = self._hits(text, prose=prose, loose=loose) + word = _masked_word() + for start, end, _ in reversed(hits): + text = text[:start] + word + text[end:] + return text, [value for _, _, value in hits] + + def spans(self, text: str, *, prose: bool = False, loose: bool | None = None) -> list[tuple[int, int]]: + """scrub 会换掉的那几段(起, 止),按先后、不重叠。质询工件按位置重放回答时换掉的地方(view_edits)。""" + return [(start, end) for start, end, _ in self._hits(text, prose=prose, loose=loose)] + + def _hits(self, text: str, *, prose: bool, loose: bool | None) -> list[tuple[int, int, str]]: + """text 里要换掉的遮罩值:(起, 止, 值),按先后、不重叠。换了写法的号码先认(+86 19914742785 整个换掉,不留下 + 一截「+86」),按值认的落在它们里面的不再算。""" if not text or not self.strong: - return text - if prose if loose is None else loose: - # 先换换了写法的:+86 19914742785 整个换掉,不留下一截「+86」 - for start, end, _ in reversed(self.loose_spans(text, prose=prose)): - text = text[:start] + _masked_word() + text[end:] + return [] + hits = [(start, end, key) for start, end, key in self.loose_spans(text, prose=prose)] \ + if (prose if loose is None else loose) else [] pattern = self.pattern(text, prose=prose) - return pattern.sub(_masked_word(), text) if pattern is not None else text + if pattern is not None: + taken = list(hits) + hits += [(m.start(), m.end(), m.group()) for m in pattern.finditer(text) + if not any(m.start() < end and start < m.end() for start, end, _ in taken)] + return sorted(hits) + + def copy(self) -> _Secrets: + """另一份一样的遮罩值(改它不影响这一份):交给线程、按句另加免遮的值时用。""" + return _Secrets(strong=set(self.strong), weak=set(self.weak), shown=set(self.shown), protected=self.protected) def loose_spans(self, text: str, *, prose: bool = False) -> list[tuple[int, int, str]]: """text 里换了写法的长号码(199-1474-2785、(199)1474-2785、+86 199 1474 2785、全角数字……):(起, 止, 纯数字 @@ -1058,6 +1258,34 @@ class _Secrets: return self._index +#: 单元格引用的前一截(Q1.r0.):后面才是列名 +_CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.") + + +def _scrubbed_source(text: str, secrets: _Secrets) -> str: + """回答原文里的遮罩值换成「已遮罩」:标记之外按正文的办法换(SQL 里照原样留着的阈值不换);标记里的按段换(| 隔开 + 的几段各自换),单元格引用只换列名那一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数 + 的一截,反而认不出来。列名照快照、SQL 里的名字那样换(阈值也换,换了写法的号码也认),换得一样才对得上。 + 标记里写着遮罩值的引用换完多半解析不了(列名里写着遮罩值的除外:快照的列名也换过了,正好对上),宁可弄坏一个 + 引用,也不让值留在存下的原文、片段里,或者从解析不了的占位里露出来。""" + if not text or not secrets.strong: + return text + out: list[str] = [] + last = 0 + for m in MARKER_RE.finditer(text): + out.append(secrets.scrub(text[last:m.start()], prose=True)) + start, end = m.span("body") + parts = text[start:end].split("|") + head = _CELL_HEAD.match(parts[0]) if m.group("kind").strip() == "v" else None + first = parts[0][:head.end()] + secrets.scrub(parts[0][head.end():], loose=True) if head \ + else secrets.scrub(parts[0], prose=True) + parts = [first, *(secrets.scrub(p, prose=True) for p in parts[1:])] + out.append(text[m.start():start] + "|".join(parts) + text[end:m.end()]) + last = m.end() + out.append(secrets.scrub(text[last:], prose=True)) + return "".join(out) + + def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False, rename: bool = True) -> tuple[dict[str, Any], list[str]]: """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); @@ -1068,22 +1296,30 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, embedded 只给质询工件用:别的列里一格文字夹着遮罩值(备注里写着手机号,换了写法的也算),把那一截换成「已遮罩」, 这一格别的字照原样,门槛同整格(_Secrets.scrub);这一列不记进遮掉的列——界面把记进去的列整列画成「已遮罩」,这一列在数据源 里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。 - rename 为假时列名不换(工具边界先遮时:列名是模型自己写的,换了它引用的列名就对不上)。""" + rename 为假时列名不换(工具边界先遮时:列名是模型自己写的,换了它引用的列名就对不上)。列名按文字的办法换(换了写法的 + 号码也认,同 SQL 里带引号的名字)。 + 工具边界遮过的快照(记着 masked_at_query)里,整格就是「已遮罩」的是查询时按值换掉的:这一列照样记进遮掉的列,和回答时 + 按值换掉的一样画。""" columns = [str(c) for c in snapshot.get("columns") or []] index = {i for i, c in enumerate(columns) if c.lower() in hidden} by_value = bool(secrets.strong) - if not index and not by_value: + premasked = "masked_at_query" in snapshot + if not index and not by_value and not premasked: return snapshot, [] word = _masked_word() touched = set(index) edited = False position = {c: i for i, c in enumerate(columns)} - names = [secrets.scrub(c) for c in columns] if by_value and rename else columns + names = [secrets.scrub(c, loose=True) for c in columns] if by_value and rename else columns def cell(i: int | None, value: Any) -> Any: nonlocal edited if i in index: return word + if premasked and value == word: + if i is not None: + touched.add(i) + return value if by_value and secrets.hit(value): if i is not None: touched.add(i) @@ -1119,6 +1355,122 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, return view, [names[i] for i in sorted(touched)] +def _cell_text(value: Any) -> str | None: + """一格按文字换的样子(_masked_view 的 embedded):文字照原样,JSON(对象、数组)按界面画出来的样子;别的为 None。""" + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, default=str) + return None + + +def view_edits(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, baseline: _Secrets + ) -> dict[str, Any] | None: + """质询工件按回答时的办法遮(_masked_view 的 embedded)时,secrets 比 baseline 多换掉的地方,相对原快照记成位置, + 不含值:{"cells": [[行, 列], …](整格换成「已遮罩」), "spans": [[行, 列, 起, 止], …](文字格里换掉的那一截,起止按 + 这一格的文字算), "names": [[列, 起, 止], …](列名里换掉的那一截), "masked": [列名, …](因此画成遮罩列的)};没有 + 多换的为 None。 + agent 先查了备注(那时还不知道备注里的号码是遮罩值,原样交回、原样存下),后来又查出了手机号(查询时遮掉,值只在 + 内存里):回答时按内存里的值(secrets)认得出,事后取质询工件时只剩 baseline 认得出的。这份位置记进质询事件, + 事后照着再换一遍(replay_edits)。行是字典的快照不记(核查的查询快照都是行列表)。""" + full, full_masked = _masked_view(snapshot, hidden, secrets, embedded=True) + base, base_masked = _masked_view(snapshot, hidden, baseline, embedded=True) + if full is base: + return None + word = _masked_word() + cells: list[list[int]] = [] + spans: list[list[int]] = [] + for r, (row, shown, plain) in enumerate(zip(snapshot.get("rows") or [], full["rows"], base["rows"])): + if not (isinstance(row, list) and isinstance(shown, list) and isinstance(plain, list)): + continue + for c, (value, now, before) in enumerate(zip(row, shown, plain)): + if now == before: + continue + if now == word: + cells.append([r, c]) + elif (text := _cell_text(value)) is not None: + spans += [[r, c, start, end] for start, end in secrets.spans(text, loose=True)] + columns = [str(c) for c in snapshot.get("columns") or []] + full_names, base_names = full.get("columns", columns), base.get("columns", columns) + names = [[i, start, end] for i, (column, now, before) in enumerate(zip(columns, full_names, base_names)) + if now != before for start, end in secrets.spans(column, loose=True)] + # 按位置比:同一列在两边可能叫不同的名字(列名里的值只有一边换了) + already = {i for i, name in enumerate(base_names) if name in base_masked} + masked = [name for i, name in enumerate(full_names) if name in full_masked and i not in already] + if not (cells or spans or names or masked): + return None + return {"cells": cells, "spans": spans, "names": names, "masked": masked} + + +def replay_edits(snapshot: dict[str, Any], edits: Iterable[Any]) -> dict[str, Any]: + """照记下的位置(view_edits,可能有好几份:同一份快照被几条质询引用)把回答时换掉的地方再换一遍。位置对不上的 + (行、列越界,起止不合理,形状不对)跳过:记录是封存的事件,坏了也不能让取工件出错。没有可换的返回原快照。""" + rows = snapshot.get("rows") + columns = [str(c) for c in snapshot.get("columns") or []] + if not isinstance(rows, list): + return snapshot + + def ints(entry: Any, size: int) -> list[int] | None: + ok = isinstance(entry, list) and len(entry) == size and all( + isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in entry) + return entry if ok else None + + whole: set[tuple[int, int]] = set() + parts: dict[tuple[int, int], list[tuple[int, int]]] = {} + heads: dict[int, list[tuple[int, int]]] = {} + for edit in edits: + if not isinstance(edit, dict): + continue + for entry in edit.get("cells") or []: + if (at := ints(entry, 2)) is not None: + whole.add((at[0], at[1])) + for entry in edit.get("spans") or []: + if (at := ints(entry, 4)) is not None and at[2] < at[3]: + parts.setdefault((at[0], at[1]), []).append((at[2], at[3])) + for entry in edit.get("names") or []: + if (at := ints(entry, 3)) is not None and at[1] < at[2]: + heads.setdefault(at[0], []).append((at[1], at[2])) + word = _masked_word() + + def swapped(text: str, cuts: list[tuple[int, int]]) -> str: + merged: list[list[int]] = [] + for start, end in sorted(cuts): + if end > len(text): + continue + if merged and start <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], end) + else: + merged.append([start, end]) + for start, end in reversed(merged): + text = text[:start] + word + text[end:] + return text + + out_rows: list[Any] = [] + changed = False + for r, row in enumerate(rows): + if not isinstance(row, list): + out_rows.append(row) + continue + new_row = list(row) + for c, value in enumerate(row): + if (r, c) in whole: + new_row[c] = word + elif (r, c) in parts and (text := _cell_text(value)) is not None: + new_row[c] = swapped(text, parts[(r, c)]) + changed = changed or new_row != row + out_rows.append(new_row) + names = [swapped(name, heads[i]) if i in heads else name for i, name in enumerate(columns)] + if not changed and names == columns: + return snapshot + view = {**snapshot, "rows": out_rows} + if names != columns: + view["columns"] = names + if isinstance(snapshot.get("column_types"), dict): + rename = dict(zip(columns, names)) + view["column_types"] = {rename.get(str(k), k): v for k, v in snapshot["column_types"].items()} + return view + + # -------------------------------------------------------------------------- # 工具边界:查询结果交给核查 agent 之前先遮 # -------------------------------------------------------------------------- @@ -1137,33 +1489,77 @@ class QueryMask: secrets: _Secrets -def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str | None) -> QueryMask: +def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str | None, + known: _Secrets | None = None, *, schema: Any = None) -> QueryMask: """一份查询结果(tools/datasource 的查询工具交回的样子:columns / rows / column_types / sql)在交给核查 agent、落进 - 快照之前先遮。masked 是数据源设了遮罩的列,kind 是数据源的类型(SQL 按它的方言解析)。 + 快照之前先遮。masked 是数据源设了遮罩的列,kind 是数据源的类型(SQL 按它的方言解析)。known 是此前已经认出的遮罩值: + 这个 agent 前面的查询遮掉的,加上调用方交进来的(审批质询、反方审稿收的报告快照里的值)。schema 是数据源的表结构 + (schema_cache,和随快照冻结的是同一份),认分档标签时用(_label_columns 的 tables)。 - 整列遮:列名直接对上遮罩列的,加上按 SQL 认出值取自遮罩列的(_derived_hidden:别名、函数、子查询、WITH、 - SELECT *;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着遮罩列的,整份结果的列都遮。这几列的 - 格子换成「已遮罩」,类型记成文本 - - 别的列:整格就是这份结果里某个遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans); - 门槛同按值遮(SECRET_DIGITS、SECRET_MIN),只装着分档标签的列里的标签不算遮罩值 - - 列名不换(模型自己写的,换了它引用的列名就对不上);结果里一列都没取自遮罩列的原样交回:数值的汇总、只数个数的 - 查询不受影响 + SELECT *、表值函数;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着遮罩列的,整份结果的列都遮。 + 这几列的格子换成「已遮罩」,类型记成文本 + - 别的列按值换:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans)。遮罩值 + 是这份结果里的加上 known:先查号码、再查写着号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样 + 遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不 + 按 known 换(前面的查询认不准、当遮罩值收了的同名标签,不能把这一份的档位列换掉) + - 列名不换(模型自己写的,换了它引用的列名就对不上);结果里一列都没取自遮罩列、也没有可按值换的原样交回:数值的 + 汇总、只数个数的查询不受影响 - 分析本身出了意外,整份结果的列都遮:这一步挡在模型前面,分析的缺陷不能把原值放过去(宁可多遮)。""" + 交回的 secrets 只有这份结果里的遮罩值(known 调用方自己有)。分析本身出了意外,整份结果的列都遮:这一步挡在模型 + 前面,分析的缺陷不能把原值放过去(宁可多遮)。""" base = {str(c).strip().lower() for c in masked if str(c).strip()} columns = [str(c) for c in payload.get("columns") or []] - if not base or not columns or not _query_shaped(payload): + given = known is not None and bool(known.strong) + if not (base or given) or not columns or not _query_shaped(payload): return QueryMask(payload, [], _Secrets()) try: - hidden, _ = _derived_hidden(payload, base, kind) - if not any(c.lower() in hidden for c in columns): - return QueryMask(payload, [], _Secrets()) - secrets = _Secrets() - secrets.add(payload, hidden, labels=_snapshot_labels(payload, kind), base=base) - secrets.prepare() - view, _ = _masked_view(payload, hidden, secrets, embedded=True, rename=False) + hidden = _derived_hidden(payload, base, kind)[0] if base else set() + own = _Secrets() + labels = _snapshot_labels(payload, kind, _schema_tables(schema)) + if any(c.lower() in hidden for c in columns): + own.add(payload, hidden, labels=labels, base=base) + pool = own + if given: + pool = own.copy() + pool.merge(known) + # 这份结果自己写的标签(CASE 写死的档名)不当遮罩值,哪怕前面的查询把同名的字收了 + if written := [text for name, texts in labels.items() if name not in base for text in texts]: + pool.protect(written) + if not pool.strong and not any(c.lower() in hidden for c in columns): + return QueryMask(payload, [], own) + pool.prepare() + view, _ = _masked_view(payload, hidden, pool, embedded=True, rename=False) except Exception: # noqa: BLE001 - 认不准就整份遮掉,原值不交给模型 logger.warning("核查查询的遮罩分析出错,整份结果按遮罩处理", exc_info=True) - hidden, secrets = {c.lower() for c in columns}, _Secrets() - view, _ = _masked_view(payload, hidden, secrets, rename=False) - return QueryMask(view, [c for c in columns if c.lower() in hidden], secrets) + hidden, own = {c.lower() for c in columns}, _Secrets() + view, _ = _masked_view(payload, hidden, own, rename=False) + return QueryMask(view, [c for c in columns if c.lower() in hidden], own) + + +#: 查询报错里引号括起来的一段(Postgres、DuckDB、MySQL 把转换失败的值写在引号里:invalid input syntax for type +#: bigint: "139-0000-0001")。引号前后紧挨着字母数字的不算(can't、isn't 里的撇号) +_QUOTED_ERROR = re.compile(r"(? str: + """查询失败时交给核查 agent、写进 tool.end 的报错原话。驱动的原话是模型改写 SQL 的线索,可数据库会在报错里带上 + 数据里的值(类型转换失败、越界),数据源设了遮罩时交出去之前先换:引号里的一段、不带引号的长数,SQL 里没写着的 + 换成「已遮罩」——SQL 里写着的是模型自己写的表名、列名、字面量,照留,它要靠这些改写;此前查询遮掉的值(known) + 按值换。不认得是不是数据的也换(多遮):报错只是线索,说不全也不耽误改写。""" + if not message: + return message + word = _masked_word() + lowered = sql.lower() + + def quoted(m: re.Match[str]) -> str: + inner = m.group(1)[1:-1] + if not inner.strip() or inner.lower() in lowered: + return m.group() + return m.group(1)[0] + word + m.group(1)[-1] + + out = _QUOTED_ERROR.sub(quoted, message) + out = _ERROR_NUMBER.sub(lambda m: m.group() if m.group() in sql else word, out) + return known.scrub(out, loose=True) if known is not None and known.strong else out diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index b5c555e3..ddec305e 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -9,7 +9,11 @@ 取自的查询算,合并查询按它合并的那几个源算);这句没挂依据,就在这份报告引用的数据源里查。任务里给这句结论 和它挂的证据摘录(judge.prepare,数据源遮罩的列照旧不给;摘录里原报告的编号都标成「原报告 Qn」,和 agent 自己 的 Q1 分开),要它先想清楚「如果这句不成立,数据里会看到什么」,写只读查询去找,最后交结论(经受住 / 被削弱 / - 被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来 + 被推翻 / 无法判断)和理由。理由里的数字只能引用它自己查询的格子,裸数字由 compose_doc 标出来。 + 遮罩:agent 的查询结果在工具边界就遮好了;这份报告引用的查询快照里的遮罩值(按 SQL 认出改了名字的遮罩列,同审批 + 质询)反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。摘录按列名遮,改了名字的遮罩列原值 + agent 看得到,会照着写进理由:理由正文和理由文档按值换成「已遮罩」(换了写法的号码也认),这句结论原文里写着的 + 不换(_masked_rationale) 3. 出处:一条查询都没执行成功的,判什么都记为无法判断;判「被推翻」的,理由里还得引用到自己的查询结果——它要让 正式出具降档,审稿记录里得查得到凭什么。不采信时系统写明原因,反方原本的结论和理由照录在后面(纯文字:解析 不了的引用写成「(引用无效)」,裸数字注明「(无出处)」)。这句该查的数据源都不存在或已停用、核查模型本身用 @@ -44,8 +48,10 @@ """ from __future__ import annotations +import asyncio import re from collections.abc import Callable, Collection, Iterable +from dataclasses import replace from typing import Any from langgraph.func import task @@ -372,6 +378,52 @@ def _claim_of(cand: judging.Candidate, outcome: VerifyOutcome) -> tuple[dict[str return _claim(cand, verdict, rationale, doc=outcome.rationale, queries=outcome.queries, usage=outcome.usage), None +def _spared(seed: Any, claim: str) -> Any: + """报告快照里的遮罩值(seed)去掉这句结论原文里写着的,交给这一句的反查。seed 本身不动(下一句另算)。""" + known = seed.copy() + known.protect([claim]) + return known + + +def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[str | None]) -> VerifyOutcome: + """反方的理由里的遮罩值换成「已遮罩」,换了写法的号码也认(199-1474-2785、+86 …)。值是这份报告引用的查询快照里的 + (secrets,反查之前就交给了查询工具),加上 agent 查询时在工具边界遮掉的(outcome.masked_values);被审的那句结论 + 原文里写着的(given)已经摆在读者眼前,不换。反方 agent 的查询结果在工具边界就遮好了,可证据摘录按列名遮,改了名字 + 的遮罩列(buyer_phone AS 手机号)的原值它看得到,会写进理由。 + 换的办法同审批质询的回答(_scrubbed_source):原文里的值先换掉,再照原来的目录重新组装理由文档;引用到的格子按 + 遮好的快照渲染(文字格里夹着的遮罩值换掉那一截)。理由里认不出遮罩值的原样交回。""" + from app.engine.evidence import compose_doc + from app.engine.masking import _masked_view, _scrubbed_source + + doc, result = outcome.rationale, outcome.result + pool = secrets.copy() + pool.merge(outcome.masked_values) + pool.protect(given) + if result is None or not isinstance(doc, dict) or not pool.strong: + return outcome + pool.prepare() + source = str(doc.get("source") or "") + if not (pool.spans(str(doc.get("markdown") or ""), prose=True) or pool.spans(source, prose=True)): + return outcome + views: dict[str, Any] = {} + fetch = _fetcher() + for query in outcome.queries: + snapshot = fetch(query.get("artifact")) + if isinstance(snapshot, dict) and isinstance(snapshot.get("rows"), list): + views[str(query["artifact"])] = _masked_view(snapshot, set(), pool, embedded=True)[0] + def load(artifact: str) -> Any: + return views[artifact] if artifact in views else artifact_store.load(artifact) + + try: + again = compose_doc(_scrubbed_source(source, pool), doc.get("catalog") or {}, + node_id=str(doc.get("node_id") or ""), run_id=doc.get("run_id"), loader=load) + except Exception: # noqa: BLE001 - 重新组装出了意外,值也不能留着 + # 正文按值换掉,理由文档不要了(界面照正文显示;判「被推翻」的因此查不到出处,按无法判断记) + said = pool.scrub(str(doc.get("markdown") or ""), prose=True) + return replace(outcome, rationale=None, result={**result, RATIONALE: said}) + return replace(outcome, rationale=again, result={**result, RATIONALE: again["markdown"]}) + + def _cites(doc: dict[str, Any] | None) -> bool: """理由文档里有没有一处引用解析到了 agent 自己的查询结果(compose_doc 只把解析成功的引用记进 unit.cites)。""" return isinstance(doc, dict) and any(unit.get("cites") for _, unit in iter_units(doc)) @@ -566,6 +618,9 @@ class _Review: # 这句没挂依据时在整份报告引用的数据源里查:写作者看得到的就是这几个库 everywhere = claim_sources(self.catalog, self.catalog, self.fetch) usable = await _usable_sources(everywhere) + # 报告引用的查询快照里遮罩列的值:反查之前交给查询工具(agent 查到写着同一个号码的别的表时看不到),反查之后 + # 据此换掉理由里的遮罩值(_masked_rationale) + seed = await self._seed() claims: list[dict[str, Any]] = [] usage = _zero_usage() model: str | None = None @@ -629,11 +684,14 @@ class _Review: if allot.tightened: self.run_seconds = allot.seconds # 收紧过的:核查 agent 到点自己发的 verify_limit 改说运行时长上限将至(deadline.relabel),不写收紧后的秒数 + # 这句结论原文里写着的值已经摆在读者眼前,不当遮罩值。上万个值过一遍要几十毫秒,放进线程 + known = await asyncio.to_thread(_spared, seed, cand.text) outcome = await run_verify_agent( spec=self.spec, system=CHALLENGE_SYSTEM, task=task_text, sources=live, output_schema=CHALLENGE_SCHEMA, max_queries=self.max_queries, timeout_s=allot.timeout_s, daily_max_usd=self.daily_max_usd, emit=deadline.relabel(ctx.emit, allot), run_id=ctx.run.run_id, - node_id=ctx.node.id, data_versions=run_versions(ctx.run)) + node_id=ctx.node.id, data_versions=run_versions(ctx.run), masked_values=known) + outcome = await asyncio.to_thread(_masked_rationale, outcome, known, given=[cand.text]) if outcome.error and not outcome.model: broken = outcome.error # 连模型都没建成(没配好、接入出错):换一句也一样 model = model or outcome.model @@ -709,6 +767,14 @@ class _Review: return {"artifact": artifact, "counts": counts, "usage": usage, "unreviewed": missed, "claims": [{k: c[k] for k in ("unit", "text", "verdict")} for c in claims]} + async def _seed(self) -> Any: + """这份报告目录里的查询快照里遮罩列的值(engine/masking 的 _Secrets)。收法同审批质询收报告快照 + (inquiry.snapshot_secrets:按 SQL 认出改了名字的遮罩列,查询当时记下的遮罩加上数据源现在设的)。""" + from app.engine.inquiry import snapshot_secrets # inquiry 导入了这个模块,用到时再导入 + + return await snapshot_secrets(self.fetch(entry.get("artifact")) for entry in self.catalog.values() + if isinstance(entry, dict) and entry.get("kind") == "query") + async def _store(self, record: dict[str, Any]) -> str | None: try: return await artifact_store.put_json(record, kind="challenge", run_id=self.ctx.run.run_id, diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 04607ec8..7cc10339 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -13,7 +13,9 @@ 连取值也不给;遮罩列不给。计算式只有一项来自查询、或者写了口径说明时不给列名;几项都来自查询又没写口径说明的,列名 里看不出日期、口径的取舍时才给,好让它分得清哪一项是哪一项。 -**遮罩列上的输入不复算。** cell() 取自遮罩列的输入不能单独复算;别的输入(计算式里写成当时代进去的值)里按位置取自 +**遮罩列上的输入不复算。** cell() 取自遮罩列的输入不能单独复算,改了名字、套了函数或汇总的遮罩列(SUM(amount) AS +gmv,amount 设了遮罩)按 SQL 认出来也一样(engine/masking.derived_columns):盲算的查询在工具边界按同一套认法遮掉, +派出去也取不到数,不白花一次盲算。别的输入(计算式里写成当时代进去的值)里按位置取自 查询结果的一格(vars.res.rows[0][2]),顺着取值链认出它取自哪份快照的哪一列(recompute.input_origin,证据面板、推演、 可验证报告同一个认法),按需算那份快照要遮的列(和证据面板同一套,见 _Sources.hidden)——它不一定是 cell() 用到的 那几份。取自遮罩列、遮没遮核对不了的,整个指标按无法复算记:遮罩值不能当字面量写进交给盲算的计算式。伸进查询结果的行、 @@ -75,6 +77,7 @@ from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cel parse_expression from app.engine.replay import _in_graph, ask, once from app.engine.labels import DUAL_CHECK_FIELD_LABEL, DUAL_CHECK_ON_REJECT_LABEL +from app.engine.masking import derived_columns from app.engine.schema import DUAL_CHECK_ON_REJECT, NodeType, dual_check_card from app.engine.state import GraphState @@ -613,9 +616,15 @@ async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[st value = cell_value(item.get("value")) if not _number(value): return {}, f"输入 {item['path']} 不是数值" - if column.lower() in await sources.hidden(artifact): + hidden = await sources.hidden(artifact) + if column.lower() in hidden: return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算" found = await sources.source(source) + derived = derived_columns(snap, set(hidden), found.kind if found is not None else None) if hidden else None + if derived and column.lower() in derived: + # 改了名字、套了函数或汇总的遮罩列(SUM(amount) AS gmv):盲算的查询在工具边界按同一套认法遮掉(engine/masking), + # 交回的格子注定取不到,不白派一次盲算。SQL 解析不了的认不准,照常派 + return {}, f"输入 {item['path']} 由设置了遮罩的列算出(经改名、函数或汇总),盲算时会被遮罩,不能用于复算" if found is None or not found.enabled: return {}, f"数据源「{source}」不存在或已停用" return {"path": item["path"], "key": f"{artifact}|{row}|{column}", "value": value, diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index 55397916..9902ab0c 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -7,11 +7,12 @@ (tools/datasource.py)。这一路没有审批关卡,不能指望人在半路拦下一条 DELETE。 **先遮再交。** 建查询工具时还打开遮罩开关(tools/datasource 的 mask_results):数据源设了遮罩时,取自遮罩列的格子 -(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT * 都算)在交给模型、落进查询快照之前就换成「已遮罩」, -同一份结果里别的文字格夹着的遮罩值也换掉(engine/masking.py 的 mask_query_result)。模型看不到遮罩值,就不会把它 -写进回答、理由、SQL;以前是事后按值认,每一轮复核都找到新的漏法。快照里的 masked_at_query 记下查询时就遮了的列, -格子引用指到这几列的不采信。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询按值遮的第二道防线 -用),不进快照、不交给模型、不进 as_dict。 +(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT *、表值函数拆开的都算)在交给模型、落进查询快照之前就 +换成「已遮罩」,别的格子按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的(masked_values:审批质询、 +反方审稿收的报告快照里的值),文字格里夹着的换那一截(engine/masking.py 的 mask_query_result);查询失败时报错原话 +里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后按值认,每一轮复核都找到新的漏法。快照里的 +masked_at_query 记下查询时就遮了的列,格子引用指到这几列、指到按值换掉的格子的都不采信。遮掉的值只在内存里交回 +调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线用),不进快照、不交给模型、不进 as_dict。 **数从快照取。** 每次成功的查询是一份查询快照(工具自己落的 put_json),按这个 agent 自己的顺序编 Q1、Q2…。 最后交结果时,调用方结构里的数值字段一律改成格子引用 {alias, row, column},由代码按引用从快照里取值:模型自己 @@ -66,7 +67,7 @@ from app.engine import spend from app.engine.evidence import build_catalog, cell_eid, compose_doc, query_entry_fields, schema_ledger_entry from app.engine.expressions import CellError, column_kind, locate_cell, numeric_text from app.engine.judge import FALLBACK_MAX_TOKENS, MAX_TOKENS, _json_in, _tokens, _usd -from app.engine.masking import _Secrets +from app.engine.masking import _masked_word, _Secrets from app.engine.nodes.llm import _DIGEST_BUDGET, _digest, _split_structured, _typed, explain_model_error from app.engine.state import message_text from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded @@ -159,8 +160,8 @@ class VerifyOutcome: output_tokens, total_tokens, cost_usd, calls, unpriced_calls}。limit:触顶原因(LIMITS 之一),没触顶为 None。 answered:模型是不是自己停下来的(没被上限掐断,也没因为输出到了上限被截断)。被截断的一律没有结果,error 是 TRUNCATED_ERROR。 - masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets)。只在内存里交给调用方按值遮(审批质询的回答), - 不进 as_dict、不往外交。 + masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets),连同调用方交进来的。只在内存里交给调用方按值遮 + (审批质询的回答、反方审稿的理由),不进 as_dict、不往外交。 """ result: dict[str, Any] | None @@ -428,6 +429,12 @@ class _Cells: "row": row, "column": name, "artifact": query["artifact"], "reason": f"{alias} 的「{name}」取自设置了遮罩的列,查询时已遮罩,不采信"}) return None + if raw == _masked_word() and isinstance(content, dict) and "masked_at_query" in content: + # 这一格查询时按值换掉了(和别的查询、报告里的遮罩值相同):同样照实说 + self.cells.append({"path": path, "status": "unresolved", "ref": f"{alias}.r{row}.{name}", "alias": alias, + "row": row, "column": name, "artifact": query["artifact"], + "reason": f"{alias} 第 {row} 行「{name}」与设置了遮罩的值相同,查询时已遮罩,不采信"}) + return None truth = _typed(raw, {"type": sorted(types)}, column_kind(content, name)) cited = {"ref": f"{alias}.r{row}.{name}", "alias": alias, "row": row, "column": name, "artifact": query["artifact"], "eid": cell_eid(query["artifact"], row, name), "value": truth} @@ -663,7 +670,8 @@ class _State: queries: list[dict[str, Any]] = field(default_factory=list) ledger: list[dict[str, Any]] = field(default_factory=list) model: str | None = None - #: 查询工具在工具边界遮掉的值(经 ToolContext.extra 并进来,只在内存里) + #: 查询工具在工具边界遮掉的值(经 ToolContext.extra 并进来,只在内存里),起头是调用方交进来的。后面的查询按它们 + #: 按值遮 seen: _Secrets = field(default_factory=_Secrets) def outcome(self, **fields: Any) -> VerifyOutcome: @@ -695,6 +703,7 @@ async def run_verify_agent( run_id: str = "", node_id: str = "", data_versions: dict[str, Any] | Versions | None = Versions.UNSET, + masked_values: Any = None, ) -> VerifyOutcome: """让一个只读 agent 查库,按 output_schema 交回有出处的结果。永远不抛。 @@ -709,9 +718,15 @@ async def run_verify_agent( tool.end(role: verify,查询带 query_artifact);不在节点里(质询接口)就不传,什么都不发。 - run_id / node_id:查询快照、表结构快照记在谁名下。data_versions:上传表格用哪一版(运行固定的那份 data_versions;不在运行里传 Versions.CURRENT;缺省 UNSET 时上传源一律报错,不拿当前版本顶上)。 + - masked_values:调用方已经认出的遮罩值(engine/masking 的 _Secrets,审批质询、反方审稿收的报告快照里的值)。 + 查询工具从第一条查询起就按它们按值遮:agent 查到的备注、别的表里写着同一个号码时,模型看不到。随查询时遮掉的值 + 一起交回(VerifyOutcome.masked_values)。 """ state = _State(started=time.monotonic()) try: + if masked_values is not None: + # 上万个值并一遍要几十毫秒,放进线程,别挡住同一进程里别的运行 + await asyncio.to_thread(state.seen.merge, masked_values) return await _verify(state, spec=spec, system=system, task=task, sources=sources, output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s, max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit, diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 6374c322..7f2a7e39 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -468,6 +468,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, 缓存键相同,会把这个源平时用的可写连接也换成只读的。 mask_results:数据源设了遮罩时,取自遮罩列的格子(按 SQL 认,改了名字的也算)在交给模型、落进快照之前就换成 「已遮罩」,见 engine/masking.py 的 mask_query_result;这一次遮掉的值并进 ctx.extra[MASKED_VALUES_KEY](有的话)。 + 那里已经有的值(这个 agent 前面的查询遮掉的、调用方交进来的)这一次也按值换,数据源没设遮罩也一样:先查号码、 + 再查写着号码的备注或别的库,后一份同样遮好。查询失败时报错原话里的值也换(masking.mask_error_text)。 """ # 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句, # 引擎按它告诉界面上限是多少(metadata["timeout_s"]) @@ -495,6 +497,10 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, if is_write(statement, dialect=(source.kind or "").lower()): return f"SQL 被拒绝:{FORCED_READONLY}" limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {})) + mask = masked_columns(source.options) + # 此前认出的遮罩值:交给线程的是一份拷贝,线程里读的时候这边不会再往里并 + sink = ctx.extra.get(MASKED_VALUES_KEY) if mask_results else None + known = sink.copy() if sink is not None and sink.strong else None try: result = await run_query(source, sql, limits=limits) except SqlRejected as e: @@ -511,21 +517,29 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, # 驱动的原话留着("no such table: x" 正是模型改写 SQL 要的线索), # 类名和 SQLAlchemy 的包装前缀去掉:这一行也会原样出现在运行面板上 - return f"查询失败:{first_line(e) or explain(e)[0]}" + said = first_line(e) or explain(e)[0] + if mask_results and (mask or known is not None): + from app.engine.masking import mask_error_text + + # 数据库会把转换失败的值写进报错(invalid input syntax for type bigint: "139-0000-0001") + said = mask_error_text(said, sql, known) + return f"查询失败:{said}" payload = result.to_payload() payload["source"] = source.name - mask = masked_columns(source.options) - # 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩时为 None(快照和原来一字不差) + # 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩又没有可按值换的时为 None(快照和原来一字不差) masked_now: list[str] | None = None - if mask_results and mask: + if mask_results and (mask or known is not None): from app.engine.masking import mask_query_result # 按 SQL 认列、逐格比对是纯计算,放进线程(同 _sql_checks) - done = await asyncio.to_thread(mask_query_result, payload, mask, source.kind) - payload, masked_now = done.payload, done.columns - if (sink := ctx.extra.get(MASKED_VALUES_KEY)) is not None: - sink.merge(done.secrets) + done = await asyncio.to_thread(mask_query_result, payload, mask, source.kind, known, + schema=source.schema_cache) + if mask or done.payload is not payload: + payload, masked_now = done.payload, done.columns + if sink is not None: + # 上万个值并一遍要几十毫秒(免遮的文字要重新过一遍),也放进线程 + await asyncio.to_thread(sink.merge, done.secrets) # 对照数据目录检查这条 SQL(一对多关联后重复计算、存量跨期求和……)。结果交给模型(Agent 据此改写), # 也随查询快照存下:证据面板看得到,口径卡据此给指标标「存疑」、出具降档。没查出问题不加这个键, # 快照和以前一字不差 @@ -541,15 +555,15 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, # 只进快照、不进交给模型的结果的几样(模型看到多余的字段只会误会:以为数据被遮了, # 或者把一串十六进制抄进报告): - # - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮。查询时就遮了的结果列(改了名字的 - # 也算)一并记进去,证据面板、质询工件按这个键把它们画成遮罩列; - # - masked_at_query:开了遮罩开关时,查询时就整列遮掉的结果列(这几列的格子是「已遮罩」,不是数据源里的值); + # - 查询当时数据源设的遮罩列:数据源事后改名、删掉,证据面板仍按当时的遮。只记数据源设的列名——证据面板把 + # 同源各份快照记的这一项并起来,从表的字段清单里去掉,混进结果列的别名会把同名的真实字段也去掉; + # - masked_at_query:开了遮罩开关时,查询时就整列遮掉的结果列(这几列的格子是「已遮罩」,不是数据源里的值, + # 改了名字的也在这里)。有这一项的快照,别的格子整格是「已遮罩」的是查询时按值换掉的; # - data_version:上传表格查的是哪个快照,事后据此认得出这个数出自哪一版数据。 # 手工源、没设遮罩的,快照和原来一字不差 extra: dict[str, Any] = {} if mask: - own = {m.lower() for m in mask} - extra["mask_columns"] = [*mask, *(c for c in masked_now or [] if c.lower() not in own)] + extra["mask_columns"] = mask if masked_now is not None: extra["masked_at_query"] = masked_now if version := getattr(source, "snapshot_id", None): diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 597d15f7..441b205a 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -1128,6 +1128,88 @@ async def test_the_challenge_agent_never_sees_masked_values(monkeypatch, tmp_pat await engines.invalidate(source_id) +async def test_masked_values_written_into_the_rationale_are_masked_in_any_shape(monkeypatch, tmp_path): + """复核第 1 轮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录按列名遮不住,反方 agent 看得到号码, + 把它换个写法写进理由(139-0000-0001、+86 139 0000 0002)。审稿记录里的理由正文、理由文档都换成「已遮罩」:值从这句 + 挂的报告快照里按 SQL 认出来收(和审批质询收报告快照的办法一样),连同 agent 查询时在工具边界遮掉的值;同时报告 + 快照里的值在反查之前就交给查询工具,agent 查到写着同一个号码的别的表时也看不到。以前理由原样存下、随运行封存。""" + name, source_id = await _source(tmp_path, options={"mask_columns": ["buyer_phone"]}) + try: + sql = "SELECT region AS 区域, buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id" + said = "来自 139-0000-0001 号码的那笔属实,另有 +86 139 0000 0002 的一笔。" + plays = {"华东有一笔": Play(["SELECT COUNT(*) AS 笔数 FROM sales WHERE region = '华东'"], "stands", said)} + Models(monkeypatch, name, writer=("华东有一笔 [[v:Q1.r0.金额]] 的订单。[[see:Q1]]",), plays=plays) + row = await finish(graph(name, sql=sql), run_class="exploratory") + assert row.status == "succeeded", row.error + record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"]) + [claim] = [c for c in record["claims"] if "华东有一笔" in c["text"]] + assert claim["verdict"] == "stands" + assert claim["rationale"] == "来自 已遮罩 号码的那笔属实,另有 已遮罩 的一笔。" + assert claim["rationale_doc"]["markdown"] == claim["rationale"] + assert claim["rationale_doc"]["source"] == claim["rationale"], "存下的原文也换掉" + shown = json.dumps(record, ensure_ascii=False) + assert "0000-0001" not in shown and "0000 0002" not in shown + finally: + await engines.invalidate(source_id) + + +async def test_the_challenge_agent_does_not_see_report_values_in_other_tables(monkeypatch, tmp_path): + """报告取数选了手机号(遮罩列);反方 agent 去查回访记录,备注里写着同一个号码(备注没设遮罩)。报告快照里的遮罩值 + 在反查之前就交给了查询工具:交给模型的备注里那一截已经是「已遮罩」,它的查询快照里也是。以前原样交给模型。""" + path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" + db = sqlite3.connect(path) + db.executescript( + "CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, refunded INTEGER);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0), (2, '华南', '13900000003', 900, 0);" + "CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEXT, note TEXT);" + "INSERT INTO callbacks VALUES (1, '华东', '客户 139-0000-0001 来电改址'), (2, '华南', '例行回访');") + db.commit() + db.close() + name = f"shop_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, + options={"mask_columns": ["buyer_phone"]}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + source_id = row.id + try: + sql = "SELECT region AS 区域, buyer_phone, amount AS 金额 FROM sales ORDER BY id" + notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id" + plays = {"华东有一笔": Play([notes], "stands", "回访记录里有 [[v:Q1.r0.备注]]。[[see:Q1]]")} + models = Models(monkeypatch, name, writer=("华东有一笔 [[v:Q1.r0.金额]] 的订单。[[see:Q1]]",), plays=plays) + row = await finish(graph(name, sql=sql), run_class="exploratory") + assert row.status == "succeeded", row.error + seen = [str(m.content) for _, key, messages in models.calls if key == "华东有一笔" + for m in messages if isinstance(m, ToolMessage)] + assert any("客户 已遮罩 来电改址" in s for s in seen) and all("0000-0001" not in s for s in seen) + record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"]) + [claim] = [c for c in record["claims"] if "华东有一笔" in c["text"]] + assert claim["rationale"] == "回访记录里有 客户 已遮罩 来电改址。" + snapshot = artifact_store.load(claim["queries"][0]["artifact"]) + assert snapshot["rows"] == [["华东", "客户 已遮罩 来电改址"], ["华南", "例行回访"]] + finally: + await engines.invalidate(source_id) + + +def test_values_written_in_the_claim_itself_are_kept_in_the_rationale(): + """被审的那句结论原文里写着的号码已经摆在读者眼前:理由里复述它不换,别的号码照换。""" + from app.engine.evidence import compose_doc + from app.engine.masking import _Secrets + from app.engine.verify_agent import VerifyOutcome + + secrets = _Secrets() + secrets.add({"columns": ["p"], "rows": [["13900000001"], ["13900000003"]]}, {"p"}) + doc = compose_doc("号码 13900000003 的客户在华南,139-0000-0001 不在。", {}, node_id="chal", run_id="r1") + outcome = VerifyOutcome(result={"verdict": "stands", "rationale": doc["markdown"]}, cells=[], queries=[], + rationale=doc, usage={}) + masked = challenge_mod._masked_rationale(outcome, secrets, given=["号码 13900000003 的客户在华南。"]) + assert masked.result["rationale"] == "号码 13900000003 的客户在华南,已遮罩 不在。" + assert masked.rationale["markdown"] == masked.result["rationale"] and masked.rationale["node_id"] == "chal" + assert challenge_mod._masked_rationale(outcome, _Secrets(), given=[]) is outcome, "没有遮罩值时原样" + + async def test_a_metric_excerpt_marks_the_report_query_numbers_too(monkeypatch, shop): """句子挂的是口径卡指标(正式报告的主路径「取数 → 口径卡 → 报告」):摘录正文里「取自查询 Q1」「查询 Q1 的 SQL」 也标成原报告的编号,和反查 agent 自己的 Q1 分开;工具按指标的输入取自的查询找源。""" diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index b20aea80..85cb8b75 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -1055,6 +1055,38 @@ async def test_a_renamed_masked_column_in_the_blind_query_is_masked_before_the_b assert {row[0] for row in shown["rows"]} == {"已遮罩"}, "交给盲算模型的结果里也是「已遮罩」" +async def test_a_metric_summed_over_a_masked_column_is_not_blind_computed(monkeypatch, tmp_path): + """复核第 1 轮:金额这一列设了遮罩,指标是 SUM(amount)。核查 agent 的查询结果在工具边界遮好,汇总遮罩列的值也遮 + (只有一行的汇总就是那一行的原值),盲算交回的格子注定取不到。所以派出盲算之前按 SQL 认出这一列由遮罩列算出, + 直接按无法复算记、说清原因,不白花一次盲算的钱。以前只按列名认,gmv 认不出,盲算照样派出去,最后才在格子引用上 + 被拒,原因说成「盲算交回的格子引用不可用」。""" + name, source_id = await add_source(build(tmp_path / "amount_masked.db"), options={"mask_columns": "amount"}) + try: + blind = Blind(monkeypatch, name, {"销售额": (BLIND_TOTAL, "blind_total")}) + run = await start(graph(name, RIGHT)) + evs = await events(run.id) + out = dual_output(run, evs) + [row] = out["metrics"] + assert row["status"] == "unchecked" and blind.runs == 0, "没派盲算" + assert row["reason"] == ("输入 cell(nodes.fetch, 0, 'gmv') 由设置了遮罩的列算出(经改名、函数或汇总)," + "盲算时会被遮罩,不能用于复算") + assert not [e for e in evs if e.type == "tool.end" and e.node_id == "dual"] + finally: + await drop_source(source_id) + + +async def test_a_metric_filtered_by_a_masked_column_inside_its_sum_is_still_blind_computed(monkeypatch, masked_shop): + """遮罩列(区域)只在汇总的 CASE 条件里(SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END)):同写在 WHERE 里, + 值全部来自金额,原 SQL 和盲算的查询都不遮,照常比对。""" + name, _ = masked_shop + east = (f"SELECT SUM(CASE WHEN region = '华东' THEN amount ELSE 0 END) AS gmv FROM orders " + f"WHERE status <> 'refunded' AND paid_date {WEEK}") + blind = Blind(monkeypatch, name, {"销售额": (east, "gmv")}) + run = await start(graph(name, east)) + out = dual_output(run, await events(run.id)) + assert blind.runs == 1 and out["metrics"][0]["status"] == "agreed", out["metrics"][0] + + async def test_masked_group_columns_are_not_shown_to_the_blind_agent(monkeypatch, masked_shop): name, _ = masked_shop first = {**GMV, "id": "first", "name": "首组销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"} diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index e638ec8c..275d612a 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -1474,14 +1474,22 @@ CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, region TEX LEAKED = "回答中含有设了遮罩的值,无法遮住,已不予展示,请换个问法重新提问" -async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monkeypatch, tmp_path, client): - """备注里写着手机号(备注没设遮罩,手机号设了遮罩、被报告取数选中):agent 引用这一格,回答照旧整份不交; - 质询工件里这份快照照样记着、点得开,那一格里的号码换成「已遮罩」,别的字、别的格子照原样,不然「不予展示」 - 旁边一点就能看到原值。""" +async def test_a_masked_value_inside_a_text_cell_is_cut_out_before_the_agent_sees_it(monkeypatch, tmp_path, client): + """备注里写着手机号(备注没设遮罩,手机号设了遮罩、被报告取数选中):报告快照里的遮罩值在开始回答之前就交给核查 + agent 的查询工具,agent 查备注时那一截就换成「已遮罩」,模型看不到;它引用这一格,回答照常给出,渲染出来也是遮好的。 + 质询工件、通用工件接口里这份快照同样遮好,别的字、别的格子照原样。以前模型看得到原值,回答整份不交。""" name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS) notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id" plays = {"回访": Play([notes], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")} Models(monkeypatch, name, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + seen: list[str] = [] + answering = MockChatModel._decide + + def spy(model, messages): + seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage)) + return answering(model, messages) + + monkeypatch.setattr(MockChatModel, "_decide", spy) try: report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC" paused = await pause(name, graph=fetching(name, report_sql)) @@ -1491,20 +1499,114 @@ async def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact(monk assert r.status_code == 200, r.text assert "1390000000" not in r.text item = r.json()["inquiry"] - assert (item["answer"]["ok"], item["answer"]["text"], item["answer"]["doc_artifact"]) == (False, "", None) - assert item["answer"]["error"] == LEAKED + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "华东的回访备注是 客户已遮罩来电改址。") [query] = item["queries"] assert query["sql"] == notes [event] = await events(paused.id, "approval.inquiry") assert event.data["inquiry_artifacts"] == [query["artifact"]] - got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") - assert got.status_code == 200, got.text - assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]] - assert got.json()["masked"] == [] and "1390000000" not in got.text + assert any("客户已遮罩来电改址" in s for s in seen) and all("1390000000" not in s for s in seen), \ + "交给模型的查询结果里就是遮好的" + for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}", + f"/api/artifacts/{query['artifact']}"): + got = await client.get(url) + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]] + assert "1390000000" not in got.text + assert got.json().get("masked", []) == [], "备注只换了一截,不画成遮罩列" finally: await engines.invalidate(source_id) +#: 质询 agent 的两条查询:一条选出手机号(查询时遮掉),一条查回访备注(备注没设遮罩,里面写着手机号) +PHONES_EAST = "SELECT buyer_phone AS 号码, amount AS 金额 FROM sales WHERE region = '华东' ORDER BY amount DESC" +NOTES = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id" + + +async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_came_first(monkeypatch, tmp_path, client): + """复核第 1 轮(对基线回退):报告取数没选手机号,号码只出现在 agent 自己的另一次查询里。先查号码、后查备注:号码 + 查询时就遮掉、值并进内存,后面那次查询的备注在交给模型、落库之前就按值换掉,回答照常给出;质询工件、通用工件接口 + 给的都是遮好的。以前事后取质询工件时收不到这个号码,备注原样给出。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS) + plays = {"回访": Play([PHONES_EAST, NOTES], "华东的回访备注是 [[v:Q2.r0.备注]]。[[see:Q2]]")} + Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, QUERY)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="华东的回访说了什么?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "华东的回访备注是 客户已遮罩来电改址。") + phones, notes = item["queries"] + for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}", + f"/api/artifacts/{notes['artifact']}"): + got = await client.get(url) + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]] + assert "1390000000" not in got.text + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{phones['artifact']}") + assert got.json()["content"]["rows"] == [["已遮罩", 1200], ["已遮罩", 300]] and got.json()["masked"] == ["号码"] + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + finally: + await engines.invalidate(source_id) + + +async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_came_last(monkeypatch, tmp_path, client): + """先查备注、后查号码:查备注时还不知道里面写着遮罩值,原样交给模型、原样存下;回答引用这一格,渲染出来才露,整份 + 不交。回答时按内存里的值换掉的位置记进事件(不含值),事后取质询工件照着再换一遍:质询工件里那一截是「已遮罩」, + 和回答时一样。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS) + plays = {"回访": Play([NOTES, PHONES_EAST], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, QUERY)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="华东的回访说了什么?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert "1390000000" not in r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["error"]) == (False, LEAKED) + notes, phones = item["queries"] + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]] + assert "1390000000" not in got.text + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{phones['artifact']}") + assert got.json()["content"]["rows"] == [["已遮罩", 1200], ["已遮罩", 300]] + finally: + await engines.invalidate(source_id) + + +async def test_a_number_written_in_another_table_is_masked_whichever_query_came_first(monkeypatch, tmp_path, client): + """另一张表(联系方式)的号码列没设遮罩,存的是同一批号码:先查手机号的,查询时就按值换;后查的,事后取质询工件 + 照回答时记下的位置换。两种先后都整列画成遮罩列。""" + phones = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY id" + visits = "SELECT phone_num AS 号码, visits AS 次数 FROM contacts ORDER BY id" + for order in ([phones, visits], [visits, phones]): + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CONTACTS) + at = order.index(visits) + 1 + plays = {"常来": Play(order, f"最常来的客户来过 [[v:Q{at}.r0.次数]] 次。[[see:Q{at}]]")} + Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, QUERY)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="常来的客户来过几次?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert r.json()["inquiry"]["answer"]["text"] == "最常来的客户来过 5 次。" + query = r.json()["inquiry"]["queries"][at - 1] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["已遮罩", 5], ["已遮罩", 2]] + assert got.json()["masked"] == ["号码"] and "1390000000" not in got.text + finally: + await engines.invalidate(source_id) + + async def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql(monkeypatch, masked_shop, client): """agent 的查询带着 SQL 检查结果(对照数据目录查出的问题,片段摘自这条 SQL):质询工件里片段照查询清单的 SQL 换掉遮罩值,给模型的那句改法照抄了片段,不交出去(同证据面板)。""" @@ -1727,8 +1829,10 @@ REWRITTEN_CALLBACKS = SALES + ("CREATE TABLE callbacks (id INTEGER PRIMARY KEY, "(2, '华南', '例行回访');") -async def test_a_reformatted_number_inside_a_text_cell_stops_the_answer(monkeypatch, tmp_path, client): - """备注里夹着换了写法的号码,agent 引用这一格:渲染出来才露,最后一道关认得出,回答整份不交;质询工件里只换掉那一截。""" +async def test_a_reformatted_number_inside_a_text_cell_is_cut_out_before_the_agent_sees_it(monkeypatch, tmp_path, + client): + """备注里夹着换了写法的号码(报告取数选了手机号):开始回答之前就认得出,agent 查备注时那一截已经换掉,引用这一格 + 回答照常给出;质询工件里同样只换掉那一截。""" name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=REWRITTEN_CALLBACKS) notes = "SELECT region AS 区域, note AS 备注 FROM callbacks ORDER BY id" plays = {"回访": Play([notes], "华东的回访备注是 [[v:Q1.r0.备注]]。[[see:Q1]]")} @@ -1740,12 +1844,12 @@ async def test_a_reformatted_number_inside_a_text_cell_stops_the_answer(monkeypa approval_id=approval.id) assert r.status_code == 200, r.text item = r.json()["inquiry"] - assert (item["answer"]["ok"], item["answer"]["error"]) == (False, LEAKED) + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "华东的回访备注是 客户 已遮罩 来电改址。") [query] = item["queries"] got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") assert got.status_code == 200, got.text assert got.json()["content"]["rows"] == [["华东", "客户 已遮罩 来电改址"], ["华南", "例行回访"]] - assert "0000-0001" not in got.text + assert "0000-0001" not in got.text and "0000-0001" not in r.text finally: await engines.invalidate(source_id) diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 323c0548..a6dc4a42 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -402,6 +402,66 @@ def test_a_label_ahead_of_a_star_or_alone_behind_one_is_still_a_label_column(sql assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {"档": {"0", "1"}} +#: 没写表名的 * 连着数据源里的表和装着标签的派生来源 +STAR_JOIN = ("SELECT * FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 " + "FROM sales) b ON s.id = b.bid") +STAR_COLUMNS = ["id", "region", "amount", "bid", "档"] + + +def test_a_star_over_a_table_known_to_lack_the_column_keeps_the_label(): + """复核第 1 轮(收紧之后的多遮):没写表名的 * 展开数据源里的表,以前一律算「说不清带不带这一列」,标签照收成 + 遮罩值,回答里的「大单」、SQL 里的 '大单' 都换成「已遮罩」。冻结的表结构里查得到这张表、又没有同名的列时,照旧 + 按派生来源认标签;表里有同名的列、查不到表结构的,照旧不算(宁可多遮)。""" + from app.engine import masking + + lacking = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"}]}}} + holding = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "档"}, {"name": "amount"}]}}} + assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(lacking)) == { + "档": {"大单", "小单"}} + assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(holding)) == {} + assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS) == {} + truncated = {"tables": {"orders": {"columns": [{"name": "id"}]}}, "truncated": True} + assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(truncated)) == {} + # 写了表名的 s.* 展开的是数据源里的表:同样按表结构认它带不带这一列 + nested = ("SELECT x.档 FROM (SELECT s.*, b.* FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 " + "THEN '大单' ELSE '小单' END AS 档 FROM sales) b ON s.id = b.bid) x") + assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(lacking)) == {"档": {"大单", "小单"}} + assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(holding)) == {} + assert masking._label_columns(nested, "sqlite", ["档"]) == {} + + +async def test_a_snapshot_reads_labels_with_its_frozen_schema(): + """快照记着查询当时冻结的表结构(schema_artifact):按它认,* 连着的表没有同名的列时标签不当遮罩值,回答正文、 + SQL 里的档名照原样;表结构取不到的照旧不算。""" + from app.core import artifact_store + from app.engine import masking + + lacking = {"source": "shop", "tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, + {"name": "amount"}]}}} + schema = await artifact_store.put_json(lacking, kind="schema_snapshot", run_id="r-labels", node_id="n1") + snapshot = {"source": "shop", "sql": STAR_JOIN, "columns": STAR_COLUMNS, "schema_artifact": schema, + "rows": [[1, "华东", 1200, 1, "大单"], [2, "华南", 300, 2, "小单"]]} + assert masking._snapshot_labels(snapshot, "sqlite") == {"档": {"大单", "小单"}} + plan = plan_of(snapshot, {"amount"}) + assert plan.secrets.scrub("这一笔属于大单") == "这一笔属于大单" + sql = "SELECT COUNT(*) FROM (SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 FROM sales) WHERE 档 = '大单'" + assert inquiry_mod._scrub_sql(sql, plan.secrets, plan.tainted["a1"], "sqlite") == sql + assert masking._snapshot_labels({**snapshot, "schema_artifact": "missing"}, "sqlite") == {} + + +def test_the_tool_boundary_reads_labels_with_the_sources_frozen_schema(): + """工具边界按数据源的表结构认标签:* 连着的表没有同名的列,标签不收成遮罩值,同一份结果里别的文字格里的「大单」照常。""" + from app.engine import masking + + payload = {"sql": STAR_JOIN, "columns": [*STAR_COLUMNS, "备注"], + "rows": [[1, "华东", 1200, 1, "大单", "大单客户"], [2, "华南", 300, 2, "小单", "例行回访"]]} + schema = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"}, + {"name": "备注"}]}}} + done = masking.mask_query_result(payload, ["amount"], "sqlite", schema=schema) + assert done.columns == ["amount", "档"] and not {"大单", "小单"} & done.secrets.strong + assert [row[-1] for row in done.payload["rows"]] == ["大单客户", "例行回访"] + + # -------------------------------------------------------------------------- # 按值认:门槛、边界 # -------------------------------------------------------------------------- @@ -1373,3 +1433,56 @@ def test_hundreds_of_band_edges_are_read_within_a_budget(): assert "已遮罩" in inquiry_mod._scrub_sql(bins(600), every, {"age"}, "sqlite") elapsed = time.perf_counter() - started assert elapsed < 5, f"用了 {elapsed:.2f} 秒" + + +# -------------------------------------------------------------------------- +# 回答时才认得出的值:记下位置,事后取质询工件时照着再换一遍 +# -------------------------------------------------------------------------- + + +def test_values_known_only_at_answer_time_are_recorded_as_positions_and_replayed(): + """agent 先查了备注(那时还不知道备注里的号码是遮罩值,查询时原样交回、原样存下),后来又查出了手机号(查询时 + 遮掉,值只在内存里)。回答时按内存里的值换掉的地方记成位置(不含值);事后取质询工件时内存里的值已经不在,照着 + 位置再换一遍,和回答时看到的一样。""" + from app.engine import masking + + note = "客户13900000001来电改址,备用 139-0000-0001" + snapshot = {"columns": ["区域", "备注", "号码", "13900000001 的单"], + "rows": [["华东", note, "13900000001", 1], ["华南", "例行回访", "13800000000", 2]]} + seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) + edits = masking.view_edits(snapshot, set(), seen, inquiry_mod._Secrets()) + first, second = note.index("13900000001"), note.index("139-0000-0001") + assert edits == {"cells": [[0, 2]], "spans": [[0, 1, first, first + 11], [0, 1, second, second + 13]], + "names": [[3, 0, 11]], "masked": ["号码"]} + assert "1390000000" not in json.dumps(edits, ensure_ascii=False), "只记位置,不记值" + replayed = masking.replay_edits(snapshot, [edits]) + assert replayed["columns"] == ["区域", "备注", "号码", f"{MASKED} 的单"] + assert replayed["rows"] == [["华东", f"客户{MASKED}来电改址,备用 {MASKED}", MASKED, 1], + ["华南", "例行回访", "13800000000", 2]] + # 不靠内存里的值也认得出的(报告快照里就有),事后照样认得出,不记 + assert masking.view_edits(snapshot, set(), seen, seen) is None + # 记下的位置对不上(越界、坏形状)的跳过,不出错 + broken = {"cells": [[9, 9], "x"], "spans": [[0, 1, 50, 60], [0, 9, 0, 1]], "names": [[7, 0, 1]], "masked": [3]} + assert masking.replay_edits(snapshot, [broken, None, "x"]) == snapshot + + +def test_the_plan_records_what_only_the_values_seen_by_the_agent_masked(): + """_settle_plan 带上 agent 查询时遮掉的值(seen)时,另记下只靠它们才换掉的位置(replay);不带 seen 的(事后取 + 质询工件)不记。""" + notes = {"source": "shop", "sql": "SELECT region AS 区域, note AS 备注 FROM callbacks", "columns": ["区域", "备注"], + "rows": [["华东", "客户13900000001来电改址"], ["华南", "例行回访"]]} + seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) + plan = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {}, + frozenset(), seen) + assert plan.replay == {"a1": {"cells": [], "spans": [[0, 1, 2, 13]], "names": [], "masked": []}} + later = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {}, + frozenset({"a1"})) + assert later.replay == {} and later.views["a1"]["rows"][0][1] == "客户13900000001来电改址" + + +def test_values_the_agent_saw_survive_a_report_that_fills_the_cap(): + """报告快照里的遮罩值收满了上限(SECRETS_MAX):agent 查询时遮掉的值先收,照样认得出(以前先收报告的,满了就丢)。""" + report = {"columns": ["buyer_phone"], "rows": [[str(13800000000 + i)] for i in range(inquiry_mod.SECRETS_MAX)]} + seen = secrets_of({"columns": ["phone"], "rows": [["19914742785"]]}, {"phone"}) + plan = inquiry_mod._settle_plan({}, {}, [(report, {"buyer_phone"})], {}, [], [], {}, frozenset(), seen) + assert plan.secrets.hit("19914742785") diff --git a/backend/tests/test_masking_reformatted_numbers.py b/backend/tests/test_masking_reformatted_numbers.py index cb3c0306..0279b891 100644 --- a/backend/tests/test_masking_reformatted_numbers.py +++ b/backend/tests/test_masking_reformatted_numbers.py @@ -137,3 +137,73 @@ def test_many_masked_numbers_are_matched_without_stalling(): out = secrets.scrub(text, prose=True) assert time.perf_counter() - began < 2.0 assert "199-0000" not in out and out.count(MASKED) == text.count("199-0000") + + +#: 复核第 1 轮:别的空白、减号作分隔符的写法(模型输出里不换行空格、窄空格并不少见) +OTHER_SEPARATORS = [ + pytest.param("199 1474 2785", id="不换行空格"), + pytest.param("199 1474 2785", id="窄不换行空格"), + pytest.param("199 1474 2785", id="细空格"), + pytest.param("199 1474 2785", id="发丝空格"), + pytest.param("199\t1474\t2785", id="制表符"), + pytest.param("199−1474−2785", id="减号"), + pytest.param("199 1474 2785", id="四个空格"), + pytest.param("199 - 1474 - 2785", id="连字符两边带空格"), + pytest.param("+86 199 1474 2785", id="+86 加不换行空格"), +] + + +@pytest.mark.parametrize("written", OTHER_SEPARATORS) +def test_numbers_split_by_other_spaces_and_minus_signs_are_masked_too(written): + secrets = secrets_of([PHONE]) + assert secrets.scrub(f"客户 {written} 来电", prose=True) == f"客户 {MASKED} 来电" + assert inquiry_mod._leaked({"markdown": f"来电的是 {written}。", "blocks": []}, secrets) + assert secrets.hit(written), "整格就是这个号码的,格子按值认" + + +def test_a_line_break_still_ends_a_number(): + """不跨行:两行各写一截的不拼成一个号码。""" + secrets = secrets_of([PHONE]) + assert secrets.scrub("编号 199\n1474 2785", prose=True) == "编号 199\n1474 2785" + + +@pytest.mark.parametrize("sql, sealed", [ + pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%199-1474-2785%'", + f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%{MASKED}%'", id="LIKE 里的一截"), + pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note = '客户 199 1474 2785 来电'", + f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note = '客户 {MASKED} 来电'", id="字符串里夹着"), + pytest.param('SELECT note AS "199-1474-2785 的备注" FROM callbacks', + f'SELECT note AS "{MASKED} 的备注" FROM callbacks', id="带引号的列名"), + pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks /* 客户 199-1474-2785 */", + f"SELECT COUNT(*) AS 笔数 FROM callbacks /* 客户 {MASKED} */", id="块注释"), + pytest.param("SELECT COUNT(*) AS 笔数 FROM callbacks -- 客户 199 1474 2785\n WHERE id = 1", + f"SELECT COUNT(*) AS 笔数 FROM callbacks -- 客户 {MASKED}\n WHERE id = 1", id="行注释"), +]) +def test_a_reformatted_number_in_sql_strings_names_and_comments_is_masked(sql, sealed): + """SQL 里字符串、带引号的名字、注释里换了写法的号码也认(以前只有整个字面量就是号码的才换)。""" + assert inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite") == sealed + + +def test_a_bare_number_expression_in_sql_is_still_left_alone(): + """不带引号的 199-1474-2785 在 SQL 里是一个算式(三个数相减),照旧不按号码认。""" + sql = "SELECT 199-1474-2785 AS x FROM callbacks" + assert inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite") == sql + + +def test_a_threshold_matching_a_number_swapped_in_a_string_is_not_kept(): + """字符串里按换了写法认出、换掉的号码,同一条 SQL 里写成阈值的同一个数也不留(不然阈值把它露了)。""" + sql = f"SELECT COUNT(*) AS 笔数 FROM callbacks WHERE note LIKE '%199-1474-2785%' AND phone_no >= {PHONE}" + sealed = inquiry_mod._scrub_sql(sql, secrets_of([PHONE]), set(), "sqlite") + assert PHONE not in sealed and "1474" not in sealed + + +def test_a_reformatted_number_in_a_column_name_is_masked_like_the_sql(): + """agent 把换了写法的号码写进列名(AS "139-0000-0001 的单"):质询工件的列名、回答里引用这一列的标记照 SQL 里带引号 + 的名字一样换,三处对得上。""" + secrets = secrets_of(["13900000001"]) + snapshot = {"columns": ["139-0000-0001 的单"], "rows": [[3]]} + view, _ = _masked_view(snapshot, set(), secrets) + assert view["columns"] == [f"{MASKED} 的单"] + assert inquiry_mod._scrub_sql('SELECT COUNT(*) AS "139-0000-0001 的单" FROM sales', secrets, set(), "sqlite") == ( + f'SELECT COUNT(*) AS "{MASKED} 的单" FROM sales') + assert inquiry_mod._scrubbed_source("共 [[v:Q1.r0.139-0000-0001 的单]] 单。", secrets) == f"共 [[v:Q1.r0.{MASKED} 的单]] 单。" diff --git a/backend/tests/test_masking_table_functions.py b/backend/tests/test_masking_table_functions.py new file mode 100644 index 00000000..272d3959 --- /dev/null +++ b/backend/tests/test_masking_table_functions.py @@ -0,0 +1,109 @@ +"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):FROM、JOIN、LATERAL 里的表值函数,汇总里的 CASE。 + +核查 agent 的查询在工具边界按这套认法整列遮掉(mask_query_result),审批质询、双盲复算也用它: + +- 表值函数(SQLite 的 json_each / json_tree,Postgres 的 unnest、jsonb_each_text、regexp_split_to_table、LATERAL + VALUES,MySQL 的 JSON_TABLE)把遮罩列拆开时,它出的列都算取自遮罩列:遮罩列是 JSON 时,用 json_each 拆开是最自然的 + 写法,以前只追 SELECT 项、子查询和 *,这些写法的原值原样交给模型、落进快照。出列名认得全的(常见的几个函数、写了 + 列名表的)按名字认,认不全的整份遮 +- 汇总里的 CASE / IIF 只在条件里用到遮罩列的(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END)),同 + 写在 WHERE 里,不算;结果的某一支取的是遮罩列、不汇总的逐行 CASE 照旧算 + +数据都是虚构的:手机号、区域、金额。 +""" +from __future__ import annotations + +import pytest + +from app.engine import masking + +#: 设了遮罩的列:手机号,和装着手机号、姓名的 JSON 列 +MASKED = {"buyer_phone", "profile"} +SALES = ["id", "region", "buyer_phone", "amount", "profile"] +#: SQLite 的 json_each / json_tree 出的列 +JSON_EACH = ["key", "value", "type", "atom", "id", "parent", "fullkey", "path"] + + +def hidden_of(sql: str, columns: list[str], kind: str = "sqlite", masked: set[str] = MASKED) -> set[str]: + snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []} + return masking._derived_hidden(snapshot, set(masked), kind)[0] + + +@pytest.mark.parametrize("kind, sql, columns, shown", [ + pytest.param("sqlite", "SELECT j.key, j.value FROM sales, json_each(sales.profile) AS j", ["key", "value"], + ["key", "value"], id="json_each 拆遮罩的 JSON 列"), + pytest.param("sqlite", "SELECT t.atom AS 值 FROM sales, json_tree(sales.profile) AS t WHERE t.type = 'text'", + ["值"], ["值"], id="json_tree"), + pytest.param("sqlite", "SELECT value FROM sales, json_each(json_array(buyer_phone))", ["value"], ["value"], + id="json_each 套 json_array"), + pytest.param("sqlite", "SELECT r.value AS 号码 FROM sales s JOIN json_each(s.profile) r ON 1 = 1", ["号码"], + ["号码"], id="JOIN 里的 json_each"), + pytest.param("sqlite", "SELECT value FROM json_each((SELECT json_group_array(buyer_phone) FROM sales))", + ["value"], ["value"], id="参数是子查询"), + pytest.param("sqlite", "SELECT c.value AS 号码 FROM (SELECT json_group_array(buyer_phone) AS arr FROM sales) t, " + "json_each(t.arr) c", ["号码"], ["号码"], id="参数取自派生来源里改了名字的遮罩列"), + pytest.param("sqlite", "SELECT * FROM sales, json_each(sales.profile)", [*SALES, *JSON_EACH], + ["buyer_phone", "profile", "key", "value", "atom", "fullkey", "path"], id="* 带出 json_each 的列"), + pytest.param("sqlite", "SELECT x FROM (SELECT j.value AS x FROM sales, json_each(sales.profile) j) t", ["x"], + ["x"], id="子查询里拆开再改名"), + pytest.param("postgres", "SELECT u FROM sales, unnest(ARRAY[buyer_phone]) AS u", ["u"], ["u"], id="unnest"), + pytest.param("postgres", "SELECT x.p FROM sales s CROSS JOIN LATERAL unnest(ARRAY[s.buyer_phone]) AS x(p)", + ["p"], ["p"], id="LATERAL unnest 带列名表"), + pytest.param("postgres", "SELECT x FROM sales, LATERAL unnest(ARRAY[buyer_phone]) AS x", ["x"], ["x"], + id="LATERAL unnest"), + pytest.param("postgres", "SELECT u FROM sales CROSS JOIN LATERAL (VALUES (buyer_phone)) AS v(u)", ["u"], ["u"], + id="LATERAL VALUES"), + pytest.param("postgres", "SELECT d FROM sales, regexp_split_to_table(buyer_phone, '-') AS d", ["d"], ["d"], + id="regexp_split_to_table"), + pytest.param("postgres", "SELECT e.key, e.value FROM sales, jsonb_each_text(profile) AS e", ["key", "value"], + ["key", "value"], id="jsonb_each_text"), + pytest.param("postgres", "SELECT x FROM sales, unnest(string_to_array(buyer_phone, ',')) AS x", ["x"], ["x"], + id="unnest 套 string_to_array"), + pytest.param("postgres", "SELECT * FROM sales, unnest(ARRAY[buyer_phone])", [*SALES, "unnest"], + ["buyer_phone", "profile", "unnest"], id="没起别名的 unnest 按函数名出列"), + pytest.param("mysql", "SELECT j.p FROM sales s, JSON_TABLE(JSON_ARRAY(s.buyer_phone), '$[*]' " + "COLUMNS (p VARCHAR(20) PATH '$')) j", ["p"], ["p"], id="JSON_TABLE"), +]) +def test_a_table_function_over_a_masked_column_hides_what_it_returns(kind, sql, columns, shown): + assert {c.lower() for c in shown} <= hidden_of(sql, columns, kind) + + +def test_columns_a_table_function_does_not_return_stay_visible(): + """拆的是遮罩列,别的列(区域、金额)照常;拆的不是遮罩列的,一列都不遮。""" + hidden = hidden_of("SELECT s.region, s.amount, j.value AS 值 FROM sales s, json_each(s.profile) j", + ["region", "amount", "值"]) + assert "值" in hidden and not {"region", "amount"} & hidden + assert hidden_of("SELECT s.region, j.value AS 标签 FROM sales s, json_each(s.tags) j", ["region", "标签"]) == MASKED + + +def test_a_table_function_that_cannot_be_read_hides_the_whole_snapshot(): + """不认得的函数拆遮罩列、又没写列名表:说不清出哪几列,整份快照的列都遮(同 SQL 解析不了)。""" + columns = ["region", "piece"] + assert hidden_of("SELECT s.region, t.piece FROM sales s, my_split(s.buyer_phone) t", columns, "postgres") == { + *MASKED, "region", "piece"} + + +@pytest.mark.parametrize("sql", [ + "SELECT SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) AS total FROM sales", + "SELECT SUM(CASE buyer_phone WHEN '13900000001' THEN amount END) AS total FROM sales", + "SELECT AVG(IIF(buyer_phone LIKE '139%', amount, NULL)) AS total FROM sales", + "SELECT MAX(CASE WHEN buyer_phone IS NULL THEN 0 ELSE amount END) AS total FROM sales", + "SELECT SUM(CASE WHEN buyer_phone IS NULL THEN 0 ELSE CASE WHEN region = '华东' THEN amount END END) AS total " + "FROM sales", +]) +def test_a_masked_column_only_in_the_condition_of_an_aggregated_case_is_like_a_filter(sql): + """值全部来自金额,手机号只决定这一行算不算进来:同写在 WHERE 里,不算取自遮罩列。""" + assert hidden_of(sql, ["total"]) == MASKED + assert hidden_of(sql.replace("CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END", "amount") + + " WHERE buyer_phone IS NOT NULL", ["total"]) == MASKED + + +@pytest.mark.parametrize("sql", [ + # 结果的某一支取的就是遮罩列 + "SELECT SUM(CASE WHEN region = '华东' THEN buyer_phone END) AS total FROM sales", + "SELECT MAX(IIF(amount > 1000, buyer_phone, NULL)) AS total FROM sales", + # 不汇总的逐行 CASE:每一行各自落在哪一支 + "SELECT CASE WHEN buyer_phone IS NULL THEN 0 ELSE amount END AS total FROM sales", +]) +def test_a_case_returning_the_masked_value_or_not_aggregated_is_still_derived(sql): + assert "total" in hidden_of(sql, ["total"]) diff --git a/backend/tests/test_verify_agent_masking.py b/backend/tests/test_verify_agent_masking.py index d2f93c66..50725c47 100644 --- a/backend/tests/test_verify_agent_masking.py +++ b/backend/tests/test_verify_agent_masking.py @@ -34,12 +34,18 @@ from tests.test_verify_agent import SPEC, Script, q MASKED = "已遮罩" #: 遮罩列里的值(和备注里换了写法的号码):交给模型的、存下的、接口给的都不能出现 -SECRETS = ("1390000000", "张伟", "李娜", "王芳", "刘洋", "139-0000-0003") +SECRETS = ("1390000000", "张伟", "李娜", "王芳", "刘洋", "139-0000-0003", "139 0000 0003") SALES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER," " note TEXT);" "INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '客户13900000001来电改址')," " (2, '华东', '13900000002', '李娜', 300, '例行回访'), (3, '华南', '13900000003', '王芳', 900, '回访:联系 139-0000-0003')," - " (4, '华北', '13900000004', '刘洋', 400, '');") + " (4, '华北', '13900000004', '刘洋', 400, '');" + # 联系方式表的号码列、客户资料的 JSON 列都没设遮罩(json_shop 另设了 profile):同一批号码、姓名写在这里 + "CREATE TABLE contacts (id INTEGER PRIMARY KEY, phone_num TEXT, visits INTEGER);" + "INSERT INTO contacts VALUES (1, '13900000001', 5), (2, '139 0000 0003', 2), (3, '13800000000', 1);" + "CREATE TABLE customers (id INTEGER PRIMARY KEY, region TEXT, profile TEXT);" + "INSERT INTO customers VALUES (1, '华东', '{\"phone\": \"13900000001\", \"name\": \"张伟\"}')," + " (2, '华南', '{\"phone\": \"13900000002\", \"name\": \"李娜\"}');") VALUE_SCHEMA = {"type": "object", "properties": {"value": {"type": "number", "description": "要核对的数"}}, "required": ["value"]} @@ -76,6 +82,14 @@ async def masked_shop(tmp_path): await engines.invalidate(source_id) +@pytest.fixture +async def json_shop(tmp_path): + """同一个库,客户资料(JSON:手机号、姓名)这一列设了遮罩。返回源名。""" + name, source_id = await _source(tmp_path, {"mask_columns": "profile"}) + yield name + await engines.invalidate(source_id) + + @pytest.fixture async def plain_shop(tmp_path): name, source_id = await _source(tmp_path, None) @@ -137,7 +151,9 @@ async def test_masked_columns_are_masked_before_the_model_sees_them(monkeypatch, assert snapshot.get("column_types", {}).get(column, "text") == "text" assert _cells(snapshot, amount) == [1200, 300, 900, 400] assert snapshot["masked_at_query"] == masked - assert {c.lower() for c in snapshot["mask_columns"]} == {"buyer_phone", "buyer_name", *(c.lower() for c in masked)} + # mask_columns 照记查询当时数据源设的遮罩列(证据面板按它从表的字段清单里去掉列),改了名字的结果列只记在 + # masked_at_query:混进去的话,同源别的表里同名的真实字段(name、region)会从字段清单里消失 + assert snapshot["mask_columns"] == ["buyer_phone", "buyer_name"] # 运行详情「工件」页签走的通用接口:给的就是遮好的 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -161,6 +177,8 @@ async def test_masked_columns_are_masked_before_the_model_sees_them(monkeypatch, ("SELECT SUM(amount) AS total FROM sales WHERE buyer_phone = '13900000001'", 1200), ("SELECT region, SUM(amount) AS total FROM sales GROUP BY region ORDER BY total DESC", 1500), ("SELECT region, note, amount AS total FROM sales ORDER BY id", 1200), + # 汇总里的 CASE 只在条件里用到手机号:同写在 WHERE 里,值全部来自金额 + ("SELECT SUM(CASE WHEN buyer_phone LIKE '139%' THEN amount ELSE 0 END) AS total FROM sales", 2800), ]) async def test_queries_that_do_not_read_masked_values_are_untouched(monkeypatch, masked_shop, sql, value): """没碰遮罩列的值(汇总、只数个数、只在条件里用到):结果原样,取值照常,快照记下查询时一列都没遮。只遮这份结果 @@ -246,3 +264,124 @@ async def test_column_names_the_model_wrote_are_kept(monkeypatch, masked_shop): snapshot = artifact_store.load(out.queries[0]["artifact"]) assert snapshot["columns"] == ["buyer_phone", "13900000001 的金额"] assert set(_cells(snapshot, "buyer_phone")) == {MASKED} and out.result["value"] == 1200 + + +# -------------------------------------------------------------------------- +# 别的查询里遮掉的值、调用方给的值:后面的查询按值遮(复核第 1 轮) +# -------------------------------------------------------------------------- + + +async def test_values_masked_in_an_earlier_query_are_masked_in_later_ones(monkeypatch, masked_shop): + """Q1 选出手机号(查询时整列遮掉,值并进内存);之后的查询里,没设遮罩的联系方式表、备注写着同一批号码(换了写法的 + 也算):整格是号码的整格换,夹在文字里的换那一截。交给模型的、落库的、通用接口给的都是遮好的。以前只按这一份结果 + 里的遮罩值换,Q2 原样交给模型、原样落库,质询工件事后也收不到 Q1 的值。""" + name = masked_shop + steps = [q(name, "SELECT buyer_phone, amount AS total FROM sales ORDER BY id", "c1"), + q(name, "SELECT phone_num, visits FROM contacts ORDER BY id", "c2"), + q(name, "SELECT id, note FROM sales ORDER BY id", "c3")] + script = Script(monkeypatch, steps, {"value": {"alias": "Q2", "row": 0, "column": "visits"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.error is None and out.result["value"] == 5 + seen = script.tool_results() + assert all(_clean(seen[c]) for c in ("c1", "c2", "c3")), seen + contacts = artifact_store.load(out.queries[1]["artifact"]) + assert contacts["rows"] == [[MASKED, 5], [MASKED, 2], ["13800000000", 1]] + assert contacts["masked_at_query"] == [], "按值换的不算整列遮:没对上的号码照常显示" + notes = artifact_store.load(out.queries[2]["artifact"]) + assert _cells(notes, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""] + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + for query in out.queries: + got = await client.get(f"/api/artifacts/{query['artifact']}") + assert got.status_code == 200 and _clean(got.text) + + +async def test_values_given_by_the_caller_are_masked_before_the_model_sees_them(monkeypatch, masked_shop): + """调用方交进来的遮罩值(审批质询、反方审稿收的报告快照里的值):第一条查询就按它遮,模型看不到。""" + name = masked_shop + given = masking._Secrets() + given.add({"columns": ["buyer_phone"], "rows": [["13900000001"], ["13900000003"]]}, {"buyer_phone"}) + script = Script(monkeypatch, [q(name, "SELECT id, note, amount AS total FROM sales ORDER BY id", "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"}) + out, _ = await ask(name, masked_values=given) + assert out.result["value"] == 1200 and _clean(script.tool_results()["c1"]) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert _cells(snapshot, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""] + assert not given.strong - out.masked_values.strong, "交进来的值随遮掉的值一起交回" + + +async def test_a_reference_to_a_cell_masked_by_value_says_so(monkeypatch, masked_shop): + """格子引用指到按值换成「已遮罩」的那一格:照实说,不说成「不是数值」。""" + name = masked_shop + steps = [q(name, "SELECT buyer_phone FROM sales ORDER BY id", "c1"), + q(name, "SELECT phone_num, visits FROM contacts ORDER BY id", "c2")] + Script(monkeypatch, steps, {"value": {"alias": "Q2", "row": 0, "column": "phone_num"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.result["value"] is None + cell = out.cells[0] + assert cell["status"] == "unresolved" + assert cell["reason"] == "Q2 第 0 行「phone_num」与设置了遮罩的值相同,查询时已遮罩,不采信" + + +async def test_a_table_function_over_a_masked_json_column_is_masked(monkeypatch, json_shop): + """遮罩的是 JSON 列(客户资料):用 json_each 拆开是最自然的写法,拆出来的 key、value 在交给模型、落库之前就遮掉。 + 以前原样交给模型并落库,masked_at_query 是空的。""" + name = json_shop + sql = "SELECT c.id AS 编号, j.key, j.value FROM customers c, json_each(c.profile) j ORDER BY c.id, j.key" + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "编号"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.result["value"] == 1 and _clean(script.tool_results()["c1"]) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == ["key", "value"] and _cells(snapshot, "编号") == [1, 1, 2, 2] + assert _clean(json.dumps(snapshot, ensure_ascii=False)) + + +async def test_a_failed_query_does_not_hand_masked_values_to_the_model(monkeypatch, masked_shop): + """数据库的报错会带上数据里的值(Postgres 类型转换失败:invalid input syntax for type bigint: "139-0000-0001"): + 数据源设了遮罩时,引号里、SQL 里没写着的那一段和不带引号的长数换成「已遮罩」;模型自己写的名字照留(它据此改写 + SQL)。遮罩开关没开的(Agent 节点)照旧原话。""" + from app.tools import datasource + + said = {"message": ""} + + async def failing(source, sql, **kwargs): + raise RuntimeError(said["message"]) + + monkeypatch.setattr(datasource, "run_query", failing) + sql = "SELECT CAST(buyer_phon AS BIGINT) AS p FROM sales WHERE id = 12345" + ctx = ToolContext(run_id="run-error", node_id="n1", data_versions=Versions.CURRENT) + async with SessionLocal() as session: + [masking_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session, mask_results=True) + [plain_tool] = await build_datasource_tools([f"db_query__{masked_shop}"], ctx, session) + for message, shown in [ + ('invalid input syntax for type bigint: "139-0000-0001"', 'invalid input syntax for type bigint: "已遮罩"'), + ("value '13900000001' is out of range for type integer", "value '已遮罩' is out of range for type integer"), + ("Conversion Error: Type INT64 with value 13900000001 can't be cast to INT32", + "Conversion Error: Type INT64 with value 已遮罩 can't be cast to INT32"), + ('column "buyer_phon" does not exist', 'column "buyer_phon" does not exist'), + ("no rows near id 12345", "no rows near id 12345"), + ]: + said["message"] = message + assert await masking_tool.ainvoke({"sql": sql}) == f"查询失败:{shown}" + assert await plain_tool.ainvoke({"sql": sql}) == f"查询失败:{message}" + + +def test_a_result_from_a_source_without_masks_is_masked_by_the_values_seen_so_far(): + """数据源没设遮罩(另一个库里的联系方式表),此前查询遮掉的值照样按值换;这一份结果没有可收的值。""" + known = masking._Secrets() + known.add({"columns": ["buyer_phone"], "rows": [["13900000001"]]}, {"buyer_phone"}) + payload = {"sql": "SELECT phone_num, note FROM contacts", "columns": ["phone_num", "note"], + "rows": [["13900000001", "客户 139 0000 0001 来电"], ["13800000000", "例行回访"]]} + done = masking.mask_query_result(payload, [], "sqlite", known) + assert done.payload["rows"] == [[MASKED, f"客户 {MASKED} 来电"], ["13800000000", "例行回访"]] + assert done.columns == [] and not done.secrets.strong + assert masking.mask_query_result(payload, [], "sqlite").payload is payload, "什么都没收到时原样" + + +def test_labels_written_in_this_query_are_not_masked_by_values_from_earlier_ones(): + """分档标签是这条 SQL 自己写的字:此前查询里认不准、当遮罩值收了的同名标签,不把这一份的标签列换掉。""" + known = masking._Secrets() + known.add({"columns": ["档"], "rows": [["大单"], ["小单"]]}, {"档"}) + payload = {"sql": "SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档, COUNT(*) AS 笔数 FROM sales " + "GROUP BY 1", "columns": ["档", "笔数"], "rows": [["大单", 1], ["小单", 3]]} + assert masking.mask_query_result(payload, ["amount"], "sqlite", known).payload["rows"] == [["大单", 1], ["小单", 3]] From 6a7e51b81bfcf04976eb66783ad4e4914dc05c88 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 20:29:58 +0800 Subject: [PATCH 6/7] =?UTF-8?q?K3=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=202=20=E8=BD=AE=EF=BC=89=EF=BC=9A*=20?= =?UTF-8?q?=E6=8A=8A=E9=81=AE=E7=BD=A9=E5=88=97=E5=B8=A6=E8=BF=9B=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E5=88=97=E5=90=8D=E6=97=B6=E6=8C=89=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E8=90=BD=E5=88=B0=E7=9A=84=E4=BD=8D=E7=BD=AE=E9=81=AE=EF=BC=9B?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=8F=82=E6=95=B0=E9=87=8C=E7=9A=84=20JSON?= =?UTF-8?q?=20=E7=AE=AD=E5=A4=B4=E8=AE=A4=E4=BD=9C=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=E5=88=97=EF=BC=9Bagent=20=E6=9F=A5=E5=88=B0=E7=9A=84=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E5=80=BC=E5=92=8C=E6=8A=A5=E5=91=8A=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=88=86=E5=BC=80=E8=AE=B0=EF=BC=8C=E4=B8=8A=E9=99=90=E6=94=B6?= =?UTF-8?q?=E6=BB=A1=E4=B9=9F=E4=B8=8D=E4=B8=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - * 改名(复核点名的 blocker):WITH t(a, b, …) AS (SELECT * …) 这类列名表、UNION 后一支的 *、FROM 子查询或 LATERAL 的列名表、标量子查询和 ARRAY 子查询里的 *,以前 * 本身不算取自哪一列,原值原样交给核查 agent、落库,第二道防线也 认不出。现在 * 带出遮罩列的值时,它展开落到的那几列都算(masking._star_places):只展开一个来源、每一列都数得清的 按位置只遮取自遮罩列的那几列——子查询按选出项数,数据源里的表按查询当时冻结的表结构数(_schema_columns;工具边界 用数据源的 schema_cache,事后按快照的 schema_artifact,是同一份),几张列一样的月度表 SELECT * 连起来汇总,金额照常; 数不清的(没有表结构、列数对不上、几个来源)整段遮。表结构只用来数位置,不用来断定表里没有遮罩列。标量子查询里的 * 按 _value_columns 展开(展开数据源里的表一律算),EXISTS 里的 * 不算;递归的 CTE 不会绕不出来;带 EXCLUDE / REPLACE 的 * 不按位置数 - JSON 箭头:函数参数里没写表名的 col->'path' 被 sqlglot 解析成以 col 为参数的 lambda,_value_columns 把 lambda 的 参数名照列名收。SQLite json_each(profile->'$.tels')、Postgres jsonb_array_elements_text(profile->'tels')、MySQL JSON_UNQUOTE(profile->'$.tel') 这几种拆开、取出来的值在工具边界就遮 - 上限:run_verify_agent 以前先把调用方交进来的报告快照值并进 seen,收满 SECRETS_MAX 之后 agent 查到的值并不进去,后面 的查询、回答时的第二道防线都认不出。现在两份分开记(masking.SeenValues:given / found),查询工具按值遮、交回调用方 (VerifyOutcome.masked_values)时合起来,agent 查到的在前;反方审稿理由的按值遮也改成 agent 查到的先收 - 已知局限写进 inquiry 模块说明:表结构过期、列数没变却换了次序或改了名时可能数错;EXCEPT 后一支照 UNION 算(多遮) 测试:新增 test_masking_star_renames(真跑 SQLite 的各种 * 改名、按表结构数位置、表结构对不上、快照记着的表结构), 工具边界(交给模型的、落库的、通用工件接口)和审批质询端到端的 * 改名、JSON 箭头、上限收满;每处实现都做过去掉之后 测试变红的验证。 --- backend/app/engine/inquiry.py | 12 +- backend/app/engine/masking.py | 364 ++++++++++++++++-- backend/app/engine/nodes/challenge.py | 6 +- backend/app/engine/verify_agent.py | 55 +-- backend/app/tools/datasource.py | 15 +- backend/tests/test_challenge_node.py | 23 ++ backend/tests/test_inquiry_api.py | 77 ++++ backend/tests/test_masking_star_renames.py | 251 ++++++++++++ backend/tests/test_masking_table_functions.py | 52 +++ backend/tests/test_verify_agent_masking.py | 109 +++++- 10 files changed, 887 insertions(+), 77 deletions(-) create mode 100644 backend/tests/test_masking_star_renames.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index acb75d6c..1ca86ad7 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -56,7 +56,14 @@ IS NOT NULL THEN amount ELSE 0 END))同写在 WHERE 里,不算 - FROM、JOIN、LATERAL 里的表值函数(json_each、json_tree、unnest、jsonb_each_text、JSON_TABLE、LATERAL VALUES)拆开 遮罩列的,按出列名认它出的列(常见函数的出列名、写的列名表、别名、函数名,_function_columns),同名的别的列一并遮 - (多遮);不认得的函数又没写列名表的,整份快照都遮 + (多遮);不认得的函数又没写列名表的,整份快照都遮。函数参数里没写表名的 col->'path'(JSON 箭头,解析器当成以 col + 为参数的 lambda)按 col 认,真的 lambda(transform(arr, x -> …))的参数和遮罩列同名时也算(多遮) +- * 把遮罩列带进别的列名(WITH t(a, b, …) AS (SELECT * …) 这类列名表、UNION 后一支的 *、标量子查询里的 *):只展开一个 + 来源、那里每一列都数得清的,按位置只遮取自遮罩列的那几列——子查询按它的选出项数,数据源里的表按查询当时冻结的表结构 + 数(几张列一样的月度表 SELECT * 连起来汇总,金额照常)。表结构里查不到这张表、数出来的列数和实际展开的对不上,或者 + * 连着几个来源的,* 展开落到的那一段整段遮,同一段里的金额这类别的列一并遮(多遮);表结构过期、列数没变却换了次序 + 或改了名的,可能数错(同按表结构认标签列)。标量子查询里的 * 展开数据源里的表一律算取自遮罩列。EXCEPT 后一支只用来 + 去掉行,也照 UNION 的后一支算(多遮) - 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里 照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收: @@ -93,7 +100,8 @@ 报告快照里的遮罩值、agent 前面的查询里遮掉的值都看不到。只有一种照原样:先查了写着号码的备注(那时还不知道这个号码 是遮罩值),后来才查出号码——备注快照落库时原样,从通用接口打开照原样,质询工件接口照事件里记下的位置换。报告自己 的查询快照(写作时的查询)仍原样存着,从那里打开不经这里的遮罩 -- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)。agent 自己查到的值先收,报告快照里的后收 +- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)。agent 自己查到的值先收,报告快照里的后收:核查 + agent 把两份分开记(masking.SeenValues),查询时按值遮、交回调用方、回答时合起来,都是 agent 查到的在前 - 换了写法的号码只认数字之间夹着一到四个分隔符(空格、制表符、全角空格、不换行空格、窄空格这类空白,连字符、减号、 点、括号,含全角),带 +86 / 0086 / 86 前缀、全角数字:写成汉字(一三九……)、中间夹着别的字、拆成几处写的、跨行 写的认不出;和别的数字只隔着一个连字符或点连着的(13900000001-2、19914.74)不算号码;SQL 里不带引号的数不这样认 diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index 597d24fc..b5775c11 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -11,16 +11,19 @@ 审稿理由里的按值遮降为第二道防线。查询失败时报错原话里的值也换(mask_error_text) - 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的 - 另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数);COUNT 只数个数、窗口的分区键只决定次序、 - 按遮罩列分档又当分组键或套在汇总里数个数的、汇总里的 CASE 只在条件里用到的不算(_value_columns、_band_only)。认不出 - 落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算。双盲复算按同一套认法在派盲算之前挡下取自遮罩列的指标 - (derived_columns) + 另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数、函数参数里的 JSON 箭头 col->'path');* 把 + 遮罩列带进别的列名的(写了列名表的派生来源、UNION 后一支的 *、标量子查询里的 *)按它展开落到的位置算(_star_places, + 展开数据源里的表的按查询当时冻结的表结构数位置,_schema_columns)。 + COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在汇总里数个数的、汇总里的 CASE 只在条件里用到的 + 不算(_value_columns、_band_only)。认不出落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算。双盲复算按同一套 + 认法在派盲算之前挡下取自遮罩列的指标(derived_columns) - 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值;* 展开数据源里的表时按冻结的表结构 认它带不带同名的列(_schema_tables) - 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出 正文里真有的那几个值,不现编上万个分支的正则;正文、文字格、SQL 的字符串和注释里换了写法的长号码(199-1474-2785、 +86 …、全角数字)按纯数字写法在集合里查(loose_spans);已经摆在读者眼前的值免遮,之后并进来的照样免(protected) - 快照遮好的样子(_masked_view),回答时才认得出、事后要照位置重放的地方(view_edits、replay_edits) +- 一次核查里认出的遮罩值(SeenValues):调用方交进来的和 agent 查询时遮掉的分开记,合起来时 agent 查到的在前 除 mask_query_result、换了写法的号码和这一轮新加的几样之外,这里的函数原来都在 engine/inquiry.py 里,名字不变, inquiry 照旧导出(别处按 inquiry 的名字引用)。 @@ -32,7 +35,7 @@ import functools import json import logging import re -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from decimal import Decimal from typing import Any @@ -72,16 +75,26 @@ def _masked_word() -> str: # -------------------------------------------------------------------------- +#: _value_columns 里「值可能取自数据源里一张表的任何一列」:套在里面的子查询里的 * 展开了表,SQL 里看不出这张表有哪几列。 +#: 按它认(_reads)一律算取自遮罩列(宁可多遮);真实的列名不会是这个 +_ANY_COLUMN = "*" + + def _value_columns(node: Any) -> set[str]: """一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,结果全是写死常量的 分档(_band_only:当分组键,或者套在汇总里按条件数个数)只决定一行落在哪一档,都带不出值,不算;套在里面的子查询 只看它选出来的列(它的条件、连接里写的列带不出值)。汇总里的 CASE / IIF 只看各个结果:条件只决定这一行算不算进来, 同写在 WHERE 里(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) 的值全部来自金额);不汇总的逐行 - CASE 照旧连条件一起算(每一行落在哪一支本身就是这一行的信息)。""" + CASE 照旧连条件一起算(每一行落在哪一支本身就是这一行的信息)。 + 套在里面的子查询选的是 *(标量子查询、ARRAY(SELECT * …)):值取自 * 展开的来源(_star_inputs),派生来源看它的各个 + 选出项,表值函数看参数,展开了数据源里的表的记成 _ANY_COLUMN;EXISTS 里的 * 只说有没有,不算。函数参数里的 + col->'path'(JSON 箭头)被解析器当成以 col 为参数的 lambda,参数名照列名收(真的 lambda 的参数多算一列无害)。""" from sqlglot import exp out: set[str] = set() stack = [node] + # 已经展开过的派生来源:递归的 CTE 里 * 展开它自己时不再绕回去 + expanded = {id(node)} while stack: item = stack.pop() if isinstance(item, exp.Column): @@ -94,13 +107,32 @@ def _value_columns(node: Any) -> set[str]: stack.extend(r for r in results if r is not None) elif isinstance(item, exp.Window): stack.append(item.this) + elif isinstance(item, exp.Lambda): + out.update(p.name.lower() for p in item.expressions if isinstance(p, exp.Identifier) and p.name) + stack.append(item.this) elif isinstance(item, exp.Select): - stack.extend(item.expressions) + for part in item.expressions: + if not _is_star(part): + stack.append(part) + elif not isinstance(item.parent, exp.Exists): + inputs = _star_inputs(part, item) + if inputs is None: + out.add(_ANY_COLUMN) + continue + fresh = [n for n in inputs if id(n) not in expanded] + expanded.update(id(n) for n in fresh) + stack.extend(fresh) else: stack.extend(item.iter_expressions()) return out +def _reads(names: set[str], columns: set[str]) -> bool: + """值取自 columns(_value_columns)的一项是不是取自 names:有一列对上,或者 * 展开了数据源里的表(_ANY_COLUMN,说不清 + 带出的是哪一列)。""" + return bool(names & columns) or _ANY_COLUMN in columns + + def _aggregated(node: Any) -> bool: """node 套在同一层 SELECT 的汇总函数里(SUM(…)、AVG(…) OVER (…) 也算)。""" from sqlglot import exp @@ -230,6 +262,26 @@ def _schema_tables(schema: Any) -> dict[str, set[str]] | None: return out +def _schema_columns(schema: Any) -> dict[str, list[str]] | None: + """冻结的表结构 → {表名(小写): 按表里次序的列名(小写)},名字的记法同 _schema_tables;几个库里同名、列又不一样的表 + 不记(说不清 * 展开的是哪一张)。* 展开数据源里的表时按它数位置(_star_columns);整份拿不到的为 None。""" + tables = schema.get("tables") if isinstance(schema, dict) else None + if not isinstance(tables, dict): + return None + out: dict[str, list[str]] = {} + clashed: set[str] = set() + for key, meta in tables.items(): + columns = meta.get("columns") if isinstance(meta, dict) else None + if not isinstance(columns, list) or not all(isinstance(c, dict) and c.get("name") is not None for c in columns): + continue + names = [str(c["name"]).lower() for c in columns] + for name in {str(key).lower(), str(key).lower().rsplit(".", 1)[-1], + str(meta.get("qualified") or key).lower().rsplit(".", 1)[-1]}: + if out.setdefault(name, names) != names: + clashed.add(name) + return {name: names for name, names in out.items() if name not in clashed} + + def _table_lacks(source: Any, name: str, memo: _LabelMemo) -> bool: """FROM 里的这个来源是数据源里的一张表,冻结的表结构里查得到它、又没有 name 这一列:* 展开它带不出 name。""" from sqlglot import exp @@ -577,6 +629,165 @@ def _places(branch: list[Any], width: int | None) -> list[int | None]: for i in range(len(branch))] +#: 按位置数 * 展开成的列最多往下数几层:再深的(递归的 CTE 绕回自己)按数不清算 +_STAR_DEPTH = 16 +#: * 上的改动(DuckDB 一类的 EXCLUDE / REPLACE / RENAME / ILIKE):展开成的列不再是来源那几列的原样,不按位置数 +_STAR_EDITS = ("except_", "replace", "rename", "ilike") + + +def _star_sources(star: Any, select: Any) -> list[Any] | None: + """select 里的这个 * 展开的是哪几个来源:没写表名的是 FROM、JOIN、LATERAL VIEW 里的全部,写了的(t.*)只有那一个; + 写的表名对不上任何来源(外层的表)为 None。""" + from sqlglot import exp + + sources = _from_sources(select) + [((lateral.alias_or_name or "").lower(), lateral) + for lateral in select.args.get("laterals") or []] + qualifier = (star.table.lower() or None) if isinstance(star, exp.Column) else None + if qualifier is None: + return [source for _, source in sources] + found = [source for alias, source in sources if alias == qualifier] + return found or None + + +def _star_inputs(star: Any, select: Any) -> list[Any] | None: + """* 带出的值取自哪里:要接着看的节点——派生来源的每一支(值取自它的各个选出项),表值函数、VALUES 这类来源本身 + (值取自参数)。展开了数据源里的表、写的表名对不上任何来源的为 None:SQL 里看不出这张表有哪几列,哪一列都可能带出来。""" + from sqlglot import exp + + sources = _star_sources(star, select) + if sources is None: + return None + out: list[Any] = [] + for source in sources: + derived = _derived_block(source) + if derived is not None: + out += _leaves(derived[0]) + elif isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier): + return None + else: + out.append(source) + return out + + +def _star_feeds(star: Any, select: Any, names: set[str]) -> bool: + """select 里的这个 * 带出的值里有没有取自 names 的(展开了数据源里的表的一律算,见 _star_inputs)。""" + inputs = _star_inputs(star, select) + return inputs is None or any(_reads(names, _value_columns(node)) for node in inputs) + + +def _table_columns(source: Any, tables: dict[str, list[str]] | None) -> list[str] | None: + """FROM 里的这个来源是数据源里的一张表、冻结的表结构里查得到它时,它按次序的列名(_schema_columns);别的为 None。""" + from sqlglot import exp + + if not tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier): + return None + return tables.get(source.name.lower()) + + +def _single_source(star: Any, select: Any, depth: int) -> Any: + """* 只展开一个来源、展开成的就是那个来源的列原样(没带 EXCLUDE 这类改动)时,那个来源;别的为 None。""" + from sqlglot import exp + + node = star.this if isinstance(star, exp.Column) else star + if depth > _STAR_DEPTH or any(node.args.get(k) for k in _STAR_EDITS): + return None + sources = _star_sources(star, select) + return sources[0] if sources is not None and len(sources) == 1 else None + + +def _star_columns(star: Any, select: Any, names: set[str], memo: dict[int, Any], + tables: dict[str, list[str]] | None, depth: int = 0) -> list[bool] | None: + """* 展开成的每一列(按位置)取不取自 names:只展开一个来源时才数得清——派生来源按它的每一列(_block_columns),数据源 + 里的表按查询当时冻结的表结构(tables,_schema_columns)。表结构里查不到的表、几个来源(按同名列连起来的会合成一列)、 + 表值函数,或者 * 带着 EXCLUDE / REPLACE 这类改动的,为 None。""" + source = _single_source(star, select, depth) + if source is None: + return None + if (derived := _derived_block(source)) is not None: + return _block_columns(derived[0], names, memo, tables, depth + 1) + columns = _table_columns(source, tables) + return [column in names for column in columns] if columns is not None else None + + +def _block_columns(block: Any, names: set[str], memo: dict[int, Any], tables: dict[str, list[str]] | None, + depth: int = 0) -> list[bool] | None: + """一块查询(派生来源)的每一列(按位置)取不取自 names:每一支一项项数,* 按它展开成的几列数(_star_columns),任何 + 一支的那一列取自 names 就算;有一支数不清、几支不一样宽的为 None。memo 记下数过的(id(那块查询) → 结果)。""" + from sqlglot import exp + + if id(block) in memo: + return memo[id(block)] + memo[id(block)] = None + rows: list[list[bool]] = [] + for leaf in _leaves(block): + if not isinstance(leaf, exp.Select): + return None + row: list[bool] = [] + for item in leaf.expressions: + if not _is_star(item): + row.append(_reads(names, _value_columns(item))) + elif (spread := _star_columns(item, leaf, names, memo, tables, depth)) is not None: + row += spread + else: + return None + rows.append(row) + if not rows or len({len(row) for row in rows}) != 1: + return None + memo[id(block)] = [any(column) for column in zip(*rows)] + return memo[id(block)] + + +def _branch_names(select: Any, tables: dict[str, list[str]] | None, depth: int = 0) -> list[str | None] | None: + """一支(一条 SELECT)结果里按次序的列名:没起别名的表达式为 None(_output_name 认不准的);* 按它展开成的列名 + (只展开一个来源时:数据源里的表按表结构,派生来源按它写的列名表、第一支的列名)。有 * 说不清展开成哪几列的为 None。 + UNION 第一支是 * 时,后一支的值按位置落进的就是这几个名字(_source_names)。""" + from sqlglot import exp + + out: list[str | None] = [] + for item in select.expressions: + if not _is_star(item): + out.append(_output_name(item)) + continue + source = _single_source(item, select, depth) + if source is None: + return None + if (derived := _derived_block(source)) is not None: + block, listed = derived + first = _leaves(block)[0] + names = list(listed) if listed is not None else ( + _branch_names(first, tables, depth + 1) if isinstance(first, exp.Select) else None) + else: + names = _table_columns(source, tables) + if names is None: + return None + out += names + return out + + +def _star_places(select: Any, width: int | None, names: set[str], tables: dict[str, list[str]] | None = None + ) -> set[int] | None: + """select 这一支里的 * 把取自 names 的值带到了结果(width 列)的哪几列。* 展开的列落在第一个 * 起、最后一个 * 后面 + 那几项之前的那一段:只有一个 *、它展开成的每一列都数得清的(_star_columns:派生来源,表结构里查得到的表)只算取自 + names 的那几列,别的整段都算。数出来的列数和这一段对不上的(表结构过期了)也整段算。没有 * 带出这样的值为空;不知道 + 结果有几列、* 前后的几项就比结果还宽的为 None。 + 用在 * 展开的列换了名字的地方:写了列名表的派生来源(WITH t(a, b) AS (SELECT * FROM sales))、UNION 后一支的 * + (结果的列名取第一支的)。""" + branch = list(select.expressions) + stars = [i for i, item in enumerate(branch) if _is_star(item)] + # 展开数据源里的表的一律算带出了遮罩列的值:表结构只用来数位置,不用来断定这张表没有遮罩列(表结构可能过期) + if not any(_star_feeds(branch[i], select, names) for i in stars): + return set() + if width is None: + return None + first, end = stars[0], width - (len(branch) - 1 - stars[-1]) + if end < first: + return None + spread = _star_columns(branch[first], select, names, {}, tables) if len(stars) == 1 else None + if spread is not None and len(spread) == end - first: + return {first + k for k, hit in enumerate(spread) if hit} + return set(range(first, end)) + + def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: """SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪: - "result":最外层,结果就是快照的列; @@ -608,10 +819,16 @@ def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: return out -def _source_names(top: Any, listed: list[str] | None, names: set[str]) -> tuple[set[str], bool]: +def _source_names(top: Any, listed: list[str] | None, names: set[str], + tables: dict[str, list[str]] | None = None) -> tuple[set[str], bool]: """派生来源的结果里值取自遮罩列的那几列的名字,以及有没有认不出名字的(没起别名的表达式、两个 * 之间的项)。 - 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。""" - branches = _branches(top) + 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。 + * 带出遮罩列的值、展开的列又换了名字的(写了列名表、UNION 后一支的 *),它展开落到的那几列照位置对上(_star_places); + 单独一条 SELECT、第一支的 * 不改名,遮罩列照原名传出去,按名字就认得出。第一支的 * 展开成哪几列数得清的(_branch_names), + 后一支落进去的位置按它对上列名。tables 是查询当时冻结的表结构(_schema_columns),* 展开数据源里的表时按它数位置。""" + from sqlglot import exp + + selects = [leaf for leaf in _leaves(top) if isinstance(leaf, exp.Select)] found: set[str] = set() unknown = False @@ -622,28 +839,39 @@ def _source_names(top: Any, listed: list[str] | None, names: set[str]) -> tuple[ else: found.add(name) + def taken(select: Any, width: int | None, head: Callable[[int | None], str | None]) -> None: + branch = list(select.expressions) + for item, place in zip(branch, _places(branch, width)): + if not _is_star(item) and _reads(names, _value_columns(item)): + take(head(place)) + spread = _star_places(select, width, names, tables) + for place in sorted(spread) if spread is not None else [None]: + take(head(place)) + if listed is not None: - for branch in branches: - for item, place in zip(branch, _places(branch, len(listed))): - if names & _value_columns(item): - take(listed[place] if place is not None and place < len(listed) else None) + for select in selects: + taken(select, len(listed), lambda p: listed[p] if p is not None and p < len(listed) else None) return found, unknown - first = branches[0] if branches else [] + first = list(selects[0].expressions) if selects else [] for item in first: - if names & _value_columns(item): + if not _is_star(item) and _reads(names, _value_columns(item)): take(_output_name(item)) - if len(branches) > 1: - # 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置 - width = next((len(b) for b in branches if not any(_is_star(i) for i in b)), None) - heads = {place: _output_name(item) for item, place in zip(first, _places(first, width)) if place is not None} - for branch in branches[1:]: - for item, place in zip(branch, _places(branch, width)): - if names & _value_columns(item): - take(heads.get(place) if place is not None else None) + if len(selects) > 1: + header = _branch_names(selects[0], tables) + if header is not None: + width, heads = len(header), dict(enumerate(header)) + else: + # 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置 + width = next((len(s.expressions) for s in selects if not any(_is_star(i) for i in s.expressions)), None) + heads = {place: _output_name(item) for item, place in zip(first, _places(first, width)) + if place is not None} + for select in selects[1:]: + taken(select, width, lambda p: heads.get(p) if p is not None else None) return found, unknown -def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[set[str], set[int]] | None: +def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: dict[str, list[str]] | None = None + ) -> tuple[set[str], set[int]] | None: """SQL 里指代遮罩列的名字(小写),和结果(width 列)里值取自遮罩列的那几列的位置。解析不了返回 None。 名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的 @@ -653,7 +881,10 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[ 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 又认不准)——整份快照的列都算(同解析不了)。 FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE、LATERAL VALUES)参数里写着这些名字的,它出的列 - 都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算。""" + 都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算。 + * 展开的列换了名字(写了列名表的派生来源、UNION 后一支的 *):* 带出这些名字的值时,它展开落到的那几列照位置算 + (_star_places,展开数据源里的表的按 tables 数位置,数不清的整段算);标量子查询里的 * 按 _value_columns 算进外层的 + 表达式。""" from sqlglot import exp from app.data.sql_semantics import _parse, _read @@ -676,29 +907,37 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[ for select_ in selects: for item in select_.expressions: name = item.alias_or_name.lower() - if name and name not in names and names & _value_columns(item): + if name and not _is_star(item) and name not in names and _reads(names, _value_columns(item)): names.add(name) for top, listed in sources: - names |= _source_names(top, listed, names)[0] + names |= _source_names(top, listed, names, tables)[0] for source in functions: - if names & _value_columns(source): + if _reads(names, _value_columns(source)): columns = _function_columns(source) unreadable = unreadable or columns is None names |= columns or set() if len(names) == before: break - everything = unreadable or any(_source_names(top, listed, names)[1] for top, listed in sources) + everything = unreadable or any(_source_names(top, listed, names, tables)[1] for top, listed in sources) positions: set[int] = set() for top, role, _listed in blocks: if role != "result": continue - for branch in _branches(top): + branches = [leaf for leaf in _leaves(top) if isinstance(leaf, exp.Select)] + for k, select_ in enumerate(branches): + branch = list(select_.expressions) for item, place in zip(branch, _places(branch, width)): - if names & _value_columns(item): + if not _is_star(item) and _reads(names, _value_columns(item)): if place is None or place >= width: everything = True else: positions.add(place) + # UNION 后一支的 * 按位置落进第一支起的列名里;第一支、单独一条 SELECT 的 * 照原名带出,按名字认得出 + spread = _star_places(select_, width, names, tables) if k else set() + if spread is None or any(place >= width for place in spread): + everything = True + else: + positions |= spread return names, set(range(width)) if everything else positions except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮) logger.warning("质询的 SQL 遮罩分析失败", exc_info=True) @@ -709,8 +948,10 @@ def _mentions(sql: str, names: Iterable[str]) -> bool: return any(re.search(rf"(? tuple[set[str], set[str]]: - """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。 +def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None, + tables: dict[str, list[str]] | None = None) -> tuple[set[str], set[str]]: + """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。tables 是查询当时冻结的表结构(_schema_columns),* 展开 + 数据源里的表时按它数位置;没给的按快照记着的表结构快照(schema_artifact)认,和工具边界遮的时候是同一份。 证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名 (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的,整份快照的 @@ -720,7 +961,7 @@ def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: return base, base columns = [str(c) for c in snapshot.get("columns") or []] - found = _tainted(sql, kind, base, len(columns)) + found = _tainted(sql, kind, base, len(columns), _snapshot_columns(snapshot) if tables is None else tables) if found is None: return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) names, positions = found @@ -729,12 +970,13 @@ def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None def derived_columns(snapshot: dict[str, Any], hidden: set[str], kind: str | None) -> set[str] | None: """按 SQL 认出值取自遮罩列(hidden,小写)的结果列(小写,列名直接对上的也算)。SQL 解析不了、合并查询的快照(不看 - SQL)为 None:认不准,宽严由调用方定(_derived_hidden 整份遮;双盲复算照常派盲算,由盲算自己的查询去认)。""" + SQL)为 None:认不准,宽严由调用方定(_derived_hidden 整份遮;双盲复算照常派盲算,由盲算自己的查询去认)。表结构同 + _derived_hidden,按快照记着的表结构快照认。""" sql = snapshot.get("sql") if not hidden or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: return None columns = [str(c) for c in snapshot.get("columns") or []] - found = _tainted(sql, kind, set(hidden), len(columns)) + found = _tainted(sql, kind, set(hidden), len(columns), _snapshot_columns(snapshot)) if found is None: return None names, positions = found @@ -753,6 +995,23 @@ def _snapshot_labels(snapshot: dict[str, Any], kind: str | None, tables: dict[st return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []], tables) +def _snapshot_columns(snapshot: dict[str, Any]) -> dict[str, list[str]] | None: + """快照记着的表结构快照(schema_artifact,查询当时冻结的,和工具边界遮的时候用的是同一份)里按次序的列名。""" + artifact = snapshot.get("schema_artifact") + return _frozen_columns(artifact) if isinstance(artifact, str) else None + + +@functools.lru_cache(maxsize=64) +def _frozen_columns(artifact: str) -> dict[str, list[str]] | None: + """表结构快照工件里每张表按次序的列名(_schema_columns)。同 _frozen_tables:按内容寻址,记下来;取不到的为 None。""" + from app.core import artifact_store + + try: + return _schema_columns(artifact_store.load(artifact)) + except Exception: # noqa: BLE001 - 取不到就当不知道表结构(* 展开表的整段遮,宁可多遮) + return None + + @functools.lru_cache(maxsize=64) def _frozen_tables(artifact: str) -> dict[str, set[str]] | None: """表结构快照工件里每张表的列名(_schema_tables)。工件按内容寻址、不会变,记下来;取不到、被改过的为 None。""" @@ -1489,16 +1748,39 @@ class QueryMask: secrets: _Secrets +@dataclass +class SeenValues: + """一次核查里认出的遮罩值,只在内存里:given 是调用方交进来的(审批质询、反方审稿收的报告快照里的值),found 是 + agent 自己的查询在工具边界遮掉的(tools/datasource 每次查询并进来,add)。两份分开记:合起来(pooled)时 agent 查到的 + 在前,到了上限 SECRETS_MAX 挤掉的是报告里的值——agent 查到的就在它眼前这几份结果里,后面的查询、它写的回答和理由 + 最可能再碰到。以前先并报告的值,收满了上限,agent 查到的一个也并不进去。""" + + given: _Secrets = field(default_factory=_Secrets) + found: _Secrets = field(default_factory=_Secrets) + + def add(self, secrets: _Secrets) -> None: + """并进一次查询时遮掉的值(mask_query_result 交回的 secrets)。""" + self.found.merge(secrets) + + def pooled(self) -> _Secrets: + """合起来的另一份(改它不影响这两份):agent 查到的在前,调用方交进来的在后;调用方免遮的文字(问题、被审的那句 + 结论原文里写着的值)对两边都算。上万个值合一遍要几十毫秒,调用方放进线程。""" + out = self.found.copy() + out.merge(self.given) + return out + + def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str | None, known: _Secrets | None = None, *, schema: Any = None) -> QueryMask: """一份查询结果(tools/datasource 的查询工具交回的样子:columns / rows / column_types / sql)在交给核查 agent、落进 快照之前先遮。masked 是数据源设了遮罩的列,kind 是数据源的类型(SQL 按它的方言解析)。known 是此前已经认出的遮罩值: 这个 agent 前面的查询遮掉的,加上调用方交进来的(审批质询、反方审稿收的报告快照里的值)。schema 是数据源的表结构 - (schema_cache,和随快照冻结的是同一份),认分档标签时用(_label_columns 的 tables)。 + (schema_cache,和随快照冻结的是同一份):认分档标签(_label_columns 的 tables)、数 * 展开数据源里的表落在第几列 + (_derived_hidden 的 tables)时用。 - 整列遮:列名直接对上遮罩列的,加上按 SQL 认出值取自遮罩列的(_derived_hidden:别名、函数、子查询、WITH、 - SELECT *、表值函数;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着遮罩列的,整份结果的列都遮。 - 这几列的格子换成「已遮罩」,类型记成文本 + SELECT *(展开的列换了名字的也算)、表值函数;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着 + 遮罩列的,整份结果的列都遮。这几列的格子换成「已遮罩」,类型记成文本 - 别的列按值换:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans)。遮罩值 是这份结果里的加上 known:先查号码、再查写着号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样 遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不 @@ -1514,7 +1796,7 @@ def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str if not (base or given) or not columns or not _query_shaped(payload): return QueryMask(payload, [], _Secrets()) try: - hidden = _derived_hidden(payload, base, kind)[0] if base else set() + hidden = _derived_hidden(payload, base, kind, _schema_columns(schema))[0] if base else set() own = _Secrets() labels = _snapshot_labels(payload, kind, _schema_tables(schema)) if any(c.lower() in hidden for c in columns): diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index ddec305e..00f0c22d 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -393,11 +393,13 @@ def _masked_rationale(outcome: VerifyOutcome, secrets: Any, *, given: Iterable[s 换的办法同审批质询的回答(_scrubbed_source):原文里的值先换掉,再照原来的目录重新组装理由文档;引用到的格子按 遮好的快照渲染(文字格里夹着的遮罩值换掉那一截)。理由里认不出遮罩值的原样交回。""" from app.engine.evidence import compose_doc - from app.engine.masking import _masked_view, _scrubbed_source + from app.engine.masking import _masked_view, _scrubbed_source, _Secrets doc, result = outcome.rationale, outcome.result - pool = secrets.copy() + # agent 查询时遮掉的值先收(masked_values 里已经排在报告的值前面):报告的值收满了上限 SECRETS_MAX,也不丢 agent 查到的 + pool = _Secrets() pool.merge(outcome.masked_values) + pool.merge(secrets) pool.protect(given) if result is None or not isinstance(doc, dict) or not pool.strong: return outcome diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index 9902ab0c..fd7b3bf8 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -7,12 +7,14 @@ (tools/datasource.py)。这一路没有审批关卡,不能指望人在半路拦下一条 DELETE。 **先遮再交。** 建查询工具时还打开遮罩开关(tools/datasource 的 mask_results):数据源设了遮罩时,取自遮罩列的格子 -(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT *、表值函数拆开的都算)在交给模型、落进查询快照之前就 -换成「已遮罩」,别的格子按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的(masked_values:审批质询、 -反方审稿收的报告快照里的值),文字格里夹着的换那一截(engine/masking.py 的 mask_query_result);查询失败时报错原话 -里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后按值认,每一轮复核都找到新的漏法。快照里的 -masked_at_query 记下查询时就遮了的列,格子引用指到这几列、指到按值换掉的格子的都不采信。遮掉的值只在内存里交回 -调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线用),不进快照、不交给模型、不进 as_dict。 +(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT *(展开的列换了名字的也算)、表值函数拆开的都算)在交给 +模型、落进查询快照之前就换成「已遮罩」,别的格子按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的 +(masked_values:审批质询、反方审稿收的报告快照里的值),文字格里夹着的换那一截(engine/masking.py 的 +mask_query_result);查询失败时报错原话里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后 +按值认,每一轮复核都找到新的漏法。快照里的 masked_at_query 记下查询时就遮了的列,格子引用指到这几列、指到按值换掉 +的格子的都不采信。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线 +用),不进快照、不交给模型、不进 as_dict。调用方交进来的和查询时遮掉的分开记(masking.SeenValues),合起来时 agent +查到的在前:合起来到了上限 SECRETS_MAX,挤掉的是报告里的值。 **数从快照取。** 每次成功的查询是一份查询快照(工具自己落的 put_json),按这个 agent 自己的顺序编 Q1、Q2…。 最后交结果时,调用方结构里的数值字段一律改成格子引用 {alias, row, column},由代码按引用从快照里取值:模型自己 @@ -67,7 +69,7 @@ from app.engine import spend from app.engine.evidence import build_catalog, cell_eid, compose_doc, query_entry_fields, schema_ledger_entry from app.engine.expressions import CellError, column_kind, locate_cell, numeric_text from app.engine.judge import FALLBACK_MAX_TOKENS, MAX_TOKENS, _json_in, _tokens, _usd -from app.engine.masking import _masked_word, _Secrets +from app.engine.masking import SeenValues, _masked_word, _Secrets from app.engine.nodes.llm import _DIGEST_BUDGET, _digest, _split_structured, _typed, explain_model_error from app.engine.state import message_text from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded @@ -160,8 +162,9 @@ class VerifyOutcome: output_tokens, total_tokens, cost_usd, calls, unpriced_calls}。limit:触顶原因(LIMITS 之一),没触顶为 None。 answered:模型是不是自己停下来的(没被上限掐断,也没因为输出到了上限被截断)。被截断的一律没有结果,error 是 TRUNCATED_ERROR。 - masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets),连同调用方交进来的。只在内存里交给调用方按值遮 - (审批质询的回答、反方审稿的理由),不进 as_dict、不往外交。 + masked_values:查询时在工具边界遮掉的值(engine/masking 的 _Secrets),连同调用方交进来的:agent 查到的在前,合起来 + 到了上限 SECRETS_MAX 挤掉的是调用方的(SeenValues.pooled)。只在内存里交给调用方按值遮(审批质询的回答、反方审稿的 + 理由),不进 as_dict、不往外交。 """ result: dict[str, Any] | None @@ -670,9 +673,9 @@ class _State: queries: list[dict[str, Any]] = field(default_factory=list) ledger: list[dict[str, Any]] = field(default_factory=list) model: str | None = None - #: 查询工具在工具边界遮掉的值(经 ToolContext.extra 并进来,只在内存里),起头是调用方交进来的。后面的查询按它们 - #: 按值遮 - seen: _Secrets = field(default_factory=_Secrets) + #: 认出的遮罩值(只在内存里):调用方交进来的,加上查询工具在工具边界遮掉的(经 ToolContext.extra 并进来)。后面的 + #: 查询按它们按值遮,最后合起来交回调用方(run_verify_agent),agent 查到的在前 + seen: SeenValues = field(default_factory=SeenValues) def outcome(self, **fields: Any) -> VerifyOutcome: usage = self.usage @@ -682,8 +685,7 @@ class _State: for q in self.queries] base: dict[str, Any] = {"result": None, "cells": [], "queries": shown, "rationale": None, "usage": usage, "notes": self.notes, "model": self.model, - "duration_ms": int((time.monotonic() - self.started) * 1000), - "masked_values": self.seen} + "duration_ms": int((time.monotonic() - self.started) * 1000)} return VerifyOutcome(**{**base, **fields}) @@ -720,20 +722,25 @@ async def run_verify_agent( data_versions;不在运行里传 Versions.CURRENT;缺省 UNSET 时上传源一律报错,不拿当前版本顶上)。 - masked_values:调用方已经认出的遮罩值(engine/masking 的 _Secrets,审批质询、反方审稿收的报告快照里的值)。 查询工具从第一条查询起就按它们按值遮:agent 查到的备注、别的表里写着同一个号码时,模型看不到。随查询时遮掉的值 - 一起交回(VerifyOutcome.masked_values)。 + 一起交回(VerifyOutcome.masked_values)。不改它。 """ - state = _State(started=time.monotonic()) + state = _State(started=time.monotonic(), seen=SeenValues( + given=masked_values if masked_values is not None else _Secrets())) try: - if masked_values is not None: - # 上万个值并一遍要几十毫秒,放进线程,别挡住同一进程里别的运行 - await asyncio.to_thread(state.seen.merge, masked_values) - return await _verify(state, spec=spec, system=system, task=task, sources=sources, - output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s, - max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit, - run_id=run_id, node_id=node_id, data_versions=data_versions) + out = await _verify(state, spec=spec, system=system, task=task, sources=sources, + output_schema=output_schema, max_queries=max_queries, timeout_s=timeout_s, + max_cost_usd=max_cost_usd, daily_max_usd=daily_max_usd, max_steps=max_steps, emit=emit, + run_id=run_id, node_id=node_id, data_versions=data_versions) except Exception as e: # noqa: BLE001 - 永远不抛:库写不进去、建工具出错这类意外也落在返回值里 logger.warning("核查出错", exc_info=True) - return state.outcome(error=f"核查出错({describe_exception(e)})") + out = state.outcome(error=f"核查出错({describe_exception(e)})") + try: + # agent 查到的在前、调用方交进来的在后:上万个值合一遍要几十毫秒,放进线程,别挡住同一进程里别的运行 + pooled = await asyncio.to_thread(state.seen.pooled) + except Exception: # noqa: BLE001 - 永远不抛:合不起来就只交调用方交进来的(agent 的查询结果在工具边界已经遮过) + logger.warning("核查遮掉的值合并出错", exc_info=True) + pooled = state.seen.given + return replace(out, masked_values=pooled) async def _verify(state: _State, *, spec: ModelSpec, system: str, task: str, sources: Iterable[str], diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 7f2a7e39..1cf26969 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -53,8 +53,9 @@ RUN_VERSIONS_KEY = "data_versions" DATA_UNAVAILABLE = "查询失败(数据不可用):" #: 运行中途补固定一个源时发的那条日志事件的 code(_pin_late) LATE_PIN_CODE = "data_version_pinned" -#: ToolContext.extra 里放「查询时遮掉的值」的键(engine/masking 的 _Secrets):核查 agent(engine/verify_agent.py)放进去, -#: 打开了遮罩开关的查询工具把每次遮掉的值并进去。只在内存里给调用方按值遮用,不进快照、不交给模型 +#: ToolContext.extra 里放「认出的遮罩值」的键(engine/masking 的 SeenValues:调用方交进来的,加上查询时遮掉的):核查 +#: agent(engine/verify_agent.py)放进去,打开了遮罩开关的查询工具按它们按值遮,再把每次遮掉的值并进去。只在内存里给 +#: 调用方按值遮用,不进快照、不交给模型 MASKED_VALUES_KEY = "masked_values" #: 每次查询之后基于数据目录的 SQL 检查最多等这么久。正常一条 SQL 几毫秒;等不到就放弃这一次检查, @@ -498,9 +499,11 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, return f"SQL 被拒绝:{FORCED_READONLY}" limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {})) mask = masked_columns(source.options) - # 此前认出的遮罩值:交给线程的是一份拷贝,线程里读的时候这边不会再往里并 + # 此前认出的遮罩值合起来的另一份(agent 前面的查询遮掉的在前,调用方交进来的在后,见 masking.SeenValues):线程里 + # 读的时候这边不会再往里并。上万个值合一遍要几十毫秒,放进线程 sink = ctx.extra.get(MASKED_VALUES_KEY) if mask_results else None - known = sink.copy() if sink is not None and sink.strong else None + known = await asyncio.to_thread(sink.pooled) if sink is not None else None + known = known if known is not None and known.strong else None try: result = await run_query(source, sql, limits=limits) except SqlRejected as e: @@ -538,8 +541,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, if mask or done.payload is not payload: payload, masked_now = done.payload, done.columns if sink is not None: - # 上万个值并一遍要几十毫秒(免遮的文字要重新过一遍),也放进线程 - await asyncio.to_thread(sink.merge, done.secrets) + # 并进 agent 查到的那一份(不和调用方交进来的混在一起,上限收满了也不丢),也放进线程 + await asyncio.to_thread(sink.add, done.secrets) # 对照数据目录检查这条 SQL(一对多关联后重复计算、存量跨期求和……)。结果交给模型(Agent 据此改写), # 也随查询快照存下:证据面板看得到,口径卡据此给指标标「存疑」、出具降档。没查出问题不加这个键, # 快照和以前一字不差 diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 441b205a..22ded299 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -1405,3 +1405,26 @@ async def test_the_node_refuses_the_same_bad_settings_at_run_time(): with pytest.raises(NodeError) as caught: await challenge_mod.run_challenge({"nodes": {}}, ctx) assert str(caught.value) == message + + +def test_values_the_agent_found_are_masked_in_the_rationale_when_the_report_fills_the_cap(monkeypatch): + """复核第 2 轮:报告快照里的遮罩值收满了上限 SECRETS_MAX,反方 agent 查询时遮掉的号码照样认得出(agent 查到的先收)。 + 以前先收报告的值、满了就停,agent 查到的号码写进理由原样存下。""" + from app.engine import masking + from app.engine.evidence import compose_doc + from app.engine.verify_agent import VerifyOutcome + + monkeypatch.setattr(masking, "SECRETS_MAX", 40) + secrets = masking._Secrets() + secrets.add({"columns": ["p"], "rows": [[str(13800000000 + i)] for i in range(40)]}, {"p"}) + assert len(secrets.weak) == masking.SECRETS_MAX + found = masking._Secrets() + found.add({"columns": ["p"], "rows": [["19914742785"]]}, {"p"}) + # 和 run_verify_agent 交回的一样:agent 查到的在前,调用方交进去的在后 + seen = masking.SeenValues(given=secrets) + seen.add(found) + doc = compose_doc("号码 199-1474-2785 的客户在华南。", {}, node_id="chal", run_id="r1") + outcome = VerifyOutcome(result={"verdict": "stands", "rationale": doc["markdown"]}, cells=[], queries=[], + rationale=doc, usage={}, masked_values=seen.pooled()) + masked = challenge_mod._masked_rationale(outcome, secrets, given=[]) + assert masked.result["rationale"] == "号码 已遮罩 的客户在华南。" diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 275d612a..2f09bbd5 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -933,6 +933,49 @@ async def test_the_inquiry_agent_sees_and_stores_its_queries_masked(monkeypatch, assert content == artifact_store.load(query["artifact"]) +@pytest.mark.parametrize("sql, rows", [ + pytest.param("SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM " + "(SELECT buyer_phone, amount FROM sales ORDER BY amount DESC LIMIT 2)", + [["已遮罩", 2800], ["已遮罩", 1200], ["已遮罩", 900]], id="UNION 后一支的 *"), + pytest.param("WITH t(a, b, c, d, e) AS (SELECT * FROM sales) SELECT c AS 区域, d AS 金额 FROM t " + "ORDER BY d DESC LIMIT 2", [["已遮罩", 1200], ["已遮罩", 900]], id="CTE 列名表套 *"), +]) +async def test_a_star_renaming_the_masked_column_never_shows(monkeypatch, tmp_path, client, sql, rows): + """复核第 2 轮的 blocker:* 把手机号带进了别的列名(UNION 后一支的 *,结果的列名取第一支的「区域」;CTE 的列名表把 + SELECT * 展开的列换了名字)。以前交给模型的工具结果里就有原值,回答「金额最大的一笔来自 13900000001」照常交出,事件、 + 质询工件、通用工件接口都给原值。现在 * 展开落到的那几列查询时就遮掉:只展开一个子查询的按位置只遮号码那一列;展开 + 数据源里的表的按查询当时的表结构数出号码排在第几,也只遮那一列。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=SALES) + plays = {"最大": Play([sql], "金额最大的一笔来自 [[v:Q1.r1.区域]]。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东销售额 [[v:Q1.r0.销售额]]。[[see:Q1]]", plays=plays) + seen: list[str] = [] + answering = MockChatModel._decide + + def spy(model, messages): + seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage)) + return answering(model, messages) + + monkeypatch.setattr(MockChatModel, "_decide", spy) + try: + paused = await pause(name, graph=fetching(name, QUERY)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="金额最大的一笔来自哪里?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "金额最大的一笔来自 已遮罩。") + assert seen and all("1390000000" not in s for s in seen), "交给模型的查询结果里就是遮好的" + [query] = item["queries"] + for url in (f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}", + f"/api/artifacts/{query['artifact']}"): + got = await client.get(url) + assert got.status_code == 200 and got.json()["content"]["rows"] == rows and "1390000000" not in got.text + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + finally: + await engines.invalidate(source_id) + + async def test_a_masked_column_under_another_name_is_masked_everywhere(monkeypatch, masked_shop, client): """遮罩列起了中文别名(本仓库查询的惯例):回答里引用到的格子、事件、回答文档、质询工件里都不露出原值。 报告自己的查询没选手机号,原值哪里都认不出来,只能按 SQL 认出「手机号」就是 buyer_phone。""" @@ -1552,6 +1595,40 @@ async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_ca await engines.invalidate(source_id) +async def test_a_number_the_agent_found_is_masked_even_when_report_values_fill_the_cap(monkeypatch, tmp_path, client): + """复核第 2 轮:报告快照里的遮罩值收满了上限(这里把 SECRETS_MAX 调成报告正好收满的大小)。agent 先查出华东的号码 + (查询时遮掉),再查回访备注:备注里的号码照样在交给模型之前换掉,回答、事件、质询工件里都没有。以前开始回答时先并 + 报告的值、收满就停,agent 查到的号码并不进去:备注原样交给模型、原样落库,回答引用这一格时第二道防线也认不出,原值 + 写进了运行记录。""" + from app.engine import masking + + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CALLBACKS) + report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales WHERE region <> '华东' ORDER BY id" + plays = {"回访": Play([PHONES_EAST, NOTES], "华东的回访备注是 [[v:Q2.r0.备注]]。[[see:Q2]]")} + Models(monkeypatch, name, writer="华南一笔金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, report_sql)) + [approval] = await pending(paused.id) + report = [artifact_store.load(e.data["query_artifact"]) for e in await events(paused.id, "tool.end") + if e.data.get("query_artifact")] + full = masking._Secrets() + full.add(report[0], {"buyer_phone"}) + monkeypatch.setattr(masking, "SECRETS_MAX", len(full.weak)) + r = await ask(client, paused.id, report="write", unit=None, question="华东的回访说了什么?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "华东的回访备注是 客户已遮罩来电改址。") + phones, notes = item["queries"] + assert artifact_store.load(notes["artifact"])["rows"] == [["华东", "客户已遮罩来电改址"], ["华南", "例行回访"]] + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{notes['artifact']}") + assert got.status_code == 200 and "1390000000" not in got.text + finally: + await engines.invalidate(source_id) + + async def test_a_number_from_the_agents_other_query_is_masked_when_the_number_came_last(monkeypatch, tmp_path, client): """先查备注、后查号码:查备注时还不知道里面写着遮罩值,原样交给模型、原样存下;回答引用这一格,渲染出来才露,整份 不交。回答时按内存里的值换掉的位置记进事件(不含值),事后取质询工件照着再换一遍:质询工件里那一截是「已遮罩」, diff --git a/backend/tests/test_masking_star_renames.py b/backend/tests/test_masking_star_renames.py new file mode 100644 index 00000000..36c93230 --- /dev/null +++ b/backend/tests/test_masking_star_renames.py @@ -0,0 +1,251 @@ +"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):* 把遮罩列带进了别的列名(复核第 2 轮的 blocker)。 + +* 展开的列按位置落进结果:WITH t(a, b, …) AS (SELECT * FROM sales) 的列名表、FROM 里子查询的列名表会给它们换上别的 +名字,UNION 后一支的 * 落进第一支起的列名里,标量子查询里的 * 则把值交给外层的表达式。以前 * 本身不算取自哪一列,这些 +写法的原值原样交给核查 agent、落进快照,审批质询按值遮的第二道防线也认不出。现在 * 带出的值取自遮罩列时,它展开落到的 +那几列都算:只展开一个来源、那里的每一列都数得清的(子查询按选出项,数据源里的表按查询当时的表结构),按位置只算取自 +遮罩列的那几列;数不清的(没有表结构、列数对不上)整段都算(多遮)。不改名的 *(单独一条 SELECT 的 *、UNION 第一支的 *) +照旧按名字认;* 只带出没碰遮罩列的值、EXISTS 里的 * 都不算。 + +查询都在内存里的 SQLite 上真跑一遍,看遮完的结果里还有没有原值。数据都是虚构的:区域、手机号、姓名、金额。 +""" +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from app.db import models # noqa: F401 - 建表(conftest)之前先登记模型:表结构快照要存进工件库 +from app.engine import masking + +MASKED_WORD = "已遮罩" +#: 设了遮罩的列 +MASKED = ["buyer_phone", "buyer_name"] +#: 遮罩列里的值:遮完的结果里不能出现 +SECRETS = ("1390000000", "张伟", "李娜", "王芳") +SCRIPT = ("CREATE TABLE sales (id INTEGER, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER, note TEXT);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '改址')," + " (2, '华南', '13900000002', '李娜', 300, '回访'), (3, '华北', '13900000003', '王芳', 900, '');" + # 只有一列号码的表:标量子查询、UNION 后一支的 * 直接展开它 + "CREATE TABLE phones (buyer_phone TEXT);" + "INSERT INTO phones VALUES ('13900000009');") + + +@pytest.fixture(scope="module") +def db(): + conn = sqlite3.connect(":memory:") + conn.executescript(SCRIPT) + yield conn + conn.close() + + +def masked_run(db, sql: str) -> masking.QueryMask: + """真跑一遍 SQL,按工具边界的办法遮(mask_query_result)。""" + cursor = db.execute(sql) + columns = [d[0] for d in cursor.description] + rows = [list(r) for r in cursor.fetchall()] + payload = {"columns": columns, "rows": rows, "row_count": len(rows), "truncated": False, "sql": sql} + return masking.mask_query_result(payload, MASKED, "sqlite") + + +def _leaks(done: masking.QueryMask) -> bool: + text = json.dumps(done.payload["rows"], ensure_ascii=False) + return any(s in text for s in SECRETS) + + +@pytest.mark.parametrize("sql, hidden, shown", [ + pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT * FROM t", {"c", "d"}, set(), + id="CTE 列名表套 * 展开表"), + pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS 号码, e AS 金额 FROM t " + "ORDER BY e DESC LIMIT 1", {"号码"}, set(), id="CTE 列名表套 * 再改名"), + pytest.param("SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f UNION ALL SELECT * FROM sales", + {"c", "d"}, set(), id="UNION 后一支的 * 展开表"), + pytest.param("SELECT region FROM sales UNION ALL SELECT * FROM phones", {"region"}, set(), + id="UNION 后一支的 * 展开只有号码的表"), + pytest.param("WITH a AS (SELECT buyer_phone FROM sales), b(x) AS (SELECT * FROM a) SELECT x FROM b", {"x"}, + set(), id="列名表套 * 展开另一段 CTE"), + pytest.param("SELECT r FROM (SELECT region AS r FROM sales UNION ALL " + "SELECT * FROM (SELECT buyer_phone FROM sales))", {"r"}, set(), id="子查询里 UNION 后一支的 *"), + pytest.param("SELECT * FROM (SELECT region FROM sales UNION ALL SELECT * FROM (SELECT buyer_phone FROM sales))", + {"region"}, set(), id="外层 * 取子查询里 UNION 后一支的 *"), + pytest.param("WITH t(k) AS (SELECT * FROM (SELECT buyer_name FROM sales)) SELECT k AS 名 FROM t", {"名"}, set(), + id="列名表套 * 展开子查询"), + pytest.param("SELECT region FROM sales UNION ALL SELECT s.* FROM (SELECT buyer_name FROM sales) s", {"region"}, + set(), id="UNION 后一支的 t.*"), + pytest.param("SELECT region FROM sales UNION SELECT * FROM (SELECT buyer_phone FROM sales)", {"region"}, set(), + id="UNION 去重"), + # 只展开一个派生来源、每一列都数得清:按位置只遮取自遮罩列的那一列,金额照常 + pytest.param("SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM " + "(SELECT buyer_phone, amount FROM sales ORDER BY amount DESC LIMIT 2)", {"区域"}, {"金额"}, + id="合计行 UNION 后一支的 * 按位置"), + pytest.param("SELECT region AS 区域, amount AS 金额 FROM sales UNION ALL SELECT * FROM " + "(SELECT * FROM (SELECT buyer_name, amount FROM sales))", {"区域"}, {"金额"}, + id="套了两层的 * 按位置"), + pytest.param("WITH t(r, x) AS (SELECT * FROM (SELECT buyer_phone, amount FROM sales)) SELECT r AS 号码, x AS 金额 " + "FROM t", {"号码"}, {"金额"}, id="列名表套 * 按位置"), + pytest.param("SELECT (SELECT * FROM (SELECT buyer_phone FROM sales) LIMIT 1) AS x", {"x"}, set(), + id="标量子查询里的 * 展开子查询"), + pytest.param("SELECT region, (SELECT * FROM (SELECT s2.buyer_name FROM sales s2 WHERE s2.id = s.id)) AS 名 " + "FROM sales s", {"名"}, {"region"}, id="相关子查询里的 *"), + pytest.param("SELECT (SELECT * FROM phones LIMIT 1) AS x", {"x"}, set(), id="标量子查询里的 * 展开表"), +]) +def test_a_star_that_renames_a_masked_column_is_masked(db, sql, hidden, shown): + """* 把遮罩列的值带进了别的列名:这几列整列遮,原值不交出去。按位置认得出的,别的列照常。""" + done = masked_run(db, sql) + columns = {c.lower() for c in done.columns} + assert hidden <= columns, done.columns + assert not shown & columns, done.columns + assert not _leaks(done), done.payload["rows"] + assert done.secrets.strong, "遮掉的值交回调用方(后面的查询、回答里按值遮)" + + +@pytest.mark.parametrize("sql", [ + "SELECT * FROM (SELECT region, amount FROM sales) t", + "WITH t AS (SELECT * FROM sales) SELECT region, SUM(amount) AS 合计 FROM t GROUP BY region", + "SELECT region AS 区域 FROM sales UNION ALL SELECT * FROM (SELECT region FROM sales)", + "WITH t(a, b) AS (SELECT * FROM (SELECT region, amount FROM sales)) SELECT a, SUM(b) AS 合计 FROM t GROUP BY a", + "SELECT region, EXISTS(SELECT * FROM sales s2 WHERE s2.id = s.id AND s2.amount > 500) AS 大单 FROM sales s", + "SELECT '合计' AS 区域, SUM(amount) AS 金额 FROM sales UNION ALL SELECT * FROM " + "(SELECT region, SUM(amount) FROM sales GROUP BY region)", +]) +def test_a_star_that_brings_no_masked_values_stays_visible(db, sql): + """* 只带出没碰遮罩列的值、EXISTS 里的 *(只说有没有):一列都不遮,结果原样。""" + done = masked_run(db, sql) + assert done.columns == [] and MASKED_WORD not in json.dumps(done.payload, ensure_ascii=False) + + +def test_a_plain_star_still_masks_by_name(db): + """不改名的 *(单独一条 SELECT)照旧只遮名字对上遮罩列的那几列。""" + done = masked_run(db, "SELECT * FROM sales") + assert done.columns == ["buyer_phone", "buyer_name"] and not _leaks(done) + + +@pytest.mark.parametrize("sql, columns, hidden", [ + pytest.param("SELECT x FROM (SELECT * FROM (SELECT buyer_phone FROM sales) s) AS q(x)", ["x"], {"x"}, + id="子查询列名表套 *"), + pytest.param("SELECT l.x FROM sales s CROSS JOIN LATERAL (SELECT * FROM (SELECT s.buyer_phone) z) AS l(x)", ["x"], + {"x"}, id="LATERAL 列名表套 *"), + pytest.param("SELECT ARRAY(SELECT * FROM (SELECT buyer_phone FROM sales) s) AS arr", ["arr"], {"arr"}, + id="ARRAY 子查询里的 *"), +]) +def test_a_star_renaming_in_postgres_is_masked(sql, columns, hidden): + snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []} + assert hidden <= masking._derived_hidden(snapshot, set(MASKED), "postgres")[0] + + +def test_a_metric_summed_over_a_star_renamed_masked_column_is_not_sent_to_blind_check(): + """双盲复算按同一套认法挡下取自遮罩列的指标(derived_columns):遮罩的金额经 CTE 列名表套 * 改名之后汇总,照样认得出, + 区域照常。""" + sql = "WITH q(r, x) AS (SELECT * FROM (SELECT region, amount FROM sales)) SELECT r AS 区域, SUM(x) AS gmv FROM q " \ + "GROUP BY r" + snapshot = {"source": "shop", "sql": sql, "columns": ["区域", "gmv"], "rows": []} + assert masking.derived_columns(snapshot, {"amount"}, "sqlite") == {"gmv"} + + +def test_a_recursive_cte_with_a_star_is_read_without_looping(db): + """递归的 CTE 里 * 展开它自己:照样认得出、不会绕不出来。""" + sql = ("WITH RECURSIVE r(n, p) AS (SELECT 1, buyer_phone FROM sales WHERE id = 1 UNION ALL " + "SELECT n + 1, p FROM r WHERE n < 2) SELECT * FROM (SELECT * FROM r) t") + done = masked_run(db, sql) + assert "p" in {c.lower() for c in done.columns} and not _leaks(done) + loop = "WITH RECURSIVE r AS (SELECT buyer_phone AS p FROM sales UNION ALL SELECT * FROM r) SELECT p AS 号 FROM r" + snapshot = {"source": "shop", "sql": loop, "columns": ["号"], "rows": []} + assert "号" in masking._derived_hidden(snapshot, set(MASKED), "sqlite")[0] + + +def test_a_star_with_a_replace_is_not_counted_by_position(): + """* 带着 REPLACE / EXCLUDE 这类改动(DuckDB 一类的写法):展开成的列不再是来源那几列的原样,不按位置数,整段都算。 + 这里 REPLACE 把手机号换进了「amount」那一列。""" + sql = ("SELECT region, amount FROM sales UNION ALL SELECT * REPLACE (buyer_phone AS amount) FROM " + "(SELECT buyer_phone, amount FROM sales) t") + snapshot = {"source": "shop", "sql": sql, "columns": ["region", "amount"], "rows": []} + assert {"region", "amount"} <= masking._derived_hidden(snapshot, set(MASKED), "duckdb")[0] + + +# -------------------------------------------------------------------------- +# * 展开数据源里的表:按查询当时的表结构数位置 +# -------------------------------------------------------------------------- + + +#: 两个月的订单表列一样、次序一样(上传表格里的月度工作表常这样);三月那张的号码和区域换了次序 +MONTHS = ("CREATE TABLE jan (id INTEGER, region TEXT, buyer_phone TEXT, amount INTEGER);" + "CREATE TABLE feb (id INTEGER, region TEXT, buyer_phone TEXT, amount INTEGER);" + "CREATE TABLE mar (id INTEGER, buyer_phone TEXT, region TEXT, amount INTEGER);" + "INSERT INTO jan VALUES (1, '华东', '13900000001', 1200), (2, '华南', '13900000002', 300);" + "INSERT INTO feb VALUES (3, '华东', '13900000003', 900);" + "INSERT INTO mar VALUES (4, '13900000004', '华北', 400);") + + +@pytest.fixture(scope="module") +def months(): + conn = sqlite3.connect(":memory:") + conn.executescript(MONTHS) + yield conn + conn.close() + + +def schema_of(conn) -> dict: + """数据源的表结构(schema_cache 的样子):每张表的列按表里的次序。""" + names = [name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")] + return {"tables": {name: {"columns": [{"name": row[1]} for row in conn.execute(f"PRAGMA table_info({name})")]} + for name in names}} + + +def run_months(conn, sql: str, schema: dict | None) -> masking.QueryMask: + cursor = conn.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + return masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=schema) + + +def test_a_union_of_tables_with_the_same_layout_is_masked_by_name_with_the_schema(months): + """UNION 的几支都是 SELECT * FROM 表、表的列一样:按表结构数得出号码落在哪一列,只遮那一列,汇总照常。没有表结构时 + 说不清号码排在第几,* 展开落到的那一段整段遮(多遮)。""" + schema = schema_of(months) + total = "SELECT region, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t GROUP BY region" + done = run_months(months, total, schema) + assert done.columns == [] and done.payload["rows"] == [["华东", 2100], ["华南", 300]] + assert {"region", "合计"} <= set(run_months(months, total, None).columns) + both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM feb", schema) + assert both.columns == ["buyer_phone"] and not _leaks(both) + + +def test_a_union_of_tables_in_another_order_masks_where_the_values_land(months): + """三月那张表号码排在第二列:UNION 之后它落进第一支的「region」,按表结构认得出,这一列遮;金额照常。""" + schema = schema_of(months) + both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM mar", schema) + assert {"region", "buyer_phone"} <= set(both.columns) and "amount" not in both.columns and not _leaks(both) + total = "SELECT region AS 区域, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM mar) t " \ + "GROUP BY region" + done = run_months(months, total, schema) + assert done.columns == ["区域"] and not _leaks(done) + + +def test_a_column_list_over_a_table_is_masked_by_position_with_the_schema(months): + """列名表套 * 展开数据源里的表:按表结构只遮号码换了名字的那一列。""" + done = run_months(months, "WITH t(a, b, c, d) AS (SELECT * FROM jan) SELECT * FROM t", schema_of(months)) + assert done.columns == ["c"] and [row[3] for row in done.payload["rows"]] == [1200, 300] + + +def test_a_schema_that_does_not_match_the_table_masks_the_whole_stretch(months): + """表结构里没有这张表,或者列数和实际展开的对不上(表结构过期了):照旧整段遮。""" + stale = schema_of(months) + stale["tables"]["feb"]["columns"] = stale["tables"]["feb"]["columns"][:3] + for schema in (stale, {"tables": {"jan": schema_of(months)["tables"]["jan"]}}): + both = run_months(months, "SELECT * FROM jan UNION ALL SELECT * FROM feb", schema) + assert set(both.columns) == {"id", "region", "buyer_phone", "amount"} and not _leaks(both) + + +async def test_a_snapshot_is_read_with_its_frozen_schema(months): + """事后按快照认(双盲复算挡指标、审批质询收值)用快照记着的表结构快照(schema_artifact),和查询当时工具边界的认法 + 一样:几张同样的月度表 UNION 起来汇总的金额不算取自遮罩列。""" + from app.core import artifact_store + + sql = "SELECT region, SUM(amount) AS 合计 FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t GROUP BY region" + schema = await artifact_store.put_json({"source": "shop", **schema_of(months)}, kind="schema_snapshot", + run_id="r-star", node_id="n1") + snapshot = {"source": "shop", "sql": sql, "columns": ["region", "合计"], "rows": [], "schema_artifact": schema} + assert masking.derived_columns(snapshot, {"buyer_phone"}, "sqlite") == set() + assert masking._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[0] == {"buyer_phone"} + assert masking.derived_columns({**snapshot, "schema_artifact": "missing"}, {"buyer_phone"}, "sqlite") == { + "region", "合计"} diff --git a/backend/tests/test_masking_table_functions.py b/backend/tests/test_masking_table_functions.py index 272d3959..f37bb5e1 100644 --- a/backend/tests/test_masking_table_functions.py +++ b/backend/tests/test_masking_table_functions.py @@ -107,3 +107,55 @@ def test_a_masked_column_only_in_the_condition_of_an_aggregated_case_is_like_a_f ]) def test_a_case_returning_the_masked_value_or_not_aggregated_is_still_derived(sql): assert "total" in hidden_of(sql, ["total"]) + + +# -------------------------------------------------------------------------- +# 函数参数里的 JSON 箭头(复核第 2 轮) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("kind, sql, columns, shown", [ + pytest.param("sqlite", "SELECT j.value AS v FROM sales, json_each(profile->'$.tels') AS j", ["v"], ["v"], + id="SQLite json_each 参数带箭头"), + pytest.param("postgres", "SELECT e AS 号 FROM sales, json_array_elements_text(profile->'tels') AS e", ["号"], + ["号"], id="Postgres json_array_elements_text"), + pytest.param("postgres", "SELECT e.value AS 号 FROM sales, jsonb_array_elements(profile->'tels') AS e", ["号"], + ["号"], id="Postgres jsonb_array_elements"), + pytest.param("postgres", "SELECT t.tel FROM sales, jsonb_to_recordset(profile->'contacts') AS t(tel text)", + ["tel"], ["tel"], id="Postgres jsonb_to_recordset 列名表"), + pytest.param("postgres", "SELECT k, v FROM sales, jsonb_each_text(profile->'contact') AS x(k, v)", ["k", "v"], + ["k", "v"], id="Postgres jsonb_each_text 列名表"), + pytest.param("postgres", "SELECT jsonb_array_elements_text(profile->'tels') AS 号 FROM sales", ["号"], ["号"], + id="Postgres 选出项里拆开"), + pytest.param("postgres", "SELECT jsonb_array_elements_text(profile -> 'contact' -> 'tels') AS 号 FROM sales", + ["号"], ["号"], id="Postgres 连着两个箭头"), + pytest.param("mysql", "SELECT JSON_UNQUOTE(profile->'$.tel') AS 电话 FROM sales", ["电话"], ["电话"], + id="MySQL JSON_UNQUOTE"), + pytest.param("mysql", "SELECT CONCAT(profile->'$.tel', '') AS 电话 FROM sales", ["电话"], ["电话"], + id="MySQL CONCAT"), +]) +def test_a_json_arrow_inside_function_arguments_is_read_as_the_masked_column(kind, sql, columns, shown): + """函数参数里没写表名的 col->'path' 被解析器当成以 col 为参数的 lambda(不是一列):以前认不出遮罩列,拆开、取出来的 + 值原样交给模型、落库。遮罩列是 JSON 时这几种是最自然的写法。写了表名、用 ->> 的本来就认得出。""" + assert {c.lower() for c in shown} <= hidden_of(sql, columns, kind) + + +def test_a_json_arrow_over_an_unmasked_column_stays_visible(): + """箭头取的不是遮罩列:一列都不遮。真的 lambda 的参数照列名收,只有和遮罩列同名时才多遮。""" + assert hidden_of("SELECT j.value AS 标签 FROM sales, json_each(tags->'$.list') AS j", ["标签"]) == MASKED + assert hidden_of("SELECT transform(scores, x -> x + 1) AS 分 FROM sales", ["分"], "postgres") == MASKED + + +def test_a_json_arrow_inside_json_each_is_masked_when_really_run(): + """SQLite 里真跑一遍:json_each(profile->'$.tels') 拆出来的号码在交给模型之前就遮掉。""" + import sqlite3 + + db = sqlite3.connect(":memory:") + db.executescript("CREATE TABLE sales (id INTEGER, profile TEXT);" + "INSERT INTO sales VALUES (1, '{\"tels\": [\"13900000001\", \"13900000002\"]}');") + sql = "SELECT j.value AS v FROM sales, json_each(profile->'$.tels') AS j" + cursor = db.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + db.close() + done = masking.mask_query_result(payload, ["profile"], "sqlite") + assert done.columns == ["v"] and done.payload["rows"] == [["已遮罩"], ["已遮罩"]] diff --git a/backend/tests/test_verify_agent_masking.py b/backend/tests/test_verify_agent_masking.py index 50725c47..ea6f2ab3 100644 --- a/backend/tests/test_verify_agent_masking.py +++ b/backend/tests/test_verify_agent_masking.py @@ -58,10 +58,10 @@ async def clean_spend(): yield -async def _source(tmp_path, options: dict | None) -> tuple[str, int]: +async def _source(tmp_path, options: dict | None, script: str = SALES) -> tuple[str, int]: path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" db = sqlite3.connect(path) - db.executescript(SALES) + db.executescript(script) db.commit() db.close() name = f"shop_{uuid.uuid4().hex[:8]}" @@ -385,3 +385,108 @@ def test_labels_written_in_this_query_are_not_masked_by_values_from_earlier_ones payload = {"sql": "SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档, COUNT(*) AS 笔数 FROM sales " "GROUP BY 1", "columns": ["档", "笔数"], "rows": [["大单", 1], ["小单", 3]]} assert masking.mask_query_result(payload, ["amount"], "sqlite", known).payload["rows"] == [["大单", 1], ["小单", 3]] + + +# -------------------------------------------------------------------------- +# * 把遮罩列带进别的列名、函数参数里的 JSON 箭头(复核第 2 轮) +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, masked", [ + pytest.param("SELECT region, amount FROM sales WHERE id < 0 UNION ALL " + "SELECT * FROM (SELECT buyer_phone, amount FROM sales ORDER BY id)", ["region"], + id="UNION 后一支的 *"), + pytest.param("WITH t(k, v) AS (SELECT * FROM (SELECT buyer_name, amount FROM sales ORDER BY id)) " + "SELECT k AS 姓名, v AS amount FROM t", ["姓名"], id="CTE 列名表套 *"), + pytest.param("SELECT (SELECT * FROM (SELECT s2.buyer_phone FROM sales s2 WHERE s2.id = s.id)) AS 号码, s.amount " + "FROM sales s ORDER BY s.id", ["号码"], id="标量子查询里的 *"), +]) +async def test_a_star_renaming_a_masked_column_is_masked_before_the_model_sees_it(monkeypatch, masked_shop, sql, + masked): + """* 把遮罩列的值带进了别的列名(UNION 后一支、写了列名表的 CTE、标量子查询):交给模型的、落库的、通用接口给的都是 + 「已遮罩」,金额照常。以前 * 本身不算取自哪一列,原值原样交给模型、落进快照。""" + name = masked_shop + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "amount"}, "rationale": "见 [[see:Q1]]。"}) + out, _ = await ask(name) + assert out.error is None and out.result["value"] == 1200 + seen = script.tool_results()["c1"] + assert MASKED in seen and _clean(seen), seen + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == masked and set(_cells(snapshot, masked[0])) == {MASKED} + assert _cells(snapshot, "amount") == [1200, 300, 900, 400] + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}") + assert got.status_code == 200 and got.json()["content"] == snapshot and _clean(got.text) + + +@pytest.mark.parametrize("sql, masked", [ + pytest.param("SELECT c.id AS 编号, j.value AS 值 FROM customers c, json_each(profile->'$') AS j " + "ORDER BY c.id, j.key", ["值"], id="json_each 参数带箭头"), + pytest.param("SELECT id AS 编号, upper(profile->'$.name') AS 姓名 FROM customers ORDER BY id", ["姓名"], + id="函数参数里的箭头"), +]) +async def test_a_json_arrow_over_the_masked_column_is_masked_before_the_model_sees_it(monkeypatch, json_shop, sql, + masked): + """遮罩的是 JSON 列:函数参数里没写表名的 profile->'…' 被解析器当成 lambda,以前认不出,拆开、取出来的号码和姓名原样 + 交给模型、落库。""" + name = json_shop + script = Script(monkeypatch, [q(name, sql, "c1")], + {"value": {"alias": "Q1", "row": 0, "column": "编号"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.result["value"] == 1 and _clean(script.tool_results()["c1"]) + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == masked and set(_cells(snapshot, masked[0])) == {MASKED} + assert _clean(json.dumps(snapshot, ensure_ascii=False)) + + +# -------------------------------------------------------------------------- +# 遮罩值的上限:agent 自己查到的值先收(复核第 2 轮) +# -------------------------------------------------------------------------- + + +async def test_values_the_agent_finds_survive_caller_values_that_fill_the_cap(monkeypatch, masked_shop): + """调用方交进来的值(报告快照里的)收满了上限 SECRETS_MAX:agent 自己查到的号码照样收,后面查到的备注按它遮,交回 + 调用方的 masked_values 里也认得出。以前先并调用方的值、满了就停,agent 查到的一个也并不进去:后面的备注原样交给 + 模型、原样落库,回答时按值遮也认不出。""" + monkeypatch.setattr(masking, "SECRETS_MAX", 40) + name = masked_shop + given = masking._Secrets() + given.add({"columns": ["buyer_phone"], "rows": [[str(13800000000 + i)] for i in range(40)]}, {"buyer_phone"}) + assert len(given.weak) == masking.SECRETS_MAX and not given.hit("13900000001") + steps = [q(name, "SELECT buyer_phone, amount AS total FROM sales ORDER BY id", "c1"), + q(name, "SELECT id, note FROM sales ORDER BY id", "c2")] + script = Script(monkeypatch, steps, {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"}) + out, _ = await ask(name, masked_values=given) + assert out.result["value"] == 1200 + assert _clean(script.tool_results()["c2"]), script.tool_results()["c2"] + notes = artifact_store.load(out.queries[1]["artifact"]) + assert _cells(notes, "note") == [f"客户{MASKED}来电改址", "例行回访", f"回访:联系 {MASKED}", ""] + assert out.masked_values.hit("13900000001") and out.masked_values.hit("13900000003") + assert len(out.masked_values.weak) <= masking.SECRETS_MAX, "合起来照样不超过上限:挤掉的是调用方的值" + + +#: 两个月的订单表,列一样、次序一样(上传表格里的月度工作表常这样) +MONTHS = ("CREATE TABLE jan (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER);" + "CREATE TABLE feb (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER);" + "INSERT INTO jan VALUES (1, '华东', '13900000001', 1200), (2, '华南', '13900000002', 300);" + "INSERT INTO feb VALUES (3, '华东', '13900000003', 900);") + + +async def test_a_union_of_monthly_tables_is_counted_with_the_sources_schema(monkeypatch, tmp_path): + """几张列一样的月度表用 SELECT * 连起来再汇总:按数据源的表结构数得出号码落在第几列,汇总的金额照常交给模型、照常 + 取值;选出全部列的,只遮号码那一列。* 展开数据源里的表又不按表结构数位置的话,展开落到的那一段整段都遮,金额也拿不到。""" + name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone"}, script=MONTHS) + try: + steps = [q(name, "SELECT region, SUM(amount) AS total FROM (SELECT * FROM jan UNION ALL SELECT * FROM feb) t " + "GROUP BY region ORDER BY total DESC", "c1"), + q(name, "SELECT * FROM jan UNION ALL SELECT * FROM feb ORDER BY id", "c2")] + script = Script(monkeypatch, steps, {"value": {"alias": "Q1", "row": 0, "column": "total"}, "rationale": "x"}) + out, _ = await ask(name) + assert out.error is None and out.result["value"] == 2100 + assert all(_clean(text) for text in script.tool_results().values()) + totals, rows = (artifact_store.load(query["artifact"]) for query in out.queries) + assert totals["masked_at_query"] == [] and totals["rows"] == [["华东", 2100], ["华南", 300]] + assert rows["masked_at_query"] == ["buyer_phone"] and _cells(rows, "amount") == [1200, 300, 900] + finally: + await engines.invalidate(source_id) From b0fdf56eede6d3a3725136cd7943eb169289ecdb Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 21:50:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?K3=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E7=AC=AC=203=20=E8=BD=AE=EF=BC=89=EF=BC=9A=E5=86=99?= =?UTF-8?q?=E4=BA=86=E5=BA=93=E5=90=8D=E7=9A=84=E8=A1=A8=E6=8C=89=E3=80=8C?= =?UTF-8?q?=E5=BA=93=E5=90=8D.=E8=A1=A8=E5=90=8D=E3=80=8D=E6=9F=A5?= =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=EF=BC=9BUNION=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=94=AF=E6=8C=89=E8=A1=A8=E7=BB=93=E6=9E=84=E6=95=B0=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E5=88=97=E5=90=8D=E8=A6=81=E5=92=8C=E5=90=8E=E9=9D=A2?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E6=94=AF=E4=B8=80=E6=A0=B7=E5=AE=BD=E6=89=8D?= =?UTF-8?q?=E7=94=A8=EF=BC=9B=E6=95=B4=E8=A1=8C=E5=BC=95=E7=94=A8=E3=80=81?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=8F=82=E6=95=B0=E9=87=8C=E7=9A=84=20*?= =?UTF-8?q?=E3=80=81=E8=A1=A8=E7=9A=84=E5=88=97=E5=90=8D=E8=A1=A8=E3=80=81?= =?UTF-8?q?TABLE=20x=E3=80=81PIVOT=20=E8=BF=99=E7=B1=BB=E8=AE=A4=E5=BE=97?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写了库名的表(复核 major 1):表结构以前按表名查,查询写的是另一个库里的同名表(archive.sales,列序不同)时照探查 的那张数位置,号码原样交给模型、落库。现在表结构另按「库名.表名」记一份(库名取探查时记下的 schema、qualified 的 前缀,masking._schema_keys),写了库名的引用按它查(_table_key),查不到的按数不清整段遮;探查时没设 schema、查询又 写了库名的也查不到(多遮)。数位置(_star_columns、_branch_names)、认标签列(_table_lacks)都照这个查法 - 过期的表结构(复核 major 2,对 099473c 的回退):UNION 第一支是 * 时,按表结构数出的列名以前不核对列数就拿来对后一支 的位置,表结构过期、列数已经变了也照对,遮罩值记到错的列名上。现在后面每一支数得出的列数都和它一样宽才用 (_source_names、_width),对不上、数不清的退回整段遮 - 整行和别的 * 写法(复核 major 3):写在函数参数、表达式里的 *、s.*(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*)、 Oracle 的 JSON_OBJECT(*))按它展开的来源认(_value_columns、_scoped_star_inputs,相关子查询、FROM 里的子查询照样 找得到外层的来源)。几种写法先改写语法树再认(_normalized,原文不动):Postgres 的整行引用(row_to_json(s)、 json_agg(t)、SELECT s、s::text)按 Postgres 先认列的规矩,不是列的改成 ROW(s.*);带列名表的表(FROM sales AS s(i, r, p),引用 CTE 时写的也算)改成派生来源;解析器读错的 TABLE x 改成 SELECT * FROM x;选出项套了括号的 * ((s.*)、(s).*)按 * 数位置。PIVOT、UNPIVOT、MATCH_RECOGNIZE 读到遮罩列的整份遮。SQL 解析不了、里面有 *(COUNT(*) 除外)或 TABLE x 的(UNION ALL TABLE x、带库名的 TABLE),同写着遮罩列一样整份遮(_implicit) - Postgres 的列名表只写前几列时,不按表结构补全:别的方言里列名表比列少只能是表结构过期了,补全就丢了列数这道核对; 这时改了名的几列整段遮(多遮),照原名的按名字认 - inquiry 模块说明的已知局限补上:写了库名的表、过期表结构的列数核对、Postgres 列名表只写前几列时整段遮、探查的不是 默认库时没写库名的同名表可能数错、整行引用按表结构先认列(查不到时按整行多遮)、解析不了的 SQL 里的整行引用认不出 测试:新增 test_masking_whole_rows(整行、函数参数里的 *、列名表、TABLE x、Oracle 的 JSON_OBJECT(*) / UNPIVOT / PIVOT / MATCH_RECOGNIZE、解析不了的 SQL、不碰遮罩列的照常);test_masking_star_renames 补写了库名的表(SQLite ATTACH 真跑)、 过期的表结构;test_verify_agent_masking 补核查 agent 的真实查询工具:过期的表结构,Postgres / Oracle 形状的结果(换掉 驱动)交给模型、落库、通用工件接口都遮好。每处实现都做过去掉之后测试变红的验证;复核给的 Postgres 18 探针、端到端 在临时库上重跑,都不再漏。 --- backend/app/engine/inquiry.py | 20 +- backend/app/engine/masking.py | 284 +++++++++++++++++++-- backend/tests/test_masking_star_renames.py | 127 +++++++++ backend/tests/test_masking_whole_rows.py | 244 ++++++++++++++++++ backend/tests/test_verify_agent_masking.py | 133 ++++++++++ 5 files changed, 776 insertions(+), 32 deletions(-) create mode 100644 backend/tests/test_masking_whole_rows.py diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 1ca86ad7..696ad70d 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -58,12 +58,20 @@ 遮罩列的,按出列名认它出的列(常见函数的出列名、写的列名表、别名、函数名,_function_columns),同名的别的列一并遮 (多遮);不认得的函数又没写列名表的,整份快照都遮。函数参数里没写表名的 col->'path'(JSON 箭头,解析器当成以 col 为参数的 lambda)按 col 认,真的 lambda(transform(arr, x -> …))的参数和遮罩列同名时也算(多遮) -- * 把遮罩列带进别的列名(WITH t(a, b, …) AS (SELECT * …) 这类列名表、UNION 后一支的 *、标量子查询里的 *):只展开一个 - 来源、那里每一列都数得清的,按位置只遮取自遮罩列的那几列——子查询按它的选出项数,数据源里的表按查询当时冻结的表结构 - 数(几张列一样的月度表 SELECT * 连起来汇总,金额照常)。表结构里查不到这张表、数出来的列数和实际展开的对不上,或者 - * 连着几个来源的,* 展开落到的那一段整段遮,同一段里的金额这类别的列一并遮(多遮);表结构过期、列数没变却换了次序 - 或改了名的,可能数错(同按表结构认标签列)。标量子查询里的 * 展开数据源里的表一律算取自遮罩列。EXCEPT 后一支只用来 - 去掉行,也照 UNION 的后一支算(多遮) +- * 把遮罩列带进别的列名(WITH t(a, b, …) AS (SELECT * …) 这类列名表、表的列名表 FROM sales AS s(i, r, p)、UNION 后一支 + 的 *、TABLE x、标量子查询里的 *):只展开一个来源、那里每一列都数得清的,按位置只遮取自遮罩列的那几列——子查询按它的 + 选出项数,数据源里的表按查询当时冻结的表结构数(几张列一样的月度表 SELECT * 连起来汇总,金额照常)。表结构里查不到 + 这张表(写了库名的按「库名.表名」查:另一个库里的同名表 archive.sales 不按探查的 sales 数,探查时没设 schema、查询 + 又写了库名的也一样)、数出来的列数和实际展开的对不上(UNION 第一支的 * 按表结构数出的列名,后面每一支都要一样宽才用; + Postgres 的列名表只写前几列、后面的照原名时也对不上),或者 * 连着几个来源的,* 展开落到的那一段整段遮,同一段里的 + 金额这类别的列一并遮(多遮)。表结构过期、列数没变却换了次序或改了名的,可能数错(同按表结构认标签列);没写库名的表 + 按探查的那张数,探查的不是默认的库、默认的库里又有一张列序不同的同名表时也会数错。标量子查询里的 * 展开数据源里的表 + 一律算取自遮罩列。EXCEPT 后一支只用来去掉行,也照 UNION 的后一支算(多遮) +- 整行和函数参数里的 *(Postgres 的 row_to_json(s)、json_agg(t)、SELECT s、s::text、to_json(s.*)、concat_ws(',', s.*), + Oracle 的 JSON_OBJECT(*))按 * 展开的来源算:取自子查询、CTE 的看它选出的列,取自数据源里的表的一律算取自遮罩列(整列 + 遮)。Postgres 先按列认:表结构里查得到和表同名的列(visits 表的 visits 列)才当列,查不到时按整行算(多遮)。PIVOT、 + UNPIVOT、MATCH_RECOGNIZE 读到遮罩列的,整份快照的列都遮(多遮)。SQL 解析不了的只看文字:写着遮罩列、*(COUNT(*) 除外)、 + TABLE x 的整份遮,整行引用这类不写列名的写法认不出 - 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里 照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent 在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收: diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index b5775c11..58f3bac0 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -13,10 +13,12 @@ - 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的 另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数、函数参数里的 JSON 箭头 col->'path');* 把 遮罩列带进别的列名的(写了列名表的派生来源、UNION 后一支的 *、标量子查询里的 *)按它展开落到的位置算(_star_places, - 展开数据源里的表的按查询当时冻结的表结构数位置,_schema_columns)。 + 展开数据源里的表的按查询当时冻结的表结构数位置,_schema_columns;写了库名的表按「库名.表名」查,另一个库里的同名表 + 查不到)。整行和函数参数里的 *(Postgres 的 row_to_json(s)、SELECT s、to_json(s.*),Oracle 的 JSON_OBJECT(*))按 * + 展开的来源算;表的列名表(FROM sales AS s(i, r, p))、TABLE x、套了括号的 * 先改写成认得出的样子(_normalized)。 COUNT 只数个数、窗口的分区键只决定次序、按遮罩列分档又当分组键或套在汇总里数个数的、汇总里的 CASE 只在条件里用到的 - 不算(_value_columns、_band_only)。认不出落在哪一列的、SQL 解析不了又写着遮罩列的,整份快照的列都算。双盲复算按同一套 - 认法在派盲算之前挡下取自遮罩列的指标(derived_columns) + 不算(_value_columns、_band_only)。认不出落在哪一列的(PIVOT、UNPIVOT 这类也算)、SQL 解析不了又写着遮罩列(或者 *、 + TABLE x)的,整份快照的列都算。双盲复算按同一套认法在派盲算之前挡下取自遮罩列的指标(derived_columns) - 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值;* 展开数据源里的表时按冻结的表结构 认它带不带同名的列(_schema_tables) - 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出 @@ -35,7 +37,7 @@ import functools import json import logging import re -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field from decimal import Decimal from typing import Any @@ -87,7 +89,9 @@ def _value_columns(node: Any) -> set[str]: 同写在 WHERE 里(SUM(CASE WHEN buyer_phone IS NOT NULL THEN amount ELSE 0 END) 的值全部来自金额);不汇总的逐行 CASE 照旧连条件一起算(每一行落在哪一支本身就是这一行的信息)。 套在里面的子查询选的是 *(标量子查询、ARRAY(SELECT * …)):值取自 * 展开的来源(_star_inputs),派生来源看它的各个 - 选出项,表值函数看参数,展开了数据源里的表的记成 _ANY_COLUMN;EXISTS 里的 * 只说有没有,不算。函数参数里的 + 选出项,表值函数看参数,展开了数据源里的表的记成 _ANY_COLUMN;EXISTS 里的 * 只说有没有,不算。写在函数参数、表达式 + 里的 *(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*)、Oracle 的 JSON_OBJECT(*),Postgres 的整行引用改写成的 ROW(s.*), + 见 _normalized)同样按它展开的来源算(_scoped_star_inputs);COUNT(*)、count(s.*) 只数个数,不算。函数参数里的 col->'path'(JSON 箭头)被解析器当成以 col 为参数的 lambda,参数名照列名收(真的 lambda 的参数多算一列无害)。""" from sqlglot import exp @@ -97,8 +101,16 @@ def _value_columns(node: Any) -> set[str]: expanded = {id(node)} while stack: item = stack.pop() - if isinstance(item, exp.Column): - if not isinstance(item.this, exp.Star) and item.name: + if isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)): + inputs = _scoped_star_inputs(item) + if inputs is None: + out.add(_ANY_COLUMN) + continue + fresh = [n for n in inputs if id(n) not in expanded] + expanded.update(id(n) for n in fresh) + stack.extend(fresh) + elif isinstance(item, exp.Column): + if item.name: out.add(item.name.lower()) elif isinstance(item, exp.Count) or (isinstance(item, (exp.Case, exp.If)) and _band_only(item)): continue @@ -217,6 +229,8 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str], tables: dict[ if root is None: return {} try: + # 和认遮罩列时一样先改写(套了括号的 * 按 * 数位置)。这里的表结构只有列名的集合,认整行引用时不看它(多遮) + _normalized(root, _read(kind), None) result = next((top for top, role, _listed in _blocks(root) if role == "result"), None) if result is None: return {} @@ -243,9 +257,23 @@ class _LabelMemo(dict): # type: ignore[type-arg] tables: dict[str, set[str]] | None = None +def _schema_keys(key: Any, meta: dict[str, Any]) -> set[str]: + """表结构里的一张表按哪些名字查(小写,查法见 _table_key):没写库名的引用按登记的名字、不带库名的表名查;写了库名的 + (archive.sales)按「库名.表名」查,库名取表结构记下的 schema 和 qualified 的前缀。表结构探查的是一个库(schema), + 查询写了别的库名的同名表是另一张表(列的次序可能不一样),查不到,按说不清处理;探查时没设 schema 的,表结构里没有 + 库名,写了库名的一律查不到(宁可多遮)。""" + name = str(key).lower() + qualified = str(meta.get("qualified") or key).lower() + bare = qualified.rsplit(".", 1)[-1] + keys = {name, name.rsplit(".", 1)[-1], bare, qualified} + if meta.get("schema"): + keys.add(f"{str(meta['schema']).lower()}.{bare}") + return keys + + def _schema_tables(schema: Any) -> dict[str, set[str]] | None: """冻结的表结构(数据源的 schema_cache、表结构快照的内容:{"tables": {表名: {"columns": [{"name": …}]}}})→ - {表名(小写): 列名(小写)},按登记的名字和不带库名的表名各记一份(几个库里同名的表合起来算,宁可多遮)。表结构 + {表名(小写): 列名(小写)},按 _schema_keys 的几个名字各记一份(几个库里同名的表合起来算,宁可多遮)。表结构 里没列出的表不记(截断了的表结构也一样),查不到就当说不清;整份拿不到的为 None。""" tables = schema.get("tables") if isinstance(schema, dict) else None if not isinstance(tables, dict): @@ -256,15 +284,14 @@ def _schema_tables(schema: Any) -> dict[str, set[str]] | None: if not isinstance(columns, list): continue names = {str(c["name"]).lower() for c in columns if isinstance(c, dict) and c.get("name") is not None} - for name in {str(key).lower(), str(key).lower().rsplit(".", 1)[-1], - str(meta.get("qualified") or key).lower().rsplit(".", 1)[-1]}: + for name in _schema_keys(key, meta): out.setdefault(name, set()).update(names) return out def _schema_columns(schema: Any) -> dict[str, list[str]] | None: """冻结的表结构 → {表名(小写): 按表里次序的列名(小写)},名字的记法同 _schema_tables;几个库里同名、列又不一样的表 - 不记(说不清 * 展开的是哪一张)。* 展开数据源里的表时按它数位置(_star_columns);整份拿不到的为 None。""" + 不按表名记(说不清 * 展开的是哪一张)。* 展开数据源里的表时按它数位置(_star_columns);整份拿不到的为 None。""" tables = schema.get("tables") if isinstance(schema, dict) else None if not isinstance(tables, dict): return None @@ -275,20 +302,26 @@ def _schema_columns(schema: Any) -> dict[str, list[str]] | None: if not isinstance(columns, list) or not all(isinstance(c, dict) and c.get("name") is not None for c in columns): continue names = [str(c["name"]).lower() for c in columns] - for name in {str(key).lower(), str(key).lower().rsplit(".", 1)[-1], - str(meta.get("qualified") or key).lower().rsplit(".", 1)[-1]}: + for name in _schema_keys(key, meta): if out.setdefault(name, names) != names: clashed.add(name) return {name: names for name, names in out.items() if name not in clashed} +def _table_key(source: Any) -> str: + """FROM 里一张数据源里的表按什么名字查表结构(_schema_keys):没写库名的是表名,写了的连着库名(archive.sales,写了 + 三段的连着 catalog),小写。""" + return ".".join(part for part in (source.catalog, source.db, source.name) if part).lower() + + def _table_lacks(source: Any, name: str, memo: _LabelMemo) -> bool: - """FROM 里的这个来源是数据源里的一张表,冻结的表结构里查得到它、又没有 name 这一列:* 展开它带不出 name。""" + """FROM 里的这个来源是数据源里的一张表,冻结的表结构里查得到它、又没有 name 这一列:* 展开它带不出 name。写了库名的 + 按「库名.表名」查(_table_key)。""" from sqlglot import exp if not memo.tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier): return False - columns = memo.tables.get(source.name.lower()) + columns = memo.tables.get(_table_key(source)) return columns is not None and name not in columns @@ -669,6 +702,32 @@ def _star_inputs(star: Any, select: Any) -> list[Any] | None: return out +def _outer_selects(node: Any) -> Iterator[Any]: + """由里往外,node 所在的位置看得见 FROM 里的来源的那几层 SELECT。CTE、FROM / JOIN 里的子查询(不是 LATERAL)看不见 + 同一层 FROM 里的别的来源:WITH、FROM 属于的那一层跳过,再外层的照样看得见(相关子查询)。""" + from sqlglot import exp + + skip = None + node = node.parent + while node is not None: + if isinstance(node, exp.Select) and node is not skip: + yield node + if isinstance(node, exp.CTE) or (isinstance(node, exp.Subquery) + and isinstance(node.parent, (exp.From, exp.Join))): + skip = node.parent.parent + node = node.parent + + +def _scoped_star_inputs(star: Any) -> list[Any] | None: + """写在表达式里的 *(函数参数里的 s.*、JSON_OBJECT(*))带出的值取自哪里(_star_inputs):由里往外找 FROM 里有这个来源的 + 那一层 SELECT(_outer_selects,相关子查询里的 s.* 指外层的 s),没写表名的 * 展开最近一层的全部来源。找不到为 None + (说不清,按取自遮罩列算)。""" + for select in _outer_selects(star): + if _star_sources(star, select) is not None: + return _star_inputs(star, select) + return None + + def _star_feeds(star: Any, select: Any, names: set[str]) -> bool: """select 里的这个 * 带出的值里有没有取自 names 的(展开了数据源里的表的一律算,见 _star_inputs)。""" inputs = _star_inputs(star, select) @@ -676,12 +735,13 @@ def _star_feeds(star: Any, select: Any, names: set[str]) -> bool: def _table_columns(source: Any, tables: dict[str, list[str]] | None) -> list[str] | None: - """FROM 里的这个来源是数据源里的一张表、冻结的表结构里查得到它时,它按次序的列名(_schema_columns);别的为 None。""" + """FROM 里的这个来源是数据源里的一张表、冻结的表结构里查得到它时,它按次序的列名(_schema_columns);别的为 None。 + 写了库名的按「库名.表名」查(_table_key):另一个库里的同名表查不到,不拿探查的那张表的列序去数。""" from sqlglot import exp if not tables or not isinstance(source, exp.Table) or not isinstance(source.this, exp.Identifier): return None - return tables.get(source.name.lower()) + return tables.get(_table_key(source)) def _single_source(star: Any, select: Any, depth: int) -> Any: @@ -764,6 +824,12 @@ def _branch_names(select: Any, tables: dict[str, list[str]] | None, depth: int = return out +def _width(select: Any, tables: dict[str, list[str]] | None) -> int | None: + """一支(一条 SELECT)结果的列数,按 _branch_names 数;* 说不清展开成几列的为 None。""" + names = _branch_names(select, tables) + return len(names) if names is not None else None + + def _star_places(select: Any, width: int | None, names: set[str], tables: dict[str, list[str]] | None = None ) -> set[int] | None: """select 这一支里的 * 把取自 names 的值带到了结果(width 列)的哪几列。* 展开的列落在第一个 * 起、最后一个 * 后面 @@ -788,6 +854,145 @@ def _star_places(select: Any, width: int | None, names: set[str], tables: dict[s return set(range(first, end)) +#: 有整行引用的方言(sqlglot 的方言名):不带表名的名字是 FROM 里一个来源时,可能指它的整行(Postgres 的 +#: row_to_json(s)、SELECT s,DuckDB 同)。SQLite、MySQL、Oracle 里这样写只能是列 +_ROW_DIALECTS = frozenset({"postgres", "duckdb"}) + + +def _normalized(root: Any, read: str | None, tables: dict[str, list[str]] | None) -> None: + """几种写法先就地改写成认得出的样子,再认遮罩列、标签列(_tainted、_label_columns)。只改解析出来的语法树,原文不动; + 改写前后结果有哪几列、每一列的值取自哪里都一样: + - TABLE x(Postgres、MySQL):解析器认不出,CTE、子查询里的读成了一个叫 TABLE 的列、表。改成 SELECT * FROM x;带库名、 + ORDER BY 的解析不了,按解析不了处理(_implicit) + - 选出项套了括号的 *((s.*))、(s).*:Postgres 照样展开成好几列,改成 s.*,不然按一列数位置。括号里不是来源的 + ((t.profile).* 展开一个复合类型的列)改成展开一个对不上任何来源的 *:说不清展开成几列,整段遮 + - 带列名表的表(Postgres 的 FROM sales AS s(i, r, p),引用 CTE 时再写列名表的也算):改成 (SELECT * FROM sales) AS + s(i, r, p),按派生来源的列名表认改了名的列。Postgres 的列名表可以比列少(只改前几列的名字,后面的照原名),这时 + * 展开的列数和列名表对不上,列名表里的几列整段算(_star_places),后面照原名的按名字认。不按表结构补全列名表: + 别的方言里列名表比列少只能是表结构过期了,补全就丢了这一道核对 + - 整行引用(read 是 _ROW_DIALECTS 之一,_row_source):row_to_json(s)、json_agg(t)、SELECT s、s::text 里的 s 改成 + ROW(s.*),按 * 展开的来源认(_value_columns) + tables 是查询当时冻结的表结构(_schema_columns,按次序的列名),认整行引用时看来源出不出某一列;没有为 None。""" + from sqlglot import exp + + for cte in list(root.find_all(exp.CTE)): + body = cte.this + if isinstance(body, exp.Alias) and _table_keyword(body.this) and isinstance(body.args.get("alias"), + exp.Identifier): + cte.set("this", exp.select("*").from_(exp.Table(this=body.args["alias"].copy()))) + for sub in list(root.find_all(exp.Subquery)): + body = sub.this + alias = body.args.get("alias") if isinstance(body, exp.Table) else None + if alias is not None and _table_keyword(body.this) and not alias.columns \ + and isinstance(alias.this, exp.Identifier): + sub.set("this", exp.select("*").from_(exp.Table(this=alias.this.copy()))) + selects = list(root.find_all(exp.Select)) + for select in selects: + for item in list(select.expressions): + if (star := _bare_star(item)) is not None: + item.replace(star) + for select in selects: + for _name, source in _from_sources(select): + alias = source.args.get("alias") + if isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier) and alias is not None \ + and alias.columns: + inner = source.copy() + inner.set("alias", None) + source.replace(exp.Subquery(this=exp.select("*").from_(inner), alias=alias.copy())) + if read in _ROW_DIALECTS: + for column in list(root.find_all(exp.Column)): + if column.table or not isinstance(column.this, exp.Identifier) or not _carries_value(column): + continue + source = _row_source(column, tables) + if source is None: + continue + row = exp.Anonymous(this="ROW", expressions=[exp.Column(this=exp.Star(), table=exp.to_identifier(source))]) + # 直接是选出项的起回原名:结果的列名照旧(SELECT s 的列叫 s) + bare = isinstance(column.parent, exp.Select) and column.arg_key == "expressions" + column.replace(exp.alias_(row, column.this.copy()) if bare else row) + + +def _table_keyword(node: Any) -> bool: + """解析器把 TABLE x 读成的那个名字:不带引号、不带表名的 TABLE。""" + from sqlglot import exp + + ident = node.this if isinstance(node, exp.Column) and not node.table else node + return isinstance(ident, exp.Identifier) and not ident.quoted and ident.name.upper() == "TABLE" + + +def _bare_star(item: Any) -> Any: + """选出项是套了括号的 *((s.*)、((*)))或 (s).* 时,改写成的 *、s.*;别的为 None(_normalized)。""" + from sqlglot import exp + + node = item + while isinstance(node, exp.Paren): + node = node.this + if node is not item and _is_star(node): + return node.copy() + if not (isinstance(item, exp.Dot) and isinstance(item.expression, exp.Star)): + return None + inner = item.this + while isinstance(inner, exp.Paren): + inner = inner.this + if isinstance(inner, exp.Column) and not inner.table and isinstance(inner.this, exp.Identifier): + return exp.Column(this=exp.Star(), table=inner.this.copy()) + # 展开的不是 FROM 里的来源(复合类型的列、函数交回的记录):记成一个对不上任何来源的 *(_star_sources 为 None) + return exp.Column(this=exp.Star(), table=exp.to_identifier("*")) + + +def _carries_value(column: Any) -> bool: + """列引用写在选出项里、FROM 里(函数参数、LATERAL):值会流进结果。WHERE、GROUP BY、ORDER BY、JOIN 的 ON / USING + 里的不算(_normalized 只改写会带出值的整行引用)。""" + from sqlglot import exp + + node = column + while node.parent is not None and not isinstance(node.parent, exp.Select): + if isinstance(node.parent, exp.Join) and node.arg_key in ("on", "using"): + return False + node = node.parent + return node.parent is not None and node.arg_key in ("expressions", "from", "from_", "joins", "laterals") + + +def _row_source(column: Any, tables: dict[str, list[str]] | None) -> str | None: + """Postgres 里不带表名的名字 column 指整行时,它指的来源的名字(别名,没起别名的是表名);是列、不是任何来源的为 None。 + Postgres 先按列认:由里往外看得见的每一层(_outer_selects)的来源里,有一个出得了这一列(_has_column)就是列;都不是、 + 又有一个来源叫这个名字,才是整行(取最里面的那个)。说不清出不出这一列的(表结构里查不到的表、* 展开的列)按出不了算, + 当整行(宁可多遮)。""" + name = column.name.lower() + found: str | None = None + for select in _outer_selects(column): + for alias, source in _from_sources(select): + if _has_column(source, name, tables): + return None + if found is None and alias == name: + found = source.alias_or_name + return found + + +def _has_column(source: Any, name: str, tables: dict[str, list[str]] | None) -> bool: + """FROM 里的这个来源说得准出 name 这一列:派生来源看列名表、第一支里起了名字的选出项(列名表改了名的不算),数据源里 + 的表看表结构(_table_columns),表值函数这类看它出的列名(_function_columns)。* 展开的列说不准,不算。""" + from sqlglot import exp + + derived = _derived_block(source) + if derived is not None: + block, listed = derived + first = _leaves(block)[0] + items = list(first.expressions) if isinstance(first, exp.Select) else [] + if listed is not None: + if name in listed: + return True + if any(_is_star(item) for item in items): + return False + items = items[len(listed):] + return any(_output_name(item) == name for item in items if not _is_star(item)) + if isinstance(source, exp.Table) and isinstance(source.this, exp.Identifier): + columns = _table_columns(source, tables) + return columns is not None and name in columns + columns = _function_columns(source) + return columns is not None and name in columns + + def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: """SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪: - "result":最外层,结果就是快照的列; @@ -825,7 +1030,8 @@ def _source_names(top: Any, listed: list[str] | None, names: set[str], 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。 * 带出遮罩列的值、展开的列又换了名字的(写了列名表、UNION 后一支的 *),它展开落到的那几列照位置对上(_star_places); 单独一条 SELECT、第一支的 * 不改名,遮罩列照原名传出去,按名字就认得出。第一支的 * 展开成哪几列数得清的(_branch_names), - 后一支落进去的位置按它对上列名。tables 是查询当时冻结的表结构(_schema_columns),* 展开数据源里的表时按它数位置。""" + 后面每一支数出的列数又都和它一样宽时,后一支落进去的位置按它对上列名;对不上的(表结构过期了)不按它对。tables 是 + 查询当时冻结的表结构(_schema_columns),* 展开数据源里的表时按它数位置。""" from sqlglot import exp selects = [leaf for leaf in _leaves(top) if isinstance(leaf, exp.Select)] @@ -858,6 +1064,11 @@ def _source_names(top: Any, listed: list[str] | None, names: set[str], take(_output_name(item)) if len(selects) > 1: header = _branch_names(selects[0], tables) + # 第一支的 * 按表结构数出来的列名:后面每一支数得出的列数都和它一样宽才按它对位置。表结构过期了(这张表后来多了、 + # 少了列),按它数出的列名和实际的不一样宽,对上的名字是错的;有一支对不上、数不清的,退回不按表结构数(* 展开的 + # 位置认不出,整段遮) + if header is not None and any(_width(select, tables) != len(header) for select in selects[1:]): + header = None if header is not None: width, heads = len(header), dict(enumerate(header)) else: @@ -883,8 +1094,11 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: d FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE、LATERAL VALUES)参数里写着这些名字的,它出的列 都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算。 * 展开的列换了名字(写了列名表的派生来源、UNION 后一支的 *):* 带出这些名字的值时,它展开落到的那几列照位置算 - (_star_places,展开数据源里的表的按 tables 数位置,数不清的整段算);标量子查询里的 * 按 _value_columns 算进外层的 - 表达式。""" + (_star_places,展开数据源里的表的按 tables 数位置,数不清的整段算);标量子查询里的 *、函数参数里的 *(to_json(s.*)、 + JSON_OBJECT(*))、整行引用(row_to_json(s))按 _value_columns 算进外层的表达式。表的列名表、TABLE x、套了括号的 * + 先改写成上面这些认得出的样子(_normalized)。 + PIVOT、UNPIVOT、MATCH_RECOGNIZE 读到这些名字的:值转进了它们自己起名的列(UNPIVOT 的值列、PIVOT 按 IN 里的值起名的列、 + MEASURES),出的是哪几列不好认,整份快照的列都算。""" from sqlglot import exp from app.data.sql_semantics import _parse, _read @@ -896,6 +1110,7 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: d if root is None: return None try: + _normalized(root, _read(kind), tables) names = set(masked) selects = list(root.find_all(exp.Select)) blocks = _blocks(root) @@ -918,7 +1133,8 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: d names |= columns or set() if len(names) == before: break - everything = unreadable or any(_source_names(top, listed, names, tables)[1] for top, listed in sources) + everything = unreadable or any(_source_names(top, listed, names, tables)[1] for top, listed in sources) \ + or any(_reads(names, _value_columns(node)) for node in root.find_all(exp.Pivot, exp.MatchRecognize)) positions: set[int] = set() for top, role, _listed in blocks: if role != "result": @@ -948,14 +1164,28 @@ def _mentions(sql: str, names: Iterable[str]) -> bool: return any(re.search(rf"(? bool: + """解析不了的 SQL 里有没有不写列名也会带出遮罩列的写法(*、TABLE x):它们能把遮罩列带进别的列名(列名表、UNION 的 + 后一支),同写着遮罩列一样整份遮(_derived_hidden)。只看文字,字符串、注释里的也算(多遮)。""" + return bool(_IMPLICIT.search(_COUNT_STAR.sub("", sql))) + + def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None, tables: dict[str, list[str]] | None = None) -> tuple[set[str], set[str]]: """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。tables 是查询当时冻结的表结构(_schema_columns),* 展开 数据源里的表时按它数位置;没给的按快照记着的表结构快照(schema_artifact)认,和工具边界遮的时候是同一份。 证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名 - (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的,整份快照的 - 列都遮掉;合并查询的快照不看 SQL,它记着自己的来历(_hidden_columns 已按 _merge_masks 遮好)。""" + (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的(*、TABLE x 不写 + 列名也会把遮罩列带进别的列名,同样算,_implicit),整份快照的列都遮掉;合并查询的快照不看 SQL,它记着自己的来历 + (_hidden_columns 已按 _merge_masks 遮好)。""" base = set(hidden) sql = snapshot.get("sql") if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: @@ -963,7 +1193,8 @@ def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None columns = [str(c) for c in snapshot.get("columns") or []] found = _tainted(sql, kind, base, len(columns), _snapshot_columns(snapshot) if tables is None else tables) if found is None: - return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) + hides = _mentions(sql, base) or _implicit(sql) + return (base | {c.lower() for c in columns}, base) if hides else (base, base) names, positions = found return names | {columns[i].lower() for i in positions if i < len(columns)}, names @@ -1779,8 +2010,9 @@ def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str (_derived_hidden 的 tables)时用。 - 整列遮:列名直接对上遮罩列的,加上按 SQL 认出值取自遮罩列的(_derived_hidden:别名、函数、子查询、WITH、 - SELECT *(展开的列换了名字的也算)、表值函数;COUNT、只在条件里用到、分档当分组键的不算);SQL 解析不了、又写着 - 遮罩列的,整份结果的列都遮。这几列的格子换成「已遮罩」,类型记成文本 + SELECT *(展开的列换了名字的也算)、表值函数、整行引用和函数参数里的 *、列名表、TABLE x;COUNT、只在条件里用到、 + 分档当分组键的不算);SQL 解析不了、又写着遮罩列(或者 *、TABLE x)的,整份结果的列都遮。这几列的格子换成 + 「已遮罩」,类型记成文本 - 别的列按值换:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans)。遮罩值 是这份结果里的加上 known:先查号码、再查写着号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样 遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不 diff --git a/backend/tests/test_masking_star_renames.py b/backend/tests/test_masking_star_renames.py index 36c93230..aebab95a 100644 --- a/backend/tests/test_masking_star_renames.py +++ b/backend/tests/test_masking_star_renames.py @@ -249,3 +249,130 @@ async def test_a_snapshot_is_read_with_its_frozen_schema(months): assert masking._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[0] == {"buyer_phone"} assert masking.derived_columns({**snapshot, "schema_artifact": "missing"}, {"buyer_phone"}, "sqlite") == { "region", "合计"} + + +# -------------------------------------------------------------------------- +# 写了库名的表、过期的表结构(复核第 3 轮) +# -------------------------------------------------------------------------- + + +#: 同名的两张表:主库的 sales 和另一个库(archive)里的 sales,号码、区域换了次序。表结构探查的是主库 +TWO_SALES = ("CREATE TABLE sales (id INTEGER, region TEXT, buyer_phone TEXT, buyer_name TEXT, amount INTEGER, " + "note TEXT);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', '张伟', 1200, '改址');" + "CREATE TABLE archive.sales (id INTEGER, buyer_phone TEXT, region TEXT, buyer_name TEXT, amount INTEGER, " + "note TEXT);" + "INSERT INTO archive.sales VALUES (7, '13900000007', '华北', '王芳', 500, '回访');") +SALES_LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"] + + +@pytest.fixture +def two_sales(): + conn = sqlite3.connect(":memory:") + conn.execute("ATTACH DATABASE ':memory:' AS archive") + conn.executescript(TWO_SALES) + yield conn + conn.close() + + +def probed(schema: str | None = None) -> dict: + """探查主库得到的表结构(schema_cache 的样子):没设 schema 的探查当前库,qualified 就是表名。""" + meta = {"qualified": f"{schema}.sales" if schema else "sales", "schema": schema, + "columns": [{"name": c} for c in SALES_LAYOUT]} + return {"tables": {"sales": meta}, "schema": schema} + + +def run_two(conn, sql: str, schema: dict | None) -> masking.QueryMask: + cursor = conn.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + return masking.mask_query_result(payload, MASKED, "sqlite", schema=schema) + + +@pytest.mark.parametrize("sql", [ + pytest.param("WITH t(a, b, c, d, e, f) AS (SELECT * FROM archive.sales) SELECT b AS j, a AS n FROM t", + id="列名表套 * 展开另一个库里的同名表"), + pytest.param("SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f WHERE 0 UNION ALL " + "SELECT * FROM archive.sales", id="UNION 后一支的 * 展开另一个库里的同名表"), + pytest.param("WITH t AS (SELECT * FROM sales UNION ALL SELECT * FROM archive.sales) SELECT region AS 区域 FROM t", + id="派生来源里 UNION 后一支展开另一个库里的同名表"), +]) +def test_a_table_in_another_schema_is_not_counted_by_the_probed_one(two_sales, sql): + """表结构探查的是主库:查询写了别的库名的同名表(archive.sales,号码排在第二列),不能按主库那张表的列序数位置, + 按数不清处理,* 展开落到的那一段整段遮。以前按表名查表结构,号码落进的那一列被当成区域,原样交给模型、落库。""" + done = run_two(two_sales, sql, probed()) + assert not _leaks(done), done.payload["rows"] + + +def test_a_table_named_with_its_probed_schema_is_still_counted(two_sales): + """库名和表结构记下的对得上(探查时设了 schema、qualified 带着它):照样按表结构数位置,只遮号码那一列。没写库名的 + 照旧按表名查。库名对不上表结构记下的(这里探查时没设 schema,查询写了 main.)按数不清处理,整段遮(多遮)。""" + precise = "WITH t(a, b, c, d, e, f) AS (SELECT * FROM {}) SELECT c AS j, a AS n FROM t" + assert run_two(two_sales, precise.format("main.sales"), probed("main")).columns == ["j"] + assert run_two(two_sales, precise.format("sales"), probed()).columns == ["j"] + assert set(run_two(two_sales, precise.format("main.sales"), probed()).columns) == {"j", "n"} + + +def test_schema_columns_are_also_keyed_by_the_probed_schema(): + """表结构按「库名.表名」另记一份(库名取 schema、qualified 的前缀);写了别的库名的查不到。""" + tables = masking._schema_columns(probed("public")) + assert tables["sales"] == tables["public.sales"] == SALES_LAYOUT and "archive.sales" not in tables + assert "public.sales" not in masking._schema_columns(probed()) + assert "archive.sales" not in (masking._schema_tables(probed("public")) or {}) + + +#: 一月的表结构是早先探查的(那时还没有 extra 这一列),表里现在多了一列,排在第二;二月那张号码排在第二列 +STALE = ("CREATE TABLE jan (id INTEGER, extra TEXT, region TEXT, buyer_phone TEXT, amount INTEGER);" + "CREATE TABLE feb (id INTEGER, buyer_phone TEXT, region TEXT, amount INTEGER, extra TEXT);" + "INSERT INTO jan VALUES (1, '加急', '华东', '13900000001', 1200);" + "INSERT INTO feb VALUES (2, '13900000002', '华南', 300, '普通');") + + +@pytest.fixture +def stale(): + conn = sqlite3.connect(":memory:") + conn.executescript(STALE) + yield conn + conn.close() + + +def layout(**tables: list[str]) -> dict: + return {"tables": {name: {"qualified": name, "columns": [{"name": c} for c in columns]} + for name, columns in tables.items()}} + + +#: 过期的表结构:一月那张少了 extra(列数和表里实际的不一样) +OLD_JAN = layout(jan=["id", "region", "buyer_phone", "amount"], feb=["id", "buyer_phone", "region", "amount", "extra"]) +#: 准的表结构 +NOW = layout(jan=["id", "extra", "region", "buyer_phone", "amount"], + feb=["id", "buyer_phone", "region", "amount", "extra"]) + + +@pytest.mark.parametrize("sql", [ + pytest.param("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, amount, extra FROM feb) " + "SELECT extra AS 备注 FROM t", id="后一支明写各列"), + pytest.param("WITH t AS (SELECT * FROM jan UNION ALL SELECT * FROM feb) SELECT extra AS 备注 FROM t", + id="后一支也是 *"), +]) +def test_a_first_branch_counted_by_a_stale_schema_is_not_trusted(stale, sql): + """UNION 第一支是 *、按表结构数出的列名(表结构过期了,少一列)和后一支的列数对不上:不按它对列名,* 所在的位置按认不出 + 算,整份遮。以前照过期的列名表对,二月的号码落进的「extra」没认出来,原样交回。""" + cursor = stale.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + for schema in (OLD_JAN, None, NOW): + done = masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=schema) + assert done.columns == ["备注"] and not _leaks(done), (schema, done.payload["rows"]) + + +def test_a_first_branch_counted_by_an_accurate_schema_still_places_the_values(stale): + """表结构准、每一支数得出的列数都对得上:照样按第一支的列名对上位置,只遮号码落进的那一列,金额照常。""" + sql = ("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, buyer_phone, amount FROM feb) " + "SELECT region AS 区域, amount AS 金额 FROM t") + cursor = stale.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + assert masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=NOW).columns == [] + moved = sql.replace("SELECT id, buyer_phone, region,", "SELECT id, region, buyer_phone,") + cursor = stale.execute(moved) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], + "sql": moved} + done = masking.mask_query_result(payload, ["buyer_phone"], "sqlite", schema=NOW) + assert done.columns == ["区域"] and [row[1] for row in done.payload["rows"]] == [1200, 300] diff --git a/backend/tests/test_masking_whole_rows.py b/backend/tests/test_masking_whole_rows.py new file mode 100644 index 00000000..b3fc11a3 --- /dev/null +++ b/backend/tests/test_masking_whole_rows.py @@ -0,0 +1,244 @@ +"""按 SQL 认取自遮罩列的结果列(engine/masking.py 的 _derived_hidden):整行、函数参数里的 *、列名表把遮罩列带进别的列名 +(复核第 3 轮)。 + +Postgres 里不带表名、名字是 FROM 里一个来源的列引用指它的整行(row_to_json(s)、json_agg(t)、SELECT s、s::text),函数 +参数里的 s.*(to_json(s.*)、concat_ws(',', s.*)、ROW(s.*))也把整行交给函数;数据源里的表可以带列名表(FROM sales AS +s(i, r, p),只改前几列的名字),TABLE sales 等于 SELECT * FROM sales;Oracle 的 JSON_OBJECT(*) 把整行拼成 JSON,UNPIVOT、 +PIVOT、MATCH_RECOGNIZE 把遮罩列的值转进它们自己起名的列。这些写法以前都认不出:遮罩列的值落进了别的列名,原样交给核查 +agent、落进快照。现在整行、函数参数里的 * 按 * 展开的来源认(数据源里的表一律算带出了遮罩列),列名表按位置对上(表结构 +查得到这张表的只遮号码改了名字的那一列),TABLE x 当 SELECT * FROM x,PIVOT 这类读到遮罩列的整份遮。 + +这里没有真的 Postgres、Oracle:结果按它们交回的样子造(行转成 JSON、记录转成文字),只看遮完的结果里还有没有原值。数据 +都是虚构的:区域、手机号、姓名、金额。 +""" +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from app.engine import masking + +MASKED_WORD = "已遮罩" +MASKED = ["buyer_phone", "buyer_name"] +SECRETS = ("1390000000", "张伟", "李娜") +LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"] +#: 探查得到的表结构(没设 schema,探查的是当前库) +SCHEMA = {"tables": {"sales": {"qualified": "sales", "schema": None, "columns": [{"name": c} for c in LAYOUT]}}} +ROWS = [[1, "华东", "13900000001", "张伟", 1200, "改址"], [2, "华南", "13900000002", "李娜", 300, "回访"]] + + +def as_json(row: list) -> str: + return json.dumps(dict(zip(LAYOUT, row)), ensure_ascii=False) + + +def as_record(row: list) -> str: + return "(" + ",".join(str(v) for v in row) + ")" + + +def hidden_of(sql: str, columns: list[str], kind: str = "postgres", schema: dict | None = None) -> set[str]: + snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []} + return masking._derived_hidden(snapshot, set(MASKED), kind, masking._schema_columns(schema))[0] + + +def masked(sql: str, columns: list[str], rows: list[list], kind: str = "postgres", + schema: dict | None = SCHEMA) -> masking.QueryMask: + payload = {"columns": columns, "rows": rows, "sql": sql} + return masking.mask_query_result(payload, MASKED, kind, schema=schema) + + +def leaks(done: masking.QueryMask) -> bool: + text = json.dumps(done.payload["rows"], ensure_ascii=False) + return any(s in text for s in SECRETS) + + +# -------------------------------------------------------------------------- +# 整行引用、函数参数里的 * +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, cells", [ + pytest.param("SELECT row_to_json(s) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="row_to_json(s)"), + pytest.param("SELECT to_jsonb(s) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="to_jsonb(s)"), + pytest.param("SELECT s AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="SELECT s"), + pytest.param("SELECT s::text AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="s::text"), + pytest.param("SELECT to_json(s.*) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], id="to_json(s.*)"), + pytest.param("SELECT concat_ws(',', s.*) AS j, s.id AS n FROM sales s", + [",".join(str(v) for v in r) for r in ROWS], id="concat_ws(',', s.*)"), + pytest.param("SELECT ROW(s.*) AS j, s.id AS n FROM sales s", [as_record(r) for r in ROWS], id="ROW(s.*)"), + pytest.param("SELECT (s).buyer_phone AS j, s.id AS n FROM sales s", [r[2] for r in ROWS], id="(s).buyer_phone"), + pytest.param("SELECT (s.*).buyer_name AS j, s.id AS n FROM sales s", [r[3] for r in ROWS], id="(s.*).buyer_name"), + pytest.param("SELECT (SELECT row_to_json(s)) AS j, s.id AS n FROM sales s", [as_json(r) for r in ROWS], + id="相关子查询里的整行"), + pytest.param("SELECT kv.value AS j, s.id AS n FROM sales s, LATERAL jsonb_each_text(to_jsonb(s)) AS kv " + "WHERE kv.key = 'buyer_phone'", [r[2] for r in ROWS], id="LATERAL 拆开整行"), +]) +def test_a_whole_row_carrying_masked_columns_is_masked(sql, cells): + """整行、函数参数里的 s.* 把遮罩列的值交给了别的列:这一列遮,编号照常。""" + done = masked(sql, ["j", "n"], [[cell, row[0]] for cell, row in zip(cells, ROWS)]) + assert done.columns == ["j"] and not leaks(done), done.payload["rows"] + assert [row[1] for row in done.payload["rows"]] == [1, 2] + + +@pytest.mark.parametrize("sql", [ + "SELECT json_agg(t) AS j FROM (SELECT id, buyer_phone FROM sales) t", + "SELECT row_to_json(t) AS j FROM (SELECT buyer_name AS 名 FROM sales) t", + "WITH t AS (SELECT id, buyer_phone AS p FROM sales) SELECT json_agg(t) AS j FROM t", +]) +def test_a_whole_row_of_a_derived_source_is_read_through_its_columns(sql): + """整行取自子查询、CTE:按它选出的列认,里面有遮罩列(改了名字的也算)就算。""" + assert "j" in hidden_of(sql, ["j"]) + + +@pytest.mark.parametrize("sql", [ + "SELECT (SELECT j FROM (SELECT row_to_json(s) AS j) z) AS j FROM sales s", + "SELECT (SELECT j FROM (SELECT json_agg(t) AS j) z) AS j FROM (SELECT id, buyer_phone FROM sales) t", + "SELECT (WITH w AS (SELECT row_to_json(s) AS j) SELECT j FROM w) AS j FROM sales s", + "SELECT (SELECT j FROM (SELECT to_json(s.*) AS j) z) AS j FROM sales s", +]) +def test_a_whole_row_of_an_outer_query_is_found_from_a_nested_derived_table(sql): + """FROM 里的子查询、CTE 看不见同一层 FROM 里的别的来源,却看得见再外层的查询:写在里面的整行引用、s.* 指外层的来源, + 照样认得出。""" + assert "j" in hidden_of(sql, ["j"]) + + +@pytest.mark.parametrize("sql", [ + "SELECT json_agg(t) AS j FROM (SELECT id, region, amount FROM sales) t", + "SELECT count(s.*) AS j FROM sales s", + "SELECT COUNT(*) AS j FROM sales s", + "SELECT region AS j, SUM(amount) AS total FROM sales s GROUP BY region", + "SELECT EXISTS(SELECT * FROM sales s2 WHERE s2.id = s.id) AS j FROM sales s", +]) +def test_rows_and_stars_that_bring_no_masked_values_stay_visible(sql): + """整行取自没有遮罩列的子查询、count(s.*) 只数个数:不算。""" + assert hidden_of(sql, ["j", "total"]) == set(MASKED) + + +def test_a_column_named_like_its_table_is_a_column_where_the_schema_says_so(): + """Postgres 先按列认:表里有一列和表同名(visits 表的 visits 列),SELECT visits 取的是这一列,不是整行。表结构里查得到 + 这一列就不按整行算;SQLite、MySQL 没有整行引用,不看表结构也不按整行算。""" + schema = {"tables": {"visits": {"columns": [{"name": c} for c in ("id", "visits", "buyer_phone")]}}} + sql = "SELECT visits AS j FROM visits" + assert hidden_of(sql, ["j"], schema=schema) == set(MASKED) + assert hidden_of(sql, ["j"], kind="sqlite") == set(MASKED) + assert "j" in hidden_of(sql, ["j"]), "表结构里查不到这一列:可能是整行,按整行算(多遮)" + + +def test_a_star_inside_a_function_reads_every_source_it_expands(): + """Oracle 的 JSON_OBJECT(*) 把整行拼成 JSON:同 to_json(s.*)。""" + done = masked("SELECT JSON_OBJECT(*) AS j FROM sales", ["j"], [[as_json(r)] for r in ROWS], kind="oracle") + assert done.columns == ["j"] and not leaks(done) + + +# -------------------------------------------------------------------------- +# 列名表、TABLE x +# -------------------------------------------------------------------------- + + +def test_a_table_with_a_column_list_masks_the_renamed_masked_column(): + """数据源里的表带列名表:号码改叫 p。列名表写全了的,按表结构对上位置,只遮 p 和姓名改叫的 q,编号照常;Postgres 的 + 列名表只写前几列(后面的照原名)时,列名表和表的列数对不上,改了名的几列整段遮(多遮),照原名的姓名按名字遮。""" + full = "SELECT p AS j, i AS n FROM sales AS s(i, r, p, q, a, o)" + done = masked(full, ["j", "n"], [[r[2], r[0]] for r in ROWS]) + assert done.columns == ["j"] and not leaks(done) + assert set(masked(full, ["j", "n"], [[r[2], r[0]] for r in ROWS], schema=None).columns) == {"j", "n"}, \ + "表结构里查不到这张表:说不清哪几列改了名,都遮" + short = masked("SELECT p AS j, i AS n FROM sales AS s(i, r, p)", ["j", "n"], [[r[2], r[0]] for r in ROWS]) + assert set(short.columns) == {"j", "n"} and not leaks(short) + star = masked("SELECT * FROM sales AS s(i, r, p)", ["i", "r", "p", *LAYOUT[3:]], [list(r) for r in ROWS]) + assert star.columns == ["i", "r", "p", "buyer_name"] and not leaks(star) + + +def test_a_column_list_on_a_cte_reference_renames_it_too(): + """引用 CTE 时再写列名表(FROM t AS x(p, a)):同样按位置对上。""" + sql = "WITH t AS (SELECT buyer_phone, amount FROM sales) SELECT p AS j, a AS total FROM t AS x(p, a)" + assert hidden_of(sql, ["j", "total"]) >= {"j"} + assert "total" not in hidden_of(sql, ["j", "total"]) + + +@pytest.mark.parametrize("sql, columns, shown", [ + pytest.param("WITH t(a, b, c, d, e, f) AS (TABLE sales) SELECT c AS j, a AS n FROM t", ["j", "n"], {"n"}, + id="CTE 里的 TABLE"), + pytest.param("SELECT * FROM (TABLE sales) AS t(a, b, c, d, e, f)", ["a", "b", "c", "d", "e", "f"], {"a", "b", "e"}, + id="子查询里的 TABLE"), + pytest.param("WITH t(a, b, c) AS (TABLE sales) SELECT c AS j FROM t", ["j"], set(), id="列名表只写前几列"), +]) +def test_a_table_statement_is_read_as_select_star(sql, columns, shown): + """TABLE sales(Postgres、MySQL)就是 SELECT * FROM sales:列名表照样按位置对上,号码改叫 c 的那一列遮。""" + hidden = hidden_of(sql, columns, schema=SCHEMA) + assert {"j", "c"} & hidden and not shown & hidden, hidden + + +@pytest.mark.parametrize("sql", [ + "SELECT 0 AS a, '' AS b, '' AS c, '' AS d, 0 AS e, '' AS f WHERE false UNION ALL TABLE sales", + "WITH t(a, b, c) AS (TABLE archive.sales) SELECT c AS j FROM t", + "WITH t(a, b, c) AS (TABLE sales ORDER BY id LIMIT 1) SELECT c AS j FROM t", + "WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS j FROM t WHERE (", +]) +def test_sql_that_cannot_be_read_but_has_a_star_or_a_table_statement_hides_everything(sql): + """解析器认不出的写法(UNION 后一支的 TABLE、带库名或 ORDER BY 的 TABLE):SQL 里没写遮罩列,可 TABLE 语句、* 会把它 + 带进别的列名,整份快照的列都算,同解析不了又写着遮罩列的。""" + columns = ["a", "b", "c", "j"] + assert hidden_of(sql, columns) >= set(columns) + + +@pytest.mark.parametrize("sql", [ + "SELECT COUNT(*) AS j FROM sales WHERE (", + "SELECT amount * 2 AS j FROM sales WHERE (", +]) +def test_sql_that_cannot_be_read_with_only_a_count_or_a_product_shows_the_rest(sql): + """只有 COUNT(*)、乘号:不会把遮罩列带出来,照旧只遮遮罩列本身。""" + assert hidden_of(sql, ["j"]) == set(MASKED) + + +# -------------------------------------------------------------------------- +# UNPIVOT、PIVOT、MATCH_RECOGNIZE +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sql, columns", [ + pytest.param("SELECT id, val FROM sales UNPIVOT (val FOR col IN (buyer_phone, region))", ["id", "val"], + id="UNPIVOT"), + pytest.param("SELECT * FROM (SELECT region, buyer_phone FROM sales) PIVOT (MAX(buyer_phone) FOR region IN " + "('华东' AS e, '华南' AS s))", ["e", "s"], id="PIVOT"), + pytest.param("SELECT * FROM sales MATCH_RECOGNIZE (PARTITION BY region ORDER BY id MEASURES buyer_phone AS x " + "ONE ROW PER MATCH PATTERN (a) DEFINE a AS amount > 0)", ["region", "x"], id="MATCH_RECOGNIZE"), +]) +def test_pivots_reading_masked_columns_hide_everything(sql, columns): + """UNPIVOT 的值列、PIVOT 按 IN 里的值起的列、MATCH_RECOGNIZE 的 MEASURES 装着遮罩列的值:出的是哪几列不好认,整份遮。""" + assert hidden_of(sql, columns, kind="oracle") >= {c.lower() for c in columns} + + +@pytest.mark.parametrize("sql, columns", [ + ("SELECT id, val FROM sales UNPIVOT (val FOR col IN (region, note))", ["id", "val"]), + ("SELECT * FROM (SELECT region, buyer_phone FROM sales) PIVOT (COUNT(buyer_phone) FOR region IN " + "('华东' AS e, '华南' AS s))", ["e", "s"]), +]) +def test_pivots_that_bring_no_masked_values_stay_visible(sql, columns): + assert hidden_of(sql, columns, kind="oracle") == set(MASKED) + + +# -------------------------------------------------------------------------- +# 选出项套了括号的 * +# -------------------------------------------------------------------------- + + +def test_a_star_in_parentheses_is_counted_as_a_star(): + """Postgres 里选出项 (s.*)、(s).* 照样展开成好几列:后面没起别名的表达式按结果末尾倒着数,不按一列数位置。""" + columns = [*LAYOUT, "upper"] + for item in ("(s.*)", "(s).*"): + sql = f"SELECT {item}, UPPER(s.buyer_name) FROM sales s" + assert hidden_of(sql, columns) == {*MASKED, "upper"}, item + + +def test_sqlite_runs_of_the_same_shapes_are_unchanged(): + """SQLite 没有整行引用、列名表:同样的写法照旧(表和列同名时按列认,不多遮)。""" + conn = sqlite3.connect(":memory:") + conn.executescript("CREATE TABLE region (id INTEGER, region TEXT, buyer_phone TEXT);" + "INSERT INTO region VALUES (1, '华东', '13900000001');") + sql = "SELECT region AS 区域, COUNT(*) AS n FROM region GROUP BY region" + cursor = conn.execute(sql) + payload = {"columns": [d[0] for d in cursor.description], "rows": [list(r) for r in cursor.fetchall()], "sql": sql} + assert masking.mask_query_result(payload, MASKED, "sqlite").payload is payload + conn.close() diff --git a/backend/tests/test_verify_agent_masking.py b/backend/tests/test_verify_agent_masking.py index ea6f2ab3..01a4968a 100644 --- a/backend/tests/test_verify_agent_masking.py +++ b/backend/tests/test_verify_agent_masking.py @@ -490,3 +490,136 @@ async def test_a_union_of_monthly_tables_is_counted_with_the_sources_schema(monk assert rows["masked_at_query"] == ["buyer_phone"] and _cells(rows, "amount") == [1200, 300, 900] finally: await engines.invalidate(source_id) + + +# -------------------------------------------------------------------------- +# 过期的表结构、写了库名的表、整行与列名表(复核第 3 轮) +# -------------------------------------------------------------------------- + + +#: 一月那张表后来多了一列 extra(排在第二),数据源的表结构是那之前探查的;二月那张号码排在第二列 +REBUILT = ("CREATE TABLE jan (id INTEGER PRIMARY KEY, extra TEXT, region TEXT, buyer_phone TEXT, amount INTEGER);" + "CREATE TABLE feb (id INTEGER PRIMARY KEY, buyer_phone TEXT, region TEXT, amount INTEGER, extra TEXT);" + "INSERT INTO jan VALUES (1, '加急', '华东', '13900000001', 1200);" + "INSERT INTO feb VALUES (2, '13900000002', '华南', 300, '普通');") + + +async def test_a_stale_schema_does_not_place_masked_values_by_its_old_layout(monkeypatch, tmp_path): + """数据源的表结构过期了(一月那张少了 extra):UNION 第一支的 * 按它数出的列名和后一支的列数对不上,不按它对位置,整份 + 遮。以前照过期的列名对,二月的号码落进的「extra」认不出,原样交给模型、落库。""" + name, source_id = await _source(tmp_path, {"mask_columns": "buyer_phone"}, script=REBUILT) + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + cache = dict(row.schema_cache) + jan = dict(cache["tables"]["jan"]) + jan["columns"] = [c for c in jan["columns"] if c["name"] != "extra"] + row.schema_cache = {**cache, "tables": {**cache["tables"], "jan": jan}} + await session.commit() + try: + sql = ("WITH t AS (SELECT * FROM jan UNION ALL SELECT id, buyer_phone, region, amount, extra FROM feb) " + "SELECT extra AS 备注, id AS n FROM t ORDER BY id") + script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "n"}, + "rationale": "x"}) + out, _ = await ask(name) + seen = script.tool_results()["c1"] + assert MASKED in seen and _clean(seen), seen + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert "备注" in snapshot["masked_at_query"] and _clean(json.dumps(snapshot, ensure_ascii=False)) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}") + assert got.status_code == 200 and _clean(got.text) + finally: + await engines.invalidate(source_id) + + +#: 造出来的 Postgres、Oracle 数据源的表结构:探查的是当前库(没设 schema),sales 一张表 +PG_LAYOUT = ["id", "region", "buyer_phone", "buyer_name", "amount", "note"] +PG_SCHEMA = {"tables": {"sales": {"qualified": "sales", "schema": None, + "columns": [{"name": c, "type": "TEXT"} for c in PG_LAYOUT]}}, "schema": None} +PG_ROWS = [[1, "华东", "13900000001", "张伟", 1200, "改址"], [2, "华南", "13900000002", "李娜", 300, "回访"]] + + +def _json_row(row: list) -> str: + return json.dumps(dict(zip(PG_LAYOUT, row)), ensure_ascii=False) + + +#: (数据源类型, SQL, 数据库交回的行, 查询时就整列遮掉的列, 编号那一格取到的值)。结果列按 SQL 里起的别名(j、n)。行照 +#: 驱动交回的样子造:JSON 是字符串,记录转成文字;archive.sales 是另一个库里的同名表,号码排在第二列。表的列名表只写了 +#: 前几列(Postgres 后面的列照原名)、另一个库里的同名表:说不清号码改叫了哪个名字,那一段整段遮,编号也遮 +DRIVER_CASES = [ + ("postgresql", "SELECT json_agg(t) AS j, 1 AS n FROM (SELECT id, buyer_phone FROM sales) t", + [[json.dumps([{"id": r[0], "buyer_phone": r[2]} for r in PG_ROWS]), 1]], ["j"], 1), + ("postgresql", "SELECT row_to_json(s) AS j, s.id AS n FROM sales s ORDER BY s.id", + [[_json_row(r), r[0]] for r in PG_ROWS], ["j"], 1), + ("postgresql", "SELECT to_json(s.*) AS j, s.id AS n FROM sales s ORDER BY s.id", + [[_json_row(r), r[0]] for r in PG_ROWS], ["j"], 1), + ("postgresql", "SELECT s AS j, s.id AS n FROM sales s ORDER BY s.id", + [["(" + ",".join(map(str, r)) + ")", r[0]] for r in PG_ROWS], ["j"], 1), + ("postgresql", "SELECT p AS j, i AS n FROM sales AS s(i, r, p) ORDER BY i", [[r[2], r[0]] for r in PG_ROWS], + ["j", "n"], None), + ("postgresql", "WITH t(a, b, c, d, e, f) AS (TABLE sales) SELECT c AS j, a AS n FROM t ORDER BY a", + [[r[2], r[0]] for r in PG_ROWS], ["j"], 1), + ("postgresql", "WITH t(a, b, c, d, e, f) AS (SELECT * FROM archive.sales) SELECT b AS j, a AS n FROM t", + [["13900000007", 7]], ["j", "n"], None), + ("postgresql", "WITH t(a, b, c, d, e, f) AS (SELECT * FROM sales) SELECT c AS j, a AS n FROM t ORDER BY a", + [[r[2], r[0]] for r in PG_ROWS], ["j"], 1), + ("oracle", "SELECT JSON_OBJECT(*) AS j, id AS n FROM sales ORDER BY id", [[_json_row(r), r[0]] for r in PG_ROWS], + ["j"], 1), + ("oracle", "SELECT id AS n, val AS j FROM sales UNPIVOT (val FOR col IN (buyer_phone, region)) ORDER BY id", + [[1, "13900000001"], [1, "华东"], [2, "13900000002"], [2, "华南"]], ["n", "j"], None), +] + + +@pytest.fixture +async def driver_shop(): + """造一个 Postgres / Oracle 数据源(查询交给下面换掉的 run_query,不连库):买家手机号、姓名设了遮罩。返回建源的函数。""" + made: list[int] = [] + + async def make(kind: str) -> str: + name = f"pg_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind=kind, host="127.0.0.1", port=5432, username="probe", database="shop", + readonly=True, enabled=True, options={"mask_columns": "buyer_phone, buyer_name"}, + schema_cache=PG_SCHEMA) + session.add(row) + await session.commit() + made.append(row.id) + return name + + yield make + for source_id in made: + await engines.invalidate(source_id) + + +@pytest.mark.parametrize("kind, sql, rows, masked, value", DRIVER_CASES, + ids=["json_agg(t)", "row_to_json(s)", "to_json(s.*)", "SELECT s", "表的列名表", "CTE 里的 TABLE", + "另一个库里的同名表", "本库的表按位置数", "JSON_OBJECT(*)", "UNPIVOT"]) +async def test_whole_rows_and_renamed_columns_from_other_databases_are_masked(monkeypatch, driver_shop, kind, sql, + rows, masked, value): + """Postgres 的整行(json_agg(t)、row_to_json(s)、to_json(s.*)、SELECT s)、表的列名表、CTE 里的 TABLE sales、另一个库里的 + 同名表,Oracle 的 JSON_OBJECT(*)、UNPIVOT:交给模型的、落库的、通用接口给的都是遮好的,编号照常(认不出位置的整份遮)。 + 以前这些写法 masked_at_query 是空的,号码、姓名原样交给模型、落库。""" + from app.data.engine import QueryResult + from app.tools import datasource + + async def driver(source, statement, **kwargs): + assert statement == sql + columns = ["n", "j"] if statement.startswith("SELECT id AS n") else ["j", "n"] + return QueryResult(columns=columns, rows=[list(r) for r in rows], row_count=len(rows), truncated=False, + elapsed_ms=1, sql=statement) + + monkeypatch.setattr(datasource, "run_query", driver) + name = await driver_shop(kind) + script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "n"}, + "rationale": "x"}) + out, events = await ask(name) + seen = script.tool_results()["c1"] + assert MASKED in seen and _clean(seen), seen + snapshot = artifact_store.load(out.queries[0]["artifact"]) + assert snapshot["masked_at_query"] == masked and _clean(json.dumps(snapshot, ensure_ascii=False)) + assert out.result["value"] == value + [end] = [d for k, d in events if k == EventType.TOOL_END] + assert _clean(end["preview"]) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}") + assert got.status_code == 200 and _clean(got.text)