From 812424765702f2eee96c6b0ae58b63d976921751 Mon Sep 17 00:00:00 2001 From: ordinary being Date: Tue, 6 Oct 2026 18:58:15 +0800 Subject: [PATCH] fix(safety): scan production SQL with the connection's lexer rules --- .../src/lib/database/productionSafety.ts | 31 +++++++++----- apps/desktop/src/lib/sql/sqlRisk.ts | 17 ++++---- tests/fixtures/production-safety-corpus.json | 40 +++++++++++++++++++ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/lib/database/productionSafety.ts b/apps/desktop/src/lib/database/productionSafety.ts index af6f18bad0..b3dd3e35f0 100644 --- a/apps/desktop/src/lib/database/productionSafety.ts +++ b/apps/desktop/src/lib/database/productionSafety.ts @@ -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()): 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()): SqlTargetSafetyText { + const mysqlLexer = usesMysqlLexerRules(dialect); let output = ""; let index = 0; @@ -273,7 +286,7 @@ function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map 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(["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()); } diff --git a/tests/fixtures/production-safety-corpus.json b/tests/fixtures/production-safety-corpus.json index 3f1cc4c387..20d6829c18 100644 --- a/tests/fixtures/production-safety-corpus.json +++ b/tests/fixtures/production-safety-corpus.json @@ -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": [] } ]