postgres-scout-mcp 1.0.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/LICENSE +190 -0
- package/README.md +234 -0
- package/bin/cli.js +67 -0
- package/dist/config/environment.js +52 -0
- package/dist/index.js +59 -0
- package/dist/server/setup.js +122 -0
- package/dist/tools/data-quality.js +442 -0
- package/dist/tools/database.js +148 -0
- package/dist/tools/export.js +223 -0
- package/dist/tools/index.js +52 -0
- package/dist/tools/live-monitoring.js +369 -0
- package/dist/tools/maintenance.js +617 -0
- package/dist/tools/monitoring.js +286 -0
- package/dist/tools/mutations.js +410 -0
- package/dist/tools/optimization.js +1094 -0
- package/dist/tools/query.js +138 -0
- package/dist/tools/relationships.js +261 -0
- package/dist/tools/schema.js +253 -0
- package/dist/tools/temporal.js +313 -0
- package/dist/types.js +2 -0
- package/dist/utils/database.js +123 -0
- package/dist/utils/logger.js +73 -0
- package/dist/utils/query-builder.js +180 -0
- package/dist/utils/rate-limiter.js +39 -0
- package/dist/utils/result-formatter.js +42 -0
- package/dist/utils/sanitize.js +525 -0
- package/dist/utils/zod-to-json-schema.js +85 -0
- package/package.json +58 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
requests = [];
|
|
3
|
+
maxRequests;
|
|
4
|
+
windowMs;
|
|
5
|
+
enabled;
|
|
6
|
+
constructor(maxRequests, windowMs, enabled = true) {
|
|
7
|
+
this.maxRequests = maxRequests;
|
|
8
|
+
this.windowMs = windowMs;
|
|
9
|
+
this.enabled = enabled;
|
|
10
|
+
}
|
|
11
|
+
checkLimit() {
|
|
12
|
+
if (!this.enabled)
|
|
13
|
+
return;
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const windowStart = now - this.windowMs;
|
|
16
|
+
this.requests = this.requests.filter(timestamp => timestamp > windowStart);
|
|
17
|
+
if (this.requests.length >= this.maxRequests) {
|
|
18
|
+
const oldestRequest = this.requests[0];
|
|
19
|
+
const resetIn = Math.ceil((oldestRequest + this.windowMs - now) / 1000);
|
|
20
|
+
throw new Error(`Rate limit exceeded. Maximum ${this.maxRequests} requests per ${this.windowMs / 1000} seconds. ` +
|
|
21
|
+
`Try again in ${resetIn} seconds.`);
|
|
22
|
+
}
|
|
23
|
+
this.requests.push(now);
|
|
24
|
+
}
|
|
25
|
+
reset() {
|
|
26
|
+
this.requests = [];
|
|
27
|
+
}
|
|
28
|
+
getStats() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const windowStart = now - this.windowMs;
|
|
31
|
+
const currentRequests = this.requests.filter(timestamp => timestamp > windowStart).length;
|
|
32
|
+
return {
|
|
33
|
+
current: currentRequests,
|
|
34
|
+
max: this.maxRequests,
|
|
35
|
+
windowMs: this.windowMs
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=rate-limiter.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function formatQueryResult(result, executionTimeMs) {
|
|
2
|
+
return {
|
|
3
|
+
rows: result.rows,
|
|
4
|
+
rowCount: result.rowCount || 0,
|
|
5
|
+
fields: result.fields.map(field => ({
|
|
6
|
+
name: field.name,
|
|
7
|
+
dataType: getPostgresTypeName(field.dataTypeID)
|
|
8
|
+
})),
|
|
9
|
+
executionTimeMs
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function formatError(error) {
|
|
13
|
+
if (error instanceof Error) {
|
|
14
|
+
return error.message;
|
|
15
|
+
}
|
|
16
|
+
return String(error);
|
|
17
|
+
}
|
|
18
|
+
function getPostgresTypeName(oid) {
|
|
19
|
+
const typeMap = {
|
|
20
|
+
16: 'bool',
|
|
21
|
+
20: 'int8',
|
|
22
|
+
21: 'int2',
|
|
23
|
+
23: 'int4',
|
|
24
|
+
25: 'text',
|
|
25
|
+
114: 'json',
|
|
26
|
+
1043: 'varchar',
|
|
27
|
+
1082: 'date',
|
|
28
|
+
1083: 'time',
|
|
29
|
+
1114: 'timestamp',
|
|
30
|
+
1184: 'timestamptz',
|
|
31
|
+
1700: 'numeric',
|
|
32
|
+
2950: 'uuid',
|
|
33
|
+
3802: 'jsonb'
|
|
34
|
+
};
|
|
35
|
+
return typeMap[oid] || `oid_${oid}`;
|
|
36
|
+
}
|
|
37
|
+
export function truncateText(text, maxLength = 100) {
|
|
38
|
+
if (text.length <= maxLength)
|
|
39
|
+
return text;
|
|
40
|
+
return text.substring(0, maxLength - 3) + '...';
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=result-formatter.js.map
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
const ALLOWED_READ_ONLY_OPERATIONS = ['SELECT', 'EXPLAIN', 'WITH'];
|
|
2
|
+
const ALLOWED_READ_WRITE_OPERATIONS = [
|
|
3
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE',
|
|
4
|
+
'EXPLAIN', 'WITH'
|
|
5
|
+
];
|
|
6
|
+
const DANGEROUS_PATTERNS = [
|
|
7
|
+
/;\s*DROP\b/i,
|
|
8
|
+
/;\s*DELETE\s+FROM\b/i,
|
|
9
|
+
/;\s*TRUNCATE\b/i,
|
|
10
|
+
/;\s*ALTER\b/i,
|
|
11
|
+
/;\s*INSERT\b/i,
|
|
12
|
+
/;\s*UPDATE\b/i,
|
|
13
|
+
/;\s*CREATE\b/i,
|
|
14
|
+
/;\s*GRANT\b/i,
|
|
15
|
+
/;\s*REVOKE\b/i,
|
|
16
|
+
/--/,
|
|
17
|
+
/\/\*/,
|
|
18
|
+
/\*\//,
|
|
19
|
+
/;\s*EXEC\b/i,
|
|
20
|
+
/;\s*EXECUTE\b/i,
|
|
21
|
+
/xp_/i,
|
|
22
|
+
/UNION\s+(ALL\s+)?SELECT/i
|
|
23
|
+
];
|
|
24
|
+
const QUERY_DANGEROUS_FUNCTIONS = [
|
|
25
|
+
// Filesystem access
|
|
26
|
+
/\bpg_read_file\s*\(/i,
|
|
27
|
+
/\bpg_read_binary_file\s*\(/i,
|
|
28
|
+
/\bpg_ls_dir\s*\(/i,
|
|
29
|
+
/\bpg_ls_logdir\s*\(/i,
|
|
30
|
+
/\bpg_ls_waldir\s*\(/i,
|
|
31
|
+
/\bpg_ls_tmpdir\s*\(/i,
|
|
32
|
+
/\bpg_ls_archive_statusdir\s*\(/i,
|
|
33
|
+
/\bpg_stat_file\s*\(/i,
|
|
34
|
+
// Timing / sleep
|
|
35
|
+
/\bpg_sleep\s*\(/i,
|
|
36
|
+
// Large object API — complete set (R3-004)
|
|
37
|
+
/\blo_import\s*\(/i,
|
|
38
|
+
/\blo_export\s*\(/i,
|
|
39
|
+
/\blo_creat\s*\(/i,
|
|
40
|
+
/\blo_create\s*\(/i,
|
|
41
|
+
/\blo_open\s*\(/i,
|
|
42
|
+
/\blo_close\s*\(/i,
|
|
43
|
+
/\blo_get\s*\(/i,
|
|
44
|
+
/\blo_put\s*\(/i,
|
|
45
|
+
/\blo_from_bytea\s*\(/i,
|
|
46
|
+
/\blo_truncate\s*\(/i,
|
|
47
|
+
/\blo_unlink\s*\(/i,
|
|
48
|
+
/\bloread\s*\(/i,
|
|
49
|
+
/\blowrite\s*\(/i,
|
|
50
|
+
// Remote execution
|
|
51
|
+
/\bdblink\s*\(/i,
|
|
52
|
+
// Configuration
|
|
53
|
+
/\bcurrent_setting\s*\(/i,
|
|
54
|
+
/\bset_config\s*\(/i,
|
|
55
|
+
// XML export (execute arbitrary SQL via string arguments)
|
|
56
|
+
/\bquery_to_xml\s*\(/i,
|
|
57
|
+
/\bquery_to_xml_and_xmlschema\s*\(/i,
|
|
58
|
+
/\btable_to_xml\s*\(/i,
|
|
59
|
+
/\btable_to_xml_and_xmlschema\s*\(/i,
|
|
60
|
+
/\bschema_to_xml\s*\(/i,
|
|
61
|
+
/\bschema_to_xml_and_xmlschema\s*\(/i,
|
|
62
|
+
/\bdatabase_to_xml\s*\(/i,
|
|
63
|
+
/\bdatabase_to_xml_and_xmlschema\s*\(/i,
|
|
64
|
+
/\bcursor_to_xml\s*\(/i,
|
|
65
|
+
// Process control (DoS)
|
|
66
|
+
/\bpg_terminate_backend\s*\(/i,
|
|
67
|
+
/\bpg_cancel_backend\s*\(/i,
|
|
68
|
+
/\bpg_reload_conf\s*\(/i,
|
|
69
|
+
/\bpg_rotate_logfile\s*\(/i,
|
|
70
|
+
// Resource abuse (advisory locks, notifications)
|
|
71
|
+
/\bpg_advisory_lock\s*\(/i,
|
|
72
|
+
/\bpg_advisory_lock_shared\s*\(/i,
|
|
73
|
+
/\bpg_try_advisory_lock\s*\(/i,
|
|
74
|
+
/\bpg_try_advisory_lock_shared\s*\(/i,
|
|
75
|
+
/\bpg_advisory_xact_lock\s*\(/i,
|
|
76
|
+
/\bpg_advisory_xact_lock_shared\s*\(/i,
|
|
77
|
+
/\bpg_notify\s*\(/i,
|
|
78
|
+
// Network topology disclosure (R3-010)
|
|
79
|
+
/\binet_server_addr\s*\(/i,
|
|
80
|
+
/\binet_server_port\s*\(/i,
|
|
81
|
+
/\binet_client_addr\s*\(/i,
|
|
82
|
+
/\binet_client_port\s*\(/i,
|
|
83
|
+
// Server metadata disclosure (R3-012, R3-015)
|
|
84
|
+
/\bpg_export_snapshot\s*\(/i,
|
|
85
|
+
/\bpg_current_logfile\s*\(/i,
|
|
86
|
+
/\bpg_postmaster_start_time\s*\(/i,
|
|
87
|
+
/\bpg_conf_load_time\s*\(/i,
|
|
88
|
+
/\bpg_backend_pid\s*\(/i,
|
|
89
|
+
/\bpg_tablespace_location\s*\(/i,
|
|
90
|
+
// DoS / resource exhaustion (R3-016, R3-017)
|
|
91
|
+
/\bgenerate_series\s*\(/i,
|
|
92
|
+
/\brepeat\s*\(/i,
|
|
93
|
+
// Stats reset (R4-001) — can zero out monitoring data
|
|
94
|
+
/\bpg_stat_reset\s*\(/i,
|
|
95
|
+
/\bpg_stat_reset_shared\s*\(/i,
|
|
96
|
+
/\bpg_stat_reset_single_table_counters\s*\(/i,
|
|
97
|
+
/\bpg_stat_reset_slru\s*\(/i,
|
|
98
|
+
/\bpg_stat_reset_replication_slot\s*\(/i,
|
|
99
|
+
// Sequence manipulation (R4-002) — can alter auto-increment state
|
|
100
|
+
/\bsetval\s*\(/i,
|
|
101
|
+
/\bnextval\s*\(/i,
|
|
102
|
+
// WAL / restore-point / logical replication (R4-003)
|
|
103
|
+
/\bpg_switch_wal\s*\(/i,
|
|
104
|
+
/\bpg_create_restore_point\s*\(/i,
|
|
105
|
+
/\bpg_logical_emit_message\s*\(/i,
|
|
106
|
+
// Info disclosure — server metadata (R4-010)
|
|
107
|
+
/\bversion\s*\(/i,
|
|
108
|
+
// Info disclosure — role identity (R4-011)
|
|
109
|
+
/\bcurrent_user\b/i,
|
|
110
|
+
/\bsession_user\b/i,
|
|
111
|
+
// Info disclosure — physical paths (R4-012)
|
|
112
|
+
/\bpg_relation_filepath\s*\(/i,
|
|
113
|
+
// Info disclosure — WAL/recovery state (R4-013)
|
|
114
|
+
/\bpg_is_in_recovery\s*\(/i,
|
|
115
|
+
/\bpg_last_wal_replay_lsn\s*\(/i,
|
|
116
|
+
/\bpg_current_wal_lsn\s*\(/i,
|
|
117
|
+
// Info disclosure — privilege enumeration (R4-016)
|
|
118
|
+
/\bhas_\w+_privilege\s*\(/i,
|
|
119
|
+
// Info disclosure — transaction IDs (R4-017)
|
|
120
|
+
/\btxid_current\s*\(/i,
|
|
121
|
+
/\btxid_current_snapshot\s*\(/i,
|
|
122
|
+
// DoS — string padding as repeat() alternative (R4-018)
|
|
123
|
+
/\brpad\s*\(/i,
|
|
124
|
+
/\blpad\s*\(/i,
|
|
125
|
+
];
|
|
126
|
+
const SENSITIVE_CATALOGS = [
|
|
127
|
+
/\bpg_shadow\b/i,
|
|
128
|
+
/\bpg_authid\b/i,
|
|
129
|
+
/\bpg_auth_members\b/i,
|
|
130
|
+
/\bpg_hba_file_rules\b/i,
|
|
131
|
+
/\bpg_file_settings\b/i,
|
|
132
|
+
/\bpg_roles\b/i,
|
|
133
|
+
/\bpg_stat_ssl\b/i,
|
|
134
|
+
/\bpg_largeobject\b/i,
|
|
135
|
+
/\bpg_largeobject_metadata\b/i,
|
|
136
|
+
/\bpg_available_extensions\b/i, // R4-015
|
|
137
|
+
];
|
|
138
|
+
const USER_QUERY_SENSITIVE_CATALOGS = [
|
|
139
|
+
/\bpg_settings\b/i,
|
|
140
|
+
/\bpg_stat_activity\b/i,
|
|
141
|
+
/\bpg_stat_replication\b/i,
|
|
142
|
+
/\bpg_stat_gssapi\b/i,
|
|
143
|
+
/\bpg_ident_file_mappings\b/i,
|
|
144
|
+
/\bpg_proc\b/i,
|
|
145
|
+
/\bpg_database\b/i,
|
|
146
|
+
/\bpg_tablespace\b/i,
|
|
147
|
+
/\bpg_prepared_statements\b/i,
|
|
148
|
+
/\binformation_schema\.enabled_roles\b/i,
|
|
149
|
+
/\binformation_schema\.role_table_grants\b/i,
|
|
150
|
+
/\binformation_schema\.applicable_roles\b/i,
|
|
151
|
+
/\binformation_schema\.role_routine_grants\b/i,
|
|
152
|
+
/\bpg_stat_database\b/i, // R4-009
|
|
153
|
+
/\bpg_stat_user_tables\b/i, // R4-014
|
|
154
|
+
];
|
|
155
|
+
const CTE_DATA_MODIFYING_PATTERN = /\bAS\s+(NOT\s+)?MATERIALIZED\s*\(\s*(INSERT|UPDATE|DELETE|TRUNCATE)\b|\bAS\s*\(\s*(INSERT|UPDATE|DELETE|TRUNCATE)\b/i;
|
|
156
|
+
const WHERE_DANGEROUS_PATTERNS = [
|
|
157
|
+
/;\s*\w/i,
|
|
158
|
+
/--/,
|
|
159
|
+
/\/\*/,
|
|
160
|
+
/\*\//,
|
|
161
|
+
/UNION\s+(ALL\s+)?SELECT/i,
|
|
162
|
+
/INTO\s+(OUT|DUMP)FILE/i,
|
|
163
|
+
/LOAD_FILE\s*\(/i,
|
|
164
|
+
/\bSELECT\b/i,
|
|
165
|
+
/\bEXECUTE\b/i,
|
|
166
|
+
/\bCOPY\b/i,
|
|
167
|
+
...QUERY_DANGEROUS_FUNCTIONS,
|
|
168
|
+
];
|
|
169
|
+
const ALLOWED_CTE_MAIN_OPERATIONS = ['SELECT', 'EXPLAIN'];
|
|
170
|
+
function assertNoMatch(patterns, input, message) {
|
|
171
|
+
for (const pattern of patterns) {
|
|
172
|
+
if (pattern.test(input)) {
|
|
173
|
+
throw new Error(message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function assertNoSensitiveCatalogAccess(query) {
|
|
178
|
+
assertNoMatch(USER_QUERY_SENSITIVE_CATALOGS, query, 'Access to sensitive system catalog is not allowed in user queries');
|
|
179
|
+
}
|
|
180
|
+
function isWordChar(c) {
|
|
181
|
+
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c === '_';
|
|
182
|
+
}
|
|
183
|
+
function skipWhitespace(query, pos) {
|
|
184
|
+
while (pos < query.length && /\s/.test(query[pos]))
|
|
185
|
+
pos++;
|
|
186
|
+
return pos;
|
|
187
|
+
}
|
|
188
|
+
function skipDollarQuotedString(query, pos) {
|
|
189
|
+
if (query[pos] !== '$')
|
|
190
|
+
return null;
|
|
191
|
+
// Find the tag: $$ or $tag$
|
|
192
|
+
let tagEnd = pos + 1;
|
|
193
|
+
while (tagEnd < query.length && query[tagEnd] !== '$' && /[a-zA-Z0-9_]/.test(query[tagEnd])) {
|
|
194
|
+
tagEnd++;
|
|
195
|
+
}
|
|
196
|
+
if (tagEnd >= query.length || query[tagEnd] !== '$')
|
|
197
|
+
return null;
|
|
198
|
+
const tag = query.substring(pos, tagEnd + 1); // e.g. "$$" or "$tag$"
|
|
199
|
+
const searchFrom = tagEnd + 1;
|
|
200
|
+
const closeIdx = query.indexOf(tag, searchFrom);
|
|
201
|
+
if (closeIdx === -1)
|
|
202
|
+
return null; // unterminated
|
|
203
|
+
return closeIdx + tag.length;
|
|
204
|
+
}
|
|
205
|
+
function skipSingleQuotedString(query, pos) {
|
|
206
|
+
if (query[pos] !== "'")
|
|
207
|
+
return null;
|
|
208
|
+
// Detect PostgreSQL E-string literals (E'...' with backslash escapes)
|
|
209
|
+
const isEString = pos > 0 && (query[pos - 1] === 'E' || query[pos - 1] === 'e') &&
|
|
210
|
+
(pos < 2 || !isWordChar(query[pos - 2]));
|
|
211
|
+
let i = pos + 1;
|
|
212
|
+
while (i < query.length) {
|
|
213
|
+
if (isEString && query[i] === '\\') {
|
|
214
|
+
i += 2; // skip backslash-escaped character
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (query[i] === "'" && query[i + 1] === "'") {
|
|
218
|
+
i += 2; // doubled quote escape
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (query[i] === "'") {
|
|
222
|
+
return i + 1;
|
|
223
|
+
}
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
return null; // unterminated
|
|
227
|
+
}
|
|
228
|
+
function skipDoubleQuotedIdentifier(query, pos) {
|
|
229
|
+
if (query[pos] !== '"')
|
|
230
|
+
return null;
|
|
231
|
+
let i = pos + 1;
|
|
232
|
+
while (i < query.length) {
|
|
233
|
+
if (query[i] === '"' && query[i + 1] === '"') {
|
|
234
|
+
i += 2; // escaped double quote
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (query[i] === '"') {
|
|
238
|
+
return i + 1;
|
|
239
|
+
}
|
|
240
|
+
i++;
|
|
241
|
+
}
|
|
242
|
+
return null; // unterminated
|
|
243
|
+
}
|
|
244
|
+
function findKeywordAt(query, pos, keyword) {
|
|
245
|
+
const upper = query.toUpperCase();
|
|
246
|
+
if (upper.substring(pos, pos + keyword.length) !== keyword)
|
|
247
|
+
return false;
|
|
248
|
+
if (pos > 0 && isWordChar(query[pos - 1]))
|
|
249
|
+
return false;
|
|
250
|
+
if (pos + keyword.length < query.length && isWordChar(query[pos + keyword.length]))
|
|
251
|
+
return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
function extractMainStatementAfterCTEs(query) {
|
|
255
|
+
const len = query.length;
|
|
256
|
+
let i = 0;
|
|
257
|
+
// Skip past leading "WITH" (and optional "RECURSIVE")
|
|
258
|
+
const withMatch = query.match(/^\s*WITH\s+/i);
|
|
259
|
+
if (!withMatch)
|
|
260
|
+
return null;
|
|
261
|
+
i = withMatch[0].length;
|
|
262
|
+
i = skipWhitespace(query, i);
|
|
263
|
+
if (findKeywordAt(query, i, 'RECURSIVE')) {
|
|
264
|
+
i += 'RECURSIVE'.length;
|
|
265
|
+
i = skipWhitespace(query, i);
|
|
266
|
+
}
|
|
267
|
+
// Process each CTE definition
|
|
268
|
+
while (i < len) {
|
|
269
|
+
// Skip CTE name (identifier)
|
|
270
|
+
i = skipWhitespace(query, i);
|
|
271
|
+
while (i < len && !(/\s/.test(query[i])) && query[i] !== '(' && query[i] !== ',')
|
|
272
|
+
i++;
|
|
273
|
+
i = skipWhitespace(query, i);
|
|
274
|
+
// Skip optional column list: cte(col1, col2)
|
|
275
|
+
if (i < len && query[i] === '(') {
|
|
276
|
+
let depth = 1;
|
|
277
|
+
i++; // skip opening (
|
|
278
|
+
while (i < len && depth > 0) {
|
|
279
|
+
const skipSQ = skipSingleQuotedString(query, i);
|
|
280
|
+
if (skipSQ !== null) {
|
|
281
|
+
i = skipSQ;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const skipDQI = skipDoubleQuotedIdentifier(query, i);
|
|
285
|
+
if (skipDQI !== null) {
|
|
286
|
+
i = skipDQI;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (query[i] === '(')
|
|
290
|
+
depth++;
|
|
291
|
+
else if (query[i] === ')')
|
|
292
|
+
depth--;
|
|
293
|
+
if (depth > 0)
|
|
294
|
+
i++;
|
|
295
|
+
}
|
|
296
|
+
if (depth !== 0)
|
|
297
|
+
return null; // unterminated column list
|
|
298
|
+
i++; // skip closing )
|
|
299
|
+
i = skipWhitespace(query, i);
|
|
300
|
+
}
|
|
301
|
+
// Expect AS keyword
|
|
302
|
+
if (!findKeywordAt(query, i, 'AS'))
|
|
303
|
+
return null;
|
|
304
|
+
i += 2; // skip "AS"
|
|
305
|
+
i = skipWhitespace(query, i);
|
|
306
|
+
// Skip optional NOT MATERIALIZED / MATERIALIZED
|
|
307
|
+
if (findKeywordAt(query, i, 'NOT')) {
|
|
308
|
+
i += 3;
|
|
309
|
+
i = skipWhitespace(query, i);
|
|
310
|
+
if (findKeywordAt(query, i, 'MATERIALIZED')) {
|
|
311
|
+
i += 12;
|
|
312
|
+
i = skipWhitespace(query, i);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (findKeywordAt(query, i, 'MATERIALIZED')) {
|
|
316
|
+
i += 12;
|
|
317
|
+
i = skipWhitespace(query, i);
|
|
318
|
+
}
|
|
319
|
+
// Expect opening ( of CTE body
|
|
320
|
+
if (i >= len || query[i] !== '(')
|
|
321
|
+
return null;
|
|
322
|
+
// Walk through CTE body tracking depth, handling strings and identifiers
|
|
323
|
+
let depth = 1;
|
|
324
|
+
i++; // skip opening (
|
|
325
|
+
while (i < len && depth > 0) {
|
|
326
|
+
const skipSQ = skipSingleQuotedString(query, i);
|
|
327
|
+
if (skipSQ !== null) {
|
|
328
|
+
i = skipSQ;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const skipDollar = skipDollarQuotedString(query, i);
|
|
332
|
+
if (skipDollar !== null) {
|
|
333
|
+
i = skipDollar;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const skipDQI = skipDoubleQuotedIdentifier(query, i);
|
|
337
|
+
if (skipDQI !== null) {
|
|
338
|
+
i = skipDQI;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (query[i] === '(')
|
|
342
|
+
depth++;
|
|
343
|
+
else if (query[i] === ')')
|
|
344
|
+
depth--;
|
|
345
|
+
if (depth > 0)
|
|
346
|
+
i++;
|
|
347
|
+
}
|
|
348
|
+
if (depth !== 0)
|
|
349
|
+
return null; // unterminated CTE body
|
|
350
|
+
i++; // skip closing )
|
|
351
|
+
i = skipWhitespace(query, i);
|
|
352
|
+
// Check for comma (another CTE follows)
|
|
353
|
+
if (i < len && query[i] === ',') {
|
|
354
|
+
i++; // skip comma
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// No comma — what follows is the main statement
|
|
358
|
+
const rest = query.substring(i).trimStart();
|
|
359
|
+
return rest.length > 0 ? rest : null;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
export function sanitizeQuery(query, mode, options) {
|
|
364
|
+
const trimmedQuery = query.trim();
|
|
365
|
+
if (!trimmedQuery) {
|
|
366
|
+
throw new Error('Query cannot be empty');
|
|
367
|
+
}
|
|
368
|
+
const operation = trimmedQuery.split(/\s+/)[0].toUpperCase();
|
|
369
|
+
const allowedOps = mode === 'read-only'
|
|
370
|
+
? ALLOWED_READ_ONLY_OPERATIONS
|
|
371
|
+
: ALLOWED_READ_WRITE_OPERATIONS;
|
|
372
|
+
if (!allowedOps.includes(operation)) {
|
|
373
|
+
throw new Error(`Operation ${operation} not allowed in ${mode} mode. Allowed operations: ${allowedOps.join(', ')}`);
|
|
374
|
+
}
|
|
375
|
+
assertNoMatch(DANGEROUS_PATTERNS, trimmedQuery, 'Potentially dangerous query pattern detected');
|
|
376
|
+
if (!options?.internal) {
|
|
377
|
+
assertNoMatch(QUERY_DANGEROUS_FUNCTIONS, trimmedQuery, 'Potentially dangerous function call detected');
|
|
378
|
+
assertNoMatch(SENSITIVE_CATALOGS, trimmedQuery, 'Access to sensitive system catalog is not allowed');
|
|
379
|
+
}
|
|
380
|
+
if (mode === 'read-only' && CTE_DATA_MODIFYING_PATTERN.test(trimmedQuery)) {
|
|
381
|
+
throw new Error('Data-modifying statements (INSERT, UPDATE, DELETE, TRUNCATE) are not allowed within CTEs in read-only mode');
|
|
382
|
+
}
|
|
383
|
+
if (mode === 'read-only' && operation === 'WITH') {
|
|
384
|
+
const mainStatement = extractMainStatementAfterCTEs(trimmedQuery);
|
|
385
|
+
if (!mainStatement) {
|
|
386
|
+
throw new Error('Unable to determine main statement after CTEs; query not allowed in read-only mode.');
|
|
387
|
+
}
|
|
388
|
+
const mainOp = mainStatement.split(/\s+/)[0].toUpperCase();
|
|
389
|
+
if (!ALLOWED_CTE_MAIN_OPERATIONS.includes(mainOp)) {
|
|
390
|
+
throw new Error(`Operation ${mainOp} not allowed in read-only mode. CTE queries must use a read-only main statement (${ALLOWED_CTE_MAIN_OPERATIONS.join(', ')}).`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (trimmedQuery.includes(';') && trimmedQuery.indexOf(';') !== trimmedQuery.length - 1) {
|
|
394
|
+
throw new Error('Multiple statements not allowed. Use single queries only.');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
export function sanitizeIdentifier(identifier) {
|
|
398
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
|
|
399
|
+
throw new Error(`Invalid identifier: ${identifier}. Must contain only letters, numbers, and underscores, and start with a letter or underscore.`);
|
|
400
|
+
}
|
|
401
|
+
return identifier;
|
|
402
|
+
}
|
|
403
|
+
export function sanitizeSchemaTable(schema, table) {
|
|
404
|
+
return {
|
|
405
|
+
schema: sanitizeIdentifier(schema),
|
|
406
|
+
table: sanitizeIdentifier(table)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
export function escapeIdentifier(identifier) {
|
|
410
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
411
|
+
}
|
|
412
|
+
export function validateUserWhereClause(where) {
|
|
413
|
+
if (!where || !where.trim()) {
|
|
414
|
+
throw new Error('WHERE clause cannot be empty');
|
|
415
|
+
}
|
|
416
|
+
const trimmed = where.trim();
|
|
417
|
+
assertNoMatch(WHERE_DANGEROUS_PATTERNS, trimmed, 'Potentially dangerous pattern detected in WHERE clause');
|
|
418
|
+
const openParens = (trimmed.match(/\(/g) || []).length;
|
|
419
|
+
const closeParens = (trimmed.match(/\)/g) || []).length;
|
|
420
|
+
if (openParens !== closeParens) {
|
|
421
|
+
throw new Error('Unbalanced parentheses in WHERE clause');
|
|
422
|
+
}
|
|
423
|
+
const singleQuotes = (trimmed.match(/'/g) || []).length;
|
|
424
|
+
if (singleQuotes % 2 !== 0) {
|
|
425
|
+
throw new Error('Unbalanced quotes in WHERE clause');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
export function validateCondition(condition) {
|
|
429
|
+
if (!condition || !condition.trim()) {
|
|
430
|
+
throw new Error('Condition cannot be empty');
|
|
431
|
+
}
|
|
432
|
+
const trimmed = condition.trim();
|
|
433
|
+
assertNoMatch(WHERE_DANGEROUS_PATTERNS, trimmed, 'Potentially dangerous pattern detected in condition');
|
|
434
|
+
const openParens = (trimmed.match(/\(/g) || []).length;
|
|
435
|
+
const closeParens = (trimmed.match(/\)/g) || []).length;
|
|
436
|
+
if (openParens !== closeParens) {
|
|
437
|
+
throw new Error('Unbalanced parentheses in condition');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const SHORTHAND_UNITS = {
|
|
441
|
+
s: 'seconds',
|
|
442
|
+
m: 'minutes',
|
|
443
|
+
min: 'minutes',
|
|
444
|
+
h: 'hours',
|
|
445
|
+
hr: 'hours',
|
|
446
|
+
d: 'days',
|
|
447
|
+
w: 'weeks',
|
|
448
|
+
mo: 'months',
|
|
449
|
+
y: 'years',
|
|
450
|
+
yr: 'years',
|
|
451
|
+
};
|
|
452
|
+
export function normalizeInterval(interval) {
|
|
453
|
+
if (!interval || !interval.trim()) {
|
|
454
|
+
throw new Error('Interval cannot be empty');
|
|
455
|
+
}
|
|
456
|
+
const trimmed = interval.trim();
|
|
457
|
+
// Already in full format: "7 days", "2 hours"
|
|
458
|
+
const fullPattern = /^\d+\s+(second|minute|hour|day|week|month|year)s?$/i;
|
|
459
|
+
if (fullPattern.test(trimmed)) {
|
|
460
|
+
return trimmed;
|
|
461
|
+
}
|
|
462
|
+
// Shorthand format: "365d", "30m", "1y", "2h", "3mo", "1yr", "5hr", "10min"
|
|
463
|
+
const shorthandPattern = /^(\d+)(s|min|mo|yr|hr|m|h|d|w|y)$/i;
|
|
464
|
+
const match = trimmed.match(shorthandPattern);
|
|
465
|
+
if (match) {
|
|
466
|
+
const num = match[1];
|
|
467
|
+
const unit = SHORTHAND_UNITS[match[2].toLowerCase()];
|
|
468
|
+
if (unit) {
|
|
469
|
+
return `${num} ${unit}`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
throw new Error(`Invalid interval format: "${interval}". Use format like "7 days", "2 hours", "30 minutes", or shorthand "7d", "2h", "30m"`);
|
|
473
|
+
}
|
|
474
|
+
export function validateInterval(interval) {
|
|
475
|
+
normalizeInterval(interval);
|
|
476
|
+
}
|
|
477
|
+
export function validateOrderBy(orderBy) {
|
|
478
|
+
if (!orderBy || !orderBy.trim()) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const trimmed = orderBy.trim();
|
|
482
|
+
assertNoMatch(WHERE_DANGEROUS_PATTERNS, trimmed, 'Potentially dangerous pattern detected in ORDER BY clause');
|
|
483
|
+
const validOrderPattern = /^[\w"]+(\s+(ASC|DESC))?(,\s*[\w"]+(\s+(ASC|DESC))?)*$/i;
|
|
484
|
+
if (!validOrderPattern.test(trimmed)) {
|
|
485
|
+
throw new Error('Invalid ORDER BY format. Use column names with optional ASC/DESC');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export function parseIntSafe(value, defaultValue) {
|
|
489
|
+
const parsed = parseInt(value, 10);
|
|
490
|
+
if (Number.isNaN(parsed)) {
|
|
491
|
+
return defaultValue;
|
|
492
|
+
}
|
|
493
|
+
return parsed;
|
|
494
|
+
}
|
|
495
|
+
const PG_ERROR_CATEGORIES = [
|
|
496
|
+
{ pattern: /syntax error/i, message: () => 'Query syntax error' },
|
|
497
|
+
{ pattern: /statement timeout/i, message: () => 'Query timed out' },
|
|
498
|
+
{ pattern: /permission denied/i, message: () => 'Permission denied' },
|
|
499
|
+
{ pattern: /does not exist/i, message: () => 'Referenced object does not exist' },
|
|
500
|
+
{ pattern: /already exists/i, message: () => 'Object already exists' },
|
|
501
|
+
{ pattern: /duplicate key/i, message: () => 'Duplicate key violation' },
|
|
502
|
+
{ pattern: /not-null/i, message: () => 'NOT NULL constraint violation' },
|
|
503
|
+
{ pattern: /foreign key/i, message: () => 'Foreign key constraint violation' },
|
|
504
|
+
{ pattern: /check constraint/i, message: () => 'Check constraint violation' },
|
|
505
|
+
{ pattern: /deadlock detected/i, message: () => 'Deadlock detected' },
|
|
506
|
+
{ pattern: /connection refused/i, message: () => 'Database connection refused' },
|
|
507
|
+
{ pattern: /too many connections/i, message: () => 'Too many database connections' },
|
|
508
|
+
{ pattern: /division by zero/i, message: () => 'Division by zero' },
|
|
509
|
+
{ pattern: /invalid input/i, message: () => 'Invalid input value' },
|
|
510
|
+
{ pattern: /out of range/i, message: () => 'Value out of range' },
|
|
511
|
+
{ pattern: /cannot be cast/i, message: () => 'Type cast error' },
|
|
512
|
+
];
|
|
513
|
+
export function sanitizeErrorMessage(error) {
|
|
514
|
+
for (const category of PG_ERROR_CATEGORIES) {
|
|
515
|
+
if (category.pattern.test(error)) {
|
|
516
|
+
return category.message(error.match(category.pattern));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return 'Database operation failed';
|
|
520
|
+
}
|
|
521
|
+
export function sanitizeLogValue(value) {
|
|
522
|
+
const str = typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
|
|
523
|
+
return str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\r\n\t]/g, ' ');
|
|
524
|
+
}
|
|
525
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts a Zod schema to a JSON Schema object suitable for MCP tool inputSchema.
|
|
3
|
+
*/
|
|
4
|
+
export function zodToJsonSchema(schema) {
|
|
5
|
+
return convertZodObject(schema, new Set());
|
|
6
|
+
}
|
|
7
|
+
function convertZodObject(schema, seen) {
|
|
8
|
+
const shape = schema._def?.shape?.() || {};
|
|
9
|
+
const properties = {};
|
|
10
|
+
const required = [];
|
|
11
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
12
|
+
const field = value;
|
|
13
|
+
properties[key] = convertZodType(field, seen);
|
|
14
|
+
if (isRequired(field)) {
|
|
15
|
+
required.push(key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties,
|
|
21
|
+
required: required.length > 0 ? required : undefined
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function isRequired(field) {
|
|
25
|
+
const typeName = field._def?.typeName;
|
|
26
|
+
if (typeName === 'ZodOptional' || typeName === 'ZodDefault')
|
|
27
|
+
return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
function convertZodType(zodType, seen) {
|
|
31
|
+
const typeName = zodType._def?.typeName;
|
|
32
|
+
switch (typeName) {
|
|
33
|
+
case 'ZodString':
|
|
34
|
+
return { type: 'string' };
|
|
35
|
+
case 'ZodNumber':
|
|
36
|
+
return { type: 'number' };
|
|
37
|
+
case 'ZodBoolean':
|
|
38
|
+
return { type: 'boolean' };
|
|
39
|
+
case 'ZodArray':
|
|
40
|
+
return {
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: convertZodType(zodType._def?.type, seen)
|
|
43
|
+
};
|
|
44
|
+
case 'ZodEnum':
|
|
45
|
+
return { type: 'string', enum: zodType._def?.values || [] };
|
|
46
|
+
case 'ZodOptional':
|
|
47
|
+
return convertZodType(zodType._def?.innerType, seen);
|
|
48
|
+
case 'ZodDefault': {
|
|
49
|
+
const inner = convertZodType(zodType._def?.innerType, seen);
|
|
50
|
+
const defaultValue = zodType._def?.defaultValue;
|
|
51
|
+
inner.default = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
|
|
52
|
+
return inner;
|
|
53
|
+
}
|
|
54
|
+
case 'ZodLazy': {
|
|
55
|
+
if (seen.has(zodType))
|
|
56
|
+
return {};
|
|
57
|
+
seen.add(zodType);
|
|
58
|
+
return convertZodType(zodType._def.getter(), seen);
|
|
59
|
+
}
|
|
60
|
+
case 'ZodUnion':
|
|
61
|
+
return {
|
|
62
|
+
anyOf: zodType._def.options.map((o) => convertZodType(o, seen))
|
|
63
|
+
};
|
|
64
|
+
case 'ZodObject':
|
|
65
|
+
return convertZodObject(zodType, seen);
|
|
66
|
+
case 'ZodRecord':
|
|
67
|
+
return { type: 'object' };
|
|
68
|
+
case 'ZodLiteral': {
|
|
69
|
+
const val = zodType._def?.value;
|
|
70
|
+
return { type: typeof val, const: val };
|
|
71
|
+
}
|
|
72
|
+
case 'ZodTuple':
|
|
73
|
+
return {
|
|
74
|
+
type: 'array',
|
|
75
|
+
prefixItems: zodType._def?.items?.map((i) => convertZodType(i, seen))
|
|
76
|
+
};
|
|
77
|
+
case 'ZodEffects':
|
|
78
|
+
return convertZodType(zodType._def?.schema, seen);
|
|
79
|
+
case 'ZodAny':
|
|
80
|
+
return {};
|
|
81
|
+
default:
|
|
82
|
+
return { type: 'string' };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=zod-to-json-schema.js.map
|