feat(diagnostics): add SQL execution debug logging

This commit is contained in:
Abeautifulsnow 2026-07-16 17:01:24 +08:00 committed by GitHub
parent fc1f02a931
commit badb3b61b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 874 additions and 54 deletions

View File

@ -49,6 +49,7 @@ pub mod schema;
pub mod schema_diff;
pub mod sql;
pub mod sql_analysis;
pub mod sql_diagnostics;
pub mod sql_dialect;
pub mod sql_editability;
pub mod sql_file_import;

View File

@ -34,6 +34,8 @@ use crate::sql::{split_sql_batches, split_sql_statements, starts_with_executable
pub const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_ROWS: usize = 10000;
pub const QUERY_CANCELED: &str = "Query canceled";
const SQL_OMITTED_ERROR_CONTEXT: &str =
"SQL text omitted from user-facing error; enable debug SQL diagnostics for a redacted statement.";
#[cfg(feature = "duckdb-bundled")]
const DUCKDB_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(feature = "duckdb-bundled")]
@ -46,6 +48,14 @@ pub enum PoolErrorAction {
ReconnectAndRetry,
}
fn query_error_with_omitted_sql_context(error: &str, _sql: &str) -> String {
if error.contains(SQL_OMITTED_ERROR_CONTEXT) {
error.to_string()
} else {
format!("{error}\n{SQL_OMITTED_ERROR_CONTEXT}")
}
}
/// A multi-statement result with metadata intended for query clients.
///
/// `execution_error` is emitted for synthesized per-statement errors so clients
@ -1194,6 +1204,7 @@ pub async fn do_execute(
cancel_token: Option<CancellationToken>,
options: QueryExecutionOptions,
) -> Result<db::QueryResult, String> {
crate::sql_diagnostics::debug_sql("do_execute", sql);
if let Some(execution_id) = options.execution_id.as_deref() {
state.running_queries.set_pool_key(execution_id, pool_key.to_string());
}
@ -1722,11 +1733,15 @@ pub async fn execute_sql_statement_with_options(
// on that tab-scoped pool so connection-level state (for example MySQL @vars)
// survives across runs.
let pool_key = if database.is_empty() {
state.get_or_create_pool_for_session(connection_id, None, options.client_session_id.as_deref()).await?
state
.get_or_create_pool_for_session(connection_id, None, options.client_session_id.as_deref())
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?
} else {
state
.get_or_create_pool_for_session(connection_id, Some(database), options.client_session_id.as_deref())
.await?
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?
};
if is_canceled(&cancel_token) {
@ -1738,19 +1753,26 @@ pub async fn execute_sql_statement_with_options(
do_execute(state, &pool_key, mysql_dialect, Some(database), sql, schema, cancel_token.clone(), options.clone())
.await;
let with_sql_context =
|r: Result<db::QueryResult, String>| r.map_err(|e| query_error_with_omitted_sql_context(&e, sql));
let action = result.as_ref().err().map(|e| query_pool_error_action(db_type, sql, e));
match action {
Some(PoolErrorAction::ReconnectAndRetry) if !is_canceled(&cancel_token) => {
let db_opt = if database.is_empty() { None } else { Some(database) };
let new_key =
state.reconnect_pool_for_session(connection_id, db_opt, options.client_session_id.as_deref()).await?;
do_execute(state, &new_key, mysql_dialect, Some(database), sql, schema, cancel_token, options).await
let new_key = state
.reconnect_pool_for_session(connection_id, db_opt, options.client_session_id.as_deref())
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?;
with_sql_context(
do_execute(state, &new_key, mysql_dialect, Some(database), sql, schema, cancel_token, options).await,
)
}
Some(PoolErrorAction::Discard) => {
state.remove_pool_by_key(&pool_key).await;
result
with_sql_context(result)
}
_ => result,
_ => with_sql_context(result),
}
}
@ -1767,7 +1789,8 @@ async fn execute_postgres_drop_database(
let admin_database = postgres_drop_database_admin_database(target_database);
let pool_key = state
.get_or_create_pool_for_session(connection_id, Some(admin_database), options.client_session_id.as_deref())
.await?;
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?;
if let Some(execution_id) = options.execution_id.as_deref() {
state.running_queries.set_pool_key(execution_id, pool_key.clone());
}
@ -1915,11 +1938,15 @@ pub async fn execute_multi_core_with_options_for_client(
}
let pool_key = if database.is_empty() {
state.get_or_create_pool_for_session(connection_id, None, options.client_session_id.as_deref()).await?
state
.get_or_create_pool_for_session(connection_id, None, options.client_session_id.as_deref())
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?
} else {
state
.get_or_create_pool_for_session(connection_id, Some(database), options.client_session_id.as_deref())
.await?
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql))?
};
if let Some(execution_id) = options.execution_id.as_deref() {
state.running_queries.set_pool_key(execution_id, pool_key.clone());
@ -2304,10 +2331,14 @@ pub async fn execute_statements(
schema: Option<&str>,
timeout_secs: Option<u64>,
) -> Result<db::QueryResult, String> {
let sql_ctx = statements.first().map(|s| s.as_str()).unwrap_or("");
let pool_key = if database.is_empty() {
connection_id.to_string()
} else {
state.get_or_create_pool(connection_id, Some(database)).await?
state
.get_or_create_pool(connection_id, Some(database))
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql_ctx))?
};
let mut total_affected: u64 = 0;
@ -2352,7 +2383,7 @@ pub async fn execute_statements(
}
PoolErrorAction::Keep => {}
}
return Err(err);
return Err(query_error_with_omitted_sql_context(&err, sql_ctx));
}
}
}
@ -2385,11 +2416,9 @@ pub async fn execute_statements(
}
PoolErrorAction::Keep => {}
}
return Err(format!(
"Statement {} failed: {}. Previous {} statement(s) may have been committed.",
i + 1,
e,
i
return Err(query_error_with_omitted_sql_context(
&format!("Statement {} failed: {}. Previous {} statement(s) may have been committed.", i + 1, e, i),
sql,
));
}
}
@ -2427,10 +2456,14 @@ pub async fn execute_statements_in_transaction(
statements: &[String],
schema: Option<&str>,
) -> Result<db::QueryResult, String> {
let sql_ctx = statements.first().map(|s| s.as_str()).unwrap_or("");
let pool_key = if database.is_empty() {
connection_id.to_string()
} else {
state.get_or_create_pool(connection_id, Some(database)).await?
state
.get_or_create_pool(connection_id, Some(database))
.await
.map_err(|e| query_error_with_omitted_sql_context(&e, sql_ctx))?
};
// Read-only check: intercept all transaction paths before dispatching
@ -2607,7 +2640,7 @@ async fn exec_tx_pg_statements(
async { tx.execute(sql, &[]).await.map_err(|e| e.to_string()) },
)
.await
.map_err(|e| format!("Statement {} failed: {}", i + 1, e))?;
.map_err(|e| query_error_with_omitted_sql_context(&format!("Statement {} failed: {}", i + 1, e), sql))?;
total_affected += affected;
}
tokio::time::timeout(budget.cleanup_timeout, tx.commit())
@ -2641,7 +2674,7 @@ async fn exec_tx_mysql_inner(
Err(e) => {
let _ = mysql_query_drop_with_timeout(&mut conn, "ROLLBACK", budget.cleanup_timeout, "ROLLBACK failed")
.await;
return Err(format!("Statement {} failed: {}", i + 1, e));
return Err(query_error_with_omitted_sql_context(&format!("Statement {} failed: {}", i + 1, e), sql));
}
}
}
@ -2701,7 +2734,10 @@ async fn exec_tx_sqlite_inner(
Ok(_) => total_affected += conn.changes(),
Err(e) => {
let _ = conn.execute_batch("ROLLBACK");
return Err(format!("Statement {} failed: {}", i + 1, e));
return Err(query_error_with_omitted_sql_context(
&format!("Statement {} failed: {}", i + 1, e),
sql,
));
}
}
}
@ -2786,7 +2822,7 @@ async fn exec_tx_explicit_inner(
{
log::error!("ROLLBACK failed after statement {} error: {}", i + 1, rb_err);
}
return Err(format!("Statement {} failed: {}", i + 1, e));
return Err(query_error_with_omitted_sql_context(&format!("Statement {} failed: {}", i + 1, e), sql));
}
}
}
@ -2819,7 +2855,7 @@ async fn exec_tx_none_inner(
) -> Result<db::QueryResult, String> {
let mut total_affected: u64 = 0;
for (i, sql) in statements.iter().enumerate() {
log::info!("[query][tx-none:statement:start] index={} sql={}", i + 1, sql);
log::info!("[query][tx-none:statement:start] index={}", i + 1);
match do_execute(state, pool_key, mysql_dialect, database, sql, schema, None, QueryExecutionOptions::default())
.await
{
@ -2829,10 +2865,9 @@ async fn exec_tx_none_inner(
}
Err(e) => {
log::warn!("Statement {} failed (no transaction support): {}", i + 1, e);
return Err(format!(
"Statement {} failed: {}. No transaction support for this database type.",
i + 1,
e
return Err(query_error_with_omitted_sql_context(
&format!("Statement {} failed: {}. No transaction support for this database type.", i + 1, e),
sql,
));
}
}
@ -4043,6 +4078,62 @@ mod tests {
assert!(is_connection_error("Error occurred while creating a new object: error communicating with the server"));
}
#[test]
fn query_error_context_omits_raw_sql_and_is_not_duplicated() {
let sql = "select 'secret-123' as token";
let error = query_error_with_omitted_sql_context("driver rejected statement", sql);
assert!(error.contains("driver rejected statement"));
assert!(error.contains(SQL_OMITTED_ERROR_CONTEXT));
assert!(!error.contains("secret-123"));
assert!(!error.contains("SQL:"));
let repeated = query_error_with_omitted_sql_context(&error, sql);
assert_eq!(repeated.matches(SQL_OMITTED_ERROR_CONTEXT).count(), 1);
}
#[test]
fn reconnect_retry_error_context_omits_raw_sql() {
let sql = "select 'secret-123' as token";
let reconnect_error = query_error_with_omitted_sql_context("connection reset after reconnect", sql);
assert!(reconnect_error.contains("connection reset after reconnect"));
assert!(reconnect_error.contains(SQL_OMITTED_ERROR_CONTEXT));
assert!(!reconnect_error.contains("secret-123"));
}
#[test]
fn execute_statements_error_omits_raw_sql() {
let sql = "select 'secret-token' as t";
let err = query_error_with_omitted_sql_context(
&format!(
"Statement {} failed: {}. Previous {} statement(s) may have been committed.",
2, "driver error", 1
),
sql,
);
assert!(err.contains("driver error"));
assert!(err.contains(SQL_OMITTED_ERROR_CONTEXT));
assert!(!err.contains("secret-token"));
assert!(!err.contains("SQL:"));
assert!(err.contains("Statement 2 failed:"));
}
#[test]
fn batch_transaction_error_omits_raw_sql() {
let sql = "delete from users where id = 'secret-id'";
let err = query_error_with_omitted_sql_context(
&format!("Statement {} failed: {}. No transaction support for this database type.", 3, "batch error"),
sql,
);
assert!(err.contains("batch error"));
assert!(err.contains(SQL_OMITTED_ERROR_CONTEXT));
assert!(!err.contains("secret-id"));
assert!(err.contains("Statement 3 failed:"));
}
#[test]
fn is_connection_error_detects_oracle_idle_timeout() {
assert!(is_connection_error("ORA-02396: exceeded maximum idle time, please connect again"));

View File

@ -0,0 +1,263 @@
const DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS: usize = 512;
fn is_sensitive_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("password")
|| key.contains("passwd")
|| key == "pwd"
|| key.contains("secret")
|| key.contains("token")
|| key.contains("api_key")
|| key.contains("apikey")
|| key.contains("access_key")
|| key.contains("private_key")
|| key.contains("credential")
|| key.contains("authorization")
|| key.contains("bearer")
}
fn truncate_for_diagnostics(value: String, max_chars: usize, input_truncated: bool) -> String {
if value.chars().count() <= max_chars {
return if input_truncated { format!("{value}…[truncated]") } else { value };
}
let head: String = value.chars().take(max_chars).collect();
format!("{head}…[truncated]")
}
fn bounded_input(sql: &str, max_chars: usize) -> (&str, bool) {
if max_chars == 0 {
return ("", !sql.is_empty());
}
match sql.char_indices().nth(max_chars) {
Some((index, _)) => (&sql[..index], true),
None => (sql, false),
}
}
fn redact_literals(sql: &str) -> String {
let chars: Vec<char> = sql.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
let next = chars.get(i + 1).copied();
if matches!(ch, '\'' | '"' | '`') {
out.push(ch);
out.push_str("[REDACTED]");
out.push(ch);
i += 1;
while i < chars.len() {
let current = chars[i];
if current == ch {
if chars.get(i + 1).copied() == Some(ch) {
i += 2;
continue;
}
i += 1;
break;
}
if current == '\\' && ch != '`' {
i += 2;
} else {
i += 1;
}
}
continue;
}
if ch == '$' {
let j = i + 1;
if j >= chars.len() {
out.push(ch);
i += 1;
continue;
}
if chars[j] == '$' {
// $$...$$ dollar-quoted string
out.push_str("$$[REDACTED]$$");
i += 2;
while i + 1 < chars.len() && !(chars[i] == '$' && chars[i + 1] == '$') {
i += 1;
}
if i + 1 < chars.len() {
i += 2;
}
continue;
}
// $tag$...$tag$ dollar-quoted string
let tag_start = j;
let mut tag_end = j;
while tag_end < chars.len() && (chars[tag_end].is_ascii_alphanumeric() || chars[tag_end] == '_') {
tag_end += 1;
}
if tag_end > tag_start && tag_end < chars.len() && chars[tag_end] == '$' {
let tag: String = chars[tag_start..tag_end].iter().collect();
out.push_str("$[REDACTED]$");
i = tag_end + 1;
let closing: Vec<char> = format!("${}$", tag).chars().collect();
while i + closing.len() <= chars.len() {
if chars[i..i + closing.len()] == closing[..] {
i += closing.len();
break;
}
i += 1;
}
continue;
}
out.push(ch);
i += 1;
continue;
}
if ch == '-' && next == Some('-') {
out.push_str("--[REDACTED_COMMENT]");
i += 2;
while i < chars.len() && chars[i] != '\n' && chars[i] != '\r' {
i += 1;
}
continue;
}
if ch == '/' && next == Some('*') {
out.push_str("/*[REDACTED_COMMENT]*/");
i += 2;
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
if i + 1 < chars.len() {
i += 2;
}
continue;
}
out.push(ch);
i += 1;
}
out
}
fn redact_sensitive_assignments(sql: &str) -> String {
let chars: Vec<char> = sql.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i].is_whitespace() {
out.push(chars[i]);
i += 1;
continue;
}
let start = i;
while i < chars.len()
&& (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '-' || chars[i] == '.')
{
i += 1;
}
if i == start {
out.push(chars[i]);
i += 1;
continue;
}
let key: String = chars[start..i].iter().collect();
let mut j = i;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && (chars[j] == '=' || chars[j] == ':') {
if is_sensitive_key(&key) {
out.push_str(&key);
for k in i..j {
out.push(chars[k]);
}
out.push(chars[j]);
j += 1;
while j < chars.len() && chars[j].is_whitespace() {
out.push(chars[j]);
j += 1;
}
while j < chars.len() && !chars[j].is_whitespace() {
j += 1;
}
out.push_str("[REDACTED]");
i = j;
continue;
}
}
out.push_str(&key);
}
out
}
pub fn redact_sql_for_diagnostics(sql: &str) -> String {
let max_chars = DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS;
let (bounded_sql, input_truncated) = bounded_input(sql, max_chars);
truncate_for_diagnostics(redact_sensitive_assignments(&redact_literals(bounded_sql)), max_chars, input_truncated)
}
pub fn debug_sql(scope: &str, sql: &str) {
log::debug!("[{scope}] sql={}", redact_sql_for_diagnostics(sql));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sensitive_literals_and_bounds_large_sql() {
let sql = format!(
"select * from users where password = 'secret-123' and api_key=abc and name = 'alice' {};",
"x".repeat(900)
);
let redacted = redact_sql_for_diagnostics(&sql);
assert!(!redacted.contains("secret-123"));
assert!(!redacted.contains("api_key=abc"));
assert!(!redacted.contains("alice"));
// Literals not part of sensitive assignments retain single-quote redaction
assert!(redacted.contains("'[REDACTED]'"), "name literal should be redacted: {}", redacted);
// Sensitive assignment values are redacted with bracket notation
assert!(redacted.contains("password = [REDACTED]"));
assert!(redacted.contains("api_key=[REDACTED]"));
assert!(redacted.contains("truncated"));
assert!(redacted.len() < sql.len());
}
#[test]
fn redacts_space_separated_sensitive_assignments() {
let sql = "select * from users where password = hunter2";
let redacted = redact_sensitive_assignments(sql);
assert!(!redacted.contains("hunter2"));
assert!(redacted.contains("password = [REDACTED]"));
assert!(redacted.contains("select"));
}
#[test]
fn redacts_dollar_quoted_strings() {
let sql = "select $$secret$$, $tag$hello$tag$ from t";
let redacted = redact_sql_for_diagnostics(sql);
assert!(redacted.contains("$$[REDACTED]$$"));
assert!(redacted.contains("$[REDACTED]$"));
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("hello"));
}
#[test]
fn large_input_bounded_allocation() {
let sql = "x".repeat(1_000_000);
let redacted = redact_sql_for_diagnostics(&sql);
assert!(redacted.len() <= 550);
}
#[test]
fn truncation_inside_unclosed_literal_does_not_leak_prefix() {
let sql = format!("select '{}'", "secret-".repeat(1_000));
let redacted = truncate_for_diagnostics(redact_literals(bounded_input(&sql, 32).0), 32, true);
assert!(!redacted.contains("secret-"));
assert!(redacted.contains("[REDACTED]"));
assert!(redacted.contains("truncated"));
}
#[test]
fn truncation_inside_sensitive_assignment_does_not_leak_prefix() {
let sql = format!("password = {}", "secret-token".repeat(1_000));
let (bounded, truncated) = bounded_input(&sql, 24);
let redacted = truncate_for_diagnostics(redact_sensitive_assignments(&redact_literals(bounded)), 24, truncated);
assert!(!redacted.contains("secret-token"));
assert!(redacted.contains("password = [REDACTED]"));
assert!(redacted.contains("truncated"));
}
}

