diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index ffbd7d7a..02988e3a 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -61,9 +61,9 @@ 复算),反方审稿核对这类汇总数的结论也拿不到数。汇总里套着结果全是写死常量的 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),同名的别的列一并遮 - (多遮);不认得的函数又没写列名表的,整份快照都遮。Postgres 里可能交回复合类型、展开成各个字段的也整份遮 +- FROM、JOIN、LATERAL 里的表值函数(json_each、json_tree、unnest、jsonb_each_text、JSON_TABLE)拆开遮罩列的,按 + 出列名认它出的列(常见函数的出列名、写的列名表、别名、函数名,_function_columns),同名的别的列一并遮(多遮); + 不认得的函数又没写列名表的,整份快照都遮。Postgres 里可能交回复合类型、展开成各个字段的也整份遮 (masking._spreads_record):unnest(数组的元素是复合类型时展开成 addr、tel 这些字段,写了列名表也只改前几列的 名字),不认得的函数只写了列名、没写类型的列名表(jsonb_populate_record(NULL::contact, …) AS r(a) 后面的字段 照原名出);拆的是普通数组的 unnest 也一样整份遮(元素是什么类型 SQL 里看不出,多遮)。函数参数里没写表名的 @@ -90,15 +90,18 @@ 按看不见算(当成数据源里的表,多遮) - 认不全的写法整份遮(多遮,同一份里没碰遮罩列的编号、金额也遮):(表达式).* 展开读到遮罩列的(复合类型的列 (p.profile).*、函数交回的记录 (jsonb_each_text(pj)).*、子查询里的 ROW (q.r).*,外面套了括号、起了别名的也算,展开成 - 几列、各叫什么 SQL 里看不出;不只选出项,VALUES 里的、行构造器和函数参数里的也算),VALUES 里的 s.*、(s.*) 读到遮罩列 - 的(同样展开成好几列);语法树和实际执行的对不上的(MySQL、MariaDB 会执行的注释 /*! … */,Postgres 的 - U&"…" 名字,没有 * 的一支选出项和结果的列数对不上);表名、SQL 写在字符串里交给函数执行、整表导出的(query_to_xml、 - table_to_xml、cursor_to_xml、schema_to_xml、database_to_xml 这一族,dblink、crosstab、ts_stat,Oracle 的 - DBMS_XMLGEN、XMLTYPE)、按页按文件按日志读原始数据的(pageinspect、逻辑解码、LOAD_FILE)、统计信息、会话记录、锁和 - 日志里存着值的(Postgres 的 pg_stats、pg_stat_statements、pg_stat_activity;SQLite 的 sqlite_stat4;MySQL 的 - column_statistics、general_log、PROCESSLIST、INNODB_TRX、OPTIMIZER_TRACE、performance_schema;MariaDB 的 - column_stats、INNODB_LOCKS、QUERY_CACHE_INFO;Oracle 的直方图、记着最小最大值的 *_tab_columns、V$SQL 一族、 - SQL 调优集、固定下来的执行计划、闪回事务、LogMiner、审计记录),按名字认(masking 的 INDIRECT_FUNCTIONS、 + 几列、各叫什么 SQL 里看不出;不只选出项,VALUES 里的、行构造器和函数参数里的也算),VALUES 当一张表用(FROM、JOIN、 + LATERAL 里的,WITH 里的一段,UNION 的一支)又读到遮罩列的(直接写遮罩列、经子查询或派生来源改了名字的、里面写着 + s.*、(s.*) 的都算;各库给 VALUES 的列起名不一样:Postgres、SQLite 叫 column1…,MySQL 叫 column_0…,MariaDB 拿第一行 + 的值当列名,不逐个认,同一份里的编号、区域也遮,只收值取自遮罩列的那几列的值);语法树和实际执行的对不上的(MySQL、 + MariaDB 会执行的注释 /*! … */,Postgres 的 U&"…" 名字,没有 * 的一支选出项和结果的列数对不上);表名、SQL 写在字符串里 + 交给函数执行、整表导出的(query_to_xml、table_to_xml、cursor_to_xml、schema_to_xml、database_to_xml 这一族,dblink、 + crosstab、ts_stat,Oracle 的 DBMS_XMLGEN、XMLTYPE)、按页按文件按日志读原始数据的(pageinspect、逻辑解码、 + LOAD_FILE)、统计信息、会话记录、锁和日志里存着值的(Postgres 的 pg_stats、pg_stat_statements、pg_stat_activity; + SQLite 的 sqlite_stat4;MySQL 的 column_statistics、general_log、PROCESSLIST、INNODB_TRX、OPTIMIZER_TRACE、 + performance_schema,sys 库里读它的函数(按 sys.ps_ 开头认:ps_thread_trx_info、ps_thread_stack 交回别的线程执行过的 + 语句原文);MariaDB 的 column_stats、INNODB_LOCKS、QUERY_CACHE_INFO;Oracle 的直方图、记着最小最大值的 *_tab_columns、 + V$SQL 一族、SQL 调优集、固定下来的执行计划、闪回事务、LogMiner、审计记录),按名字认(masking 的 INDIRECT_FUNCTIONS、 INDIRECT_NAMES,可验证报告用的同一份名单),去掉注释再认一遍(函数名和括号之间夹着注释),自己建的同类函数、视图 认不出;information_schema、pg_catalog 里只有表结构的视图不算(核查 agent 查列名、类型常用)。不是查询的语句里, 只读表结构的 SHOW(表、列、索引、库、表状态、建库语句)、DESCRIBE 一张表照常,别的 SHOW(ENGINE INNODB STATUS、 diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index 263c000a..50526a4c 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -25,7 +25,9 @@ 不算(_value_columns、_band_only)。认不全的写法整份快照的列都算,不再逐个形状补规则:认不出落在哪一列的(PIVOT、 UNPIVOT 这类也算),(表达式).* 展开读到遮罩列的(复合类型的列、函数交回的记录,外面套了括号、起了别名的也算,展开成 几列、叫什么说不清;不只选出项,VALUES、行构造器、函数参数里的也算;Postgres 里 FROM 中可能交回复合类型的表值函数 - 同理,_spreads_record),语法树和实际执行的对不上的(OPAQUE_SQL:MySQL 会执行的注释、U&"…";没有 * 的一支选出项 + 同理,_spreads_record),VALUES 当一张表用(FROM、JOIN、LATERAL 里的,WITH 里的一段,UNION 的一支)又读到遮罩列的 + (各库给它的列起名不一样:Postgres、SQLite 叫 column1…,MySQL 叫 column_0…,MariaDB 拿第一行的值当列名, + _values_names),语法树和实际执行的对不上的(OPAQUE_SQL:MySQL 会执行的注释、U&"…";没有 * 的一支选出项 和结果的列数对不上),表名、SQL 写在字符串里交给函数、统计信息和会话记录里存着值的(INDIRECT_FUNCTIONS、 INDIRECT_NAMES:query_to_xml 这一族、dblink、pg_stats、MySQL 的 column_statistics、INNODB_TRX、Oracle 的直方图和 V$SQL……,可验证报告用的同一份),不只读表结构的 SHOW、EXPLAIN 一条语句(_reads_beyond:引擎状态、日志、出错信息、 @@ -862,10 +864,23 @@ def _function_sources(select: Any) -> list[Any]: return out -def _function_columns(source: Any) -> set[str] | None: +def _function_node(source: Any) -> Any: + """表值函数这类来源里真正出行的那个节点(函数、VALUES、JSON_TABLE……):套在外面的 LATERAL、表、括号先去掉。""" + from sqlglot import exp + + node = source.this if isinstance(source, exp.Lateral) else source + func = node.this if isinstance(node, exp.Table) else node + while isinstance(func, exp.Subquery): + func = func.this + return func + + +def _function_columns(source: Any, kind: str | None = None) -> set[str] | None: """表值函数这类来源出的列名(小写):写的列名表、别名(Postgres 里一列的函数出的列就叫别名,没起别名的叫函数名)、 - 函数名、常见的出列名(_FUNCTION_COLUMNS);JSON_TABLE 按 COLUMNS 里写的,VALUES 按 column1、column2…。不认得的函数 - 又没写列名表的,说不清出哪几列,为 None。""" + 函数名、常见的出列名(_FUNCTION_COLUMNS);JSON_TABLE 按 COLUMNS 里写的,VALUES 按数据源的类型 kind 认(_values_names: + Postgres、SQLite 叫 column1…,MySQL 叫 column_0…)。不认得的函数又没写列名表的、VALUES 的列名认不准又没写列名表的 + (MariaDB、Oracle),说不清出哪几列,为 None。kind 没给的按 Postgres、SQLite 的叫法:_has_column 只在 Postgres 的 + 整行引用、(s).* 里要知道来源出不出某一列。""" from sqlglot import exp node = source.this if isinstance(source, exp.Lateral) else source @@ -877,16 +892,14 @@ def _function_columns(source: Any) -> set[str] | None: 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 + func = _function_node(source) 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)} + found = _values_names(func, kind, listed) + return names | {n for n in found if n} if listed or all(found) else None name = (func.name if isinstance(func, exp.Anonymous) else func.sql_name() if isinstance(func, exp.Func) else "").lower() if name: @@ -894,6 +907,85 @@ def _function_columns(source: Any) -> set[str] | None: return names if listed or name in _TABLE_FUNCTIONS else None +def _values_rows(values: Any, kind: str | None) -> list[list[Any]]: + """一个 VALUES 的每一行按列摊开的各项。MySQL 的一行写作 ROW(…),解析器读成一个元组里套着一个 ROW 函数,摊开它的 + 参数;Postgres 的 VALUES (ROW(…)) 是一列复合类型,不摊。""" + from sqlglot import exp + + from app.data.sql_semantics import _read + + mysql = _read(kind) == "mysql" + rows: list[list[Any]] = [] + for row in values.expressions: + items = list(row.expressions) if isinstance(row, exp.Tuple) else [row] + if mysql and len(items) == 1 and isinstance(items[0], exp.Anonymous) and items[0].name.upper() == "ROW": + items = list(items[0].expressions) + rows.append(items) + return rows + + +def _values_names(values: Any, kind: str | None, listed: list[str]) -> list[str | None]: + """一个 VALUES 按次序的列名(小写):写了列名表的前几列按列名表(Postgres 的列名表可以只写前几列),后面的按各库的 + 叫法——Postgres、SQLite 叫 column1、column2…,MySQL 8.0 叫 column_0、column_1…(从 0 数起);MariaDB 拿第一行各项的 + 写法当列名,Oracle 的文档只有写了列名表的写法,认不准,记 None。kind 是数据源的类型(sqlite / postgresql / mysql / + mariadb / oracle)或解析用的方言名,没给的按 Postgres、SQLite 的叫法。以前一律按 column1… 认,MySQL 的 column_0 对不上, + 外层按名字取、照原名带出的号码原样交了出去。""" + from app.data.sql_semantics import _read + + width = max((len(row) for row in _values_rows(values, kind)), default=1) + read = _read(kind) + if (kind or "").strip().lower() == "mariadb" or read == "oracle": + defaults: list[str | None] = [None] * width + elif read == "mysql": + defaults = [f"column_{i}" for i in range(width)] + else: + defaults = [f"column{i}" for i in range(1, width + 1)] + return [*listed, *defaults[len(listed):]] + + +def _values_listed(values: Any) -> list[str]: + """一个 VALUES 写的列名表(小写):写在它自己、套在外面的括号或 LATERAL 上((VALUES …) AS v(a, b))。WITH v(a, b) 的 + 列名表记在 WITH 的那一段上,按派生来源认(_source_names),不在这里。""" + from sqlglot import exp + + node, alias = values, values.args.get("alias") + while alias is None and isinstance(node.parent, (exp.Subquery, exp.Lateral)) and node.arg_key == "this": + node = node.parent + alias = node.args.get("alias") + return [c.name.lower() for c in alias.args.get("columns") or [] if c.name] if alias is not None else [] + + +def _values_table(values: Any) -> bool: + """这个 VALUES 当一张表用,交出的是行、外层按列名取或照原名带出:FROM、JOIN、LATERAL 里的,WITH 里的一段、UNION 这类 + 的一支(不套括号的解析器改写成 SELECT * FROM (VALUES …) AS _values,套了括号的原样挂着),整条就是它。标量子查询、 + IN / ANY / EXISTS 里的不算:值经外层的表达式带出(_value_columns 已经算上),或者只用来筛行。""" + from sqlglot import exp + + node = values + while isinstance(node.parent, exp.Subquery): + node = node.parent + return node.parent is None or isinstance(node.parent, (exp.From, exp.Join, exp.Lateral, exp.CTE, exp.SetOperation)) + + +def _values_taken(values: Any, names: set[str], kind: str | None) -> set[str]: + """一个 VALUES 里值取自 names 的那几列的列名(_values_names):按位置,任何一行的那一项取自 names 就算(同 UNION 的 + 几支)。一行里 (表达式).*、s.* 展开成几列说不清,它和排在后面的项落在第几列认不出,不算;列名认不准的(None)不算。 + 这些名字照传下去(外层取它们的列、SQL 里拿它们做比较的字面量都按指代遮罩列认),整份遮时也只收这几列的值:同一行里 + 没碰遮罩列的区域不收(以前 VALUES 出的每一列都算,区域名在同一次核查里处处被换掉)。""" + from sqlglot import exp + + labels = _values_names(values, kind, _values_listed(values)) + out: set[str] = set() + for row in _values_rows(values, kind): + for i, item in enumerate(row): + node = _unwrapped(item) + if _is_star(node) or (isinstance(node, exp.Dot) and isinstance(node.expression, exp.Star)): + break + if i < len(labels) and labels[i] and _reads(names, _value_columns(item)): + out.add(labels[i]) + return out + + def _spreads_record(source: Any) -> bool: """Postgres 里这个表值函数这类来源可能交回复合类型、展开成它的各个字段(字段名 SQL 里看不出,同 (表达式).*): - unnest:数组的元素是复合类型时展开成各个字段(unnest(ARRAY[p.card]) 出 addr、tel),元素是什么类型 SQL 里看不出; @@ -904,9 +996,7 @@ def _spreads_record(source: Any) -> bool: 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) - func = node.this if isinstance(node, exp.Table) else node - while isinstance(func, exp.Subquery): - func = func.this + func = _function_node(source) if isinstance(func, (exp.Values, exp.JSONTable)): return False name = (func.name if isinstance(func, exp.Anonymous) else func.sql_name() if isinstance(func, exp.Func) @@ -1495,9 +1585,11 @@ def _tainted(sql: str, kind: str | None, masked: set[str], width: int, tables: d 位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 又认不准)——整份快照的列都算(同解析不了)。 - FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE、LATERAL VALUES)参数里写着这些名字的,它出的列 - 都算(_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算,Postgres - 里可能交回复合类型、展开成各个字段的(unnest、只写了列名的列名表,_spreads_record)也算。 + FROM、JOIN、LATERAL 里的表值函数(json_each、unnest、JSON_TABLE)参数里写着这些名字的,它出的列都算 + (_function_columns):遮罩列是 JSON 时,json_each 拆开是最自然的写法。出哪几列说不清的整份快照都算,Postgres + 里可能交回复合类型、展开成各个字段的(unnest、只写了列名的列名表,_spreads_record)也算。VALUES 当一张表用(FROM、 + JOIN、LATERAL 里的,WITH 里的一段,UNION 的一支,_values_table)读到这些名字的也整份算:各库给它的列起名不一样 + (_values_names),不逐个方言、逐个位置认;值取自这些名字的那几列,按方言认得出名字的照传下去(_values_taken)。 * 展开的列换了名字(写了列名表的派生来源、UNION 后一支的 *):* 带出这些名字的值时,它展开落到的那几列照位置算 (_star_places,展开数据源里的表的按 tables 数位置,数不清的整段算);标量子查询里的 *、函数参数里的 *(to_json(s.*)、 JSON_OBJECT(*))、整行引用(row_to_json(s))按 _value_columns 算进外层的表达式。表的列名表、TABLE x、套了括号的 * @@ -1538,12 +1630,20 @@ def _tainted_in(root: Any, sql: str, kind: str | None, masked: set[str], width: selects = list(root.find_all(exp.Select)) blocks = _blocks(root) sources = [(top, listed) for top, role, listed in blocks if role == "source"] - functions = [source for select_ in selects for source in _function_sources(select_)] + # VALUES 不按表值函数认(下面另认):按出列名认时它出的每一列都算,同一行里没碰遮罩列的区域也收成了遮罩值 + functions = [source for select_ in selects for source in _function_sources(select_) + if not isinstance(_function_node(source), exp.Values)] # Postgres 里可能交回复合类型、展开成各个字段的表值函数(_spreads_record):读到遮罩列时出哪几列说不清,整份遮 spreading = {id(source) for source in functions if _spreads_record(source)} \ if _read(kind) in _ROW_DIALECTS else set() - unreadable = False - for _ in range(len(selects) + len(sources) + len(functions) + 1): + # VALUES 当一张表用(FROM、JOIN、LATERAL 里的,WITH 里的一段,UNION 的一支,整条就是它,_values_table)又读到遮罩列 + # (含派生出来的名字):各库给它的列起名不一样(Postgres、SQLite 叫 column1…,MySQL 叫 column_0…,MariaDB 拿第一行的 + # 值当列名),外层按名字取、照原名带出时对不上,以前 MySQL 派生表、CTE、LATERAL 里的 VALUES ROW 取了号码,原样交了 + # 出去;套了括号的 VALUES(整条就是它、UNION 的一支)不在任何 SELECT 的 FROM 里,哪个库都没认。不再逐个方言、逐个 + # 位置认,整份遮;按方言认得出的、值取自遮罩列的那几列的名字照传下去(_values_taken),整份遮时只收它们的值 + tables_of_values = [values for values in root.find_all(exp.Values) if _values_table(values)] + unreadable = tabled = False + for _ in range(len(selects) + len(sources) + len(functions) + len(tables_of_values) + 1): before = len(names) for select_ in selects: for item in select_.expressions: @@ -1554,21 +1654,24 @@ def _tainted_in(root: Any, sql: str, kind: str | None, masked: set[str], width: names |= _source_names(top, listed, names, tables)[0] for source in functions: if _reads(names, _value_columns(source)): - columns = None if id(source) in spreading else _function_columns(source) + columns = None if id(source) in spreading else _function_columns(source, kind) unreadable = unreadable or columns is None names |= columns or set() + for values in tables_of_values: + if _reads(names, _value_columns(values)): + tabled = True + names |= _values_taken(values, names, kind) if len(names) == before: break - # 认不出的整份遮:表值函数出哪几列说不清、派生来源里有认不出名字的列、PIVOT 这类读到遮罩列,(表达式).* 读到遮罩列 - # (展开成几列、叫什么都说不清),语法树和实际执行的对不上、SQL 写在字符串里交给函数执行、整表导出、统计信息里的值 - # (_opaque)。(表达式).* 不只看选出项(那几个 _normalized 已经换掉,值取自哪些列在 opaque 里):VALUES 里同样展开成 - # 好几列(Postgres 文档:选出列表、VALUES、行构造器里的 composite.* 都展开),VALUES 里的一项是 s.*、(s.*) 的也一样; - # LATERAL VALUES 按表值函数认时一个元组项只算一列,column2 起的号码原样交了出去。不逐个位置认:(表达式).* 在树上任何 - # 位置(行构造器、函数参数里的不展开成几列,也一样,多遮)、VALUES 里的 *,读到遮罩列就整份遮 + # 认不出的整份遮:表值函数出哪几列说不清、VALUES 当一张表用(见上)、派生来源里有认不出名字的列、PIVOT 这类读到遮罩列, + # (表达式).* 读到遮罩列(展开成几列、叫什么都说不清),语法树和实际执行的对不上、SQL 写在字符串里交给函数执行、整表 + # 导出、统计信息里的值(_opaque)。(表达式).* 不只看选出项(那几个 _normalized 已经换掉,值取自哪些列在 opaque 里): + # VALUES 里同样展开成好几列(Postgres 文档:选出列表、VALUES、行构造器里的 composite.* 都展开),LATERAL VALUES 按表值 + # 函数认时一个元组项只算一列,column2 起的号码原样交了出去。不逐个位置认:(表达式).* 在树上任何位置(行构造器、函数 + # 参数里的不展开成几列,也一样,多遮)读到遮罩列就整份遮;VALUES 里的 *、(s.*) 读到遮罩列的,那个 VALUES 就读到了 + # 遮罩列,按上面整份遮 expanded = [node.this for node in root.find_all(exp.Dot) if isinstance(node.expression, exp.Star)] - expanded += [item for values in root.find_all(exp.Values) for row in values.expressions - if isinstance(row, exp.Tuple) for item in row.expressions if _is_star(_unwrapped(item))] - everything = unreadable or any(_source_names(top, listed, names, tables)[1] for top, listed in sources) \ + everything = unreadable or tabled 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)) \ or any(_reads(names, cols) for cols in opaque) \ or any(_reads(names, _value_columns(node)) for node in expanded) or _opaque(sql, kind) @@ -1641,7 +1744,10 @@ INDIRECT_FUNCTIONS = ( #: - 别的会话执行过的 SQL 原文:Postgres 的 pg_stat_activity、pg_stat_statements、本连接预备过的语句;MySQL 的 general_log、 #: slow_log、PROCESSLIST、正在执行的事务(information_schema.INNODB_TRX 的 trx_query)、performance_schema(语句历史 #: events_statements_*、线程正在执行的 SQL),sys 库里建在 performance_schema 上的会话、锁等待视图(只认这几张,Oracle -#: 常写的 sys.dual 不算;带 x$ 的不排版的那一份按下面的 X$ 算);MariaDB 的查询缓存(QUERY_CACHE_INFO);Oracle 的动态视图 +#: 常写的 sys.dual 不算;带 x$ 的不排版的那一份按下面的 X$ 算)和读 performance_schema 的函数(按 sys.ps_ 开头认: +#: ps_thread_trx_info 交回那个线程的事务里执行过的语句原文,ps_thread_stack 交回它的语句栈,MySQL 8.0 实测带着条件里 +#: 的号码;同一族只交回编号、开关的 ps_thread_id、ps_is_* 也算进来,多遮。不写 sys. 的要数据源的默认库就是 sys 才 +#: 执行得了,不认,同上面的视图);MariaDB 的查询缓存(QUERY_CACHE_INFO);Oracle 的动态视图 #: V$ / GV$ / X$ 一律算,不逐个认(V$SQL 一族、v$sql_bind_capture、打开着的游标、LogMiner 的 V$LOGMNR_CONTENTS、XML 的 #: 审计记录都在里面),AWR 历史 DBA_HIST_*、审计记录,SQL 调优集里的语句和绑定变量(*_sqlset_*)、调优顾问记下的 #: (*_sqltune_*)、SQL Access Advisor 的工作负载(*_advisor_sqlw_*)、固定下来的执行计划和 SQL Profile、Patch @@ -1659,7 +1765,7 @@ INDIRECT_NAMES = ( r"pg_stat_activity", r"pg_stat_statements", r"pg_prepared_statements", r"general_log", r"slow_log", r"processlist", r"innodb_trx", r"innodb_locks", r"query_cache_info", r"optimizer_trace", r"performance_schema", r"events_statements_\w+", - r"sys[\"`]?\s*\.\s*[\"`]?(?:x\$)?(?:session|processlist|innodb_lock_waits|schema_table_lock_waits)", + r"sys[\"`]?\s*\.\s*[\"`]?(?:(?:x\$)?(?:session|processlist|innodb_lock_waits|schema_table_lock_waits)|ps_\w+)", r"(?:g?v|x)\$\w+", r"(?:dba|cdb)_hist_\w+", r"(?:all|dba|user|cdb)_(?:sqlset|sqltune|advisor_sqlw)_\w+", r"(?:dba|cdb)_sql_(?:plan_baselines|profiles|patches)", r"flashback_transaction_query", @@ -2707,9 +2813,9 @@ def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str SELECT *(展开的列换了名字的也算)、表值函数、整行引用和函数参数里的 *、列名表、TABLE x;COUNT、只在条件里用到、 分档当分组键的不算,分档写在派生来源里、外层按它分组的也不算);认不全的(SQL 解析不了又写着遮罩列、*、TABLE x, 或者是 Postgres 的;(表达式).* 读到遮罩列(VALUES 里的也算)、Postgres 里可能交回复合类型的表值函数读到遮罩列, - MySQL 会执行的注释、U&"…",表名、SQL 写在字符串里交给函数,统计信息、会话记录里存着值的视图,不只读表结构的 - SHOW、EXPLAIN 一条语句,选出项和结果的列数对不上,分析出意外、超出工作量上限)整份结果的列都遮 - (QueryMask.whole)。这几列的格子换成「已遮罩」,类型记成文本 + VALUES 当一张表用又读到遮罩列,MySQL 会执行的注释、U&"…",表名、SQL 写在字符串里交给函数,统计信息、会话记录里 + 存着值的视图和函数,不只读表结构的 SHOW、EXPLAIN 一条语句,选出项和结果的列数对不上,分析出意外、超出工作量 + 上限)整份结果的列都遮(QueryMask.whole)。这几列的格子换成「已遮罩」,类型记成文本 - 别的列按值换,只换文字格:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认, _Secrets.loose_spans),换过的格子记下来(QueryMask.cells)。遮罩值是这份结果里的加上 known:先查号码、再查写着 号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN); diff --git a/backend/tests/test_masking_values_and_show.py b/backend/tests/test_masking_values_and_show.py index d7b5b6ea..acdd22c9 100644 --- a/backend/tests/test_masking_values_and_show.py +++ b/backend/tests/test_masking_values_and_show.py @@ -8,6 +8,15 @@ (ENGINE INNODB STATUS、BINLOG / RELAYLOG EVENTS、REPLICA STATUS、WARNINGS、CREATE TABLE / VIEW……)和 EXPLAIN 一条 语句。以前工具边界的名单里没有,可验证报告那份整个挡 information_schema、不附带解析不了的快照,反而挡得住 +复核第 3 轮补的同一类: +- VALUES 当一张表用(FROM、JOIN、LATERAL 里的,WITH 里的一段,UNION 的一支)又读到遮罩列(含派生出来的名字):各库给 + VALUES 的列起名不一样(Postgres、SQLite 叫 column1…,MySQL 8.0 叫 column_0…,MariaDB 拿第一行的值当列名),以前一律 + 按 column1… 认,MySQL 派生表、CTE、LATERAL 里的 VALUES ROW 取了号码,原样交给模型、落库;套了括号的 VALUES(整条 + 就是它、UNION 的一支)解析器原样挂在树上,哪个库都漏。现在不论怎么起名都整份遮;列名按方言认,整份遮时只收值取自 + 遮罩列的那几列的值 +- MySQL sys 库里读 performance_schema 的函数(sys.ps_thread_trx_info 交回线程的事务里执行过的语句原文,ps_thread_stack + 同类):按 sys.ps_ 开头认,工具边界和可验证报告共用一份名单 + 这里没有真的数据库:结果按它交回的样子造(复合类型展开成几列、SHOW 交回的几列,照 Postgres 18、MySQL 8.0 实测的样子)。 数据都是虚构的:手机号、地址、区域。 """ @@ -93,21 +102,19 @@ def test_an_expression_star_inside_values_masks_the_whole_result(sql, columns, r assert done.payload["rows"] == [[MASKED_WORD] * len(columns)] and not _leaks(done) -@pytest.mark.parametrize("sql, columns, row, hidden", [ +@pytest.mark.parametrize("sql, columns, row", [ pytest.param("SELECT v.* FROM people p, LATERAL (VALUES ((p.address).*)) v", ["column1", "column2"], - ["某路", "某市"], [], id="没设遮罩的复合类型"), - pytest.param("SELECT v.column1 AS r FROM sales s, LATERAL (VALUES (s.region)) v", ["r"], ["华东"], [], + ["某路", "某市"], id="没设遮罩的复合类型"), + pytest.param("SELECT v.column1 AS r FROM sales s, LATERAL (VALUES (s.region)) v", ["r"], ["华东"], id="没碰遮罩列的 VALUES"), - pytest.param("SELECT v.column1 AS p FROM sales s, LATERAL (VALUES (s.buyer_phone)) v", ["p"], [PHONE], ["p"], - id="VALUES 里直接写遮罩列"), pytest.param("SELECT v.column2 AS r FROM (SELECT id, region FROM sales) q, LATERAL (VALUES (q.*)) v", ["r"], - ["华东"], [], id="没取遮罩列的子查询的 *"), + ["华东"], id="没取遮罩列的子查询的 *"), ]) -def test_values_without_an_expression_star_on_a_masked_column_is_read_as_before(sql, columns, row, hidden): - """VALUES 里没有读到遮罩列的 (表达式).*:照旧按列认,没碰遮罩列的照常,直接写遮罩列的只遮那一列。""" +def test_values_that_reads_no_masked_column_is_read_as_before(sql, columns, row): + """VALUES 没读到遮罩列(没设遮罩的复合类型、没碰遮罩列的列、没取遮罩列的子查询的 *):照常,一列都不遮。读到遮罩列的 + (直接写遮罩列的也算)当一张表用就整份遮,见下一节。""" done = masked(sql, columns, [list(row)]) - assert not done.whole and done.columns == hidden - assert done.payload["rows"] == [[MASKED_WORD if c in hidden else v for c, v in zip(columns, row)]] + assert not done.whole and done.columns == [] and done.payload["rows"] == [list(row)] def test_an_expression_star_inside_a_function_argument_masks_the_whole_result_too(): @@ -117,6 +124,142 @@ def test_an_expression_star_inside_a_function_argument_masks_the_whole_result_to assert done.whole and done.payload["rows"] == [[MASKED_WORD, MASKED_WORD]] and not _leaks(done) +# -------------------------------------------------------------------------- +# VALUES 当一张表用(复核第 3 轮) +# -------------------------------------------------------------------------- + + +#: VALUES 当一张表用、读到遮罩列:(方言, SQL, 结果列, 各行),号码在最后一列。MySQL 8.0 给 VALUES 的列起名 column_0、 +#: column_1…(从 0 数起),MariaDB 拿第一行的值当列名;以前一律按 column1… 认,MySQL、MariaDB 的这几种号码原样交了出去 +VALUES_TABLES = [ + pytest.param("mysql", "SELECT * FROM (VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) AS v", ["column_0"], + [[PHONE]], id="MySQL-派生表"), + pytest.param("mysql", "SELECT x.column_0 FROM sales s, LATERAL (VALUES ROW(s.buyer_phone)) AS x", ["column_0"], + [[PHONE]], id="MySQL-LATERAL"), + pytest.param("mysql", "WITH v AS (VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) SELECT * FROM v", + ["column_0"], [[PHONE]], id="MySQL-CTE"), + pytest.param("mysql", "SELECT v.column_0 AS c FROM (VALUES ROW((SELECT buyer_phone FROM sales WHERE id = 1)), " + "ROW((SELECT buyer_phone FROM sales WHERE id = 2))) AS v", ["c"], [[PHONE], ["13900000002"]], + id="MySQL-外层改了名字"), + pytest.param("mysql", "SELECT s.id, t.column_1 FROM sales s JOIN LATERAL (VALUES ROW(s.region, s.buyer_phone)) " + "AS t ON TRUE", ["id", "column_1"], [[1, PHONE]], id="MySQL-JOIN LATERAL 连同编号"), + pytest.param("mysql", "SELECT w.* FROM sales s, LATERAL (SELECT * FROM (VALUES ROW(s.buyer_phone)) v) AS w", + ["column_0"], [[PHONE]], id="MySQL-LATERAL 子查询里的 VALUES"), + pytest.param("mysql", "SELECT (SELECT column_0 FROM (VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) v) AS p", + ["p"], [[PHONE]], id="MySQL-标量子查询里的 VALUES"), + pytest.param("mysql", "WITH q AS (SELECT buyer_phone AS p FROM sales) " + "SELECT v.column_0 FROM q, LATERAL (VALUES ROW(q.p)) AS v", ["column_0"], [[PHONE]], + id="MySQL-取派生出来的名字"), + pytest.param("mysql", "VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1)) UNION ALL SELECT region FROM sales", + ["column_0"], [[PHONE], ["华东"]], id="MySQL-UNION 第一支"), + # 套了括号的 VALUES 解析器不改写成 FROM 里的 VALUES:整条就是它、UNION 的一支都原样挂在树上,以前不认,Postgres + # 里也漏 + pytest.param("mysql", "(VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1)))", ["column_0"], [[PHONE]], + id="MySQL-整条套括号"), + pytest.param("postgresql", "(VALUES ((SELECT buyer_phone FROM sales LIMIT 1))) UNION ALL " + "(SELECT region FROM sales)", ["column1"], [[PHONE], ["华东"]], id="Postgres-UNION 第一支套括号"), + pytest.param("mysql", "SELECT * FROM ((VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) UNION ALL " + "(SELECT 'a')) t", ["column_0"], [[PHONE], ["a"]], id="MySQL-派生表里 UNION 的一支"), + pytest.param("mariadb", "SELECT * FROM (VALUES ((SELECT buyer_phone FROM sales LIMIT 1))) AS v", + ["(SELECT buyer_phone FROM sales LIMIT 1)"], [[PHONE]], id="MariaDB-派生表"), + pytest.param("mariadb", "SELECT * FROM (VALUES ('华东'), ((SELECT buyer_phone FROM sales LIMIT 1))) AS v", + ["华东"], [["华东"], [PHONE]], id="MariaDB-第一行的值当列名"), + pytest.param("postgresql", "SELECT v.column1 AS p FROM sales s, LATERAL (VALUES (s.buyer_phone)) v", ["p"], + [[PHONE]], id="Postgres-LATERAL"), + pytest.param("postgresql", "SELECT s.id, v.k, v.val FROM sales s CROSS JOIN LATERAL (VALUES ('phone', " + "s.buyer_phone), ('region', s.region)) AS v(k, val)", ["id", "k", "val"], + [[1, "phone", PHONE], [1, "region", "华东"]], id="Postgres-行转列"), + pytest.param("sqlite", "WITH v AS (VALUES ((SELECT buyer_phone FROM sales LIMIT 1))) SELECT * FROM v", + ["column1"], [[PHONE]], id="SQLite-CTE"), +] + + +@pytest.mark.parametrize("kind, sql, columns, rows", VALUES_TABLES) +def test_a_values_table_on_a_masked_column_masks_the_whole_result(kind, sql, columns, rows): + """VALUES 当一张表用、读到遮罩列(含派生出来的名字):不论各库给它的列起什么名字,整份结果的每一格都遮,同一份里 + 没碰遮罩列的编号、区域也遮(多遮)。以前 MySQL、MariaDB 的这几种和套了括号的 VALUES masked=[],号码原样交给模型、 + 落库;Postgres、SQLite 的按列名认得出,只遮了那几列。""" + done = masked(sql, columns, [list(r) for r in rows], kind=kind) + assert done.whole and done.columns == columns + assert done.payload["rows"] == [[MASKED_WORD] * len(columns)] * len(rows) and not _leaks(done) + + +@pytest.mark.parametrize("kind, sql, columns, row", [ + pytest.param("mysql", "SELECT x.column_0 AS r FROM sales s, LATERAL (VALUES ROW(s.region)) AS x", ["r"], ["华东"], + id="MySQL-没碰遮罩列"), + pytest.param("mysql", "SELECT s.region, b.column_1 AS 档 FROM sales s JOIN (VALUES ROW(0, '小单'), " + "ROW(20000, '大单')) AS b ON s.amount >= b.column_0", ["region", "档"], ["华东", "大单"], + id="MySQL-写死的分档表"), + pytest.param("mysql", "SELECT region FROM sales UNION ALL VALUES ROW('其他')", ["region"], ["其他"], + id="MySQL-UNION 一支写死的值"), +]) +def test_a_values_table_reading_no_masked_column_stays_visible(kind, sql, columns, row): + """VALUES 当一张表用、却没读到遮罩列(写死的分档表、没碰遮罩列的 LATERAL、UNION 里写死的一支):照常。""" + done = masked(sql, columns, [list(row)], kind=kind) + assert not done.whole and done.columns == [] and done.payload["rows"] == [list(row)] + + +@pytest.mark.parametrize("kind, sql", [ + # 号码在 ROW(…) 的第二项:MySQL 的一行解析成一个元组里套着一个 ROW 函数,要摊开数位置 + pytest.param("mysql", "SELECT x.column_1 AS p, x.column_0 AS r FROM sales s, " + "LATERAL (VALUES ROW(s.region, s.buyer_phone)) AS x", id="MySQL"), + pytest.param("postgresql", "SELECT v.column1 AS p, v.column2 AS r FROM sales s, " + "LATERAL (VALUES (s.buyer_phone, s.region)) v", id="Postgres"), + pytest.param("postgresql", "SELECT v.a AS p, v.b AS r FROM sales s, LATERAL (VALUES (s.buyer_phone, s.region)) " + "v(a, b)", id="Postgres-列名表"), + pytest.param("sqlite", "WITH v AS (VALUES ((SELECT buyer_phone FROM sales LIMIT 1), '华东')) " + "SELECT column1 AS p, column2 AS r FROM v", id="SQLite-CTE"), +]) +def test_only_the_values_columns_taken_from_a_masked_column_are_collected(kind, sql): + """VALUES 的列名按方言认(MySQL 叫 column_0…,Postgres、SQLite 叫 column1…,写了列名表的按列名表):整份遮时只收值 + 取自遮罩列的那几列的值当遮罩值,后面的查询、回答里同一个号码照样按值换掉;同一行里的区域不收。以前 MySQL 按 column1 + 认,号码一个都没收;Postgres、SQLite 把 VALUES 出的每一列都收了,区域名在同一次核查里处处被换掉。""" + done = masked(sql, ["p", "r"], [[PHONE, "华东"]], kind=kind) + assert done.whole and done.payload["rows"] == [[MASKED_WORD, MASKED_WORD]] + assert PHONE in done.secrets.strong and "华东" not in done.secrets.strong + + +def test_items_after_an_expanding_star_in_a_values_row_are_not_collected_by_place(): + """一行里的 (表达式).* 展开成几列说不清:排在它后面的号码实际落在第三列,按位置数成第二列就收了没设遮罩的城市。 + 整份照遮,这一行不按位置收值(少收不露:这份结果的值本来就没交给模型)。""" + done = masked("SELECT v.column2 AS c, v.column3 AS p FROM people p, sales s, " + "LATERAL (VALUES ((p.address).*, s.buyer_phone)) v", ["c", "p"], [["某市", PHONE]]) + assert done.whole and done.payload["rows"] == [[MASKED_WORD, MASKED_WORD]] + assert "某市" not in done.secrets.strong + + +@pytest.mark.parametrize("kind, sql, named, unnamed", [ + pytest.param("mysql", "SELECT * FROM sales s, LATERAL (VALUES ROW(s.id, s.region)) AS x", {"column_0", "column_1"}, + {"column1", "column2"}, id="MySQL"), + pytest.param("postgresql", "SELECT * FROM sales s, LATERAL (VALUES (s.id, s.region)) AS x", {"column1", "column2"}, + {"column_0"}, id="Postgres"), + pytest.param("sqlite", "SELECT * FROM (VALUES (1, '华东')) AS x", {"column1", "column2"}, {"column_0"}, + id="SQLite"), + # 列名表只改前几列的名字:第一列叫 n,不再叫 column1(Postgres 里按 column1 取会报错) + pytest.param("postgresql", "SELECT * FROM (VALUES (1, '华东')) AS x(n)", {"n", "column2"}, {"column1"}, + id="Postgres-列名表只写前几列"), +]) +def test_values_columns_are_named_the_way_each_database_names_them(kind, sql, named, unnamed): + """表值函数这类来源出的列名(_function_columns)里,VALUES 的列按方言起名:别处按列名认来源出不出某一列时对得上。""" + from app.data.sql_semantics import _parse, _read + + root, _ = _parse(sql, _read(kind)) + columns = masking._function_columns(masking._function_sources(root)[-1], kind) + assert named <= columns and not unnamed & columns + + +@pytest.mark.parametrize("kind, sql", [ + pytest.param("mariadb", "SELECT * FROM (VALUES (1, '华东')) AS x", id="MariaDB 拿第一行的值当列名"), + pytest.param("oracle", "SELECT * FROM (VALUES (1, '华东')) x", id="Oracle 没写列名表"), +]) +def test_values_columns_that_cannot_be_named_are_unknown(kind, sql): + """VALUES 的列名认不准(MariaDB 拿第一行的值当列名,Oracle 的文档只有写了列名表的写法)又没写列名表:说不清出哪几列。""" + from app.data.sql_semantics import _parse, _read + + root, _ = _parse(sql, _read(kind)) + assert masking._function_columns(masking._function_sources(root)[-1], kind) is None + + # -------------------------------------------------------------------------- # 端到端:核查 agent 的真实查询工具 # -------------------------------------------------------------------------- @@ -178,6 +321,20 @@ async def test_values_expansions_never_reach_the_model_or_the_store(monkeypatch, assert "1390000000" not in json.dumps(snapshot, ensure_ascii=False) and "1390000000" not in api +@pytest.mark.parametrize("sql", [ + "SELECT * FROM (VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) AS v", + "SELECT x.column_0 FROM sales s, LATERAL (VALUES ROW(s.buyer_phone)) AS x", + "WITH v AS (VALUES ROW((SELECT buyer_phone FROM sales LIMIT 1))) SELECT * FROM v", +], ids=["派生表", "LATERAL", "CTE"]) +async def test_mysql_values_tables_never_reach_the_model_or_the_store(monkeypatch, fake_source, sql): + """端到端(MySQL,VALUES 的列叫 column_0):交给模型的、落库的、通用工件接口给的都只有「已遮罩」,masked_at_query + 是全部的列,快照记着整份遮。以前 masked_at_query 是空的,号码原样交出。""" + seen, snapshot, api = await _asked(monkeypatch, fake_source, "mysql", sql, ["column_0"], [[PHONE], ["13900000002"]]) + assert MASKED_WORD in seen and "1390000000" not in seen, seen + assert snapshot["masked_at_query"] == ["column_0"] and snapshot["masked_whole"] is True + assert "1390000000" not in json.dumps(snapshot, ensure_ascii=False) and "1390000000" not in api + + # -------------------------------------------------------------------------- # 记着别的会话的 SQL、被锁那一行的值的视图 # -------------------------------------------------------------------------- @@ -185,6 +342,10 @@ async def test_values_expansions_never_reach_the_model_or_the_store(monkeypatch, #: 另一个会话正在执行的语句(号码写在条件里):会话、事务、锁、日志一类的视图交回的就是它 BUSY = f"SELECT id, SLEEP(4) FROM sales WHERE buyer_phone = '{PHONE}' FOR UPDATE" +#: MySQL sys 库的 ps_thread_trx_info 交回的 JSON:那个线程的事务里执行过的语句原文在 statements_executed[].sql_text 里 +#: (MySQL 8.0 实测) +TRX_INFO = json.dumps([{"trx_id": "1234", "state": "ACTIVE", "statements_executed": [{"sql_text": BUSY}]}], + ensure_ascii=False) @pytest.mark.parametrize("sql, kind", [ @@ -220,6 +381,32 @@ def test_views_that_keep_other_sessions_sql_mask_the_whole_result(sql, kind): assert done.whole and done.payload["rows"] == [[MASKED_WORD]] +@pytest.mark.parametrize("sql, column", [ + pytest.param("SELECT sys.ps_thread_trx_info(81) AS t", "t", id="事务里执行过的语句"), + pytest.param("SELECT sys.ps_thread_trx_info(81)", "sys.ps_thread_trx_info(81)", id="不起别名"), + pytest.param("SELECT `sys`.`ps_thread_trx_info`(81) AS t", "t", id="反引号"), + pytest.param("SELECT SYS . PS_THREAD_TRX_INFO (81) AS t", "t", id="大写、隔着空格"), + pytest.param("SELECT sys.ps_thread_stack(81, FALSE) AS t", "t", id="语句栈"), + pytest.param("SELECT sys.ps_thread_trx_info(sys.ps_thread_id(NULL)) AS t", "t", id="套着取线程号"), +]) +def test_sys_functions_reading_performance_schema_mask_the_whole_result(sql, column): + """MySQL sys 库里读 performance_schema 的函数交回别的会话执行过的 SQL 原文(条件里的号码):按 sys.ps_ 开头认,整份 + 遮。以前名单里 sys 库只认会话、锁等待那几张视图,号码原样交给模型、落库。""" + done = masked(sql, [column], [[TRX_INFO]], kind="mysql") + assert done.whole and done.payload["rows"] == [[MASKED_WORD]] and not _leaks(done) + + +@pytest.mark.parametrize("kind, sql, column, value", [ + pytest.param("mysql", "SELECT sys.format_bytes(2048) AS b", "b", "2.00 KiB", id="sys 库里只排版的函数"), + pytest.param("mysql", "SELECT ps_total AS t FROM stats", "t", 12, id="ps_ 开头的列"), + pytest.param("oracle", "SELECT 1 AS n FROM sys.dual", "n", 1, id="Oracle 的 sys.dual"), +]) +def test_other_sys_names_stay_visible(kind, sql, column, value): + """sys 库里别的函数(只排版、换单位的)、不写 sys. 的 ps_ 开头的列名、Oracle 的 sys.dual:照常。""" + done = masked(sql, [column], [[value]], kind=kind) + assert not done.whole and done.payload["rows"] == [[value]] + + @pytest.mark.parametrize("sql, columns", [ pytest.param("SHOW ENGINE INNODB STATUS", ["Type", "Name", "Status"], id="InnoDB 状态"), pytest.param("show engine innodb status", ["Type", "Name", "Status"], id="小写"), @@ -289,7 +476,8 @@ def test_explaining_a_statement_masks_the_whole_result(sql): ("SELECT trx_query AS t FROM information_schema.INNODB_TRX", ["t"], [[BUSY]]), ("SHOW ENGINE INNODB STATUS", ["Type", "Name", "Status"], [["InnoDB", "", f"---TRANSACTION 1234, ACTIVE 2 sec\nMySQL thread id 9, query id 70 localhost root\n{BUSY}"]]), -], ids=["INNODB_TRX", "SHOW ENGINE INNODB STATUS"]) + ("SELECT sys.ps_thread_trx_info(81) AS t", ["t"], [[TRX_INFO]]), +], ids=["INNODB_TRX", "SHOW ENGINE INNODB STATUS", "sys.ps_thread_trx_info"]) async def test_other_sessions_sql_never_reaches_the_model_or_the_store(monkeypatch, fake_source, sql, columns, rows): """端到端(MySQL):另一个会话正在执行的 SQL 里写着号码,交给模型的、落库的、通用工件接口给的都只有「已遮罩」, 快照记着整份遮。以前 masked_at_query 为空,SQL 原文连同号码原样交出。""" @@ -306,3 +494,18 @@ def test_the_new_views_are_on_the_list_the_bundle_shares(): for sql in ("SELECT trx_query FROM information_schema.INNODB_TRX", "SELECT sql_text FROM dba_sqlset_statements", "SELECT undo_sql FROM flashback_transaction_query", "SELECT sql_redo FROM v$logmnr_contents"): assert build._INDIRECT_READS.search(sql) and masking._INDIRECT.search(sql), sql + + +@pytest.mark.parametrize("sql", [ + "SELECT sys.ps_thread_trx_info(81) AS t", + "SELECT `sys`.`ps_thread_stack`(81, FALSE) AS t", +]) +def test_the_sys_functions_are_on_the_list_the_bundle_shares(sql): + """可验证报告和工具边界共用一份名单:sys.ps_ 开头的函数两边都认,报告不附带这样的快照(只给哈希),它的列整份算取自 + 遮罩列。以前报告判它没碰遮罩列,照常附带快照,别的会话 SQL 里的号码随文件交出去。""" + from app.bundle import build + + snapshot = {"source": "shop", "sql": sql, "columns": ["t"], "rows": [[TRX_INFO]]} + assert build._INDIRECT_READS.search(sql) and masking._INDIRECT.search(sql) + assert build._untouched(snapshot, {"buyer_phone"}) is False + assert build._derived_columns(snapshot, {"buyer_phone"}) == {"buyer_phone", "t"}