@berthojoris/mcp-mysql-server 1.16.3 → 1.17.0
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/CHANGELOG.md +27 -0
- package/DOCUMENTATIONS.md +378 -24
- package/README.md +36 -453
- package/dist/config/featureConfig.js +20 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +68 -0
- package/dist/mcp-server.js +275 -1
- package/dist/tools/forecastingTools.d.ts +36 -0
- package/dist/tools/forecastingTools.js +256 -0
- package/dist/tools/indexRecommendationTools.d.ts +50 -0
- package/dist/tools/indexRecommendationTools.js +451 -0
- package/dist/tools/queryVisualizationTools.d.ts +22 -0
- package/dist/tools/queryVisualizationTools.js +155 -0
- package/dist/tools/schemaDesignTools.d.ts +67 -0
- package/dist/tools/schemaDesignTools.js +359 -0
- package/dist/tools/schemaPatternTools.d.ts +19 -0
- package/dist/tools/schemaPatternTools.js +253 -0
- package/dist/tools/securityAuditTools.d.ts +39 -0
- package/dist/tools/securityAuditTools.js +319 -0
- package/dist/tools/testDataTools.d.ts +26 -0
- package/dist/tools/testDataTools.js +325 -0
- package/manifest.json +189 -1
- package/package.json +2 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SchemaDesignTools = void 0;
|
|
4
|
+
class SchemaDesignTools {
|
|
5
|
+
constructor(security) {
|
|
6
|
+
this.security = security;
|
|
7
|
+
}
|
|
8
|
+
async designSchemaFromRequirements(params) {
|
|
9
|
+
try {
|
|
10
|
+
const requirementsText = params?.requirements_text?.trim();
|
|
11
|
+
if (!requirementsText) {
|
|
12
|
+
return { status: "error", error: "requirements_text is required" };
|
|
13
|
+
}
|
|
14
|
+
const naming = params.naming_convention ?? "snake_case";
|
|
15
|
+
const includeAudit = params.include_audit_columns ?? true;
|
|
16
|
+
const idType = params.id_type ?? "BIGINT";
|
|
17
|
+
const engine = params.engine ?? "InnoDB";
|
|
18
|
+
const charset = params.charset ?? "utf8mb4";
|
|
19
|
+
const collation = params.collation ?? "utf8mb4_unicode_ci";
|
|
20
|
+
const notes = [
|
|
21
|
+
"This tool uses deterministic heuristics (no external LLM) and may require manual refinement.",
|
|
22
|
+
"Review data types, nullability, and indexes before applying DDL in production.",
|
|
23
|
+
];
|
|
24
|
+
const extracted = this.extractEntitiesAndRelations(requirementsText);
|
|
25
|
+
const explicitEntities = (params.entities || [])
|
|
26
|
+
.map((e) => ({
|
|
27
|
+
name: e.name,
|
|
28
|
+
fields: e.fields || [],
|
|
29
|
+
}))
|
|
30
|
+
.filter((e) => !!e.name?.trim());
|
|
31
|
+
const entityNames = new Set();
|
|
32
|
+
for (const e of explicitEntities)
|
|
33
|
+
entityNames.add(e.name.trim());
|
|
34
|
+
for (const e of extracted.entities)
|
|
35
|
+
entityNames.add(e);
|
|
36
|
+
if (entityNames.size === 0) {
|
|
37
|
+
// Fallback: create a single generic table
|
|
38
|
+
entityNames.add("items");
|
|
39
|
+
notes.push("No entities could be inferred from the text; generated a single fallback table 'items'.");
|
|
40
|
+
}
|
|
41
|
+
// Build initial table specs
|
|
42
|
+
const tablesSpec = Array.from(entityNames).map((rawName, idx) => {
|
|
43
|
+
const tableName = this.normalizeIdentifier(rawName, naming, `entity_${idx + 1}`);
|
|
44
|
+
const idColumn = idType === "UUID"
|
|
45
|
+
? { name: "id", type: "CHAR(36)", nullable: false, primary_key: true }
|
|
46
|
+
: {
|
|
47
|
+
name: "id",
|
|
48
|
+
type: "BIGINT UNSIGNED",
|
|
49
|
+
nullable: false,
|
|
50
|
+
primary_key: true,
|
|
51
|
+
};
|
|
52
|
+
const baseColumns = [
|
|
53
|
+
idType === "UUID"
|
|
54
|
+
? idColumn
|
|
55
|
+
: { ...idColumn, type: "BIGINT UNSIGNED AUTO_INCREMENT" },
|
|
56
|
+
];
|
|
57
|
+
if (includeAudit) {
|
|
58
|
+
baseColumns.push({ name: "created_at", type: "DATETIME", nullable: false }, { name: "updated_at", type: "DATETIME", nullable: false });
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
table_name: tableName,
|
|
62
|
+
columns: baseColumns,
|
|
63
|
+
indexes: [],
|
|
64
|
+
_raw_name: rawName,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const rawNameToTable = new Map();
|
|
68
|
+
for (const t of tablesSpec) {
|
|
69
|
+
rawNameToTable.set(t._raw_name, t.table_name);
|
|
70
|
+
}
|
|
71
|
+
// Apply field hints from explicit entities and text patterns
|
|
72
|
+
for (const t of tablesSpec) {
|
|
73
|
+
const explicit = explicitEntities.find((e) => this.normalizeLoose(e.name) === this.normalizeLoose(t._raw_name));
|
|
74
|
+
const fieldsFromText = extracted.fieldsByEntity.get(t._raw_name) || [];
|
|
75
|
+
const hintedFields = [
|
|
76
|
+
...(explicit?.fields || []),
|
|
77
|
+
...fieldsFromText,
|
|
78
|
+
].map((f) => f.trim());
|
|
79
|
+
for (const field of hintedFields) {
|
|
80
|
+
const colName = this.normalizeIdentifier(field, naming, undefined);
|
|
81
|
+
if (!colName || colName === "id" || colName === "created_at" || colName === "updated_at") {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (t.columns.some((c) => c.name === colName))
|
|
85
|
+
continue;
|
|
86
|
+
const inferred = this.inferColumnType(colName);
|
|
87
|
+
t.columns.push(inferred.column);
|
|
88
|
+
if (inferred.uniqueIndex) {
|
|
89
|
+
t.indexes.push({
|
|
90
|
+
name: this.makeIndexName(t.table_name, [colName], true),
|
|
91
|
+
columns: [colName],
|
|
92
|
+
unique: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Apply inferred relationships
|
|
98
|
+
const relationships = [];
|
|
99
|
+
for (const rel of extracted.relationships) {
|
|
100
|
+
const fromTableRaw = rel.from;
|
|
101
|
+
const toTableRaw = rel.to;
|
|
102
|
+
const fromTable = this.findTableNameForRaw(fromTableRaw, tablesSpec) ?? this.normalizeIdentifier(fromTableRaw, naming, undefined);
|
|
103
|
+
const toTable = this.findTableNameForRaw(toTableRaw, tablesSpec) ?? this.normalizeIdentifier(toTableRaw, naming, undefined);
|
|
104
|
+
if (!fromTable || !toTable)
|
|
105
|
+
continue;
|
|
106
|
+
const parentTable = rel.type === "many_to_one" ? toTable : fromTable;
|
|
107
|
+
const childTable = rel.type === "many_to_one" ? fromTable : toTable;
|
|
108
|
+
const child = tablesSpec.find((t) => t.table_name === childTable);
|
|
109
|
+
const parent = tablesSpec.find((t) => t.table_name === parentTable);
|
|
110
|
+
if (!child || !parent)
|
|
111
|
+
continue;
|
|
112
|
+
const fkColumn = this.normalizeIdentifier(`${parentTable}_id`, naming, `${parentTable}_id`);
|
|
113
|
+
if (!child.columns.some((c) => c.name === fkColumn)) {
|
|
114
|
+
child.columns.push({
|
|
115
|
+
name: fkColumn,
|
|
116
|
+
type: idType === "UUID" ? "CHAR(36)" : "BIGINT UNSIGNED",
|
|
117
|
+
nullable: false,
|
|
118
|
+
references: { table: parentTable, column: "id" },
|
|
119
|
+
});
|
|
120
|
+
child.indexes.push({
|
|
121
|
+
name: this.makeIndexName(childTable, [fkColumn], false),
|
|
122
|
+
columns: [fkColumn],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
relationships.push({
|
|
126
|
+
from_table: childTable,
|
|
127
|
+
from_column: fkColumn,
|
|
128
|
+
to_table: parentTable,
|
|
129
|
+
to_column: "id",
|
|
130
|
+
type: "many_to_one",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Generate DDL
|
|
134
|
+
const ddlStatements = [];
|
|
135
|
+
for (const t of tablesSpec) {
|
|
136
|
+
ddlStatements.push(this.generateCreateTableDDL(t, engine, charset, collation));
|
|
137
|
+
for (const idx of t.indexes) {
|
|
138
|
+
ddlStatements.push(this.generateCreateIndexDDL(t.table_name, idx));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Basic sanity: validate generated identifiers
|
|
142
|
+
for (const t of tablesSpec) {
|
|
143
|
+
if (!this.security.validateIdentifier(t.table_name).valid) {
|
|
144
|
+
notes.push(`Generated table name '${t.table_name}' may be invalid. Consider renaming.`);
|
|
145
|
+
}
|
|
146
|
+
for (const c of t.columns) {
|
|
147
|
+
if (!this.security.validateIdentifier(c.name).valid) {
|
|
148
|
+
notes.push(`Generated column name '${t.table_name}.${c.name}' may be invalid. Consider renaming.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
status: "success",
|
|
154
|
+
data: {
|
|
155
|
+
input: {
|
|
156
|
+
requirements_text: requirementsText,
|
|
157
|
+
inferred_entities_count: tablesSpec.length,
|
|
158
|
+
},
|
|
159
|
+
tables: tablesSpec.map(({ _raw_name, ...t }) => t),
|
|
160
|
+
relationships,
|
|
161
|
+
ddl_statements: ddlStatements,
|
|
162
|
+
notes,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return { status: "error", error: error.message };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
normalizeLoose(value) {
|
|
171
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
172
|
+
}
|
|
173
|
+
toSnakeCase(value) {
|
|
174
|
+
return value
|
|
175
|
+
.trim()
|
|
176
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
177
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
178
|
+
.replace(/^_+|_+$/g, "")
|
|
179
|
+
.replace(/_+/g, "_")
|
|
180
|
+
.toLowerCase();
|
|
181
|
+
}
|
|
182
|
+
toCamelCase(value) {
|
|
183
|
+
const parts = value
|
|
184
|
+
.trim()
|
|
185
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
186
|
+
.split(/\s+/)
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
if (parts.length === 0)
|
|
189
|
+
return "";
|
|
190
|
+
return (parts[0].toLowerCase() +
|
|
191
|
+
parts
|
|
192
|
+
.slice(1)
|
|
193
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
194
|
+
.join(""));
|
|
195
|
+
}
|
|
196
|
+
normalizeIdentifier(raw, naming, fallback) {
|
|
197
|
+
const base = naming === "camelCase" ? this.toCamelCase(raw) : this.toSnakeCase(raw);
|
|
198
|
+
let ident = base;
|
|
199
|
+
if (!ident)
|
|
200
|
+
ident = fallback || "";
|
|
201
|
+
if (!ident)
|
|
202
|
+
return "";
|
|
203
|
+
// Ensure valid start char
|
|
204
|
+
if (!/^[a-zA-Z_$]/.test(ident)) {
|
|
205
|
+
ident = `_${ident}`;
|
|
206
|
+
}
|
|
207
|
+
// Strip invalid chars (safety)
|
|
208
|
+
ident = ident.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
209
|
+
ident = ident.slice(0, 64);
|
|
210
|
+
// If still invalid, fallback
|
|
211
|
+
if (!this.security.validateIdentifier(ident).valid) {
|
|
212
|
+
const fb = fallback ? this.toSnakeCase(fallback) : "entity";
|
|
213
|
+
const safe = fb && this.security.validateIdentifier(fb).valid ? fb : "entity";
|
|
214
|
+
return safe;
|
|
215
|
+
}
|
|
216
|
+
return ident;
|
|
217
|
+
}
|
|
218
|
+
inferColumnType(colName) {
|
|
219
|
+
const name = colName.toLowerCase();
|
|
220
|
+
if (name === "email") {
|
|
221
|
+
return {
|
|
222
|
+
column: { name: colName, type: "VARCHAR(255)", nullable: false, unique: true },
|
|
223
|
+
uniqueIndex: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (name.endsWith("_id")) {
|
|
227
|
+
return { column: { name: colName, type: "BIGINT UNSIGNED", nullable: false } };
|
|
228
|
+
}
|
|
229
|
+
if (name.includes("amount") ||
|
|
230
|
+
name.includes("price") ||
|
|
231
|
+
name.includes("total") ||
|
|
232
|
+
name.includes("cost")) {
|
|
233
|
+
return { column: { name: colName, type: "DECIMAL(10,2)", nullable: false } };
|
|
234
|
+
}
|
|
235
|
+
if (name.startsWith("is_") || name.startsWith("has_") || name.includes("enabled") || name.includes("active")) {
|
|
236
|
+
return { column: { name: colName, type: "BOOLEAN", nullable: false } };
|
|
237
|
+
}
|
|
238
|
+
if (name.endsWith("_at") || name.includes("date") || name.includes("time")) {
|
|
239
|
+
return { column: { name: colName, type: "DATETIME", nullable: true } };
|
|
240
|
+
}
|
|
241
|
+
if (name.includes("count") || name.includes("qty") || name.includes("quantity") || name.includes("number")) {
|
|
242
|
+
return { column: { name: colName, type: "INT", nullable: false } };
|
|
243
|
+
}
|
|
244
|
+
if (name.includes("description") || name.includes("notes") || name.includes("comment")) {
|
|
245
|
+
return { column: { name: colName, type: "TEXT", nullable: true } };
|
|
246
|
+
}
|
|
247
|
+
if (name.includes("name") || name.includes("title") || name.includes("status")) {
|
|
248
|
+
return { column: { name: colName, type: "VARCHAR(255)", nullable: false } };
|
|
249
|
+
}
|
|
250
|
+
return { column: { name: colName, type: "VARCHAR(255)", nullable: true } };
|
|
251
|
+
}
|
|
252
|
+
extractEntitiesAndRelations(text) {
|
|
253
|
+
const entities = new Set();
|
|
254
|
+
const relationships = [];
|
|
255
|
+
const fieldsByEntity = new Map();
|
|
256
|
+
const raw = text;
|
|
257
|
+
// Entity hints: "table for X", "entity X", "model X"
|
|
258
|
+
for (const match of raw.matchAll(/\b(?:table|entity|model)\s+(?:for\s+)?([a-zA-Z][a-zA-Z0-9 _-]{1,40})/gi)) {
|
|
259
|
+
const name = match[1].trim();
|
|
260
|
+
if (name)
|
|
261
|
+
entities.add(name);
|
|
262
|
+
}
|
|
263
|
+
// Relationship patterns: "A has many B" / "A belongs to B"
|
|
264
|
+
for (const match of raw.matchAll(/\b([a-zA-Z][a-zA-Z0-9_-]{1,40})\s+has\s+many\s+([a-zA-Z][a-zA-Z0-9_-]{1,40})\b/gi)) {
|
|
265
|
+
const from = match[1].trim();
|
|
266
|
+
const to = match[2].trim();
|
|
267
|
+
if (from && to) {
|
|
268
|
+
entities.add(from);
|
|
269
|
+
entities.add(to);
|
|
270
|
+
relationships.push({ from, to, type: "one_to_many" });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const match of raw.matchAll(/\b([a-zA-Z][a-zA-Z0-9_-]{1,40})\s+belongs\s+to\s+([a-zA-Z][a-zA-Z0-9_-]{1,40})\b/gi)) {
|
|
274
|
+
const from = match[1].trim();
|
|
275
|
+
const to = match[2].trim();
|
|
276
|
+
if (from && to) {
|
|
277
|
+
entities.add(from);
|
|
278
|
+
entities.add(to);
|
|
279
|
+
relationships.push({ from, to, type: "many_to_one" });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Field list patterns: "X fields: a, b, c" or "X columns: ..."
|
|
283
|
+
for (const match of raw.matchAll(/\b([a-zA-Z][a-zA-Z0-9_-]{1,40})\s+(?:fields|columns|attributes)\s*:\s*([^\n\.]+)(?:\.|\n|$)/gi)) {
|
|
284
|
+
const entity = match[1].trim();
|
|
285
|
+
const list = match[2].trim();
|
|
286
|
+
if (!entity || !list)
|
|
287
|
+
continue;
|
|
288
|
+
entities.add(entity);
|
|
289
|
+
const fields = list
|
|
290
|
+
.split(",")
|
|
291
|
+
.map((s) => s.trim())
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.map((s) => s.replace(/[^a-zA-Z0-9 _-]/g, "").trim())
|
|
294
|
+
.filter(Boolean);
|
|
295
|
+
if (fields.length > 0) {
|
|
296
|
+
fieldsByEntity.set(entity, fields);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
entities: Array.from(entities.values()),
|
|
301
|
+
relationships,
|
|
302
|
+
fieldsByEntity,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
findTableNameForRaw(raw, tablesSpec) {
|
|
306
|
+
const target = this.normalizeLoose(raw);
|
|
307
|
+
const match = tablesSpec.find((t) => this.normalizeLoose(t._raw_name) === target);
|
|
308
|
+
return match?.table_name;
|
|
309
|
+
}
|
|
310
|
+
makeIndexName(table, columns, unique) {
|
|
311
|
+
const base = `${unique ? "uidx" : "idx"}_${table}_${columns.join("_")}`;
|
|
312
|
+
const name = base.replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 64);
|
|
313
|
+
return name;
|
|
314
|
+
}
|
|
315
|
+
generateCreateTableDDL(table, engine, charset, collation) {
|
|
316
|
+
const colLines = [];
|
|
317
|
+
const pk = [];
|
|
318
|
+
const fkLines = [];
|
|
319
|
+
for (const c of table.columns) {
|
|
320
|
+
const nullSql = c.nullable ? "NULL" : "NOT NULL";
|
|
321
|
+
colLines.push(" `" + c.name + "` " + c.type + " " + nullSql);
|
|
322
|
+
if (c.primary_key)
|
|
323
|
+
pk.push(c.name);
|
|
324
|
+
if (c.references) {
|
|
325
|
+
const fkName = `fk_${table.table_name}_${c.name}`.slice(0, 64);
|
|
326
|
+
fkLines.push(" CONSTRAINT `" +
|
|
327
|
+
fkName +
|
|
328
|
+
"` FOREIGN KEY (`" +
|
|
329
|
+
c.name +
|
|
330
|
+
"`) REFERENCES `" +
|
|
331
|
+
c.references.table +
|
|
332
|
+
"`(`" +
|
|
333
|
+
c.references.column +
|
|
334
|
+
"`)");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (pk.length > 0) {
|
|
338
|
+
colLines.push(" PRIMARY KEY (" + pk.map((c) => "`" + c + "`").join(", ") + ")");
|
|
339
|
+
}
|
|
340
|
+
const allLines = [...colLines, ...fkLines];
|
|
341
|
+
return ("CREATE TABLE `" +
|
|
342
|
+
table.table_name +
|
|
343
|
+
"` (\n" +
|
|
344
|
+
allLines.join(",\n") +
|
|
345
|
+
"\n) ENGINE=" +
|
|
346
|
+
engine +
|
|
347
|
+
" DEFAULT CHARSET=" +
|
|
348
|
+
charset +
|
|
349
|
+
" COLLATE=" +
|
|
350
|
+
collation +
|
|
351
|
+
";");
|
|
352
|
+
}
|
|
353
|
+
generateCreateIndexDDL(tableName, idx) {
|
|
354
|
+
const uniq = idx.unique ? "UNIQUE " : "";
|
|
355
|
+
const cols = idx.columns.map((c) => "`" + c + "`").join(", ");
|
|
356
|
+
return "CREATE " + uniq + "INDEX `" + idx.name + "` ON `" + tableName + "` (" + cols + ");";
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
exports.SchemaDesignTools = SchemaDesignTools;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SecurityLayer } from "../security/securityLayer";
|
|
2
|
+
export declare class SchemaPatternTools {
|
|
3
|
+
private db;
|
|
4
|
+
private security;
|
|
5
|
+
constructor(security: SecurityLayer);
|
|
6
|
+
private validateDatabaseAccess;
|
|
7
|
+
/**
|
|
8
|
+
* Recognize common schema patterns and anti-patterns based on INFORMATION_SCHEMA.
|
|
9
|
+
*/
|
|
10
|
+
analyzeSchemaPatterns(params?: {
|
|
11
|
+
scope?: "database" | "table";
|
|
12
|
+
table_name?: string;
|
|
13
|
+
database?: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
status: string;
|
|
16
|
+
data?: any;
|
|
17
|
+
error?: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SchemaPatternTools = void 0;
|
|
7
|
+
const connection_1 = __importDefault(require("../db/connection"));
|
|
8
|
+
const config_1 = require("../config/config");
|
|
9
|
+
class SchemaPatternTools {
|
|
10
|
+
constructor(security) {
|
|
11
|
+
this.db = connection_1.default.getInstance();
|
|
12
|
+
this.security = security;
|
|
13
|
+
}
|
|
14
|
+
validateDatabaseAccess(requestedDatabase) {
|
|
15
|
+
const connectedDatabase = config_1.dbConfig.database;
|
|
16
|
+
if (!connectedDatabase) {
|
|
17
|
+
return {
|
|
18
|
+
valid: false,
|
|
19
|
+
database: "",
|
|
20
|
+
error: "No database configured. Please specify a database in your connection settings.",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (requestedDatabase && requestedDatabase !== connectedDatabase) {
|
|
24
|
+
return {
|
|
25
|
+
valid: false,
|
|
26
|
+
database: "",
|
|
27
|
+
error: `Access denied: You are connected to '${connectedDatabase}' but requested '${requestedDatabase}'. Cross-database access is not permitted.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { valid: true, database: connectedDatabase };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Recognize common schema patterns and anti-patterns based on INFORMATION_SCHEMA.
|
|
34
|
+
*/
|
|
35
|
+
async analyzeSchemaPatterns(params = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const dbValidation = this.validateDatabaseAccess(params?.database);
|
|
38
|
+
if (!dbValidation.valid) {
|
|
39
|
+
return { status: "error", error: dbValidation.error };
|
|
40
|
+
}
|
|
41
|
+
const database = dbValidation.database;
|
|
42
|
+
const scope = params.scope || (params.table_name ? "table" : "database");
|
|
43
|
+
if (scope === "table") {
|
|
44
|
+
if (!params.table_name) {
|
|
45
|
+
return { status: "error", error: "table_name is required for table scope" };
|
|
46
|
+
}
|
|
47
|
+
if (!this.security.validateIdentifier(params.table_name).valid) {
|
|
48
|
+
return { status: "error", error: "Invalid table name" };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const tables = await this.db.query(`
|
|
52
|
+
SELECT TABLE_NAME, TABLE_ROWS
|
|
53
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
54
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
|
55
|
+
${scope === "table" ? "AND TABLE_NAME = ?" : ""}
|
|
56
|
+
ORDER BY TABLE_NAME
|
|
57
|
+
`, scope === "table" ? [database, params.table_name] : [database]);
|
|
58
|
+
if (!tables.length) {
|
|
59
|
+
return {
|
|
60
|
+
status: "success",
|
|
61
|
+
data: {
|
|
62
|
+
database,
|
|
63
|
+
scope,
|
|
64
|
+
tables_analyzed: 0,
|
|
65
|
+
findings: [],
|
|
66
|
+
summary: { high: 0, medium: 0, low: 0 },
|
|
67
|
+
message: "No tables found.",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const tableNames = tables.map((t) => t.TABLE_NAME);
|
|
72
|
+
const placeholders = tableNames.map(() => "?").join(",");
|
|
73
|
+
const columns = await this.db.query(`
|
|
74
|
+
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
|
|
75
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
76
|
+
WHERE TABLE_SCHEMA = ?
|
|
77
|
+
AND TABLE_NAME IN (${placeholders})
|
|
78
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
|
79
|
+
`, [database, ...tableNames]);
|
|
80
|
+
const stats = await this.db.query(`
|
|
81
|
+
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
|
82
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
83
|
+
WHERE TABLE_SCHEMA = ?
|
|
84
|
+
AND TABLE_NAME IN (${placeholders})
|
|
85
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
|
86
|
+
`, [database, ...tableNames]);
|
|
87
|
+
const fks = await this.db.query(`
|
|
88
|
+
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
89
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
|
90
|
+
WHERE TABLE_SCHEMA = ?
|
|
91
|
+
AND TABLE_NAME IN (${placeholders})
|
|
92
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
93
|
+
`, [database, ...tableNames]);
|
|
94
|
+
const columnsByTable = new Map();
|
|
95
|
+
for (const c of columns) {
|
|
96
|
+
if (!columnsByTable.has(c.TABLE_NAME))
|
|
97
|
+
columnsByTable.set(c.TABLE_NAME, []);
|
|
98
|
+
columnsByTable.get(c.TABLE_NAME).push(c);
|
|
99
|
+
}
|
|
100
|
+
const indexesByTable = new Map();
|
|
101
|
+
for (const s of stats) {
|
|
102
|
+
if (!indexesByTable.has(s.TABLE_NAME))
|
|
103
|
+
indexesByTable.set(s.TABLE_NAME, []);
|
|
104
|
+
indexesByTable.get(s.TABLE_NAME).push(s);
|
|
105
|
+
}
|
|
106
|
+
const fksByTable = new Map();
|
|
107
|
+
for (const fk of fks) {
|
|
108
|
+
if (!fksByTable.has(fk.TABLE_NAME))
|
|
109
|
+
fksByTable.set(fk.TABLE_NAME, []);
|
|
110
|
+
fksByTable.get(fk.TABLE_NAME).push(fk);
|
|
111
|
+
}
|
|
112
|
+
const findings = [];
|
|
113
|
+
const isColumnIndexed = (table, column) => {
|
|
114
|
+
const idx = indexesByTable.get(table) || [];
|
|
115
|
+
return idx.some((s) => s.COLUMN_NAME === column);
|
|
116
|
+
};
|
|
117
|
+
for (const t of tables) {
|
|
118
|
+
const tCols = columnsByTable.get(t.TABLE_NAME) || [];
|
|
119
|
+
const tFks = fksByTable.get(t.TABLE_NAME) || [];
|
|
120
|
+
const pkCols = tCols.filter((c) => c.COLUMN_KEY === "PRI");
|
|
121
|
+
if (!pkCols.length) {
|
|
122
|
+
findings.push({
|
|
123
|
+
severity: "high",
|
|
124
|
+
table_name: t.TABLE_NAME,
|
|
125
|
+
pattern: "MISSING_PRIMARY_KEY",
|
|
126
|
+
description: "Table has no PRIMARY KEY.",
|
|
127
|
+
recommendations: [
|
|
128
|
+
"Add a surrogate primary key (e.g., BIGINT AUTO_INCREMENT) or a natural composite key.",
|
|
129
|
+
"Primary keys improve replication, indexing, and ORM compatibility.",
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (tCols.length > 40) {
|
|
134
|
+
findings.push({
|
|
135
|
+
severity: "medium",
|
|
136
|
+
table_name: t.TABLE_NAME,
|
|
137
|
+
pattern: "WIDE_TABLE",
|
|
138
|
+
description: `Table has ${tCols.length} columns, which can indicate overloading multiple concerns.`,
|
|
139
|
+
evidence: { column_count: tCols.length },
|
|
140
|
+
recommendations: [
|
|
141
|
+
"Consider vertical partitioning or splitting into related tables.",
|
|
142
|
+
"Move sparse/optional fields into a separate table.",
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const nullableCount = tCols.filter((c) => c.IS_NULLABLE === "YES").length;
|
|
147
|
+
if (tCols.length > 0 && nullableCount / tCols.length > 0.6) {
|
|
148
|
+
findings.push({
|
|
149
|
+
severity: "medium",
|
|
150
|
+
table_name: t.TABLE_NAME,
|
|
151
|
+
pattern: "HIGH_NULLABILITY",
|
|
152
|
+
description: `More than 60% of columns are NULLable (${nullableCount}/${tCols.length}).`,
|
|
153
|
+
evidence: { nullable_columns: nullableCount, total_columns: tCols.length },
|
|
154
|
+
recommendations: [
|
|
155
|
+
"Review whether columns should be NOT NULL with defaults.",
|
|
156
|
+
"Consider splitting rarely-used columns into another table.",
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Foreign key columns without any index
|
|
161
|
+
for (const fk of tFks) {
|
|
162
|
+
if (!isColumnIndexed(t.TABLE_NAME, fk.COLUMN_NAME)) {
|
|
163
|
+
findings.push({
|
|
164
|
+
severity: "medium",
|
|
165
|
+
table_name: t.TABLE_NAME,
|
|
166
|
+
pattern: "FK_NOT_INDEXED",
|
|
167
|
+
description: `Foreign key column '${fk.COLUMN_NAME}' is not indexed.`,
|
|
168
|
+
evidence: {
|
|
169
|
+
column: fk.COLUMN_NAME,
|
|
170
|
+
references: `${fk.REFERENCED_TABLE_NAME}.${fk.REFERENCED_COLUMN_NAME}`,
|
|
171
|
+
},
|
|
172
|
+
recommendations: [
|
|
173
|
+
`Add an index on '${fk.COLUMN_NAME}' to improve join and delete/update performance.`,
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Heuristic EAV detection: entity_id + attribute_id + value-like
|
|
179
|
+
const colNames = new Set(tCols.map((c) => c.COLUMN_NAME.toLowerCase()));
|
|
180
|
+
const hasEntityId = colNames.has("entity_id") || colNames.has("object_id");
|
|
181
|
+
const hasAttrId = colNames.has("attribute_id") || colNames.has("field_id");
|
|
182
|
+
const hasValue = colNames.has("value") ||
|
|
183
|
+
colNames.has("value_string") ||
|
|
184
|
+
colNames.has("value_int") ||
|
|
185
|
+
colNames.has("value_decimal");
|
|
186
|
+
if (hasEntityId && hasAttrId && hasValue) {
|
|
187
|
+
findings.push({
|
|
188
|
+
severity: "high",
|
|
189
|
+
table_name: t.TABLE_NAME,
|
|
190
|
+
pattern: "EAV_PATTERN",
|
|
191
|
+
description: "Table resembles an Entity-Attribute-Value (EAV) design, which often causes performance and integrity issues.",
|
|
192
|
+
recommendations: [
|
|
193
|
+
"Consider modeling attributes as real columns or separate typed tables.",
|
|
194
|
+
"If EAV is required, ensure strong indexing and constraints on (entity_id, attribute_id).",
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
// Soft delete without index
|
|
199
|
+
const softDeleteCol = tCols.find((c) => ["deleted_at", "is_deleted", "deleted"].includes(c.COLUMN_NAME.toLowerCase()));
|
|
200
|
+
if (softDeleteCol && !isColumnIndexed(t.TABLE_NAME, softDeleteCol.COLUMN_NAME)) {
|
|
201
|
+
findings.push({
|
|
202
|
+
severity: "low",
|
|
203
|
+
table_name: t.TABLE_NAME,
|
|
204
|
+
pattern: "SOFT_DELETE_NOT_INDEXED",
|
|
205
|
+
description: `Soft-delete column '${softDeleteCol.COLUMN_NAME}' exists but is not indexed.`,
|
|
206
|
+
recommendations: [
|
|
207
|
+
`If queries frequently filter on '${softDeleteCol.COLUMN_NAME}', add an index (possibly composite with tenant/account keys).`,
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Potential implicit relationships: *_id columns without FK constraints
|
|
212
|
+
const idCols = tCols
|
|
213
|
+
.filter((c) => c.COLUMN_NAME.toLowerCase().endsWith("_id"))
|
|
214
|
+
.map((c) => c.COLUMN_NAME);
|
|
215
|
+
if (idCols.length > 0 && tFks.length === 0) {
|
|
216
|
+
findings.push({
|
|
217
|
+
severity: "low",
|
|
218
|
+
table_name: t.TABLE_NAME,
|
|
219
|
+
pattern: "IMPLICIT_RELATIONSHIPS",
|
|
220
|
+
description: "Table has *_id columns but no declared foreign keys; this may be intentional, but can reduce integrity and join discoverability.",
|
|
221
|
+
evidence: { id_columns: idCols.slice(0, 10), total_id_columns: idCols.length },
|
|
222
|
+
recommendations: [
|
|
223
|
+
"If appropriate, add foreign keys for critical relationships.",
|
|
224
|
+
"At minimum, ensure *_id columns are indexed.",
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const summary = {
|
|
230
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
231
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
232
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
status: "success",
|
|
236
|
+
data: {
|
|
237
|
+
database,
|
|
238
|
+
scope,
|
|
239
|
+
tables_analyzed: tables.length,
|
|
240
|
+
findings,
|
|
241
|
+
summary,
|
|
242
|
+
notes: [
|
|
243
|
+
"Heuristic-based analysis; review recommendations before changing production schema.",
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return { status: "error", error: error.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
exports.SchemaPatternTools = SchemaPatternTools;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type Severity = "info" | "low" | "medium" | "high" | "critical";
|
|
2
|
+
export declare class SecurityAuditTools {
|
|
3
|
+
private db;
|
|
4
|
+
constructor();
|
|
5
|
+
private validateDatabaseAccess;
|
|
6
|
+
auditDatabaseSecurity(params?: {
|
|
7
|
+
database?: string;
|
|
8
|
+
include_user_account_checks?: boolean;
|
|
9
|
+
include_privilege_checks?: boolean;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
status: string;
|
|
12
|
+
data?: {
|
|
13
|
+
database: string;
|
|
14
|
+
findings: Array<{
|
|
15
|
+
severity: Severity;
|
|
16
|
+
title: string;
|
|
17
|
+
evidence?: string;
|
|
18
|
+
recommendation: string;
|
|
19
|
+
}>;
|
|
20
|
+
summary: {
|
|
21
|
+
critical: number;
|
|
22
|
+
high: number;
|
|
23
|
+
medium: number;
|
|
24
|
+
low: number;
|
|
25
|
+
info: number;
|
|
26
|
+
};
|
|
27
|
+
notes: string[];
|
|
28
|
+
};
|
|
29
|
+
error?: string;
|
|
30
|
+
}>;
|
|
31
|
+
private severityRank;
|
|
32
|
+
private summarizeFindings;
|
|
33
|
+
private asOnOff;
|
|
34
|
+
private asInt;
|
|
35
|
+
private readVariables;
|
|
36
|
+
private tryReadUserAccounts;
|
|
37
|
+
private tryReadPrivilegeSummaries;
|
|
38
|
+
}
|
|
39
|
+
export {};
|