View File

@ -23,7 +23,7 @@ serde_json = "1.0"
uuid = { version = "1", features = ["v4"] }
argon2 = "0.5"
log = "0.4"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "tracing-log"] }
tracing = "0.1"
async-stream = "0.3"
futures = "0.3"

View File

@ -320,6 +320,8 @@ pub async fn execute_query(
);
let cancel_token = registered.token();
tracing::debug!(connection_id = %req.connection_id, "execute_query");
let result = dbx_core::query::execute_sql_statement_with_options(
&state.app,
&req.connection_id,
@ -359,6 +361,8 @@ pub async fn execute_multi(
);
let cancel_token = registered.token();
tracing::debug!(connection_id = %req.connection_id, "execute_multi");
let result = dbx_core::query::execute_multi_core_with_options_for_client(
&state.app,
&req.connection_id,
@ -390,6 +394,7 @@ pub async fn execute_batch(
State(state): State<Arc<WebState>>,
Json(req): Json<ExecuteBatchRequest>,
) -> Result<Json<dbx_core::db::QueryResult>, AppError> {
tracing::debug!(connection_id = %req.connection_id, "execute_batch");
let result = dbx_core::query::execute_statements(
&state.app,
&req.connection_id,
@ -447,6 +452,7 @@ pub async fn execute_script(
State(state): State<Arc<WebState>>,
Json(req): Json<ExecuteQueryRequest>,
) -> Result<Json<dbx_core::db::QueryResult>, AppError> {
tracing::debug!(connection_id = %req.connection_id, "execute_script");
let db_type = {
let configs = state.app.configs.read().await;
configs.get(&req.connection_id).map(|config| config.db_type)
@ -472,6 +478,7 @@ pub async fn execute_in_transaction(
State(state): State<Arc<WebState>>,
Json(req): Json<ExecuteBatchRequest>,
) -> Result<Json<dbx_core::db::QueryResult>, AppError> {
tracing::debug!(connection_id = %req.connection_id, "execute_in_transaction");
let result = dbx_core::query::execute_statements_in_transaction(
&state.app,
&req.connection_id,

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { buildSchemaContext, createBackend, DIRECT_QUERY_TYPES, BRIDGE_REQUIRED_TYPES, evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, isMainModule, postBridge, type Backend, type DbxDiagnostics, type SqlSafetyOptions } from "@dbx-app/node-core";
import { buildSchemaContext, createBackend, DIRECT_QUERY_TYPES, BRIDGE_REQUIRED_TYPES, evaluateSqlSafety, formatSchemaContext, getDbxDiagnostics, isMainModule, postBridge, supportsHashLineComments, type Backend, type DbxDiagnostics, type SqlSafetyOptions } from "@dbx-app/node-core";
import { connectionSummary, csvTable, errorPayload, formatCell, formatErrorMessage, mdTable } from "./cli-format.js";
export interface CliResult {
@ -165,6 +165,7 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise<
}
const sqlArg = usesDefaultConnection ? args[1] : args[2];
const sql = flags.file ? await readFile(flags.file, "utf-8") : required(sqlArg, "SQL string or --file is required.");
const config = await findConnectionOrThrow(backend, connectionName);
const envSafety = sqlSafetyFromCliEnv(env);
if (flags.allowDangerous && !flags.allowWrites && !envSafety.allowWrites) {
throw new CliError("INVALID_OPTION", "--allow-dangerous-sql requires --allow-writes.");
@ -172,10 +173,10 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise<
const safetyOptions: SqlSafetyOptions = {
allowWrites: flags.allowWrites || envSafety.allowWrites,
allowDangerous: flags.allowDangerous || envSafety.allowDangerous,
hashLineComments: supportsHashLineComments(config.db_type),
};
const safety = evaluateSqlSafety(sql, safetyOptions);
if (!safety.allowed) return fail("SQL_BLOCKED", safety.reason ?? "SQL blocked.", flags.json);
const config = await findConnectionOrThrow(backend, connectionName);
const result = await backend.executeQuery(config, sql, { maxRows: flags.maxRows, timeoutMs: flags.timeoutMs });
if (flags.format === "json") {
return okJson({ connection: connectionName, columns: result.columns, rows: result.rows, row_count: result.row_count });

View File

@ -124,6 +124,10 @@ DBX_MCP_ALLOW_DANGEROUS_SQL=1
Redis connections use `dbx_execute_redis_command` instead of `dbx_execute_query`. Redis write commands honor `DBX_MCP_ALLOW_WRITES`; dangerous Redis commands such as `KEYS`, `FLUSHALL`, and `EVAL` require `DBX_MCP_ALLOW_DANGEROUS_SQL=1`.
## SQL Diagnostics Privacy
SQL statements are not included in normal MCP errors and are not logged by default. To enable temporary diagnostics, set `DBX_MCP_DEBUG_SQL=1` (or `DBX_SQL_DEBUG=1`). Diagnostic statements redact quoted literals and common secret assignments, and are truncated to 512 characters. Do not enable this setting unless the resulting diagnostic metadata is appropriate for the environment.
## How It Works
```

View File

@ -19,8 +19,10 @@ import {
isLikelyMongoMutation,
isProductionDatabase,
postBridge,
logSqlDiagnostic,
sqlSafetyFromEnv,
splitSqlStatements,
supportsHashLineComments,
type Backend,
type ConnectionConfig,
type QueryResult,
@ -231,6 +233,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
sql: z.string().describe("SQL query to execute"),
},
async ({ connection_id, connection_name, database, sql }) => {
logSqlDiagnostic("dbx_execute_query", sql, { connection_id, connection_name, database });
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const scopedConfig = config!;
@ -238,7 +241,8 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
return toolError("REDIS_COMMAND_REQUIRED", "Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command with a Redis command such as GET key or INFO.");
}
if (scopedConfig.db_type !== "mongodb") {
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
const hashLineComments = supportsHashLineComments(scopedConfig.db_type);
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true, hashLineComments });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
const production = assessProductionSql(sql, scopedConfig, database ?? scope.database ?? scopedConfig.database);
if (production.active && production.isMutation) {
@ -250,7 +254,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
// (node-core executeQuery) applies command-aware read/write gating.
try {
const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql, { hashLineComments: supportsHashLineComments(scopedConfig.db_type) });
const results = [];
for (const statement of statements) {
results.push(await backend.executeQuery(withDatabase(scopedConfig, database ?? scope.database), statement));
@ -479,7 +483,8 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked.");
}
} else {
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true });
const hashLineComments = supportsHashLineComments(config?.db_type);
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true, hashLineComments });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
if (config?.db_type === "mongodb") {
@ -494,6 +499,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
}
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
// safety flags to the desktop executor for command-aware gating.
logSqlDiagnostic("dbx_execute_in_app", sql, { connection_id: config!.id, connection_name: config!.name, database });
return bridgeRequest(
"/execute-query",
{

View File

@ -256,6 +256,52 @@ test("redis command tool executes redis commands on the selected database", asyn
assert.match(result.content[0].text, /value-1/);
});
test("dbx_execute_query does not log SQL when debug diagnostics are disabled", async () => {
const original = console.error;
const originalDebug = process.env.DBX_SQL_DEBUG;
const originalMcpDebug = process.env.DBX_MCP_DEBUG_SQL;
const messages: unknown[][] = [];
delete process.env.DBX_SQL_DEBUG;
delete process.env.DBX_MCP_DEBUG_SQL;
console.error = (...args: unknown[]) => messages.push(args);
try {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "select 'secret-123' as token",
});
assert.equal(result.isError, undefined);
} finally {
console.error = original;
if (originalDebug === undefined) delete process.env.DBX_SQL_DEBUG;
else process.env.DBX_SQL_DEBUG = originalDebug;
if (originalMcpDebug === undefined) delete process.env.DBX_MCP_DEBUG_SQL;
else process.env.DBX_MCP_DEBUG_SQL = originalMcpDebug;
}
assert.equal(messages.length, 0);
});
test("dbx_execute_query omits raw SQL from user-facing query errors", async () => {
const sensitiveSql = "select 'secret-123' as token";
const scopedBackend: Backend = {
...backend,
executeQuery: async () => {
throw new Error("driver rejected statement");
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: sensitiveSql,
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /QUERY_ERROR: driver rejected statement/);
assert.doesNotMatch(result.content[0].text, /secret-123|SQL:/);
});
test("redis command tool blocks write commands in read-only MCP sessions", async () => {
let executed = false;
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" };
@ -808,3 +854,66 @@ test("dbx_execute_query with connection_id routes correctly on bridge-backed (SS
assert.equal(usedConfigs[0].host, "private.local");
assert.equal(usedConfigs[0].ssh_enabled, true);
});
// --- Dialect-aware `#` comment handling ---
test("dbx_execute_query splits PG `#` operator statements correctly", async () => {
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// On a postgres connection, `#` is an operator, not a comment.
// `SELECT 1 # 2; SELECT 3` should produce TWO executeQuery calls.
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "SELECT 1 # 2; SELECT 3",
});
assert.deepEqual(executed, ["SELECT 1 # 2", "SELECT 3"]);
});
test("dbx_execute_query treats `#` as line comment on MySQL connections", async () => {
const mysqlConn: ConnectionConfig = { ...connection, id: "mysql-1", name: "mysql-local", db_type: "mysql" };
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mysqlConn],
findConnection: async (name) => (name === "mysql-local" ? mysqlConn : undefined),
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// On a mysql connection, `#` IS a line comment.
// The `;` in `SELECT 1;` splits the first statement. The `# comment\nSELECT 2`
// is a single statement — the `#` makes everything on that line a comment,
// and after the newline `SELECT 2` continues (no `;` to split).
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "mysql-local",
sql: "SELECT 1; # comment\nSELECT 2",
});
assert.deepEqual(executed, ["SELECT 1", "# comment\nSELECT 2"]);
});
test("dbx_execute_query blocks PG injection through `#` as comment in classification", async () => {
// `SELECT 1 # 2; DELETE FROM t` on a postgres connection: the `#` is an operator,
// so classification must see the DELETE and block it as a write in read-only mode.
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "SELECT 1 # 2; DELETE FROM t",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
});

View File

@ -20,6 +20,7 @@
"./production-safety": "./dist/production-safety.js",
"./redis-command": "./dist/redis-command.js",
"./schema-context": "./dist/schema-context.js",
"./sql-diagnostics": "./dist/sql-diagnostics.js",
"./sql-risk": "./dist/sql-risk.js",
"./sql-safety": "./dist/sql-safety.js"
},

View File

@ -970,7 +970,7 @@ async function executeRedisCommandDirect(config: ConnectionConfig, db: number, c
const command = argv[0].toUpperCase();
const safety = classifyRedisCommand(command) as RedisCommandSafety;
if (!options?.skipSafetyCheck && safety === "blocked") {
throw new Error(`Redis command is blocked for safety: ${command}`);
throw new Error("Redis command is blocked for safety. Enable dangerous commands with DBX_MCP_ALLOW_DANGEROUS_SQL=1.");
}
const { Redis } = await import("ioredis");

View File

@ -9,5 +9,6 @@ export * from "./paths.js";
export * from "./production-safety.js";
export * from "./redis-command.js";
export * from "./schema-context.js";
export * from "./sql-diagnostics.js";
export * from "./sql-risk.js";
export * from "./sql-safety.js";

View File

@ -1,5 +1,5 @@
import type { ConnectionConfig } from "./connections.js";
import { classifySqlRisk, isSqlRiskMutation } from "./sql-risk.js";
import { classifySqlRisk, isSqlRiskMutation, supportsHashLineComments } from "./sql-risk.js";
export interface ProductionSqlAssessment {
active: boolean;
@ -93,7 +93,8 @@ export function isProductionDatabase(config: ConnectionConfig | undefined, datab
export function assessProductionSql(sql: string, config: ConnectionConfig | undefined, activeDatabase?: string): ProductionSqlAssessment {
const targetText = sqlTargetSafetyText(sql);
const statements = splitTargetStatements(targetText.text);
const isMutation = isSqlRiskMutation(classifySqlRisk(sql).risk);
const hashLineComments = supportsHashLineComments(config?.db_type);
const isMutation = isSqlRiskMutation(classifySqlRisk(sql, { hashLineComments }).risk);
if (!isMutation || !config) return { active: isProductionDatabase(config, activeDatabase), isMutation, databases: [] };
if (config.is_production) return { active: true, isMutation, databases: [] };
if (isProductionDatabase(config, activeDatabase)) return { active: true, isMutation, databases: activeDatabase ? [activeDatabase] : [] };
@ -101,12 +102,12 @@ export function assessProductionSql(sql: string, config: ConnectionConfig | unde
const marked = new Set((config.production_databases ?? []).map(normalizeProductionDatabase).filter(Boolean));
if (!marked.size) return { active: false, isMutation, databases: [] };
const targets = referencedDatabases(statements, config.db_type, activeDatabase, targetText.quotedIdentifiers);
const targets = referencedDatabases(statements, config.db_type, hashLineComments, activeDatabase, targetText.quotedIdentifiers);
const databases = targets.databases.filter((database) => marked.has(normalizeProductionDatabase(database)));
return { active: databases.length > 0 || targets.uncertain, isMutation, databases: databases.length > 0 ? databases : targets.uncertain ? [...marked] : [] };
}
function referencedDatabases(statements: string[], dbType: string, activeDatabase: string | undefined, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
function referencedDatabases(statements: string[], dbType: string, hashLineComments: boolean, activeDatabase: string | undefined, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
const databases = new Set<string>();
let uncertain = false;
let useDatabase = "";
@ -114,7 +115,7 @@ function referencedDatabases(statements: string[], dbType: string, activeDatabas
for (const statement of statements) {
const statementDatabases = new Set<string>();
const statementAssessment = classifySqlRisk(statement);
const statementAssessment = classifySqlRisk(statement, { hashLineComments });
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
const useMatch = statement.match(USE_RE);
if (useMatch?.[1]) {

View File

@ -0,0 +1,130 @@
const DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS = 512;
const SENSITIVE_NAME_RE = /(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|authorization|bearer)/i;
function boundedInput(sql: string, maxChars: number): [string, boolean] {
if (maxChars <= 0) return ["", sql.length > 0];
let end = 0;
let chars = 0;
for (const character of sql) {
if (chars === maxChars) return [sql.slice(0, end), true];
end += character.length;
chars += 1;
}
return [sql, false];
}
function truncateForDiagnostic(value: string, maxChars: number, inputTruncated: boolean): string {
if (value.length > maxChars) return `${value.slice(0, maxChars)}…[truncated]`;
return inputTruncated ? `${value}…[truncated]` : value;
}
function redactSqlLiterals(sql: string): string {
let result = "";
let i = 0;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1];
if (ch === "'" || ch === '"' || ch === "`") {
const quote = ch;
result += `${quote}[REDACTED]${quote}`;
i += 1;
while (i < sql.length) {
const current = sql[i];
if (current === quote) {
if (sql[i + 1] === quote) {
i += 2;
continue;
}
i += 1;
break;
}
if (current === "\\" && quote !== "`") {
i += 2;
} else {
i += 1;
}
}
continue;
}
if (ch === "$") {
const j = i + 1;
if (j >= sql.length) {
result += "$";
i += 1;
continue;
}
if (sql[j] === "$") {
// $$...$$ empty-tag dollar-quoted string
result += "$$[REDACTED]$$";
i += 2;
while (i + 1 < sql.length && !(sql[i] === "$" && sql[i + 1] === "$")) {
i += 1;
}
if (i + 1 < sql.length) {
i += 2;
}
continue;
}
// $tag$...$tag$ dollar-quoted string — tag must be ASCII alphanumerics + underscore only
const TAG_CHAR = /^[A-Za-z0-9_]$/;
let tagEnd = j;
while (tagEnd < sql.length && TAG_CHAR.test(sql[tagEnd])) {
tagEnd += 1;
}
if (tagEnd > j && tagEnd < sql.length && sql[tagEnd] === "$") {
const tag = sql.slice(j, tagEnd);
result += "$[REDACTED]$";
i = tagEnd + 1;
const closing = "$" + tag + "$";
while (i + closing.length <= sql.length) {
if (sql.slice(i, i + closing.length) === closing) {
i += closing.length;
break;
}
i += 1;
}
continue;
}
result += "$";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
result += "--[REDACTED_COMMENT]";
i += 2;
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
continue;
}
if (ch === "/" && next === "*") {
result += "/*[REDACTED_COMMENT]*/";
i += 2;
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i += 1;
if (i < sql.length) i += 2;
continue;
}
result += ch;
i += 1;
}
return result;
}
export function redactSqlForDiagnostics(sql: string, maxChars = DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS): string {
const [boundedSql, inputTruncated] = boundedInput(sql, maxChars);
const literalRedacted = redactSqlLiterals(boundedSql);
const sensitiveRedacted = literalRedacted.replace(/\b([A-Za-z_][\w.-]*)(\s*[:=]\s*)([^\s,;)]+)/g, (match, key: string, separator: string) => {
if (!SENSITIVE_NAME_RE.test(key)) return match;
return `${key}${separator}[REDACTED]`;
});
return truncateForDiagnostic(sensitiveRedacted, maxChars, inputTruncated);
}
export function sqlDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const value = env.DBX_SQL_DEBUG ?? env.DBX_DEBUG_SQL ?? env.DBX_MCP_DEBUG_SQL;
return value === "1" || value?.toLowerCase() === "true";
}
export function logSqlDiagnostic(scope: string, sql: string, details: Record<string, unknown> = {}, env?: NodeJS.ProcessEnv): void {
if (!sqlDiagnosticsEnabled(env)) return;
console.error(`[${scope}] sql:`, JSON.stringify({ ...details, sql: redactSqlForDiagnostics(sql) }));
}

View File

@ -9,6 +9,25 @@ export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
statements: SqlRiskStatementAssessment[];
}
/** Options for SQL text utilities that parse comments and literals. */
export interface SqlTextOptions {
/** Whether `#` starts a line comment. Only MySQL-family databases support this.
* Default: false (fail-safe `#` is treated as an operator, which may over-block
* MySQL classification but never under-blocks PostgreSQL). */
hashLineComments?: boolean;
}
/** Database types whose SQL dialect uses `#` for line comments (MySQL family). */
const MYSQL_HASH_COMMENT_DB_TYPES = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]);
/** Determine whether the given database type supports `#` line comments.
* Mirrors the Rust `is_mysql_compatible_database` dialect set:
* Mysql, Doris, StarRocks, ManticoreSearch, Goldendb. */
export function supportsHashLineComments(dbType?: string): boolean {
if (!dbType) return false;
return MYSQL_HASH_COMMENT_DB_TYPES.has(dbType);
}
interface SqlRiskToken {
text: string;
normalized: string;
@ -23,15 +42,15 @@ const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS,
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
const RISK_ORDER: Record<SqlRiskLevel, number> = { read: 0, write: 1, ddl: 2, transaction: 3, unknown: 4 };
export function splitSqlStatementsForSafety(sql: string): string[] {
return sqlSafetyText(sql)
export function splitSqlStatementsForSafety(sql: string, options?: SqlTextOptions): string[] {
return sqlSafetyText(sql, options)
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export function classifySqlRisk(sql: string): SqlRiskAssessment {
const statements = splitSqlStatementsForSafety(sql).map(classifySqlStatementRisk);
export function classifySqlRisk(sql: string, options?: SqlTextOptions): SqlRiskAssessment {
const statements = splitSqlStatementsForSafety(sql, options).map(classifySqlStatementRisk);
if (!statements.length) return { risk: "unknown", statements: [] };
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
return { ...highest, statements };
@ -45,9 +64,10 @@ export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
export function sqlSafetyText(sql: string): string {
export function sqlSafetyText(sql: string, options?: SqlTextOptions): string {
let output = "";
let index = 0;
const hashLineComments = options?.hashLineComments === true;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
@ -57,7 +77,7 @@ export function sqlSafetyText(sql: string): string {
output += " ";
continue;
}
if (char === "#") {
if (hashLineComments && char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
@ -69,7 +89,7 @@ export function sqlSafetyText(sql: string): string {
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlSafetyText(sql.slice(bodyStart, close))} `;
output += ` ${sqlSafetyText(sql.slice(bodyStart, close), options)} `;
} else {
output += " ";
}

View File

@ -1,9 +1,11 @@
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText } from "./sql-risk.js";
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText, type SqlTextOptions } from "./sql-risk.js";
export interface SqlSafetyOptions {
allowWrites?: boolean;
allowDangerous?: boolean;
allowMultipleStatements?: boolean;
/** Whether `#` starts a line comment (MySQL family only). Default: false. */
hashLineComments?: boolean;
}
export interface SqlSafetyDecision {
@ -22,7 +24,7 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
}
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const statements = splitSqlStatementsForSafety(sql);
const statements = splitSqlStatementsForSafety(sql, options);
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
if (statements.length > 1 && !options.allowMultipleStatements) {
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
@ -59,7 +61,7 @@ function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions
}
if (options.allowWrites && !options.allowDangerous) {
const tokens: string[] = sqlSafetyText(sql).toLowerCase().match(/[a-z_]+/g) ?? [];
const tokens: string[] = sqlSafetyText(sql, options).toLowerCase().match(/[a-z_]+/g) ?? [];
if (firstKeyword === "update" && !tokens.includes("where")) {
return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
}
@ -80,12 +82,13 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet
};
}
export function splitSqlStatements(sql: string): string[] {
export function splitSqlStatements(sql: string, options?: SqlTextOptions): string[] {
const statements: string[] = [];
let statementStart = 0;
let index = 0;
let state: "none" | "single" | "double" | "backtick" | "bracket" | "lineComment" | "blockComment" | "dollar" = "none";
let dollarTag = "";
const hashLineComments = options?.hashLineComments === true;
const pushStatement = (end: number) => {
const statement = sql.slice(statementStart, end).trim();
@ -152,7 +155,7 @@ export function splitSqlStatements(sql: string): string[] {
index += 2;
continue;
}
if (char === "#") {
if (hashLineComments && char === "#") {
state = "lineComment";
index += 1;
continue;

View File

@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { logSqlDiagnostic, redactSqlForDiagnostics, sqlDiagnosticsEnabled } from "../src/sql-diagnostics.js";
test("SQL diagnostics are disabled unless explicitly enabled", () => {
assert.equal(sqlDiagnosticsEnabled({}), false);
assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "0" }), false);
assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "1" }), true);
assert.equal(sqlDiagnosticsEnabled({ DBX_MCP_DEBUG_SQL: "true" }), true);
});
test("redacts sensitive literals and bounds large SQL diagnostics", () => {
const sql = `select * from users where password = 'secret-123' and token="tok-456" and api_key=plain ${"x".repeat(900)}`;
const redacted = redactSqlForDiagnostics(sql);
assert.doesNotMatch(redacted, /secret-123|tok-456|api_key=plain/);
assert.match(redacted, /\[REDACTED\]/);
assert.match(redacted, /api_key=\[REDACTED\]/);
assert.match(redacted, /truncated/);
assert.ok(redacted.length < sql.length);
});
test("disabled SQL diagnostic logging does not write statements", () => {
const original = console.error;
const messages: unknown[][] = [];
console.error = (...args: unknown[]) => messages.push(args);
try {
logSqlDiagnostic("test", "select 'secret-123'", {}, {});
} finally {
console.error = original;
}
assert.equal(messages.length, 0);
});
test("enabled SQL diagnostic logging emits redacted statements only", () => {
const original = console.error;
const messages: unknown[][] = [];
console.error = (...args: unknown[]) => messages.push(args);
try {
logSqlDiagnostic("test", "select 'secret-123' as password", {}, { DBX_SQL_DEBUG: "1" });
} finally {
console.error = original;
}
assert.equal(messages.length, 1);
const rendered = messages.flat().join(" ");
assert.doesNotMatch(rendered, /secret-123/);
assert.match(rendered, /\[REDACTED\]/);
});
test("dollar-quoted strings are redacted", () => {
const redacted = redactSqlForDiagnostics("select $$secret$$");
assert.doesNotMatch(redacted, /secret/);
assert.match(redacted, /\[REDACTED\]/);
});
test("space-separated sensitive assignments are redacted", () => {
const redacted = redactSqlForDiagnostics("select * from t where password = mysecret");
assert.doesNotMatch(redacted, /mysecret/);
assert.match(redacted, /\[REDACTED\]/);
});
test("postgres positional parameters are not treated as dollar quotes ($1, $2, ...)", () => {
const redacted = redactSqlForDiagnostics("select * from t where id = $1 and name = 'alice'");
assert.match(redacted, /\$1\b/);
assert.doesNotMatch(redacted, /alice/);
});
test("multiple postgres positional parameters all survive redaction", () => {
const redacted = redactSqlForDiagnostics("select $1, $2, $3, $42 from t");
assert.match(redacted, /\$1\b/);
assert.match(redacted, /\$2\b/);
assert.match(redacted, /\$3\b/);
assert.match(redacted, /\$42\b/);
});
test("empty-tag dollar quote $$secret$$ is redacted", () => {
const redacted = redactSqlForDiagnostics("select $$secret$$ from t");
assert.match(redacted, /\$\$\[REDACTED\]\$\$/);
assert.doesNotMatch(redacted, /secret/);
});
test("named-tag dollar quote $tag$hello$tag$ is redacted", () => {
const redacted = redactSqlForDiagnostics("select $tag$hello$tag$ from t");
assert.match(redacted, /\$\[REDACTED\]\$/);
assert.doesNotMatch(redacted, /hello/);
});
test("lone trailing dollar sign does not throw and passes through", () => {
const redacted = redactSqlForDiagnostics("select 1 $");
assert.match(redacted, /\$/);
});
test("bounds redaction before scanning an unclosed literal", () => {
const redacted = redactSqlForDiagnostics(`select '${"secret-".repeat(1000)}`, 32);
assert.doesNotMatch(redacted, /secret-/);
assert.match(redacted, /\[REDACTED\]/);
assert.match(redacted, /truncated/);
});
test("does not leak a sensitive value cut at the diagnostic boundary", () => {
const redacted = redactSqlForDiagnostics(`password = ${"secret-token".repeat(1000)}`, 24);
assert.doesNotMatch(redacted, /secret-token|secret-/);
assert.match(redacted, /password = \[REDACTED\]/);
assert.match(redacted, /truncated/);
});

View File

@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { evaluateSqlSafety, splitSqlStatements, sqlSafetyFromEnv } from "../src/sql-safety.js";
import { supportsHashLineComments } from "../src/sql-risk.js";
test("allows read-only SQL by default", () => {
const decision = evaluateSqlSafety("select * from users limit 5");
@ -110,3 +111,77 @@ test("sqlSafetyFromEnv supports explicitly disabling writes", () => {
assert.equal(options.allowWrites, false);
assert.equal(options.allowDangerous, false);
});
// --- Dialect-aware `#` comment handling ---
test("supportsHashLineComments matches Rust mysql-compatible dialect set", () => {
for (const dbType of ["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]) {
assert.equal(supportsHashLineComments(dbType), true, dbType);
}
for (const dbType of ["postgres", "sqlite", "sqlserver", "oracle", "duckdb", "bigquery", "redshift", ""]) {
assert.equal(supportsHashLineComments(dbType), false, dbType);
}
assert.equal(supportsHashLineComments(undefined), false);
});
test("splitSqlStatements splits PG `#` operator correctly (hashLineComments omitted/default)", () => {
assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2"), ["SELECT a # b", "SELECT 2"]);
});
test("splitSqlStatements splits PG `#` operator correctly (hashLineComments: false)", () => {
assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2", { hashLineComments: false }), [
"SELECT a # b",
"SELECT 2",
]);
});
test("splitSqlStatements treats `#` as comment with hashLineComments: true (MySQL)", () => {
// With hashLineComments: true, the `;` inside the `#` comment must NOT split.
// The comment text is preserved in the output (splitter only delimits on `;`, it doesn't strip).
assert.deepEqual(
splitSqlStatements("SELECT 1; # trailing ; comment\nSELECT 2", { hashLineComments: true }),
["SELECT 1", "# trailing ; comment\nSELECT 2"],
);
});
test("splitSqlStatements preserves JSONB operator text verbatim", () => {
const result = splitSqlStatements("SELECT data #>> '{a,b}' FROM t");
assert.equal(result.length, 1);
assert.equal(result[0], "SELECT data #>> '{a,b}' FROM t");
});
test("splitSqlStatements handles `#` as operator mid-statement (PG)", () => {
assert.deepEqual(splitSqlStatements("SELECT 1 # 2; DELETE FROM t"), [
"SELECT 1 # 2",
"DELETE FROM t",
]);
});
test("evaluateSqlSafety blocks PG injection that bypasses # as comment (regression)", () => {
// Before fix: # would strip "2; DELETE FROM t" as comment, classify as read-only.
// After fix: # is treated as an operator, so DELETE FROM t is seen as a second write statement.
const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", {
allowWrites: false,
allowMultipleStatements: true,
});
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /read-only/i);
});
test("evaluateSqlSafety allows MySQL `#` comment with hashLineComments: true", () => {
const decision = evaluateSqlSafety("SELECT 1 # delete note", {
allowWrites: false,
allowMultipleStatements: true,
hashLineComments: true,
});
assert.equal(decision.allowed, true);
});
test("evaluateSqlSafety with hashLineComments: false still sees DELETE after `#` operator", () => {
const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", {
allowWrites: false,
allowMultipleStatements: true,
hashLineComments: false,
});
assert.equal(decision.allowed, false);
});

View File

@ -85,13 +85,13 @@ pub async fn execute_multi(
let cancel_token = registered_query.as_ref().map(|query| query.token());
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id").to_string();
let started_at = Instant::now();
dbx_core::sql_diagnostics::debug_sql("query:execute_multi:start", &sql);
log::info!(
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?} sql={}",
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?}",
trace_id,
connection_id,
database,
schema,
sql
schema
);
let result = dbx_core::query::execute_multi_core_with_options_for_client(