原文地址: https://ardentperf.com/2025/10/15/sanitized-sql/
净化 SQL
发布者:Jeremy ⋅ 2025年10月15日 ⋅ 1条评论
归档于:合规性, 数据库, PCI, PII, PostgreSQL, 敏感数据
在过去的一个月里,有几次有人给我发消息,问我是否有关于哪里可以学习PostgreSQL的建议。我喜欢分享我积累的链接收藏(如果你有好的,请一定发给我!),但我总是说的另一件事是,公共PostgreSQL Slack是一个很好的地方,可以看到人们提问(Discord、Telegram和IRC也有活跃的PostgreSQL用户社区)。尝试回答问题并帮助他人可以是一种绝佳的学习方式!
上个月,公共PostgreSQL Slack上有一个简短的讨论,关于净化SQL的想法,这个想法在我脑海里盘桓了一段时间。
敏感数据和SQL这个话题实际上相当微妙。
首先,我认为直接解决如何处理数据库模式(表和列名、函数名等)的问题很重要。我们可以从许多拥有数据目录、数据血缘和数据脱敏产品的行业厂商那里得到启示。模式应该是一个公司内部且保密的------但它们与PII(个人可识别信息)或PCI(支付卡行业)数据的敏感性质不同。通常可以放心地与供应商共享模式信息(例如,在共同处理数据库性能的支持工单时)。在公司内部,理想的情况是让大多数模式能被跨多个开发团队的工程师发现------这对于实现更好的协作和更好的内部软件架构所带来的好处是值得的。
一般原则:模式 = 源代码
不幸的是,多功能的SQL语言并没有干净地将事物分开。一条SQL语句是一个字符串,可能将关键字、模式和数据全部混在一起。正如Benoit在Slack讨论中指出的那样------虽然有预备语句(参数化查询),但你很容易漏掉某个地方,最终导致查询中存在字面量字符串。我想补充一点,大多数企业偶尔都需要手动进行"数据修复",这通常涉及简单的脚本,而在这些脚本中,字面量值很常见。
Benoit的建议是对查询文本进行完整解析。这是一个好主意------事实上,PgAnalyze已经维护了一个独立的开源库,可用于在许多语言中直接利用PostgreSQL的查询解析器。这确实是最佳解决方案。然而,值得注意的是,我关注的是对来自pg_stat_activity和pg_stat_statements的查询文本进行后处理的情况,这两者都有最大长度限制,并且会截断过长的文本。因此,查询解析仍然需要能够处理因截断而导致语法错误的文本。
PgAnalyze库的方法很有趣,但我认为一个基于正则表达式的简单方法实际上有很多优点。这可以为开发人员提供非常有用的、经过净化的SQL用于调试,它暴露敏感数据的风险非常低,而且代码极其简单......尤其是与导入整个PostgreSQL解析器并尝试在其他语言中链接已编译的C库相比!
今晚我终于抽空为此做了一个概念验证。
我在这里的设计选择是非常有意的:
- 我选择去除注释,因为像
sqlcommenter这样的库会通过注释添加唯一值,这会破坏任何聚合、汇总和报告热门查询或问题查询的能力。 - 我总是会将
query_id与净化后的SQL文本一起包含。用户以后只要拥有查询ID,就可以随时返回数据库直接查看pg_stat_statements以获取完整的查询文本。 - 我决定包含前三个单词(不包括注释)以及每个
FROM出现后的两个单词,这是非常有策略的。在大多数情况下(CTE例外),前三个单词会告诉我们正在执行什么类型的命令------SELECT、DML、DDL还是某些实用/杂项语句。通过在命令后包含两个单词,我们通常可以看到INSERT和UPDATE的表名。通过在FROM后包含单词,我们将知道查询和DELETE操作所涉及到的至少一个表。这意味着我们总是一眼就能知道"它正在更新表X"或"它正在查询表Y"。 - 当等待事件表明锁争用或I/O时间增加时,查看正在操作哪些表是非常有用的。
- 可能会有少数情况下,这个算法的净化SQL不如它本可以提供的那么有用。但这就是为什么我们包含查询ID,以便在需要时检索完整的查询文本------而我的主要目标只是拥有一个成本低廉/简单、在大多数情况下有帮助、并且我们可以确保对开发人员和操作员是安全的、无需PII/PCI数据控制的东西。
- 该算法泄露敏感数据的可能性几乎为零。我们不应该从
INSERT或UPDATE中获得字面量。函数和过程调用必须始终包含括号,因此通过一个简单的正则表达式来消除开括号后的任何内容就可以缓解这个问题。 - 如果字符串
FROM出现在字符串字面量中,那么我们将无法将其与关键字区分开来。这一点值得考虑;如果你能发现,这里存在一个注入向量。但我认为不值得为此大费周章,试图通过正则表达式解析SQL。(尽管那样做可能很有趣,但在这里简单性/可读性/可维护性胜出。)SQL语言异常复杂,如果我们要解析,那应该采用PgAnalyze的方式。但在实践中,这种基于正则表达式的函数实际可能暴露/泄露的风险面非常小,并且很可能可以得到缓解。 - 这并不会降低良好编码实践(如参数化SQL)的重要性。这只是在此之上的额外一层防御。通过参数化SQL正确传递的值首先根本不会出现在查询文本中。
净化SQL的PL/pgSQL函数
https://gist.github.com/ardentperf/44e94ac484e53ff8353f6c1dc0b8f272
以下是代码的样子:
sql
CREATE OR REPLACE FUNCTION sanitize_sql(sql_text text)
RETURNS text AS $$
DECLARE
cleaned_text text;
first_part_regex_3words text := '([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)';
first_part_regex_2words text := '([^[:space:]]+)[[:space:]]+([^[:space:]]+)';
first_part_regex_1words text := '([^[:space:]]+)';
first_part text;
match_array text;
from_parts_regex_3words text := '(FROM)[[:space:]]+([^[:space:]]+)[[:space:]]*([^[:space:]]*)';
from_parts text := '';
BEGIN
-- Remove multi-line comments (/* ... */)
cleaned_text := regexp_replace(sql_text, '/\*.*?\*/', '', 'g');
-- Remove single-line comments (-- to end of line)
cleaned_text := regexp_replace(cleaned_text, '--.*?(\n|$)', '', 'g');
-- Extract the first keyword and up to two words after it
first_part := array_to_string(regexp_match(cleaned_text,first_part_regex_3words),' ');
if first_part is null or first_part ILIKE '% FROM %' or first_part ILIKE '% FROM' then
first_part := array_to_string(regexp_match(cleaned_text,first_part_regex_2words),' ');
if first_part is null or first_part ILIKE '% FROM' then
first_part := array_to_string(regexp_match(cleaned_text,first_part_regex_1words),' ');
end if;
end if;
first_part := regexp_replace(first_part, '\(.*','(...)');
-- Find all occurrences of FROM and two words after each
FOR match_array IN
SELECT array_to_string(regexp_matches(cleaned_text,from_parts_regex_3words,'gi'),' ')
LOOP
match_array := regexp_replace(match_array, '\(.*','(...)');
from_parts := from_parts || '...' || match_array;
END LOOP;
-- Return combined result
RETURN first_part || from_parts;
END;
$$ LANGUAGE plpgsql;
测试 1: 函数调用中的敏感数据
sql
postgres=# SELECT sanitize_sql($test$
SELECT pgp_sym_encrypt('123-45-6789', 'my_secret_key') AS encrypted_ssn;
$test$);
sanitize_sql
-----------------------------
SELECT pgp_sym_encrypt(...)
(1 row)
更多例子,参见原文。