@nifrajs/mcp-db 5.5.1 → 5.7.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 +26 -0
- package/README.md +10 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +25 -4
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -37
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +4 -0
- package/dist/internal/pg-exec.d.ts.map +1 -1
- package/dist/internal/pg-exec.js +9 -3
- package/dist/internal/pg-exec.js.map +1 -1
- package/dist/internal/pg-lexer.d.ts +10 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -1
- package/dist/internal/pg-lexer.js +103 -0
- package/dist/internal/pg-lexer.js.map +1 -1
- package/dist/internal/shared.d.ts +5 -4
- package/dist/internal/shared.d.ts.map +1 -1
- package/dist/internal/shared.js +91 -113
- package/dist/internal/shared.js.map +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +14 -5
- package/dist/postgres.js.map +1 -1
- package/package.json +2 -2
- package/src/engine.ts +24 -3
- package/src/index.ts +57 -49
- package/src/internal/pg-exec.ts +9 -3
- package/src/internal/pg-lexer.ts +104 -0
- package/src/internal/shared.ts +88 -107
- package/src/postgres.ts +25 -5
package/src/internal/shared.ts
CHANGED
|
@@ -110,13 +110,83 @@ export function isDbRefusal(value: unknown): value is DbRefusal {
|
|
|
110
110
|
// Statement gates
|
|
111
111
|
// ---------------------------------------------------------------------------------------------------
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
// SQLite's four quotings, by opening delimiter: a 'literal' and the "x", `x` and [x] identifiers. Only
|
|
114
|
+
// the bracket form has no doubled-delimiter escape.
|
|
115
|
+
const QUOTE_CLOSE = new Map([
|
|
116
|
+
["'", "'"],
|
|
117
|
+
['"', '"'],
|
|
118
|
+
["`", "`"],
|
|
119
|
+
["[", "]"],
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
/** The index just past the quoted run opening at `start`, or the text's length when it never closes. */
|
|
123
|
+
function quotedEnd(sql: string, start: number, open: string, close: string): number {
|
|
124
|
+
for (let i = start + 1; i < sql.length; i++) {
|
|
125
|
+
if (sql[i] !== close) continue
|
|
126
|
+
if (open !== "[" && sql[i + 1] === close) i++
|
|
127
|
+
else return i + 1
|
|
128
|
+
}
|
|
129
|
+
return sql.length
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The index just past a comment opening at `start`, or `undefined` when none opens there. An open
|
|
133
|
+
* block comment runs to the end of the text, as SQLite reads it. */
|
|
134
|
+
function commentEnd(sql: string, start: number): number | undefined {
|
|
135
|
+
if (sql[start] === "-" && sql[start + 1] === "-") {
|
|
136
|
+
const end = sql.indexOf("\n", start + 2)
|
|
137
|
+
return end === -1 ? sql.length : end + 1
|
|
138
|
+
}
|
|
139
|
+
if (sql[start] === "/" && sql[start + 1] === "*") {
|
|
140
|
+
const end = sql.indexOf("*/", start + 2)
|
|
141
|
+
return end === -1 ? sql.length : end + 2
|
|
142
|
+
}
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `sql` with each literal and quoted identifier emptied and each comment turned into a space. */
|
|
147
|
+
function stripSqlNoise(sql: string): string {
|
|
148
|
+
let out = ""
|
|
149
|
+
let from = 0
|
|
150
|
+
let i = 0
|
|
151
|
+
while (i < sql.length) {
|
|
152
|
+
const char = sql.charAt(i)
|
|
153
|
+
const comment = commentEnd(sql, i)
|
|
154
|
+
const close = QUOTE_CLOSE.get(char)
|
|
155
|
+
if (comment !== undefined) {
|
|
156
|
+
out += `${sql.slice(from, i)} `
|
|
157
|
+
i = from = comment
|
|
158
|
+
} else if (close !== undefined) {
|
|
159
|
+
out += `${sql.slice(from, i)}${char}${close}`
|
|
160
|
+
i = from = quotedEnd(sql, i, char, close)
|
|
161
|
+
} else {
|
|
162
|
+
i++
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out + sql.slice(from)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where the statement in `sql` ends: `end` is just past its last character of code (a trailing comment
|
|
170
|
+
* is not code, so the bounded wrapper's closing parenthesis can never land in one), and `terminator` is
|
|
171
|
+
* the index of its first `;` outside a literal, quoted identifier or comment, or -1.
|
|
172
|
+
*/
|
|
173
|
+
function statementEnd(sql: string): { readonly end: number; readonly terminator: number } {
|
|
174
|
+
let end = 0
|
|
175
|
+
let i = 0
|
|
176
|
+
while (i < sql.length) {
|
|
177
|
+
const char = sql.charAt(i)
|
|
178
|
+
if (char === ";") return { end, terminator: i }
|
|
179
|
+
const comment = commentEnd(sql, i)
|
|
180
|
+
if (comment !== undefined) {
|
|
181
|
+
i = comment
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const close = QUOTE_CLOSE.get(char)
|
|
185
|
+
i = close === undefined ? i + 1 : quotedEnd(sql, i, char, close)
|
|
186
|
+
if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r" && char !== "\f") end = i
|
|
187
|
+
}
|
|
188
|
+
return { end, terminator: -1 }
|
|
189
|
+
}
|
|
120
190
|
|
|
121
191
|
// SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
|
|
122
192
|
// an identifier character; leaving it out would end a word early and split one table name into two.
|
|
@@ -167,7 +237,7 @@ function tokenizeSql(sql: string): SqlToken[] {
|
|
|
167
237
|
tokens.push({ kind: "word", value, bare: false })
|
|
168
238
|
}
|
|
169
239
|
while (i < sql.length) {
|
|
170
|
-
const char = sql
|
|
240
|
+
const char = sql.charAt(i)
|
|
171
241
|
if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
|
|
172
242
|
i++
|
|
173
243
|
} else if (char === "-" && sql[i + 1] === "-") {
|
|
@@ -303,7 +373,6 @@ function relationNames(sql: string): string[] {
|
|
|
303
373
|
return [...new Set(names)]
|
|
304
374
|
}
|
|
305
375
|
|
|
306
|
-
/** Reject multi-statement input: allow one terminator only when it is the final character. */
|
|
307
376
|
/**
|
|
308
377
|
* Names a statement defines as CTEs, column lists included (`WITH RECURSIVE c(x) AS (...)`), which
|
|
309
378
|
* {@link gateSqliteStatement}'s relation scan does not see past. Only for callers whose own check of
|
|
@@ -334,50 +403,10 @@ export function sqliteCteNames(sql: string): string[] {
|
|
|
334
403
|
return [...names]
|
|
335
404
|
}
|
|
336
405
|
|
|
406
|
+
/** Reject multi-statement input: allow one terminator only when it is the final character. */
|
|
337
407
|
const isSingleStatement = (sql: string): boolean => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
let blockComment = false
|
|
341
|
-
for (let i = 0; i < sql.length; i++) {
|
|
342
|
-
const char = sql[i]
|
|
343
|
-
const next = sql[i + 1]
|
|
344
|
-
if (lineComment) {
|
|
345
|
-
if (char === "\n") lineComment = false
|
|
346
|
-
continue
|
|
347
|
-
}
|
|
348
|
-
if (blockComment) {
|
|
349
|
-
if (char === "*" && next === "/") {
|
|
350
|
-
blockComment = false
|
|
351
|
-
i++
|
|
352
|
-
}
|
|
353
|
-
continue
|
|
354
|
-
}
|
|
355
|
-
if (quote !== null) {
|
|
356
|
-
if (char === quote) {
|
|
357
|
-
if (next === quote) i++
|
|
358
|
-
else quote = null
|
|
359
|
-
}
|
|
360
|
-
continue
|
|
361
|
-
}
|
|
362
|
-
if (char === "'" || char === '"') {
|
|
363
|
-
quote = char
|
|
364
|
-
continue
|
|
365
|
-
}
|
|
366
|
-
if (char === "-" && next === "-") {
|
|
367
|
-
lineComment = true
|
|
368
|
-
i++
|
|
369
|
-
continue
|
|
370
|
-
}
|
|
371
|
-
if (char === "/" && next === "*") {
|
|
372
|
-
blockComment = true
|
|
373
|
-
i++
|
|
374
|
-
continue
|
|
375
|
-
}
|
|
376
|
-
if (char === ";") {
|
|
377
|
-
return sql.slice(i + 1).trim() === ""
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return true
|
|
408
|
+
const { terminator } = statementEnd(sql)
|
|
409
|
+
return terminator === -1 || sql.slice(terminator + 1).trim() === ""
|
|
381
410
|
}
|
|
382
411
|
|
|
383
412
|
const isReadStatement = (sql: string): boolean => {
|
|
@@ -385,58 +414,8 @@ const isReadStatement = (sql: string): boolean => {
|
|
|
385
414
|
return head.startsWith("select") || head.startsWith("with")
|
|
386
415
|
}
|
|
387
416
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
* wrapper's closing parenthesis. */
|
|
391
|
-
function executableSql(sql: string): string {
|
|
392
|
-
let quote: "'" | '"' | null = null
|
|
393
|
-
let lineComment = false
|
|
394
|
-
let lineCommentStart = -1
|
|
395
|
-
let blockComment = false
|
|
396
|
-
for (let i = 0; i < sql.length; i++) {
|
|
397
|
-
const char = sql[i]
|
|
398
|
-
const next = sql[i + 1]
|
|
399
|
-
if (lineComment) {
|
|
400
|
-
if (char === "\n") {
|
|
401
|
-
lineComment = false
|
|
402
|
-
lineCommentStart = -1
|
|
403
|
-
}
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
if (blockComment) {
|
|
407
|
-
if (char === "*" && next === "/") {
|
|
408
|
-
blockComment = false
|
|
409
|
-
i++
|
|
410
|
-
}
|
|
411
|
-
continue
|
|
412
|
-
}
|
|
413
|
-
if (quote !== null) {
|
|
414
|
-
if (char === quote) {
|
|
415
|
-
if (next === quote) i++
|
|
416
|
-
else quote = null
|
|
417
|
-
}
|
|
418
|
-
continue
|
|
419
|
-
}
|
|
420
|
-
if (char === "'" || char === '"') {
|
|
421
|
-
quote = char
|
|
422
|
-
continue
|
|
423
|
-
}
|
|
424
|
-
if (char === "-" && next === "-") {
|
|
425
|
-
lineComment = true
|
|
426
|
-
lineCommentStart = i
|
|
427
|
-
i++
|
|
428
|
-
continue
|
|
429
|
-
}
|
|
430
|
-
if (char === "/" && next === "*") {
|
|
431
|
-
blockComment = true
|
|
432
|
-
i++
|
|
433
|
-
continue
|
|
434
|
-
}
|
|
435
|
-
if (char === ";") return sql.slice(0, i).trim()
|
|
436
|
-
}
|
|
437
|
-
if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
|
|
438
|
-
return sql.trim()
|
|
439
|
-
}
|
|
417
|
+
/** The statement without its terminator or a trailing comment, literals and identifiers intact. */
|
|
418
|
+
const executableSql = (sql: string): string => sql.slice(0, statementEnd(sql).end)
|
|
440
419
|
|
|
441
420
|
/** The outcome of {@link gateSqliteStatement}. A refusal's `text` is the message `run_query` returns. */
|
|
442
421
|
export type SqliteGate =
|
|
@@ -451,8 +430,8 @@ export type SqliteGate =
|
|
|
451
430
|
/**
|
|
452
431
|
* Gate one statement before SQLite sees it: non-empty, a single statement, SELECT/WITH only, and every
|
|
453
432
|
* relation it names after `FROM`/`JOIN`/`IN` exposed. Returns the statement without its terminator, ready
|
|
454
|
-
* to wrap.
|
|
455
|
-
*
|
|
433
|
+
* to wrap. The compiled program ({@link unexposedProgramRelation}) remains the authority on the
|
|
434
|
+
* tables a statement reads; this check refuses a named relation before SQLite compiles anything.
|
|
456
435
|
*/
|
|
457
436
|
export function gateSqliteStatement(
|
|
458
437
|
input: string,
|
|
@@ -486,7 +465,9 @@ export function gateSqliteStatement(
|
|
|
486
465
|
|
|
487
466
|
/**
|
|
488
467
|
* The first relation an `EXPLAIN QUERY PLAN` result scans or searches that `exposed` rejects, as the
|
|
489
|
-
* plan spells it, or `undefined` when every scanned relation is exposed.
|
|
468
|
+
* plan spells it, or `undefined` when every scanned relation is exposed. The plan names a scan by its
|
|
469
|
+
* alias (`FROM habits h` plans as `SCAN h`, `users AS habits` as `SCAN habits`), so it cannot decide
|
|
470
|
+
* on its own which tables a statement reads: the compiled program names them by root page.
|
|
490
471
|
*/
|
|
491
472
|
export function unexposedPlanRelation(
|
|
492
473
|
planRows: readonly { readonly detail?: unknown }[],
|
package/src/postgres.ts
CHANGED
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
type PostgresClient,
|
|
40
40
|
records,
|
|
41
41
|
} from "./internal/pg-exec.ts"
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
DENIED_FUNCTION_PREFIXES,
|
|
44
|
+
lintPostgres,
|
|
45
|
+
tableAliasLists,
|
|
46
|
+
withoutTerminator,
|
|
47
|
+
} from "./internal/pg-lexer.ts"
|
|
43
48
|
import {
|
|
44
49
|
type DbPlan,
|
|
45
50
|
type DbRedaction,
|
|
@@ -342,9 +347,14 @@ function scopeMatcher(scope: PostgresScope): (relation: PlanRelation) => string
|
|
|
342
347
|
* The plan-level table check: each relation, and every table it inherits from (partitions too). A
|
|
343
348
|
* `pg_*` function in FROM reads server state the way a catalog does - a system view such as
|
|
344
349
|
* `pg_stat_activity` plans as one once the planner drops its catalog joins - so it is out of scope too.
|
|
345
|
-
* With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused
|
|
350
|
+
* With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused,
|
|
351
|
+
* and so is any masked column of a relation `statement` reads when it gives a table a column alias list.
|
|
346
352
|
*/
|
|
347
|
-
function checkScope(
|
|
353
|
+
function checkScope(
|
|
354
|
+
scope: PostgresScope,
|
|
355
|
+
masked: ((column: string) => boolean) | undefined,
|
|
356
|
+
statement: string,
|
|
357
|
+
) {
|
|
348
358
|
const outOfScope = scopeMatcher(scope)
|
|
349
359
|
return async (connection: PgConnection, reads: PlanReads): Promise<DbRefusal | undefined> => {
|
|
350
360
|
for (const fn of reads.functions) {
|
|
@@ -393,6 +403,9 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
|
|
|
393
403
|
if (column === "*") wholeRows.push(relation)
|
|
394
404
|
else if (masked(column)) hits.add(`${relation.name}.${column}`)
|
|
395
405
|
}
|
|
406
|
+
// The plan names an alias-listed scan's columns by the list, which can reuse a real column's name.
|
|
407
|
+
const renamed = tableAliasLists(statement)
|
|
408
|
+
if (renamed.length > 0) wholeRows.push(...reads.relations)
|
|
396
409
|
const listed = [...wholeRows, ...reads.outputs.map((output) => output.relation)]
|
|
397
410
|
if (listed.length > 0) {
|
|
398
411
|
const live = new Map<string, string[]>()
|
|
@@ -423,6 +436,13 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
|
|
|
423
436
|
}
|
|
424
437
|
}
|
|
425
438
|
if (hits.size === 0) return undefined
|
|
439
|
+
const [aliased] = renamed
|
|
440
|
+
if (aliased !== undefined) {
|
|
441
|
+
return dbRefusal(
|
|
442
|
+
"NIFRA_DB_COLUMN_REFUSED",
|
|
443
|
+
`the query renames the columns of ${aliased.table} with the alias list ${aliased.alias}(...), so the plan cannot show which columns it reads, and it reads a relation holding ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}; alias columns in the select list instead`,
|
|
444
|
+
)
|
|
445
|
+
}
|
|
426
446
|
return dbRefusal(
|
|
427
447
|
"NIFRA_DB_COLUMN_REFUSED",
|
|
428
448
|
`the query reads ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}`,
|
|
@@ -465,7 +485,7 @@ export async function queryPostgres(
|
|
|
465
485
|
const fetched = await gated(client, sql, options, (connection, statement) =>
|
|
466
486
|
fetchThroughCursor(connection, statement, {
|
|
467
487
|
maxRows: options.maxRows,
|
|
468
|
-
scope: checkScope(options, options.redaction?.column),
|
|
488
|
+
scope: checkScope(options, options.redaction?.column, statement),
|
|
469
489
|
}),
|
|
470
490
|
)
|
|
471
491
|
if (isDbRefusal(fetched)) return fetched
|
|
@@ -494,7 +514,7 @@ export async function explainPostgres(
|
|
|
494
514
|
const explained = await gated(client, sql, options, (connection, statement) =>
|
|
495
515
|
explainThroughServer(connection, statement, {
|
|
496
516
|
analyze: options.analyze === true,
|
|
497
|
-
scope: checkScope(options, options.redaction?.column),
|
|
517
|
+
scope: checkScope(options, options.redaction?.column, statement),
|
|
498
518
|
}),
|
|
499
519
|
)
|
|
500
520
|
if (isDbRefusal(explained)) return explained
|