fix(safety): scan production SQL with the connection's lexer rules

This commit is contained in:
ordinary being
2026-10-06 18:58:15 +08:00
committed by GitHub
parent c87351e0df
commit 8124247657
3 changed files with 71 additions and 17 deletions
@@ -1,6 +1,6 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
import { classifySqlRisk, isSqlRiskMutation } from "@/lib/sql/sqlRisk";
import { classifySqlRisk, isSqlRiskMutation, usesMysqlLexerRules } from "@/lib/sql/sqlRisk";
export type ProductionContextReason = "connection" | "database" | "sql_target";
@@ -124,7 +124,7 @@ export function isProductionMutation(sql: string): boolean {
*/
export function assessProductionSql(sql: string, connection: ConnectionConfig | undefined, activeDatabase: string | undefined | null): ProductionSqlAssessment {
const activeContext = productionContextForDatabase(connection, activeDatabase);
const targetText = sqlTargetSafetyText(sql);
const targetText = sqlTargetSafetyText(sql, connection?.db_type);
const statements = splitTargetStatements(targetText.text);
const risk = classifySqlRisk(sql, { dialect: connection?.db_type });
const isMutation = isSqlRiskMutation(risk.risk);
@@ -258,7 +258,20 @@ function splitTargetStatements(sql: string): string[] {
.filter(Boolean);
}
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
/**
* Removes the text no database target can be found in. Everything removed here
* is text this gate cannot inspect, so reading MySQL lexer rules for a
* connection that has none hides writes: on SQL Server `#tmp` is a temporary
* table and T-SQL escapes a quote by doubling it, so both
* `SELECT * FROM #tmp; DELETE FROM prod_app.dbo.users;` and
* `SELECT 'dir\'; DELETE FROM prod_app.dbo.users;` would lose the `DELETE` and
* the write would no longer require confirmation.
*
* Mirrors `SqlScanLexerRules` in
* `crates/dbx-core/src/safety/production_safety.rs`.
*/
function sqlTargetSafetyText(sql: string, dialect?: DatabaseType | string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
const mysqlLexer = usesMysqlLexerRules(dialect);
let output = "";
let index = 0;
@@ -273,7 +286,7 @@ function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, st
continue;
}
if (char === "#") {
if (char === "#" && mysqlLexer) {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
@@ -286,7 +299,7 @@ function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, st
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), dialect, quotedIdentifiers).text} `;
} else {
output += " ";
}
@@ -303,14 +316,14 @@ function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, st
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
index = readQuotedEnd(sql, index, "'", "'", mysqlLexer);
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
const end = readQuotedEnd(sql, index, char, close, mysqlLexer);
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
const token = `__dbxq${quotedIdentifiers.size}__`;
quotedIdentifiers.set(token.toLowerCase(), identifier);
@@ -343,10 +356,10 @@ function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
function readQuotedEnd(sql: string, start: number, open: string, close: string, backslashEscapes: boolean): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
if (backslashEscapes && sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
+9 -8
View File
@@ -37,19 +37,20 @@ const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list
* string escape. Both rules are MySQL family features: on SQL Server `#tmp` is a
* temporary table, and on PostgreSQL `standard_conforming_strings` is on by
* default, so `'dir\'` is a complete string. Assuming the MySQL rules everywhere
* hides SQL from the classifier — `SELECT * FROM #tmp; DELETE FROM #tmp;` (SQL
* Server) and `SELECT 'dir\'; DELETE FROM users;` (PostgreSQL) would otherwise be
* read as one read-only statement and auto-execute unconfirmed.
* hides SQL from the safety scans — `SELECT * FROM #tmp; DELETE FROM prod.users;`
* (SQL Server) and `SELECT 'dir\'; DELETE FROM users;` (PostgreSQL) would
* otherwise be read as one read-only statement and auto-execute.
*
* Mirrors `is_mysql_compatible_database` in `crates/dbx-sql-core/src/sql.rs`,
* which gates `supports_hash_line_comments` the same way. An unknown dialect is
* deliberately not treated as MySQL: keeping `#` and backslashes literal can only
* split more statements than before, which raises the assessed risk rather than
* lowering it.
* which gates `supports_hash_line_comments` the same way, and
* `SqlScanLexerRules` in `crates/dbx-core/src/safety/production_safety.rs`.
* An unknown dialect is deliberately not treated as MySQL: keeping `#` and
* backslashes literal can only split more statements than before, which raises
* the assessed risk rather than lowering it.
*/
const MYSQL_LEXER_DATABASE_TYPES = new Set<string>(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]);
function usesMysqlLexerRules(dialect?: DatabaseType | string): boolean {
export function usesMysqlLexerRules(dialect?: DatabaseType | string): boolean {
return typeof dialect === "string" && MYSQL_LEXER_DATABASE_TYPES.has(dialect.trim().toLowerCase());
}
+40
View File
@@ -308,5 +308,45 @@
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "sqlserver hash temp table keeps a later production delete in scope",
"dialect": "sqlserver",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "SELECT * FROM #tmp; DELETE FROM prod_app.dbo.users;",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "sqlserver backslash string keeps a later production delete in scope",
"dialect": "sqlserver",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "SELECT 'dir\\'; DELETE FROM prod_app.dbo.users;",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql hash comment keeps a commented delete out of scope",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "SELECT 1; # DELETE FROM prod_app.users",
"active": false,
"isMutation": false,
"databases": []
},
{
"name": "mysql backslash escape keeps the write inside the literal",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "SELECT 'it\\'s; DELETE FROM prod_app.users;'",
"active": false,
"isMutation": false,
"databases": []
}
]