您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Detect unsafe functions in SQL queries on DataCentral ETL pages
// ==UserScript== // @name Datanet: SQL Unsafe Function Detector // @namespace http://tampermonkey.net/ // @version 1.5 // @description Detect unsafe functions in SQL queries on DataCentral ETL pages // @author [email protected] // @match https://datacentral.a2z.com/dw-platform/servlet/dwp/template/EtlViewExtractJobs.vm/job_profile_id/* // @grant none // ==/UserScript== /* REVISION HISTORY: 1 - 2025-09-16 - zhjy@ - Initial version 1.1 - 2025-09-16 - zhjy@ - Updated "match" to make it available to new Datanet UI 1.2 - 2025-09-16 - zhjy@ - Revert to 1.0 1.3 - 2025-09-16 - zhjy@ - Fixed performance issues by removing automatic monitoring, detection now only runs on button click 1.4 - 2025-09-16 - zhjy@ - Enhanced WHERE clause detection to identify complete WHERE clauses and JOIN ON clause extraction for improved accuracy. 1.5 - 2025-09-18 - zhjy@ - Fixed SQL text extraction; Modernized UI design; Refactored unsafe function detection logic */ (function() { 'use strict'; // Define unsafe functions by category const UNSAFE_FUNCTIONS = { 'Date/Time Functions': [ 'DATE_TRUNC', 'DATE_PART', 'EXTRACT', 'TO_CHAR', 'ADD_MONTHS', 'CONVERT_TIMEZONE', 'CURRENT_DATE', 'INTERVAL', 'TRUNC', '::DATE' ], 'String Functions': [ 'LENGTH', 'LEFT', 'RIGHT', 'SUBSTR', 'POSITION', 'CONCAT', 'REPLACE' ], 'String Matching Functions': [ 'SIMILAR TO', 'REGEXP_COUNT', 'LIKE' ], 'Type Conversion Functions': [ 'CAST', 'TO_NUMBER', 'TO_DATE', '::DATE', '::TIMESTAMP', '::TIME', '::INTEGER', '::NUMERIC', '::TEXT', '::VARCHAR', '::CHAR', '::BOOLEAN' ], 'Time Comparison Functions': [ 'TIME BETWEEN', 'EXTRACT.*EPOCH' ] }; // Flatten all unsafe functions for easy lookup const ALL_UNSAFE_FUNCTIONS = Object.values(UNSAFE_FUNCTIONS).flat(); // Define safe TO_DATE patterns that should not be flagged as unsafe const SAFE_TO_DATE_PATTERNS = [ /to_date\s*\(\s*['"]\{RUN_DATE_YYYYMMDD\}['"],\s*['"]YYYYMMDD['"]\s*\)/gi, /to_date\s*\(\s*['"]\{RUN_DATE_YYYY-MM-DD\}['"],\s*['"]YYYY-MM-DD['"]\s*\)/gi, /to_date\s*\(\s*['"]\{RUN_DATE_YYYY\/MM\/DD\}['"],\s*['"]YYYY\/MM\/DD['"]\s*\)/gi ]; // CSS styles for UI and highlighting const styles = ` .unsafe-function-highlight { background-color: #fef3c7 !important; border-radius: 4px !important; padding: 1px 3px !important; font-weight: 600 !important; user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; -ms-user-select: text !important; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important; } .unsafe-function-panel { position: fixed; top: 20px; right: 20px; width: 380px; max-height: 520px; background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); border: 1px solid #e2e8f0; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; overflow: hidden; backdrop-filter: blur(10px); border-top: 3px solid #334155; } .unsafe-function-panel-header { background: linear-gradient(135deg, #334155 0%, #475569 100%); color: white; padding: 16px 20px; font-weight: 600; font-size: 15px; display: flex; justify-content: space-between; align-items: center; letter-spacing: -0.025em; } .unsafe-function-panel-content { padding: 20px; max-height: 420px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #cbd5e1 #f1f5f9; } .unsafe-function-panel-content::-webkit-scrollbar { width: 6px; } .unsafe-function-panel-content::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; } .unsafe-function-panel-content::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } .unsafe-function-panel-content::-webkit-scrollbar-thumb:hover { background: #94a3b8; } .unsafe-function-category { margin-bottom: 18px; } .unsafe-function-category:last-child { margin-bottom: 0; } .unsafe-function-category-title { font-weight: 600; color: #1e293b; margin-bottom: 8px; border-bottom: 2px solid #e2e8f0; padding-bottom: 6px; font-size: 14px; letter-spacing: -0.025em; display: flex; align-items: center; gap: 8px; } .unsafe-function-category-title::before { content: "⚠️"; font-size: 16px; } .unsafe-function-item { margin: 6px 0; padding: 12px 16px; background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); border: 1px solid #e2e8f0; border-left: 4px solid #f59e0b; border-radius: 8px; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 13px; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); position: relative; line-height: 1.5; } .unsafe-function-item:hover { background: linear-gradient(135deg, #fef3c7 0%, #fef9e7 100%); border-color: #f59e0b; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); transform: translateY(-1px); } .unsafe-function-item strong { color: #dc2626; font-weight: 700; font-size: 14px; } .unsafe-function-item small { color: #64748b; font-size: 12px; line-height: 1.4; margin-top: 4px; display: block; } .unsafe-function-item::after { content: "🔍"; position: absolute; right: 12px; top: 50%; transform: translateY(-50%); opacity: 0; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); font-size: 16px; } .unsafe-function-item:hover::after { opacity: 1; transform: translateY(-50%) scale(1.1); } .temporary-line-flash { animation: lineFlash 2s ease-out; } @keyframes lineFlash { 0% { background-color: rgba(51, 65, 85, 0.3) !important; box-shadow: 0 0 0 4px rgba(51, 65, 85, 0.2) !important; } 50% { background-color: rgba(51, 65, 85, 0.2) !important; box-shadow: 0 0 0 2px rgba(51, 65, 85, 0.1) !important; } 100% { background-color: rgba(245, 158, 11, 0.15) !important; box-shadow: none !important; } } .close-btn { background: rgba(255, 255, 255, 0.2); border: none; color: white; font-size: 20px; cursor: pointer; padding: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .close-btn:hover { background: rgba(255, 255, 255, 0.3); transform: scale(1.05); } .toggle-btn { position: fixed; bottom: 24px; right: 24px; background: linear-gradient(135deg, #334155 0%, #475569 100%); color: white; border: none; padding: 14px 20px; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 600; z-index: 9998; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; letter-spacing: -0.025em; min-width: 200px; text-align: center; } .toggle-btn:hover { background: linear-gradient(135deg, #475569 0%, #64748b 100%); transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } .toggle-btn:active { transform: translateY(0); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } .no-unsafe-functions { color: #059669; font-style: normal; text-align: center; padding: 32px 20px; font-size: 15px; font-weight: 500; line-height: 1.6; background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 100%); border-radius: 8px; border: 1px solid #bbf7d0; } .no-unsafe-functions::before { content: "✅"; display: block; font-size: 32px; margin-bottom: 12px; } `; // Add styles to page const styleSheet = document.createElement('style'); styleSheet.textContent = styles; document.head.appendChild(styleSheet); let detectedFunctions = {}; let panelVisible = false; let panel = null; let toggleBtn = null; let isScanning = false; let scanTimeout = null; let highlightStyleSheet = null; // Store dynamic highlight styles let highlightedLines = new Map(); // Store highlighted line information for persistence let highlightProtectionInterval = null; // Interval for highlight protection // Create unique key for deduplication function createUniqueKey(func) { return `${func.function}-${func.line}-${func.column}-${func.context.trim()}`; } // Deduplicate detected functions using unique keys function deduplicateDetections(detectedFunctions) { const deduplicatedFunctions = {}; const seenKeys = new Set(); Object.entries(detectedFunctions).forEach(([category, functions]) => { const uniqueFunctions = []; functions.forEach(func => { const uniqueKey = createUniqueKey(func); if (!seenKeys.has(uniqueKey)) { seenKeys.add(uniqueKey); uniqueFunctions.push(func); } else { // Duplicate detection skipped } }); if (uniqueFunctions.length > 0) { deduplicatedFunctions[category] = uniqueFunctions; } }); return deduplicatedFunctions; } // Jump to line functionality - completely independent functions function jumpToLine(lineNumber, elementIndex) { try { // Find the correct CodeMirror instance const codeMirrorInstance = findCodeMirrorForLine(lineNumber, elementIndex); if (!codeMirrorInstance) { return false; } // Get the target line element const lines = codeMirrorInstance.querySelectorAll('.CodeMirror-line'); const targetLineIndex = lineNumber - 1; // Convert to 0-based index if (targetLineIndex >= 0 && targetLineIndex < lines.length) { const targetLine = lines[targetLineIndex]; // Scroll to the line with smooth behavior targetLine.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); // Add temporary flash effect addTemporaryHighlight(targetLine); return true; } else { return false; } } catch (error) { console.error('Error in jumpToLine:', error); return false; } } function findCodeMirrorForLine(lineNumber, elementIndex) { try { // Strategy 1: Use elementIndex if provided if (typeof elementIndex === 'number') { const codeMirrorElements = document.querySelectorAll('.CodeMirror-scroll'); if (elementIndex >= 0 && elementIndex < codeMirrorElements.length) { return codeMirrorElements[elementIndex]; } } // Strategy 2: Find CodeMirror instance that contains the line const allCodeMirrorElements = document.querySelectorAll('.CodeMirror-scroll'); for (let element of allCodeMirrorElements) { const lines = element.querySelectorAll('.CodeMirror-line'); if (lineNumber <= lines.length) { return element; } } // Strategy 3: Return the first available CodeMirror instance return allCodeMirrorElements[0] || null; } catch (error) { console.error('Error in findCodeMirrorForLine:', error); return null; } } function addTemporaryHighlight(lineElement) { try { if (!lineElement) return; // Add flash animation class lineElement.classList.add('temporary-line-flash'); // Remove the class after animation completes setTimeout(() => { if (lineElement && lineElement.classList) { lineElement.classList.remove('temporary-line-flash'); } }, 1500); // Match the animation duration } catch (error) { console.error('Error in addTemporaryHighlight:', error); } } function setupSummaryClickHandlers() { try { if (!panel) return; // Use event delegation to handle clicks on summary items panel.addEventListener('click', function(event) { // Check if clicked element is an unsafe function item const clickedItem = event.target.closest('.unsafe-function-item'); if (!clickedItem) return; // Prevent event bubbling event.stopPropagation(); // Extract line number from the item content const lineInfo = extractLineInfoFromItem(clickedItem); if (lineInfo) { jumpToLine(lineInfo.line, lineInfo.elementIndex); } }); } catch (error) { console.error('Error in setupSummaryClickHandlers:', error); } } function extractLineInfoFromItem(itemElement) { try { const text = itemElement.textContent || ''; // Extract line number from text like "REPLACE at line 15:23" const lineMatch = text.match(/at line (\d+):(\d+)/); if (lineMatch) { const lineNumber = parseInt(lineMatch[1], 10); const columnNumber = parseInt(lineMatch[2], 10); // Try to find the corresponding elementIndex from detectedFunctions let elementIndex = 0; // Default to first element // Search through detectedFunctions to find matching entry Object.values(detectedFunctions).forEach(functions => { functions.forEach(func => { if (func.line === lineNumber && func.column === columnNumber) { // Try to find elementIndex from highlightedLines highlightedLines.forEach((highlightInfo, lineKey) => { if (highlightInfo.lineNumber === lineNumber) { elementIndex = highlightInfo.elementIndex; } }); } }); }); return { line: lineNumber, column: columnNumber, elementIndex: elementIndex }; } return null; } catch (error) { console.error('Error in extractLineInfoFromItem:', error); return null; } } // Split SQL text into individual statements by semicolon, handling quotes and comments function splitSQLStatements(sqlText) { const statements = []; let currentStatement = ''; let i = 0; let inSingleQuote = false; let inDoubleQuote = false; let inMultiLineComment = false; let inSingleLineComment = false; let statementStartPosition = 0; // Clean the SQL text first - remove zero-width characters and normalize sqlText = sqlText.replace(/[\u200B-\u200D\uFEFF]/g, ''); // Remove zero-width characters sqlText = sqlText.replace(/\u00A0/g, ' '); // Replace non-breaking spaces with regular spaces while (i < sqlText.length) { const char = sqlText[i]; const nextChar = i + 1 < sqlText.length ? sqlText[i + 1] : ''; // Handle multi-line comments /* */ if (!inSingleQuote && !inDoubleQuote && !inSingleLineComment) { if (char === '/' && nextChar === '*') { inMultiLineComment = true; currentStatement += char + nextChar; i += 2; continue; } } if (inMultiLineComment) { currentStatement += char; if (char === '*' && nextChar === '/') { inMultiLineComment = false; currentStatement += nextChar; i += 2; continue; } i++; continue; } // Handle single-line comments -- (improved to handle multiple dashes) if (!inSingleQuote && !inDoubleQuote && !inSingleLineComment) { if (char === '-' && nextChar === '-') { inSingleLineComment = true; currentStatement += char + nextChar; i += 2; continue; } } if (inSingleLineComment) { currentStatement += char; if (char === '\n' || char === '\r') { inSingleLineComment = false; } else if (char === ';') { // Special case: semicolon can end a single-line comment if there's no newline // This handles cases where SQL statements are on the same line inSingleLineComment = false; // Don't increment i here, let the semicolon be processed normally continue; } i++; continue; } // Handle string literals with escape sequence support if (!inMultiLineComment && !inSingleLineComment) { if (char === "'" && !inDoubleQuote) { // Check for escaped quote if (i > 0 && sqlText[i-1] === '\\') { currentStatement += char; i++; continue; } inSingleQuote = !inSingleQuote; currentStatement += char; i++; continue; } else if (char === '"' && !inSingleQuote) { // Check for escaped quote if (i > 0 && sqlText[i-1] === '\\') { currentStatement += char; i++; continue; } inDoubleQuote = !inDoubleQuote; currentStatement += char; i++; continue; } } // Handle semicolon (statement separator) if (!inSingleQuote && !inDoubleQuote && !inMultiLineComment && !inSingleLineComment && char === ';') { currentStatement += char; // Trim and add statement if it's not empty const trimmedStatement = currentStatement.trim(); if (trimmedStatement && trimmedStatement !== ';') { statements.push({ text: trimmedStatement, startPosition: statementStartPosition, endPosition: i + 1 }); } // Reset for next statement - skip whitespace after semicolon currentStatement = ''; i++; // Skip whitespace and newlines to find the start of next statement while (i < sqlText.length && /\s/.test(sqlText[i])) { i++; } statementStartPosition = i; continue; } // Normal character currentStatement += char; i++; } // Add the last statement if it exists (improved handling) const trimmedStatement = currentStatement.trim(); if (trimmedStatement) { statements.push({ text: trimmedStatement, startPosition: statementStartPosition, endPosition: sqlText.length }); } return statements; } // Remove SQL comments from text while preserving line structure function removeComments(sqlText) { let result = ''; let i = 0; let inSingleQuote = false; let inDoubleQuote = false; let inMultiLineComment = false; while (i < sqlText.length) { const char = sqlText[i]; const nextChar = i + 1 < sqlText.length ? sqlText[i + 1] : ''; // Handle string literals (don't process comments inside strings) if (!inMultiLineComment) { if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; result += char; i++; continue; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; result += char; i++; continue; } } // Skip comment processing if we're inside a string literal if (inSingleQuote || inDoubleQuote) { result += char; i++; continue; } // Handle multi-line comments /* */ if (!inMultiLineComment && char === '/' && nextChar === '*') { inMultiLineComment = true; result += ' '; // Replace with spaces to maintain positioning i += 2; continue; } if (inMultiLineComment) { if (char === '*' && nextChar === '/') { inMultiLineComment = false; result += ' '; // Replace with spaces i += 2; continue; } else if (char === '\n') { result += char; // Preserve line breaks i++; continue; } else { result += ' '; // Replace comment content with space i++; continue; } } // Handle single-line comments -- if (char === '-' && nextChar === '-') { // Replace everything from -- to end of line with spaces while (i < sqlText.length && sqlText[i] !== '\n') { result += ' '; i++; } // Don't increment i here as we want to process the \n normally continue; } // Normal character result += char; i++; } return result; } // Check if a TO_DATE function call matches safe patterns function isSafeToDatePattern(expression) { // Reset all regex patterns before testing SAFE_TO_DATE_PATTERNS.forEach(pattern => { pattern.lastIndex = 0; }); // Test against all safe patterns return SAFE_TO_DATE_PATTERNS.some(pattern => { pattern.lastIndex = 0; // Reset regex state return pattern.test(expression); }); } // Extract complete TO_DATE function call from text around a match position function extractToDateFunctionCall(text, matchIndex) { // Find the start of the function call let start = matchIndex; while (start > 0 && text[start - 1] !== ' ' && text[start - 1] !== '\t' && text[start - 1] !== '\n' && text[start - 1] !== '(' && text[start - 1] !== ',') { start--; } // Find the end of the function call by matching parentheses let parenCount = 0; let end = matchIndex; let foundOpenParen = false; // Move to the opening parenthesis while (end < text.length) { if (text[end] === '(') { foundOpenParen = true; parenCount++; end++; break; } else if (text[end] === ' ' || text[end] === '\t') { end++; } else { end++; } } if (!foundOpenParen) { return text.substring(start, Math.min(start + 50, text.length)); // Fallback } // Find the matching closing parenthesis while (end < text.length && parenCount > 0) { if (text[end] === '(') { parenCount++; } else if (text[end] === ')') { parenCount--; } end++; } return text.substring(start, end).trim(); } // Create regex patterns for detecting unsafe functions function createFunctionRegex(functionName) { // Handle special cases if (functionName === 'SIMILAR TO') { return new RegExp('\\bSIMILAR\\s+TO\\b', 'gi'); } if (functionName === 'TIME BETWEEN') { return new RegExp('\\bTIME\\s+BETWEEN\\b', 'gi'); } if (functionName === 'EXTRACT.*EPOCH') { return new RegExp('\\bEXTRACT\\s*\\([^)]*EPOCH[^)]*\\)', 'gi'); } // Handle POSITION function which can have different syntax: POSITION(x IN y) or POSITION(x,y) if (functionName === 'POSITION') { return new RegExp('\\bPOSITION\\s*\\(', 'gi'); } // Handle INTERVAL function - supports both INTERVAL(...) and INTERVAL 'value' formats if (functionName === 'INTERVAL') { return new RegExp('\\bINTERVAL\\s*(?:\\(|\'|\\d)', 'gi'); } // Handle :: type conversion patterns (e.g., ::DATE, ::TEXT, ::INTEGER) // Allow any non-whitespace character before :: to handle cases like (expression)::date if (functionName.startsWith('::')) { const typeName = functionName.substring(2); // Remove the :: return new RegExp('[^\\s]::\\s*' + typeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'); } // Standard function pattern: FUNCTION_NAME followed by opening parenthesis return new RegExp('\\b' + functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(', 'gi'); } // Parse comparison expressions and extract only the left side (field being compared) function parseComparisonExpression(condition) { const leftSideExpressions = []; // First, remove any nested SELECT...FROM statements from the condition const cleanedCondition = removeNestedSelectStatements(condition); // Handle BETWEEN operator specially const betweenMatch = cleanedCondition.match(/^(.+?)\s+BETWEEN\s+/i); if (betweenMatch) { leftSideExpressions.push(betweenMatch[1].trim()); return leftSideExpressions; } // Handle IN operator with potential subqueries const inMatch = cleanedCondition.match(/^(.+?)\s+(?:NOT\s+)?IN\s*\(/i); if (inMatch) { leftSideExpressions.push(inMatch[1].trim()); return leftSideExpressions; } // Handle EXISTS operator (left side is empty for EXISTS) if (cleanedCondition.match(/^\s*(?:NOT\s+)?EXISTS\s*\(/i)) { return leftSideExpressions; // No left side for EXISTS } // Handle standard comparison operators (=, >, <, >=, <=, !=, <>) // Use a more robust approach to handle nested parentheses in complex expressions let parenDepth = 0; let inQuotes = false; let quoteChar = ''; let operatorIndex = -1; // Find the comparison operator at the top level (not inside parentheses or quotes) for (let i = 0; i < cleanedCondition.length; i++) { const char = cleanedCondition[i]; // Handle quotes (including escaped quotes) if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar && inQuotes) { // Check if it's escaped if (i > 0 && cleanedCondition[i-1] === '\\') { continue; // Skip escaped quote } inQuotes = false; quoteChar = ''; } else if (!inQuotes) { // Handle parentheses if (char === '(') { parenDepth++; } else if (char === ')') { parenDepth--; } else if (parenDepth === 0) { // Check for comparison operators at top level const remaining = cleanedCondition.substring(i); // Include PostgreSQL regex operators: ~, ~*, !~, !~* // Also include LIKE, ILIKE, SIMILAR TO if (remaining.match(/^(>=|<=|<>|!=|!~\*|!~|~\*|~|>|<|=|\s+(?:NOT\s+)?(?:LIKE|ILIKE)\s+|\s+SIMILAR\s+TO\s+)/i)) { operatorIndex = i; break; } } } } if (operatorIndex !== -1) { const leftSide = cleanedCondition.substring(0, operatorIndex).trim(); if (leftSide) { leftSideExpressions.push(leftSide); } } return leftSideExpressions; } // Remove nested SELECT...FROM statements from a condition to avoid detecting unsafe functions within them function removeNestedSelectStatements(condition) { let result = condition; let changed = true; // Keep removing nested SELECT statements until no more are found while (changed) { changed = false; let parenDepth = 0; let inQuotes = false; let quoteChar = ''; let selectStart = -1; for (let i = 0; i < result.length; i++) { const char = result[i]; // Handle quotes if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar && inQuotes) { if (i > 0 && result[i-1] === '\\') { continue; // Skip escaped quote } inQuotes = false; quoteChar = ''; } else if (!inQuotes) { // Handle parentheses if (char === '(') { parenDepth++; // Check if this starts a SELECT statement const remaining = result.substring(i + 1).trim(); if (remaining.match(/^SELECT\s+/i) && selectStart === -1) { selectStart = i; } } else if (char === ')') { parenDepth--; // If we're closing a SELECT statement if (selectStart !== -1 && parenDepth === 0) { // Replace the SELECT statement with placeholder const beforeSelect = result.substring(0, selectStart); const afterSelect = result.substring(i + 1); result = beforeSelect + '(SUBQUERY_PLACEHOLDER)' + afterSelect; changed = true; break; } } } } } return result; } // Split WHERE clause into individual conditions, handling nested parentheses function splitWhereConditions(whereClause) { const conditions = []; let current = ''; let parenDepth = 0; let inQuotes = false; let quoteChar = ''; for (let i = 0; i < whereClause.length; i++) { const char = whereClause[i]; const nextChars = whereClause.substr(i, 4).toUpperCase(); // Handle quotes if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; current += char; } else if (char === quoteChar && inQuotes) { inQuotes = false; quoteChar = ''; current += char; } else if (inQuotes) { current += char; } else { // Handle parentheses if (char === '(') { parenDepth++; current += char; } else if (char === ')') { parenDepth--; current += char; } else if (parenDepth === 0 && (nextChars === 'AND ' || nextChars === 'OR ')) { // Found AND/OR at top level if (current.trim()) { conditions.push(current.trim()); } current = ''; i += 3; // Skip past 'AND' or 'OR ' } else { current += char; } } } // Add the last condition if (current.trim()) { conditions.push(current.trim()); } return conditions; } // Remove SELECT...FROM portion of statement to reduce false positives function removeSelectFromPortion(statement) { // Remove SELECT...FROM between content, keep FROM and after return statement.replace(/SELECT[\s\S]*?FROM\s+/i, 'FROM '); } function extractCompleteWhereClauses(statement) { // Remove SELECT...FROM part to reduce false positives let cleanedStatement = removeSelectFromPortion(statement); // Use non-greedy matching to extract WHERE clauses const whereRegex = /\bWHERE\s+([\s\S]*?)(?=\s+(?:GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|;|$))/gi; const whereClauses = []; let match; while ((match = whereRegex.exec(cleanedStatement)) !== null) { const whereClause = match[1].trim(); if (whereClause) { const parts = splitAtUnmatchedParen(whereClause); whereClauses.push(...parts); } } return whereClauses; } // Helper function: Calculate statement line range boundaries function getStatementLineRange(statement, fullSqlText, lines) { try { // Strategy 1: Use character positions to find line boundaries const startPos = statement.startPosition; const endPos = statement.endPosition; let startLine = 1; let endLine = lines.length; let currentPos = 0; // Find start line by counting characters for (let i = 0; i < lines.length; i++) { const lineText = lines[i].textContent || ''; const lineLength = lineText.length + 1; // +1 for newline character if (currentPos <= startPos && currentPos + lineLength > startPos) { startLine = i + 1; } if (currentPos < endPos && currentPos + lineLength >= endPos) { endLine = i + 1; break; } currentPos += lineLength; } // Strategy 2: Fallback using content matching if position-based fails if (startLine === 1 && endLine === lines.length) { const statementLines = statement.text.split('\n'); const firstStatementLine = statementLines[0].trim(); const lastStatementLine = statementLines[statementLines.length - 1].trim(); if (firstStatementLine) { for (let i = 0; i < lines.length; i++) { const lineText = (lines[i].textContent || '').trim(); if (lineText.includes(firstStatementLine) || firstStatementLine.includes(lineText)) { startLine = i + 1; break; } } } if (lastStatementLine && lastStatementLine !== firstStatementLine) { for (let i = startLine - 1; i < lines.length; i++) { const lineText = (lines[i].textContent || '').trim(); if (lineText.includes(lastStatementLine) || lastStatementLine.includes(lineText)) { endLine = i + 1; break; } } } } // Ensure valid range startLine = Math.max(1, startLine); endLine = Math.min(lines.length, Math.max(startLine, endLine)); return { startLine, endLine }; } catch (error) { console.error('Error calculating statement line range:', error); // Fallback to full range return { startLine: 1, endLine: lines.length }; } } // Optimized function: Extract WHERE clauses with line number information (limited to statement range) function extractWhereClausesWithLineNumbers(statement, fullSqlText, lines) { const whereClauses = extractCompleteWhereClauses(statement.text); const clausesWithLineNumbers = []; // Get the line range for this statement to limit search scope const { startLine, endLine } = getStatementLineRange(statement, fullSqlText, lines); whereClauses.forEach(clause => { const clauseInfo = { text: clause, lineNumbers: [] }; // Only search within the statement's line range for better performance and accuracy for (let lineIndex = startLine - 1; lineIndex < endLine && lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex]; const lineText = line.textContent || ''; const lineNumber = lineIndex + 1; // Check if this line belongs to the current statement and clause // Since we're already within the statement range, we can skip the expensive isLineInStatement check if (isLineMatchingClause(lineText, clause)) { clauseInfo.lineNumbers.push({ lineNumber: lineNumber, lineText: lineText.trim(), lineElement: line }); } } if (clauseInfo.lineNumbers.length > 0) { clausesWithLineNumbers.push(clauseInfo); } }); return clausesWithLineNumbers; } // Optimized function: Extract JOIN ON clauses with line number information (limited to statement range) function extractJoinOnClausesWithLineNumbers(statement, fullSqlText, lines) { const joinOnClauses = extractCompleteJoinOnClauses(statement.text); const clausesWithLineNumbers = []; // Get the line range for this statement to limit search scope const { startLine, endLine } = getStatementLineRange(statement, fullSqlText, lines); joinOnClauses.forEach(clause => { const clauseInfo = { text: clause, lineNumbers: [] }; // Only search within the statement's line range for better performance and accuracy for (let lineIndex = startLine - 1; lineIndex < endLine && lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex]; const lineText = line.textContent || ''; const lineNumber = lineIndex + 1; // Check if this line belongs to the current statement and clause // Since we're already within the statement range, we can skip the expensive isLineInStatement check if (isLineMatchingClause(lineText, clause)) { clauseInfo.lineNumbers.push({ lineNumber: lineNumber, lineText: lineText.trim(), lineElement: line }); } } if (clauseInfo.lineNumbers.length > 0) { clausesWithLineNumbers.push(clauseInfo); } }); return clausesWithLineNumbers; } function splitAtUnmatchedParen(clause) { let results = []; let start = 0; let depth = 0; for (let i = 0; i < clause.length; i++) { const ch = clause[i]; if (ch === "(") { depth++; } else if (ch === ")") { if (depth === 0) { // find single ),end WHERE clause results.push(clause.slice(start, i).trim()); start = i + 1; } else { depth--; } } } const rest = clause.slice(start).trim(); if (rest) results.push(rest); return results; } // Extract complete JOIN ON clauses from a SQL statement using regex function extractCompleteJoinOnClauses(statement) { // Remove SELECT...FROM part to reduce false positives let cleanedStatement = removeSelectFromPortion(statement); //console.log(cleanedStatement); cleanedStatement = cleanedStatement.replace( /\b(LEFT\s+JOIN|RIGHT\s+JOIN|INNER\s+JOIN|JOIN)\s+([\s\S]*?)\s+ON\s+/gi, '$1 TABLE_PLACEHOLDER ON ' ); //console.log(cleanedStatement); // Match various JOIN...ON patterns const joinOnRegex = /\b(INNER\s+JOIN|LEFT\s+(?:OUTER\s+)?JOIN|RIGHT\s+(?:OUTER\s+)?JOIN|FULL\s+(?:OUTER\s+)?JOIN|CROSS\s+JOIN|JOIN)\s+[\w.]+(?:\s+AS\s+\w+)?\s+ON\s+([\s\S]*?)(?=\s+(?:INNER\s+JOIN|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN|CROSS\s+JOIN|JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|;|$))/gi; const joinOnClauses = []; let match; while ((match = joinOnRegex.exec(cleanedStatement)) !== null) { const onCondition = match[2].trim(); if (onCondition) { joinOnClauses.push(onCondition); } } return joinOnClauses; } // Split clause into individual lines while preserving logical completeness function splitClauseIntoLines(clause) { if (!clause) return []; // Split by newlines first const rawLines = clause.split('\n'); const processedLines = []; rawLines.forEach(line => { const trimmedLine = line.trim(); // Skip empty lines and pure comment lines if (!trimmedLine || trimmedLine.startsWith('--')) { return; } // Remove inline comments but keep the main content const cleanedLine = trimmedLine.replace(/--.*$/, '').trim(); if (cleanedLine) { processedLines.push(cleanedLine); } }); return processedLines; } // Enhanced line matching with multiple strategies function isLineMatchingClause(lineText, clause) { const trimmedLine = lineText.trim(); if (!trimmedLine) return false; // Remove comments from the line for comparison const cleanedLine = trimmedLine.replace(/--.*$/, '').trim(); if (!cleanedLine) return false; const clauseLines = splitClauseIntoLines(clause); const filteredClauseLines = clauseLines.filter(line => line.length >= 5); /* // Strategy 1: Direct content matching in the complete clause if (clause.includes(trimmedLine) || clause.includes(cleanedLine)) { return true; } // Strategy 2: Normalized whitespace matching const normalizedLine = cleanedLine.replace(/\s+/g, ' '); const normalizedClause = clause.replace(/\s+/g, ' '); if (normalizedClause.includes(normalizedLine)) { return true; } // Strategy 3: Split clause into lines and check each line for (const clauseLine of clauseLines) { // Direct match if (clauseLine === cleanedLine || clauseLine === trimmedLine) { return true; } // Normalized match const normalizedClauseLine = clauseLine.replace(/\s+/g, ' '); if (normalizedClauseLine === normalizedLine) { return true; } // Partial match - check if the line is contained within a clause line if (clauseLine.includes(cleanedLine) || cleanedLine.includes(clauseLine)) { return true; } // Handle cases where the line might be a subset of a larger expression // For example, line: "user_id = 123" and clauseLine: "AND user_id = 123" if (clauseLine.includes(cleanedLine) && (clauseLine.startsWith('AND ') || clauseLine.startsWith('OR ') || clauseLine.endsWith(' AND') || clauseLine.endsWith(' OR'))) { return true; } }*/ // Strategy 4: Reverse check - see if any clause line is contained in the current line // This handles cases where the line has extra content like comments //console.log(`cleanedLine: ${cleanedLine}`); for (const clauseLine of filteredClauseLines) { if (cleanedLine.includes(clauseLine)) { return true; } } return false; } // Check if a line is within a complete WHERE clause function isLineInCompleteWhereClause(lineText, whereClauses) { const trimmedLine = lineText.trim(); if (!trimmedLine) return false; return whereClauses.some(whereClause => { return isLineMatchingClause(lineText, whereClause); }); } // Check if a line is within a complete JOIN ON clause function isLineInCompleteJoinOnClause(lineText, joinOnClauses) { const trimmedLine = lineText.trim(); if (!trimmedLine) return false; return joinOnClauses.some(joinOnClause => { return isLineMatchingClause(lineText, joinOnClause); }); } // Detect unsafe functions in SQL text (in WHERE and JOIN ON clauses) with accurate line numbers function detectUnsafeFunctions(sqlText, codeMirrorElement = null) { const detected = {}; if (!codeMirrorElement) { return detected; } // Step 1: Split SQL text into individual statements const sqlStatements = splitSQLStatements(sqlText); console.log(`Split SQL into ${sqlStatements.length} statements:`, sqlStatements.map(s => s.text.substring(0, 50) + '...')); // Debug log // Get all CodeMirror lines const lines = codeMirrorElement.querySelectorAll('.CodeMirror-line'); // Step 2: Process each SQL statement separately sqlStatements.forEach((statement, statementIndex) => { // Check if this statement contains any WHERE or JOIN ON patterns const hasRelevantClauses = /\b(WHERE|JOIN[\s\S]*?ON)\b/gi.test(statement.text); if (!hasRelevantClauses) { return; // No relevant clauses found in this statement } // Extract WHERE and JOIN ON clauses with line number information const whereClausesWithLines = extractWhereClausesWithLineNumbers(statement, sqlText, lines); const joinOnClausesWithLines = extractJoinOnClausesWithLineNumbers(statement, sqlText, lines); console.log(`Statement ${statementIndex + 1}: Found ${whereClausesWithLines.length} WHERE clauses and ${joinOnClausesWithLines.length} JOIN ON clauses`); // Process WHERE clauses whereClausesWithLines.forEach(clauseInfo => { clauseInfo.lineNumbers.forEach(lineInfo => { // Remove comments from the line before processing const cleanedLineText = removeComments(lineInfo.lineText); // For WHERE clauses: extract only left-side expressions const expressionsToCheck = extractLeftSideExpressionsFromLine(cleanedLineText); // Check each expression for unsafe functions expressionsToCheck.forEach(expression => { Object.entries(UNSAFE_FUNCTIONS).forEach(([category, functions]) => { functions.forEach(functionName => { const regex = createFunctionRegex(functionName); let match; regex.lastIndex = 0; // Reset regex while ((match = regex.exec(expression)) !== null) { // Special handling for TO_DATE function - check if it matches safe patterns if (functionName === 'TO_DATE') { // Extract the complete TO_DATE function call const toDateCall = extractToDateFunctionCall(expression, match.index); // Check if this TO_DATE call matches any safe patterns if (isSafeToDatePattern(toDateCall)) { //console.log(`Skipping safe TO_DATE pattern: ${toDateCall}`); continue; // Skip this detection as it's a safe pattern } } if (!detected[category]) { detected[category] = []; } // Calculate the actual column position in the original line const expressionStartInLine = cleanedLineText.indexOf(expression); const actualColumn = expressionStartInLine >= 0 ? expressionStartInLine + match.index + 1 : match.index + 1; detected[category].push({ function: functionName, line: lineInfo.lineNumber, column: actualColumn, context: lineInfo.lineText, // Keep original line text for context display statementIndex: statementIndex // Track which statement this detection belongs to }); } }); }); }); }); }); // Process JOIN ON clauses joinOnClausesWithLines.forEach(clauseInfo => { clauseInfo.lineNumbers.forEach(lineInfo => { // Remove comments from the line before processing const cleanedLineText = removeComments(lineInfo.lineText); // For JOIN ON clauses: check the entire line for unsafe functions const expressionsToCheck = [cleanedLineText]; // Check each expression for unsafe functions expressionsToCheck.forEach(expression => { Object.entries(UNSAFE_FUNCTIONS).forEach(([category, functions]) => { functions.forEach(functionName => { const regex = createFunctionRegex(functionName); let match; regex.lastIndex = 0; // Reset regex while ((match = regex.exec(expression)) !== null) { // Special handling for TO_DATE function - check if it matches safe patterns if (functionName === 'TO_DATE') { // Extract the complete TO_DATE function call const toDateCall = extractToDateFunctionCall(expression, match.index); // Check if this TO_DATE call matches any safe patterns if (isSafeToDatePattern(toDateCall)) { //console.log(`Skipping safe TO_DATE pattern: ${toDateCall}`); continue; // Skip this detection as it's a safe pattern } } if (!detected[category]) { detected[category] = []; } // Calculate the actual column position in the original line const expressionStartInLine = cleanedLineText.indexOf(expression); const actualColumn = expressionStartInLine >= 0 ? expressionStartInLine + match.index + 1 : match.index + 1; detected[category].push({ function: functionName, line: lineInfo.lineNumber, column: actualColumn, context: lineInfo.lineText, // Keep original line text for context display statementIndex: statementIndex // Track which statement this detection belongs to }); } }); }); }); }); }); }); return detected; } // Check if a line belongs to a specific SQL statement using improved content matching function isLineInStatement(lineText, fullSqlText, statement) { const trimmedLineText = lineText.trim(); if (!trimmedLineText) return false; // Strategy 1: Check if the line content exists within the statement text if (statement.text.includes(trimmedLineText)) { return true; } // Strategy 2: Try normalized whitespace matching within the statement const normalizedLine = trimmedLineText.replace(/\s+/g, ' '); const normalizedStatement = statement.text.replace(/\s+/g, ' '); if (normalizedStatement.includes(normalizedLine)) { return true; } // Strategy 3: Check for partial matches with key SQL keywords from the line const sqlKeywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'ON', 'AND', 'OR', 'CREATE', 'INSERT', 'UPDATE', 'DELETE']; const lineKeywords = sqlKeywords.filter(keyword => trimmedLineText.toUpperCase().includes(keyword)); if (lineKeywords.length > 0) { // If the line contains SQL keywords, check if those keywords exist in the statement const hasMatchingKeywords = lineKeywords.every(keyword => statement.text.toUpperCase().includes(keyword) ); if (hasMatchingKeywords) { return true; } } // Strategy 4: Fallback to position-based matching (improved) let lineIndex = fullSqlText.indexOf(trimmedLineText); if (lineIndex === -1) { lineIndex = fullSqlText.replace(/\s+/g, ' ').indexOf(normalizedLine); } if (lineIndex !== -1) { const inRange = lineIndex >= statement.startPosition && lineIndex < statement.endPosition; return inRange; } return false; } // Extract left-side expressions from a single line of SQL function extractLeftSideExpressionsFromLine(lineText) { const leftSideExpressions = []; // Split the line by common logical operators while preserving the structure const conditions = splitLineConditions(lineText); conditions.forEach(condition => { // Check if condition contains comparison operators (including regex operators) if (/[=<>~]|BETWEEN/i.test(condition)) { // Parse the comparison and extract only the left side const leftSides = parseComparisonExpression(condition); leftSideExpressions.push(...leftSides); } }); return leftSideExpressions; } // Split a line into individual conditions, similar to splitWhereConditions but for single lines function splitLineConditions(lineText) { const conditions = []; let current = ''; let parenDepth = 0; let inQuotes = false; let quoteChar = ''; for (let i = 0; i < lineText.length; i++) { const char = lineText[i]; const nextChars = lineText.substr(i, 4).toUpperCase(); // Handle quotes if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; current += char; } else if (char === quoteChar && inQuotes) { inQuotes = false; quoteChar = ''; current += char; } else if (inQuotes) { current += char; } else { // Handle parentheses if (char === '(') { parenDepth++; current += char; } else if (char === ')') { parenDepth--; current += char; } else if (parenDepth === 0 && (nextChars === 'AND ' || nextChars === 'OR ')) { // Found AND/OR at top level if (current.trim()) { conditions.push(current.trim()); } current = ''; i += 3; // Skip past 'AND' or 'OR ' } else { current += char; } } } // Add the last condition if (current.trim()) { conditions.push(current.trim()); } // If no conditions were found, return the entire line as a single condition if (conditions.length === 0 && lineText.trim()) { conditions.push(lineText.trim()); } return conditions; } // Create summary panel function createSummaryPanel() { if (panel) { panel.remove(); } panel = document.createElement('div'); panel.className = 'unsafe-function-panel'; panel.style.display = panelVisible ? 'block' : 'none'; const header = document.createElement('div'); header.className = 'unsafe-function-panel-header'; header.innerHTML = ` <span>Unsafe Functions</span> <button class="close-btn" onclick="this.parentElement.parentElement.style.display='none'">×</button> `; const content = document.createElement('div'); content.className = 'unsafe-function-panel-content'; const hasDetections = Object.keys(detectedFunctions).length > 0; if (!hasDetections) { content.innerHTML = '<div class="no-unsafe-functions">No unsafe functions detected in SQL queries.</div>'; } else { Object.entries(detectedFunctions).forEach(([category, functions]) => { const categoryDiv = document.createElement('div'); categoryDiv.className = 'unsafe-function-category'; const titleDiv = document.createElement('div'); titleDiv.className = 'unsafe-function-category-title'; titleDiv.textContent = `${category} (${functions.length})`; categoryDiv.appendChild(titleDiv); functions.forEach(func => { const itemDiv = document.createElement('div'); itemDiv.className = 'unsafe-function-item'; itemDiv.innerHTML = ` <strong>${func.function}</strong> at line ${func.line}:${func.column}<br> <small>${func.context}</small> `; categoryDiv.appendChild(itemDiv); }); content.appendChild(categoryDiv); }); } panel.appendChild(header); panel.appendChild(content); document.body.appendChild(panel); // Set up click handlers for jump-to-line functionality (non-intrusive addition) setupSummaryClickHandlers(); } // Create toggle button function createToggleButton() { if (toggleBtn) { toggleBtn.remove(); } toggleBtn = document.createElement('button'); toggleBtn.className = 'toggle-btn'; toggleBtn.textContent = 'Unsafe Functions Detector'; toggleBtn.title = 'Built by ARTS DE' toggleBtn.onclick = () => { panelVisible = !panelVisible; if (panel) { panel.style.display = panelVisible ? 'block' : 'none'; } if (panelVisible) { scanForUnsafeFunctions(); } }; document.body.appendChild(toggleBtn); } // Extract SQL text from CodeMirror element, excluding gutter content function extractSQLText(codeMirrorElement) { // Get all CodeMirror-line elements directly from the original element const lines = codeMirrorElement.querySelectorAll('.CodeMirror-line'); // Extract text from each line and join with newlines to preserve line structure const lineTexts = []; lines.forEach(line => { const lineText = line.textContent || line.innerText || ''; lineTexts.push(lineText); }); // Join lines with newline characters to maintain proper word boundaries return lineTexts.join('\n'); } // Clear existing CSS-only highlights function clearCSSHighlights() { // Remove existing highlight stylesheet if (highlightStyleSheet) { highlightStyleSheet.remove(); highlightStyleSheet = null; } // Remove ALL highlight classes and data attributes from ALL CodeMirror lines const allLines = document.querySelectorAll('.CodeMirror-line'); allLines.forEach(line => { line.classList.remove('has-unsafe-function'); line.removeAttribute('data-unsafe-function'); }); // Remove data attributes from CodeMirror elements const codeMirrorElements = document.querySelectorAll('.CodeMirror-scroll'); codeMirrorElements.forEach(element => { element.removeAttribute('data-unsafe-functions'); }); // Force remove any remaining highlight styles by creating an empty stylesheet const cleanupStyleSheet = document.createElement('style'); cleanupStyleSheet.id = 'unsafe-function-cleanup'; cleanupStyleSheet.textContent = ` .CodeMirror-line { background-color: transparent !important; border-left: none !important; padding-left: 0 !important; } .CodeMirror-line::after { display: none !important; } `; document.head.appendChild(cleanupStyleSheet); // Remove cleanup stylesheet after a brief moment setTimeout(() => { if (cleanupStyleSheet && cleanupStyleSheet.parentNode) { cleanupStyleSheet.remove(); } }, 100); } // Apply robust highlighting with persistence and text selectability function applyCSSOnlyHighlighting(codeMirrorElement, elementIndex) { const fullSqlText = extractSQLText(codeMirrorElement); if (!fullSqlText) return; ////console.log('Full SQL Text:', fullSqlText.substring(0, 200) + '...'); // Debug log // Use the SAME logic as detectUnsafeFunctions to ensure consistency const detectedFunctions = detectUnsafeFunctions(fullSqlText, codeMirrorElement); ////console.log('Detection results:', detectedFunctions); // Debug log // If no unsafe functions detected, don't highlight anything if (Object.keys(detectedFunctions).length === 0) { ////console.log('No unsafe functions detected - skipping highlighting'); // Debug log return; } // Find all text nodes within CodeMirror lines const lines = codeMirrorElement.querySelectorAll('.CodeMirror-line'); // Clear previous highlight tracking for this element highlightedLines.clear(); // Split SQL into statements to get statement-specific clause ranges const sqlStatements = splitSQLStatements(fullSqlText); ////console.log(`Highlighting: Split SQL into ${sqlStatements.length} statements`); // Debug log // Directly highlight lines based on detection results Object.entries(detectedFunctions).forEach(([category, functions]) => { functions.forEach(func => { ////console.log(`Highlighting function: ${func.function} at line ${func.line} (statement ${func.statementIndex + 1})`); // Debug log // Find the specific line by line number (func.line is 1-based, array is 0-based) const targetLineIndex = func.line - 1; if (targetLineIndex >= 0 && targetLineIndex < lines.length) { const line = lines[targetLineIndex]; const lineText = line.textContent || ''; // Get the statement this function belongs to const statement = sqlStatements[func.statementIndex]; if (!statement) { ////console.log(`Statement ${func.statementIndex} not found, skipping highlight`); // Debug log return; } // Verify this line contains the function and is in relevant clause of the correct statement const cleanedLineText = removeComments(lineText); const functionRegex = createFunctionRegex(func.function); // Extract complete WHERE and JOIN ON clauses for this statement const whereClauses = extractCompleteWhereClauses(statement.text); const joinOnClauses = extractCompleteJoinOnClauses(statement.text); if (functionRegex.test(cleanedLineText) && isLineInStatement(lineText, fullSqlText, statement) && isLineInRelevantClause(lineText, statement.text, statement, whereClauses, joinOnClauses)) { ////console.log(`Highlighting line ${func.line}: ${lineText.trim()} for function: ${func.function}`); // Debug log // Apply both CSS class and inline styles for maximum persistence line.classList.add('has-unsafe-function'); // Apply inline styles that are harder to override line.style.setProperty('background-color', 'rgba(255, 235, 59, 0.3)', 'important'); line.style.setProperty('border-left', '3px solid #ff9800', 'important'); line.style.setProperty('padding-left', '5px', 'important'); line.style.setProperty('user-select', 'text', 'important'); line.style.setProperty('-webkit-user-select', 'text', 'important'); line.style.setProperty('-moz-user-select', 'text', 'important'); line.style.setProperty('-ms-user-select', 'text', 'important'); // Store multiple functions if they exist on the same line const existingFunctions = line.getAttribute('data-unsafe-function') || ''; const functionList = existingFunctions ? existingFunctions.split(',') : []; if (!functionList.includes(func.function)) { functionList.push(func.function); line.setAttribute('data-unsafe-function', functionList.join(',')); } // Store highlight information for persistence tracking const lineKey = `${elementIndex}-${func.line}`; highlightedLines.set(lineKey, { element: line, functions: functionList, lineNumber: func.line, elementIndex: elementIndex }); } else { ////console.log(`Skipping highlight for line ${func.line}: function test=${functionRegex.test(cleanedLineText)}, inStatement=${isLineInStatement(lineText, fullSqlText, statement)}, inRelevantClause=${isLineInRelevantClause(lineText, statement.text, null)}`); // Debug log } } }); }); // Generate CSS rules for highlighting generateHighlightCSS(); // Start highlight protection if not already running startHighlightProtection(); } // Note: findRelevantClauseRanges method removed - we now use semantic detection // instead of position-based range matching for better accuracy and maintainability // Check if a line is within a relevant clause (WHERE or JOIN ON) using content-based semantic detection function isLineInRelevantClause(lineText, fullSqlText, statement, whereClauses, joinOnClauses) { const cleanedLineText = removeComments(lineText.trim()); if (!cleanedLineText) return false; // Method 1: Complete clause matching (more accurate) if (whereClauses && isLineInCompleteWhereClause(lineText, whereClauses)) { return true; } if (joinOnClauses && isLineInCompleteJoinOnClause(lineText, joinOnClauses)) { return true; } // Method 2: Fallback to semantic detection if (isWhereConditionLine(cleanedLineText)) { return true; } if (isJoinOnConditionLine(cleanedLineText)) { return true; } return false; } // Check if a line contains WHERE condition expressions function isWhereConditionLine(lineText) { const upperLineText = lineText.toUpperCase(); // Skip if this looks like a SELECT statement or FROM clause if (upperLineText.includes('SELECT ') || upperLineText.includes('FROM ')) { // But allow if it's clearly a WHERE condition with SELECT in a subquery if (!upperLineText.includes('WHERE ')) { return false; } } // Skip if this is obviously a JOIN ON context if (isObviousJoinOnContext(lineText)) { return false; } // Skip if this is part of a CASE expression if (isCaseExpressionLine(lineText)) { return false; } // Skip if this is part of CREATE TABLE or other DDL statements if (isDDLContextLine(lineText)) { return false; } // Check for comparison operators const hasComparisonOperators = /[=<>!~]|BETWEEN|IN\s*\(|EXISTS\s*\(|LIKE|ILIKE|SIMILAR\s+TO/i.test(lineText); // Check for logical operators (but not in SELECT context) const hasLogicalOperators = /\b(AND|OR)\b/i.test(lineText) && !upperLineText.includes('SELECT '); // Check for WHERE keyword const hasWhereKeyword = /\bWHERE\b/i.test(lineText); // Check for common WHERE condition patterns const hasConditionPatterns = /\b(TRUNC|TO_DATE|EXTRACT|DATE_PART|CAST)\s*\(/i.test(lineText); const isWhereCondition = hasComparisonOperators || hasLogicalOperators || hasWhereKeyword || hasConditionPatterns; return isWhereCondition; } // Check if a line is obviously in JOIN ON context function isObviousJoinOnContext(lineText) { const upperLineText = lineText.toUpperCase(); // Direct JOIN keywords with ON in the same line if (/\b(INNER\s+JOIN|LEFT\s+(?:OUTER\s+)?JOIN|RIGHT\s+(?:OUTER\s+)?JOIN|FULL\s+(?:OUTER\s+)?JOIN|CROSS\s+JOIN|JOIN)\b.*\bON\b/i.test(lineText)) { return true; } // Lines that start with JOIN keywords if (/^\s*(INNER\s+JOIN|LEFT\s+(?:OUTER\s+)?JOIN|RIGHT\s+(?:OUTER\s+)?JOIN|FULL\s+(?:OUTER\s+)?JOIN|CROSS\s+JOIN|JOIN)\b/i.test(lineText)) { return true; } // Lines that contain table.column = table.column patterns (typical JOIN conditions) // This pattern is more specific than general comparisons and strongly suggests JOIN context if (/\b\w+\.\w+\s*[=<>!]\s*\w+\.\w+\b/i.test(lineText)) { // Additional check: if the line also contains JOIN or ON keywords, it's definitely JOIN context if (/\b(JOIN|ON)\b/i.test(lineText)) { return true; } // Even without explicit JOIN/ON keywords, table.column = table.column is typically JOIN // But be more conservative - only if it doesn't contain WHERE keyword if (!upperLineText.includes('WHERE')) { return true; } } return false; } // Check if a line is part of a CASE expression function isCaseExpressionLine(lineText) { const upperLineText = lineText.toUpperCase().trim(); // Direct CASE keywords if (/\b(CASE|WHEN|THEN|ELSE|END)\b/i.test(lineText)) { return true; } // Lines that are likely part of CASE expression context // Look for patterns like "WHEN condition" or "AND condition" following CASE if (/^\s*(WHEN\s+|AND\s+|OR\s+).*[=<>!~]/i.test(lineText)) { return true; } // Lines with comparison operators that start with logical operators (likely CASE conditions) if (/^\s*(AND|OR)\s+.*[=<>!~]/i.test(lineText)) { return true; } return false; } // Check if a line is in DDL (Data Definition Language) context function isDDLContextLine(lineText) { const upperLineText = lineText.toUpperCase(); // DDL statement keywords if (/\b(CREATE\s+TABLE|ALTER\s+TABLE|DROP\s+TABLE|CREATE\s+INDEX|CREATE\s+VIEW)\b/i.test(lineText)) { return true; } // Column definition patterns in CREATE TABLE if (/\b(DISTKEY|SORTKEY|ENCODE|NOT\s+NULL|PRIMARY\s+KEY|FOREIGN\s+KEY)\b/i.test(lineText)) { return true; } // Table constraint patterns if (/^\s*--.*\b(table|column|constraint)\b/i.test(lineText)) { return true; } return false; } // Check if a line contains JOIN ON condition expressions function isJoinOnConditionLine(lineText) { const upperLineText = lineText.toUpperCase(); // Check for JOIN ON keywords const hasJoinOnKeywords = /\b(JOIN|ON)\b/i.test(lineText); // Check for table join patterns (table.column = table.column) const hasJoinPatterns = /\w+\.\w+\s*[=<>!]\s*\w+\.\w+/i.test(lineText); // Check for comparison operators in JOIN context const hasComparisonInJoin = hasJoinOnKeywords && /[=<>!~]/i.test(lineText); const isJoinOnCondition = hasJoinOnKeywords || hasJoinPatterns || hasComparisonInJoin; return isJoinOnCondition; } // Generate dynamic CSS rules for highlighting unsafe functions function generateHighlightCSS() { // Remove existing highlight stylesheet if (highlightStyleSheet) { highlightStyleSheet.remove(); } // Create new stylesheet for highlights highlightStyleSheet = document.createElement('style'); highlightStyleSheet.id = 'unsafe-function-highlights'; // Create CSS rules that highlight lines containing unsafe functions let cssRules = ` .CodeMirror-line.has-unsafe-function { background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(251, 191, 36, 0.1) 100%) !important; border-left: 4px solid #f59e0b !important; padding-left: 8px !important; user-select: text !important; -webkit-user-select: text !important; -moz-user-select: text !important; -ms-user-select: text !important; position: relative !important; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; } .CodeMirror-line.has-unsafe-function:hover { background: linear-gradient(135deg, rgba(245, 158, 11, 0.25) 0%, rgba(251, 191, 36, 0.2) 100%) !important; border-left-color: #d97706 !important; box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06) !important; } `; // Add specific highlighting for each unsafe function type ALL_UNSAFE_FUNCTIONS.forEach(functionName => { const escapedName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); cssRules += ` .CodeMirror-line[data-unsafe-function*="${functionName}"] { position: relative; } .CodeMirror-line[data-unsafe-function*="${functionName}"]::after { content: "⚠️ ${functionName}"; position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); color: white; padding: 3px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; pointer-events: none; z-index: 10; box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06); opacity: 0.9; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .CodeMirror-line[data-unsafe-function*="${functionName}"]:hover::after { opacity: 1; transform: translateY(-50%) scale(1.05); } `; }); highlightStyleSheet.textContent = cssRules; document.head.appendChild(highlightStyleSheet); } // Main scanning function function scanForUnsafeFunctions() { if (isScanning) { return; // Prevent concurrent scans } isScanning = true; detectedFunctions = {}; try { // Clear existing CSS highlights clearCSSHighlights(); // Find all CodeMirror-scroll elements (top-level containers only to avoid duplicate scanning) const codeMirrorElements = document.querySelectorAll('.CodeMirror-scroll'); codeMirrorElements.forEach((element, index) => { const sqlText = extractSQLText(element); if (sqlText && sqlText.trim()) { // Pass the CodeMirror element to detectUnsafeFunctions for accurate line numbers const detected = detectUnsafeFunctions(sqlText, element); // Merge detected functions with comprehensive deduplication Object.entries(detected).forEach(([category, functions]) => { if (!detectedFunctions[category]) { detectedFunctions[category] = []; } // Add all functions from this element functions.forEach(func => { detectedFunctions[category].push(func); }); }); // Apply CSS-only highlighting to WHERE/AND clauses only applyCSSOnlyHighlighting(element, index); } }); // Apply comprehensive deduplication to the final results detectedFunctions = deduplicateDetections(detectedFunctions); createSummaryPanel(); } finally { isScanning = false; } } // Highlight protection system - restores lost highlights function startHighlightProtection() { if (highlightProtectionInterval) { return; // Already running } highlightProtectionInterval = setInterval(() => { if (!panelVisible || highlightedLines.size === 0) { return; } // Check each highlighted line and restore if needed highlightedLines.forEach((highlightInfo, lineKey) => { const { element, functions, lineNumber, elementIndex } = highlightInfo; // Check if element still exists in DOM if (!document.contains(element)) { highlightedLines.delete(lineKey); return; } // Check if highlight styles are still applied const hasClass = element.classList.contains('has-unsafe-function'); const hasInlineStyle = element.style.backgroundColor && element.style.backgroundColor.includes('255, 235, 59'); if (!hasClass || !hasInlineStyle) { // Restore CSS class element.classList.add('has-unsafe-function'); // Restore inline styles element.style.setProperty('background-color', 'rgba(255, 235, 59, 0.3)', 'important'); element.style.setProperty('border-left', '3px solid #ff9800', 'important'); element.style.setProperty('padding-left', '5px', 'important'); element.style.setProperty('user-select', 'text', 'important'); element.style.setProperty('-webkit-user-select', 'text', 'important'); element.style.setProperty('-moz-user-select', 'text', 'important'); element.style.setProperty('-ms-user-select', 'text', 'important'); // Restore data attribute element.setAttribute('data-unsafe-function', functions.join(',')); } }); }, 500); // Check every 500ms } // Handle click events on CodeMirror lines to prevent highlight loss function setupClickProtection() { document.addEventListener('click', function(event) { const clickedLine = event.target.closest('.CodeMirror-line'); if (clickedLine && clickedLine.classList.contains('has-unsafe-function')) { // Schedule highlight restoration after a short delay setTimeout(() => { if (clickedLine.classList.contains('has-unsafe-function')) { // Ensure inline styles are still applied clickedLine.style.setProperty('background-color', 'rgba(255, 235, 59, 0.3)', 'important'); clickedLine.style.setProperty('border-left', '3px solid #ff9800', 'important'); clickedLine.style.setProperty('padding-left', '5px', 'important'); clickedLine.style.setProperty('user-select', 'text', 'important'); clickedLine.style.setProperty('-webkit-user-select', 'text', 'important'); clickedLine.style.setProperty('-moz-user-select', 'text', 'important'); clickedLine.style.setProperty('-ms-user-select', 'text', 'important'); } }, 50); } }, true); // Use capture phase to catch events early } // Debounced scan function function debouncedScan() { if (scanTimeout) { clearTimeout(scanTimeout); } scanTimeout = setTimeout(() => { if (panelVisible && !isScanning) { scanForUnsafeFunctions(); } }, 300); } // Initialize the script function initialize() { createToggleButton(); setupClickProtection(); } // Wait for page to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } })();