「敏感数据管控」是一个所有企业都绕不开的课题。不管是出于对用户和自己负责,还是面对监管审查,亦或是为了合规,都需要对访问敏感数据的权限进行管理。另一方面,大家都很熟悉的课题「管控与效率的权衡」同样笼罩在敏感数据管控这一课题的上空。
基于这两个原因,为敏感信息列在查询语句结果中脱敏的需求应运而生。一个简单的例子,数据库中存放用户信息的表 user 有一列是存放用户的手机号或其他个人信息,我们希望这些敏感信息以任何方式被查询时都能够脱敏。
当前,广为使用的脱敏功能大多基于 goInception 提供,例如 Archery 和 Yearning。遗憾的是,goInception 提供的脱敏功能只支持 MySQL 且有相当多的绕过方式,稍有 SQL 使用经验的 DBA 和研发都可以轻松获取敏感数据。
例如我们指定表 sql_users 中的 password 列为敏感信息列,下图左侧可以看到,一个简单的 SELECT 语句 goInception 能够成功脱敏。但是如下图右侧中,只要简单嵌套一下,使用一个子查询语句,本应该被脱敏的 password 列,其中的敏感数据被直接展示了出来
Bytebase 看到了这个问题,并选择接过这一难题。凭借团队深厚的技术底蕴,于本周上线的 Bytebase 1.9.1 中提供了支持 MySQL & TiDB 所有类型查询语句的数据脱敏功能。goInception 数据脱敏功能的绕过方式都被拒之门外。下图展示了与上文相同的子查询示例在 Bytebase 中的结果,可以看到脱敏成功。goInception 的数据脱敏功能存在绕过方式的原因在于实现数据脱敏功能存在一定的难度和技术壁垒。数据脱敏功能的实现需要对数据库引擎有足够了解,知晓查询语句的执行原理和各种细节,才能够做到将各种绕过方式拒之门外。接下来我们会介绍 Bytebase 数据脱敏功能的具体实现。输入信息
数据库实例的所有 Schema 信息
敏感列的集合
一个查询语句
输出
- 查询语句会返回的列,需要标注其中哪些列是需要脱敏的敏感列
为了方便后续描述,这里同步一下我们会在下面使用的术语,术语会用斜体表示。- 输出列:一个查询语句最终会返回多行数据,每行数据拥有相同的列类型和列数量。输出列在下文中特指查询语句最终返回的数据的列。
敏感信息脱敏是一个典型的防守方功能。这个特性就对功能有着极高的要求,严格来讲不允许该功能有着哪怕一点漏洞。只要有一种能够绕过脱敏的方法,这个脱敏功能就形同虚设。从问题定义可以看出,实现这个功能需要对查询语句进行解析。我们需要知道敏感列的数据可能会出现在输出列中的那些列,然后对这些可能出现敏感数据的列进行脱敏。查询语句在任何地方都有可能出现子查询。例如,在 SELECT 子句中出现子查询:SELECT (SELECT * FROM t LIMIT 1)
SELECT * FROM (SELECT * FROM t) t1
子查询分为两类,关联子查询和非关联自查询。其中非关联子查询比较好处理,可以把它当作单独的一个查询语句进行处理。关联子查询是指子查询中会使用子查询之外的列或者表。举个例子:SELECT t1.a, (SELECT max(t2.b) > t1.a FROM t2) FROM t1
可以看到子查询 `(SELECT max(t2.b) > t1.a FROM t2)` 中引用了子查询外面的表 `t1` 。非关联子查询的出现使得我们在处理子查询的时候需要记录外部的表信息。子查询本质上也是个查询语句,理所因当也能使用子查询,所以子查询是可以无限嵌套的。这使得我们需要使用一个列表来记录子查询的外部表信息。Common Table Expression (CTE)
CTE 可以简单理解为查询语句级别的临时表。一个例子:WITH t1 AS (SELECT * FROM t) SELECT * FROM t1
这个语句中,我们使用 `WITH t1 AS (SELECT * FROM t)` 定义了一个 CTE `t1`,接着在后面的查询语句中使用了 CTE `t1`。CTE 与子查询有一些相同之处。CTE 可以定义在任何 SELECT 语句之前,所以也能够定义在子查询之前。我们定义每层子查询为一级,那么一个多级嵌套的子查询中每一级都能定义 CTE。与嵌套子查询类似,里层的子查询和 CTE 是可以引用外层定义的 CTE。更加复杂的是,CTE 的定义方式也是查询语句,例如上面例子的 `AS` 之后的部分。所以 CTE 的定义中也是可以使用子查询和定义新的 CTE。(不知道你们看晕了没,我感觉我都要讲晕了。😵💫此外,CTE 中还有一种特殊但功能强大的 CTE,被称为 Recursive Common Table Expression,即,递归 CTE。顾名思义,递归 CTE 可以在其定义语句中引用它自己,就如编程语言中的函数递归一样。1. 初始化部分。这部分不会引用自身,初始化部分会决定整个 CTE 的初始数据和列结构。2. 递归部分。递归部分会在初始化初始化之后开始递归。大概的过程是使用初始数据和递归定义计算新的数据,然后递归处理直到没有新的数据出现。NATURAL JOIN 会合并列名相同的列。
JOIN … USING(column_list) 会合并 USING 中出现的列。
MySQL 中列名是大小写无关的。
MySQL 中表名是否区分大小写与操作系统有关,但是同一个查询语句中是区分大小写的。
看到这里大家可能已经有点晕了,不要担心,接下来会由简单到复杂逐渐搭建起整个实现。同时,这也是 Bytebase 在实现这个功能时的真实过程,包教包会!最简单的情况当然是 `SELECT * FROM t` 啦。这种情况下我们只需要去获取表 t 的表结构,然后查看哪些列是敏感列即可。现在考虑 `SELECT * FROM t1 JOIN t2`。从 MySQL 文档和实际操作中我们可以得知,这个 SQL 的输出列是由 t1 的列拼接上 t2 的列组成的。考虑自然连接 NATURAL JOIN 和 JOIN … USING(column_list) 中会按照各自的规则合并一些列,我们也只需要分别处理即可。对于合并的列,只要其中一列是敏感列,那么合并之后的列就是敏感列,同样没什么难度。一个背景知识,如果不进行优化,最朴素的 SELECT 语句执行顺序是先执行 FROM 子句,然后执行 SELECT 子句。那么基于此,我们也将他们分开处理,先把 FROM 子句单独处理,起结果就是 `SELECT * FROM t1`,然后基于这个单独处理得到的结果处理 SELECT 子句,整个过程合起来就是处理 `SELECT a FROM t1` 的过程。处理 FROM 子句时不用理会 SELECT 子句。
- 处理 SELECT 子句的时候也可以不用管 FROM 子句是怎样的,我们将面对的始终是一个列的集合。这些列的列名是由 DatabaseName.TableName.ColumnName 构成的。
SELECT 语句中可以显示指定数据库名,如果不指定,则使用当前选择的数据库。
在 SELECT 语句的 SELECT 子句中引用列时,如果不指定表名不会产生冲突,则可以不指定表名。
- 在 SELECT 子句中,如果出现了函数或者其他表达式,只需要识别并分别处理即可。举个例子,`SELECT a < b FROM t`,我们只需要判断表达式中是否有敏感列即可,如果有则整个表达式代表的输出列是敏感的。
细心的同学可以发现,其实在第三步时候我们已经介绍了非关联子查询的处理方式。我们处理 `SELECT a FROM t1` 的方法即是把它看作 `SELECT a FROM (SELECT * FROM t1) t1`。那么同理,非关联子查询也是同样的处理方法。比较麻烦的是关联子查询,不过也只需要引入一个新的变量来存储外部表即可。在数据库中,我们把外部表称做 outer tables。所以我们可以定义一个列表 `outerFieldList []fieldInfo` 。没出现一层子查询,我们就在列表尾部附加 (append) 一个 `fieldInfo` 来表示这一层新增的 outer tables。- 不同层的子查询中的 outer tables 是可以使用相同的别名,此时如果出现引用 outer tables 的情况,会引用最近的那一个,所以遍历 `outerFieldList` 查找信息时需要从后往前遍历。
- 在结束处理一层子查询时,我们需要把对应的 outer tables 从 `outerFieldList` 中删除,所以这个列表本质上是个栈。
集合运算是指 UNION, INTERSECT 和 EXCEPT。他们都会形如SELECT ...
UNION
SELECT ...
且需要集合运算左右两个 SELECT 语句的输出列列长度相同。所以只需按照「左右两列中只要有一列是敏感列,最后对应的输出列便是敏感列」的方式来合并就行。WITH t1 AS (SELECT * FROM t) SELECT * FROM t1
我们观察一下可以发现,CTE 定义好之后,在 SELECT 语句中与其他表没有什么区别。所以我们只需要在处理 SELECT 语句之前,先构建好 CTE 即可,然后在按照之前的方式处理 SELECT 语句。再仔细观察可以发现,因为非递归 CTE 的定义就是一个普通的 SELECT 语句,所以它的处理方式和 SELECT 语句一致,即按照前五步构建的方式来处理就行。一个细节是 WITH 子句可以定义多个 CTE,例如 `WITH t1 AS (...), t2 AS (...)`,构建顺序是从前往后,即构建 t2 时,t1 已经构建好了,所以 t2 也可以引用 t1。最后是处理递归 CTE。按照上面对递归 CTE 的介绍可以知道,我们首先要做的是把初始化部分和递归部分找到。一个递归 CTE 的定义方式是用 UNION/UNION ALL 将多个 SELECT 语句并起来,例如,WITH t1 AS (
SELECT 1 as n, 1 as p
UNION
SELECT n+1, p*2 FROM t1 WHERE n < 5
)
根据介绍可以知道,递归部分的特点是引用了自己,所以我们先找一遍每个 SELECT 语句是否引用了自己就可以将初始化部分和递归部分分开。我们还知道,递归 CTE 的列是由初始化部分定义的,又因为初始部分并没有子引用,所以只是个普通的查询语句,因此按照之前的步骤处理即可。
处理递归部分的时候其实也不困难,我们只需要把处理好的初始化部分假象为已经构建完成的 CTE,按照想过的方式来构建递归部分即可。
with recursive t1 as (
select a as c1, b as c2, c as c3, 1 as n from t
union
select c1 * c2, c2 + c1, c3 * c2, n + 1 from t1 where n < 5
)
select * from t1
我们假设只有 t.a 是敏感列,那么处理完初试部分的时候,输出列为:
c1: 敏感
c2: 不敏感
c3: 不敏感
n: 不敏感
c1: 敏感
c2: 敏感
c3: 不敏感
n: 不敏感
可以发现,在递归过程中,因为递归定义会使得输出列的列之间会有“传染性”,我们如果把这个传染性定义为依赖的话,可以发现最终所有依赖闭包中只要有一个列初始是敏感的,那么最后整个闭包中的列都是敏感的。所以这里是有个闭包依赖需要处理。当然,我们可以使用标准的闭包依赖算法来计算依赖闭包。但是为了实现上更加简单,我们选择了另外一种实现。仔细观察可以发现,每次递归之后,只会有不敏感的列变成敏感的列。并且可以知道,如果某次递归之后,没有新的敏感列出现,则无论再递归多少次,都不会有新的递归列出现了,即找到了结束状态。那么我们可以利用这个特性,来模拟递归,直到找到结束状态时退出。容易发现,因为没有到结束状态时,每次递归敏感列个数最少加一,所以递归次数不会超过列的长度。而在实际使用中,列长度超过 20 的都不是特别常见,这个复杂度是可以接受的。虽然本文列举了 goInception 在数据脱敏上的一些问题,但我们内部也一直认可 goInception 的价值。正是因为有了 goInception 以及它的前身 Inception 开放出来的一系列基础能力,业界才会出现像 Yearning, Archery 这样的 SQL 审核工具。下图是 Archery 的功能列表,可以看到其对于 MySQL 的支持是远超其它数据库的,这主要就是受益于 goInception。而相比起来,市面上此前一直没有对于 PostgreSQL 有很好支持的数据库管理工具,也正是因为 goInception 只支持了 MySQL。不过这个局面随着 Bytebase 的演进正在逐渐改变,因为我们构建的系统是同时支持 MySQL 和 PostgreSQL (还有 TiDB, ClickHouse, Snowflake 以及即将推出的 MongoDB) 。事实上,因为采取了自研的原生技术,Bytebase 对于 PostgreSQL 的兼容性支持会更好。马上我们也会把本文提到的数据脱敏能力带给 PostgreSQL,敬请期待吧!😆如果你看到了这儿,说明你对数据脱敏还是很感兴趣的!更加细节的内容可以参考以下链接 🔗代码在这里!https://sourcegraph.com/github.com/bytebase/bytebase/-/blob/plugin/db/util/mask_sensitive_data.go
单元测试在这里!有一些 SQL 例子:https://sourcegraph.com/github.com/bytebase/bytebase/-/blob/plugin/db/util/driverutil_test.go?L240
细节记得看看 MySQL 官方文档!https://dev.mysql.com/doc/refman/8.0/en/select.html
- CTE 的文档要单独看看!https://dev.mysql.com/doc/refman/8.0/en/with.html