Files
dbx/packages/node-core/src/sql-risk.ts
T

255 lines
10 KiB
TypeScript

export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
export interface SqlRiskStatementAssessment {
risk: SqlRiskLevel;
firstKeyword?: string;
}
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;
}
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
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, options?: SqlTextOptions): string[] {
return sqlSafetyText(sql, options)
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
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 };
}
export function classifySqlStatementRisk(sql: string): SqlRiskStatementAssessment {
return classifyTokens(tokenizeSqlForRisk(sql));
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
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] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (hashLineComments && char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return output;
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlSafetyText(sql.slice(bodyStart, close), options)} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
index = end;
continue;
}
output += char;
index += 1;
}
return output;
}
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
const tokens: SqlRiskToken[] = [];
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
for (const match of sql.matchAll(re)) {
const text = match[0] ?? "";
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
}
return tokens;
}
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const useful = trimWrappingParentheses(tokens);
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
if (!firstKeyword) return { risk: "unknown" };
if (READ_KEYWORDS.has(firstKeyword)) {
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
}
if (firstKeyword === "with") return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
if (firstKeyword === "explain") return classifyExplainTokens(useful);
if (firstKeyword === "copy") return { risk: classifyCopyTokens(useful), firstKeyword };
if (firstKeyword === "pragma") return { risk: classifyPragmaTokens(useful), firstKeyword };
if (firstKeyword === "use") return { risk: "read", firstKeyword };
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
return { risk: "unknown", firstKeyword };
}
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
const inner = classifyTokens(tokens.slice(innerIndex));
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
}
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
if (tokens.some((token) => token.normalized === "from")) return "write";
if (tokens.some((token) => token.normalized === "to")) return "read";
return "unknown";
}
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
return "write";
}
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
let result: SqlRiskLevel | undefined;
for (const token of tokens) {
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
}
return result;
}
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
let depth = 0;
for (const token of tokens) {
if (token.text === "(") depth += 1;
if (token.text === ")") depth = Math.max(0, depth - 1);
if (depth !== 0) continue;
if (token.normalized === "into") return true;
}
return false;
}
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
let start = 0;
let end = tokens.length;
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
start += 1;
end -= 1;
}
return tokens.slice(start, end);
}
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < tokens.length; index += 1) {
if (tokens[index]?.text === "(") depth += 1;
if (tokens[index]?.text === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
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 {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}