mcp-database-inspector 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/dist/database/connection.d.ts +13 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +155 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/database/manager.d.ts +28 -0
- package/dist/database/manager.d.ts.map +1 -0
- package/dist/database/manager.js +621 -0
- package/dist/database/manager.js.map +1 -0
- package/dist/database/postgres-connection.d.ts +10 -0
- package/dist/database/postgres-connection.d.ts.map +1 -0
- package/dist/database/postgres-connection.js +113 -0
- package/dist/database/postgres-connection.js.map +1 -0
- package/dist/database/types.d.ts +84 -0
- package/dist/database/types.d.ts.map +1 -0
- package/dist/database/types.js +6 -0
- package/dist/database/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +186 -0
- package/dist/server.js.map +1 -0
- package/dist/test-defaults.d.ts +2 -0
- package/dist/test-defaults.d.ts.map +1 -0
- package/dist/test-defaults.js +57 -0
- package/dist/test-defaults.js.map +1 -0
- package/dist/tools/analyze-query.d.ts +27 -0
- package/dist/tools/analyze-query.d.ts.map +1 -0
- package/dist/tools/analyze-query.js +71 -0
- package/dist/tools/analyze-query.js.map +1 -0
- package/dist/tools/execute-query.d.ts +33 -0
- package/dist/tools/execute-query.d.ts.map +1 -0
- package/dist/tools/execute-query.js +57 -0
- package/dist/tools/execute-query.js.map +1 -0
- package/dist/tools/get-foreign-keys.d.ts +38 -0
- package/dist/tools/get-foreign-keys.d.ts.map +1 -0
- package/dist/tools/get-foreign-keys.js +391 -0
- package/dist/tools/get-foreign-keys.js.map +1 -0
- package/dist/tools/get-indexes.d.ts +38 -0
- package/dist/tools/get-indexes.d.ts.map +1 -0
- package/dist/tools/get-indexes.js +472 -0
- package/dist/tools/get-indexes.js.map +1 -0
- package/dist/tools/information-schema-query.d.ts +33 -0
- package/dist/tools/information-schema-query.d.ts.map +1 -0
- package/dist/tools/information-schema-query.js +76 -0
- package/dist/tools/information-schema-query.js.map +1 -0
- package/dist/tools/inspect-table.d.ts +38 -0
- package/dist/tools/inspect-table.d.ts.map +1 -0
- package/dist/tools/inspect-table.js +351 -0
- package/dist/tools/inspect-table.js.map +1 -0
- package/dist/tools/list-databases.d.ts +14 -0
- package/dist/tools/list-databases.d.ts.map +1 -0
- package/dist/tools/list-databases.js +83 -0
- package/dist/tools/list-databases.js.map +1 -0
- package/dist/tools/list-tables.d.ts +19 -0
- package/dist/tools/list-tables.d.ts.map +1 -0
- package/dist/tools/list-tables.js +130 -0
- package/dist/tools/list-tables.js.map +1 -0
- package/dist/utils/errors.d.ts +32 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +98 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +28 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +132 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/validators/input-validator.d.ts +76 -0
- package/dist/validators/input-validator.d.ts.map +1 -0
- package/dist/validators/input-validator.js +295 -0
- package/dist/validators/input-validator.js.map +1 -0
- package/dist/validators/query-validator.d.ts +19 -0
- package/dist/validators/query-validator.d.ts.map +1 -0
- package/dist/validators/query-validator.js +229 -0
- package/dist/validators/query-validator.js.map +1 -0
- package/enhanced_sql_prompt.md +324 -0
- package/examples/claude-config.json +23 -0
- package/examples/roo-config.json +16 -0
- package/package.json +42 -0
- package/src/database/connection.ts +165 -0
- package/src/database/manager.ts +682 -0
- package/src/database/postgres-connection.ts +123 -0
- package/src/database/types.ts +93 -0
- package/src/index.ts +136 -0
- package/src/server.ts +254 -0
- package/src/test-defaults.ts +63 -0
- package/src/tools/analyze-query.test.ts +100 -0
- package/src/tools/analyze-query.ts +112 -0
- package/src/tools/execute-query.ts +91 -0
- package/src/tools/get-foreign-keys.test.ts +51 -0
- package/src/tools/get-foreign-keys.ts +488 -0
- package/src/tools/get-indexes.test.ts +51 -0
- package/src/tools/get-indexes.ts +570 -0
- package/src/tools/information-schema-query.ts +125 -0
- package/src/tools/inspect-table.test.ts +59 -0
- package/src/tools/inspect-table.ts +440 -0
- package/src/tools/list-databases.ts +119 -0
- package/src/tools/list-tables.ts +181 -0
- package/src/utils/errors.ts +103 -0
- package/src/utils/logger.ts +158 -0
- package/src/validators/input-validator.ts +318 -0
- package/src/validators/query-validator.ts +267 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ValidationResult } from '../database/types.js';
|
|
3
|
+
|
|
4
|
+
export class InputValidator {
|
|
5
|
+
// Schema for database connection URLs
|
|
6
|
+
static readonly connectionUrlSchema = z.string()
|
|
7
|
+
.url()
|
|
8
|
+
.refine(url => url.startsWith('mysql://') || url.startsWith('postgresql://') || url.startsWith('postgres://'), {
|
|
9
|
+
message: 'URL must start with mysql://, postgresql://, or postgres://'
|
|
10
|
+
})
|
|
11
|
+
.refine(url => {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
return parsed.hostname && parsed.username && parsed.password;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}, {
|
|
19
|
+
message: 'URL must contain hostname, username, and password'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Schema for database names
|
|
23
|
+
static readonly databaseNameSchema = z.string()
|
|
24
|
+
.min(1, 'Database name cannot be empty')
|
|
25
|
+
.max(64, 'Database name cannot exceed 64 characters')
|
|
26
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_$-]*$/, 'Invalid database name format');
|
|
27
|
+
|
|
28
|
+
// Schema for table names
|
|
29
|
+
static readonly tableNameSchema = z.string()
|
|
30
|
+
.min(1, 'Table name cannot be empty')
|
|
31
|
+
.max(64, 'Table name cannot exceed 64 characters')
|
|
32
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_$-]*$|^`[^`]+`$/, 'Invalid table name format');
|
|
33
|
+
|
|
34
|
+
// Schema for column names
|
|
35
|
+
static readonly columnNameSchema = z.string()
|
|
36
|
+
.min(1, 'Column name cannot be empty')
|
|
37
|
+
.max(64, 'Column name cannot exceed 64 characters')
|
|
38
|
+
.regex(/^[a-zA-Z_][a-zA-Z0-9_$-]*$|^`[^`]+`$/, 'Invalid column name format');
|
|
39
|
+
|
|
40
|
+
// Schema for general text input
|
|
41
|
+
static readonly textInputSchema = z.string()
|
|
42
|
+
.max(10000, 'Input too long')
|
|
43
|
+
.refine(text => !text.includes('\0'), {
|
|
44
|
+
message: 'Input cannot contain null bytes'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a connection URL
|
|
49
|
+
*/
|
|
50
|
+
static validateConnectionUrl(url: string): ValidationResult {
|
|
51
|
+
try {
|
|
52
|
+
this.connectionUrlSchema.parse(url);
|
|
53
|
+
return { isValid: true };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof z.ZodError) {
|
|
56
|
+
return {
|
|
57
|
+
isValid: false,
|
|
58
|
+
error: error.issues.map(e => e.message).join(', ')
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
isValid: false,
|
|
63
|
+
error: 'Invalid connection URL format'
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a database name
|
|
70
|
+
*/
|
|
71
|
+
static validateDatabaseName(name: string): ValidationResult {
|
|
72
|
+
try {
|
|
73
|
+
this.databaseNameSchema.parse(name);
|
|
74
|
+
return { isValid: true };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof z.ZodError) {
|
|
77
|
+
return {
|
|
78
|
+
isValid: false,
|
|
79
|
+
error: error.issues.map(e => e.message).join(', ')
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
isValid: false,
|
|
84
|
+
error: 'Invalid database name'
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a table name
|
|
91
|
+
*/
|
|
92
|
+
static validateTableName(name: string): ValidationResult {
|
|
93
|
+
try {
|
|
94
|
+
this.tableNameSchema.parse(name);
|
|
95
|
+
return { isValid: true };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof z.ZodError) {
|
|
98
|
+
return {
|
|
99
|
+
isValid: false,
|
|
100
|
+
error: error.issues.map(e => e.message).join(', ')
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
isValid: false,
|
|
105
|
+
error: 'Invalid table name'
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate a column name
|
|
112
|
+
*/
|
|
113
|
+
static validateColumnName(name: string): ValidationResult {
|
|
114
|
+
try {
|
|
115
|
+
this.columnNameSchema.parse(name);
|
|
116
|
+
return { isValid: true };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof z.ZodError) {
|
|
119
|
+
return {
|
|
120
|
+
isValid: false,
|
|
121
|
+
error: error.issues.map(e => e.message).join(', ')
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
isValid: false,
|
|
126
|
+
error: 'Invalid column name'
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate text input
|
|
133
|
+
*/
|
|
134
|
+
static validateTextInput(text: string): ValidationResult {
|
|
135
|
+
try {
|
|
136
|
+
this.textInputSchema.parse(text);
|
|
137
|
+
return { isValid: true };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof z.ZodError) {
|
|
140
|
+
return {
|
|
141
|
+
isValid: false,
|
|
142
|
+
error: error.issues.map(e => e.message).join(', ')
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
isValid: false,
|
|
147
|
+
error: 'Invalid text input'
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sanitize string input by removing dangerous characters
|
|
154
|
+
*/
|
|
155
|
+
static sanitizeString(input: string): string {
|
|
156
|
+
if (!input) return '';
|
|
157
|
+
|
|
158
|
+
return input
|
|
159
|
+
// Remove null bytes
|
|
160
|
+
.replace(/\0/g, '')
|
|
161
|
+
// Remove control characters except tab, newline, carriage return
|
|
162
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
|
163
|
+
// Trim whitespace
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Escape MySQL identifiers (table names, column names)
|
|
169
|
+
*/
|
|
170
|
+
static escapeIdentifier(identifier: string): string {
|
|
171
|
+
if (!identifier) return '';
|
|
172
|
+
|
|
173
|
+
// If already quoted, return as is
|
|
174
|
+
if (identifier.startsWith('`') && identifier.endsWith('`')) {
|
|
175
|
+
return identifier;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Remove any existing backticks and escape them
|
|
179
|
+
const cleaned = identifier.replace(/`/g, '``');
|
|
180
|
+
return `\`${cleaned}\``;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate tool arguments based on schema
|
|
185
|
+
*/
|
|
186
|
+
static validateToolArgs<T>(args: unknown, schema: z.ZodSchema<T>): ValidationResult & { data?: T } {
|
|
187
|
+
try {
|
|
188
|
+
const data = schema.parse(args);
|
|
189
|
+
return { isValid: true, data };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof z.ZodError) {
|
|
192
|
+
return {
|
|
193
|
+
isValid: false,
|
|
194
|
+
error: `Invalid arguments: ${error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
isValid: false,
|
|
199
|
+
error: 'Invalid arguments format'
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate that a string represents a valid number
|
|
206
|
+
*/
|
|
207
|
+
static validateNumeric(value: string, options?: { min?: number; max?: number; integer?: boolean }): ValidationResult {
|
|
208
|
+
const num = Number(value);
|
|
209
|
+
|
|
210
|
+
if (isNaN(num)) {
|
|
211
|
+
return { isValid: false, error: 'Value must be a valid number' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (options?.integer && !Number.isInteger(num)) {
|
|
215
|
+
return { isValid: false, error: 'Value must be an integer' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (options?.min !== undefined && num < options.min) {
|
|
219
|
+
return { isValid: false, error: `Value must be at least ${options.min}` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (options?.max !== undefined && num > options.max) {
|
|
223
|
+
return { isValid: false, error: `Value must be at most ${options.max}` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { isValid: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validate an array of values
|
|
231
|
+
*/
|
|
232
|
+
static validateArray<T>(
|
|
233
|
+
values: unknown[],
|
|
234
|
+
itemValidator: (item: unknown) => ValidationResult & { data?: T }
|
|
235
|
+
): ValidationResult & { data?: T[] } {
|
|
236
|
+
if (!Array.isArray(values)) {
|
|
237
|
+
return { isValid: false, error: 'Value must be an array' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const validatedItems: T[] = [];
|
|
241
|
+
const errors: string[] = [];
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < values.length; i++) {
|
|
244
|
+
const result = itemValidator(values[i]);
|
|
245
|
+
if (result.isValid && result.data !== undefined) {
|
|
246
|
+
validatedItems.push(result.data);
|
|
247
|
+
} else {
|
|
248
|
+
errors.push(`Item ${i}: ${result.error || 'Validation failed'}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (errors.length > 0) {
|
|
253
|
+
return { isValid: false, error: errors.join(', ') };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { isValid: true, data: validatedItems };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Validate email format (for potential user management features)
|
|
261
|
+
*/
|
|
262
|
+
static validateEmail(email: string): ValidationResult {
|
|
263
|
+
const emailSchema = z.string().email();
|
|
264
|
+
try {
|
|
265
|
+
emailSchema.parse(email);
|
|
266
|
+
return { isValid: true };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return { isValid: false, error: 'Invalid email format' };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Validate URL format
|
|
274
|
+
*/
|
|
275
|
+
static validateUrl(url: string): ValidationResult {
|
|
276
|
+
const urlSchema = z.string().url();
|
|
277
|
+
try {
|
|
278
|
+
urlSchema.parse(url);
|
|
279
|
+
return { isValid: true };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return { isValid: false, error: 'Invalid URL format' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check if a string contains only safe characters for logging
|
|
287
|
+
*/
|
|
288
|
+
static isSafeForLogging(text: string): boolean {
|
|
289
|
+
// Check for sensitive patterns that shouldn't be logged
|
|
290
|
+
const sensitivePatterns = [
|
|
291
|
+
/password\s*[=:]\s*[^\s&]+/gi,
|
|
292
|
+
/pwd\s*[=:]\s*[^\s&]+/gi,
|
|
293
|
+
/secret\s*[=:]\s*[^\s&]+/gi,
|
|
294
|
+
/token\s*[=:]\s*[^\s&]+/gi,
|
|
295
|
+
/key\s*[=:]\s*[^\s&]+/gi,
|
|
296
|
+
/(mysql|postgresql?):\/\/[^@]+:[^@]+@/gi, // Connection strings with credentials
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
return !sensitivePatterns.some(pattern => pattern.test(text));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sanitize text for safe logging by masking sensitive information
|
|
304
|
+
*/
|
|
305
|
+
static sanitizeForLogging(text: string): string {
|
|
306
|
+
if (!text) return '';
|
|
307
|
+
|
|
308
|
+
return text
|
|
309
|
+
// Mask passwords in URLs
|
|
310
|
+
.replace(/((?:mysql|postgresql?):\/\/[^:]+:)[^@]+(@)/gi, '$1***$2')
|
|
311
|
+
// Mask password parameters
|
|
312
|
+
.replace(/(password\s*[=:]\s*)[^\s&]+/gi, '$1***')
|
|
313
|
+
.replace(/(pwd\s*[=:]\s*)[^\s&]+/gi, '$1***')
|
|
314
|
+
.replace(/(secret\s*[=:]\s*)[^\s&]+/gi, '$1***')
|
|
315
|
+
.replace(/(token\s*[=:]\s*)[^\s&]+/gi, '$1***')
|
|
316
|
+
.replace(/(key\s*[=:]\s*)[^\s&]+/gi, '$1***');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { DatabaseType, ValidationResult } from '../database/types.js';
|
|
2
|
+
|
|
3
|
+
export class QueryValidator {
|
|
4
|
+
// Keywords that are forbidden in queries
|
|
5
|
+
private static readonly FORBIDDEN_KEYWORDS = [
|
|
6
|
+
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE',
|
|
7
|
+
'ALTER', 'TRUNCATE', 'REPLACE', 'MERGE', 'CALL',
|
|
8
|
+
'EXEC', 'EXECUTE', 'LOAD', 'IMPORT', 'BULK',
|
|
9
|
+
'GRANT', 'REVOKE', 'SET', 'USE', 'START',
|
|
10
|
+
'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT',
|
|
11
|
+
'LOCK', 'UNLOCK', 'FLUSH', 'RESET', 'PURGE',
|
|
12
|
+
'KILL', 'SHUTDOWN', 'RESTART', 'COPY'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Allowed keywords for read-only operations
|
|
16
|
+
private static readonly ALLOWED_KEYWORDS = [
|
|
17
|
+
'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN',
|
|
18
|
+
'ANALYZE', 'CHECK', 'CHECKSUM', 'OPTIMIZE', 'WITH', 'VALUES'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Dangerous functions that should be blocked
|
|
22
|
+
private static readonly FORBIDDEN_FUNCTIONS = [
|
|
23
|
+
'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE',
|
|
24
|
+
'SYSTEM', 'USER_DEFINED_FUNCTION', 'BENCHMARK',
|
|
25
|
+
'PG_READ_FILE', 'PG_LS_DIR', 'PG_EXECUTE'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
static validateQuery(query: string, type: DatabaseType = DatabaseType.MySQL): ValidationResult {
|
|
29
|
+
if (!query || query.trim().length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
isValid: false,
|
|
32
|
+
error: 'Query cannot be empty'
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const normalizedQuery = this.normalizeQuery(query);
|
|
37
|
+
|
|
38
|
+
// Check for forbidden keywords
|
|
39
|
+
const forbiddenCheck = this.checkForbiddenKeywords(normalizedQuery);
|
|
40
|
+
if (!forbiddenCheck.isValid) {
|
|
41
|
+
return forbiddenCheck;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for forbidden functions
|
|
45
|
+
const functionCheck = this.checkForbiddenFunctions(normalizedQuery);
|
|
46
|
+
if (!functionCheck.isValid) {
|
|
47
|
+
return functionCheck;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if query starts with an allowed keyword
|
|
51
|
+
const allowedCheck = this.checkAllowedStart(normalizedQuery);
|
|
52
|
+
if (!allowedCheck.isValid) {
|
|
53
|
+
return allowedCheck;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check for SQL injection patterns
|
|
57
|
+
const injectionCheck = this.checkSqlInjectionPatterns(normalizedQuery, type);
|
|
58
|
+
if (!injectionCheck.isValid) {
|
|
59
|
+
return injectionCheck;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for suspicious patterns
|
|
63
|
+
const suspiciousCheck = this.checkSuspiciousPatterns(normalizedQuery);
|
|
64
|
+
if (!suspiciousCheck.isValid) {
|
|
65
|
+
return suspiciousCheck;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
isValid: true,
|
|
70
|
+
warnings: this.getWarnings(normalizedQuery)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static normalizeQuery(query: string): string {
|
|
75
|
+
// Remove comments and normalize whitespace
|
|
76
|
+
return query
|
|
77
|
+
.replace(/--[^\r\n]*/g, '') // Remove -- comments
|
|
78
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
|
|
79
|
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
80
|
+
.trim()
|
|
81
|
+
.toUpperCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private static checkForbiddenKeywords(query: string): ValidationResult {
|
|
85
|
+
for (const keyword of this.FORBIDDEN_KEYWORDS) {
|
|
86
|
+
// Use word boundaries to avoid false positives
|
|
87
|
+
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
|
88
|
+
if (regex.test(query)) {
|
|
89
|
+
return {
|
|
90
|
+
isValid: false,
|
|
91
|
+
error: `Forbidden keyword detected: ${keyword}. Only read-only operations are allowed.`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { isValid: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private static checkForbiddenFunctions(query: string): ValidationResult {
|
|
99
|
+
for (const func of this.FORBIDDEN_FUNCTIONS) {
|
|
100
|
+
if (query.includes(func)) {
|
|
101
|
+
return {
|
|
102
|
+
isValid: false,
|
|
103
|
+
error: `Forbidden function detected: ${func}. This function is not allowed for security reasons.`
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { isValid: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static checkAllowedStart(query: string): ValidationResult {
|
|
111
|
+
const firstWord = query.split(' ')[0];
|
|
112
|
+
|
|
113
|
+
if (!this.ALLOWED_KEYWORDS.includes(firstWord)) {
|
|
114
|
+
return {
|
|
115
|
+
isValid: false,
|
|
116
|
+
error: `Query must start with one of: ${this.ALLOWED_KEYWORDS.join(', ')}`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { isValid: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private static checkSqlInjectionPatterns(query: string, type: DatabaseType): ValidationResult {
|
|
124
|
+
const suspiciousPatterns = [
|
|
125
|
+
/;\s*(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)/i, // Multiple statements
|
|
126
|
+
/UNION\s+(ALL\s+)?SELECT/i, // Union-based injection
|
|
127
|
+
/'\s*(OR|AND)\s*'[^']*'\s*=/i, // Quote-based injection
|
|
128
|
+
/'\s*(OR|AND)\s*\d+\s*=\s*\d+/i, // Numeric injection
|
|
129
|
+
/CONCAT\s*\(\s*0x[0-9a-f]+/i, // Hex concatenation
|
|
130
|
+
/(SLEEP|BENCHMARK)\s*\(/i, // Time-based attacks
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
if (type === DatabaseType.MySQL) {
|
|
134
|
+
suspiciousPatterns.push(/INFORMATION_SCHEMA\.\w+\s+(WHERE|AND|OR)/i);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const pattern of suspiciousPatterns) {
|
|
138
|
+
if (pattern.test(query)) {
|
|
139
|
+
return {
|
|
140
|
+
isValid: false,
|
|
141
|
+
error: 'Query contains suspicious patterns that may indicate SQL injection'
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { isValid: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static checkSuspiciousPatterns(query: string): ValidationResult {
|
|
150
|
+
// Check for extremely long queries (potential DoS)
|
|
151
|
+
if (query.length > 10000) {
|
|
152
|
+
return {
|
|
153
|
+
isValid: false,
|
|
154
|
+
error: 'Query is too long. Maximum allowed length is 10,000 characters.'
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check for excessive nesting
|
|
159
|
+
const nestedCount = (query.match(/\(/g) || []).length;
|
|
160
|
+
if (nestedCount > 50) {
|
|
161
|
+
return {
|
|
162
|
+
isValid: false,
|
|
163
|
+
error: 'Query has too many nested expressions. Maximum allowed is 50.'
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { isValid: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private static getWarnings(query: string): string[] {
|
|
171
|
+
const warnings: string[] = [];
|
|
172
|
+
|
|
173
|
+
// Warn about potentially slow operations
|
|
174
|
+
if (query.includes('SELECT *')) {
|
|
175
|
+
warnings.push('Using SELECT * may return large result sets. Consider specifying specific columns.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (query.includes('ORDER BY') && !query.includes('LIMIT')) {
|
|
179
|
+
warnings.push('ORDER BY without LIMIT may be slow on large tables.');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (query.includes('LIKE %') || query.includes("LIKE '%")) {
|
|
183
|
+
warnings.push('Leading wildcard in LIKE patterns may cause slow queries.');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check for cross-joins
|
|
187
|
+
if (query.match(/FROM\s+\w+\s*,\s*\w+/i) && !query.includes('WHERE')) {
|
|
188
|
+
warnings.push('Potential cartesian product detected. Consider adding WHERE conditions.');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return warnings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Validate table and column names to prevent injection through identifiers
|
|
195
|
+
static validateIdentifier(identifier: string): ValidationResult {
|
|
196
|
+
if (!identifier || identifier.trim().length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
isValid: false,
|
|
199
|
+
error: 'Identifier cannot be empty'
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MySQL/PostgreSQL identifier rules (simplified common)
|
|
204
|
+
const validIdentifier = /^[a-zA-Z_][a-zA-Z0-9_$]*$|^`[^`]+`$/;
|
|
205
|
+
|
|
206
|
+
if (!validIdentifier.test(identifier.trim())) {
|
|
207
|
+
return {
|
|
208
|
+
isValid: false,
|
|
209
|
+
error: 'Invalid identifier format. Use only letters, numbers, underscore, and dollar sign.'
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check length (common limit is 64 characters)
|
|
214
|
+
const cleanIdentifier = identifier.replace(/[`"]/g, '');
|
|
215
|
+
if (cleanIdentifier.length > 64) {
|
|
216
|
+
return {
|
|
217
|
+
isValid: false,
|
|
218
|
+
error: 'Identifier too long. Maximum length is 64 characters.'
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { isValid: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Sanitize user input
|
|
226
|
+
static sanitizeInput(input: string): string {
|
|
227
|
+
if (!input) return '';
|
|
228
|
+
|
|
229
|
+
// Remove null bytes and control characters
|
|
230
|
+
return input
|
|
231
|
+
.replace(/\0/g, '')
|
|
232
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
|
233
|
+
.trim();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check if a query is a simple read operation
|
|
237
|
+
static isSimpleReadQuery(query: string): boolean {
|
|
238
|
+
const normalized = this.normalizeQuery(query);
|
|
239
|
+
const firstWord = normalized.split(' ')[0];
|
|
240
|
+
|
|
241
|
+
return ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'WITH', 'VALUES'].includes(firstWord);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Estimate query complexity
|
|
245
|
+
static getQueryComplexity(query: string): 'low' | 'medium' | 'high' {
|
|
246
|
+
const normalized = this.normalizeQuery(query);
|
|
247
|
+
let complexity = 0;
|
|
248
|
+
|
|
249
|
+
// Count joins
|
|
250
|
+
complexity += (normalized.match(/\bJOIN\b/g) || []).length * 2;
|
|
251
|
+
|
|
252
|
+
// Count subqueries
|
|
253
|
+
complexity += (normalized.match(/\bSELECT\b/g) || []).length - 1;
|
|
254
|
+
|
|
255
|
+
// Count aggregation functions
|
|
256
|
+
complexity += (normalized.match(/\b(COUNT|SUM|AVG|MAX|MIN|GROUP_CONCAT)\b/g) || []).length;
|
|
257
|
+
|
|
258
|
+
// Count sorting and grouping
|
|
259
|
+
if (normalized.includes('ORDER BY')) complexity += 1;
|
|
260
|
+
if (normalized.includes('GROUP BY')) complexity += 2;
|
|
261
|
+
if (normalized.includes('HAVING')) complexity += 1;
|
|
262
|
+
|
|
263
|
+
if (complexity <= 2) return 'low';
|
|
264
|
+
if (complexity <= 6) return 'medium';
|
|
265
|
+
return 'high';
|
|
266
|
+
}
|
|
267
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true,
|
|
17
|
+
"experimentalDecorators": true,
|
|
18
|
+
"emitDecoratorMetadata": true,
|
|
19
|
+
"lib": ["ES2022", "DOM"]
|
|
20
|
+
},
|
|
21
|
+
"include": [
|
|
22
|
+
"src/**/*"
|
|
23
|
+
],
|
|
24
|
+
"exclude": [
|
|
25
|
+
"node_modules",
|
|
26
|
+
"dist",
|
|
27
|
+
"**/*.test.ts",
|
|
28
|
+
"**/*.spec.ts"
|
|
29
|
+
]
|
|
30
|
+
}
|