dsh-sql 0.1.1 → 0.2.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/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/adapters.d.ts +1 -1
- package/lib/adapters.js +75 -10
- package/lib/tools.d.ts +2 -0
- package/lib/tools.js +119 -7
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -63,10 +63,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
63
63
|
|
|
64
64
|
## Safety
|
|
65
65
|
|
|
66
|
-
- **
|
|
66
|
+
- **Lexer-grade read-only guard**: sql_query strips strings/comments before validation, then rejects data-modifying CTEs (WITH…DELETE/UPDATE), SELECT INTO, FOR UPDATE/FOR SHARE, PRAGMA assignment, and multi-statement input
|
|
67
67
|
- **Write approval gate**: sql_exec asks for approval by default (mirroring dsh-email's send approval); headless environments without an approval channel are denied
|
|
68
68
|
- **readOnly mode**: lock out writes entirely for production databases
|
|
69
|
-
- **
|
|
69
|
+
- **Streaming row cap**: SQLite iterator / MySQL stream / PostgreSQL portal all stop at maxRows+1, so large queries are never fully materialized; overflow is flagged with truncated
|
|
70
70
|
- **Identifier validation**: table names restricted to alphanumerics and underscores — no schema injection
|
|
71
71
|
- **Secrets stay out of config**: passwords via `DSH_SQL_PASSWORD_<CONNECTION>` env vars
|
|
72
72
|
|
|
@@ -80,7 +80,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
82
|
pnpm install
|
|
83
|
-
pnpm test # build +
|
|
83
|
+
pnpm test # build + 33 tests, including a real SQLite integration suite
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## License
|
package/README.md
CHANGED
|
@@ -63,10 +63,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
63
63
|
|
|
64
64
|
## 安全设计
|
|
65
65
|
|
|
66
|
-
-
|
|
66
|
+
- **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句
|
|
67
67
|
- **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
|
|
68
68
|
- **readOnly 模式**:生产库可整体禁用写
|
|
69
|
-
-
|
|
69
|
+
- **流式行数钳制**:SQLite 迭代器 / MySQL stream / PostgreSQL portal 都按 maxRows+1 停表,大查询不会全量载入内存,超量标记 truncated
|
|
70
70
|
- **标识符校验**:表名只允许字母/数字/下划线,杜绝 schema 注入
|
|
71
71
|
- **密钥不落配置**:密码支持 `DSH_SQL_PASSWORD_<连接名>` 环境变量
|
|
72
72
|
|
|
@@ -80,7 +80,7 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
82
|
pnpm install
|
|
83
|
-
pnpm test # 构建 +
|
|
83
|
+
pnpm test # 构建 + 33 个测试(含真实 SQLite 集成)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## License
|
package/lib/adapters.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface DatabaseAdapter {
|
|
|
16
16
|
engine: 'sqlite' | 'mysql' | 'postgres';
|
|
17
17
|
listTables(): Promise<string[]>;
|
|
18
18
|
describeTable(table: string): Promise<ColumnInfo[]>;
|
|
19
|
-
query(sql: string): Promise<QueryResult>;
|
|
19
|
+
query(sql: string, limit?: number): Promise<QueryResult>;
|
|
20
20
|
exec(sql: string): Promise<number>;
|
|
21
21
|
ping(): Promise<void>;
|
|
22
22
|
close(): Promise<void>;
|
package/lib/adapters.js
CHANGED
|
@@ -49,9 +49,21 @@ class SqliteAdapter {
|
|
|
49
49
|
primaryKey: Number(row.pk) === 1,
|
|
50
50
|
}));
|
|
51
51
|
}
|
|
52
|
-
async query(sql) {
|
|
53
|
-
const
|
|
54
|
-
|
|
52
|
+
async query(sql, limit) {
|
|
53
|
+
const statement = this.db.prepare(sql);
|
|
54
|
+
if (limit === undefined || limit <= 0) {
|
|
55
|
+
const rows = statement.all();
|
|
56
|
+
return rowsToColumns(rows);
|
|
57
|
+
}
|
|
58
|
+
const columns = statement.columns().map((column) => column.name);
|
|
59
|
+
const rows = [];
|
|
60
|
+
for (const raw of statement.iterate()) {
|
|
61
|
+
const row = raw;
|
|
62
|
+
rows.push(columns.map((name) => toValue(row[name])));
|
|
63
|
+
if (rows.length >= limit)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
return { columns, rows };
|
|
55
67
|
}
|
|
56
68
|
async exec(sql) {
|
|
57
69
|
const single = sql.replace(/;\s*$/, '').trim();
|
|
@@ -98,9 +110,42 @@ class MysqlAdapter {
|
|
|
98
110
|
primaryKey: String(row.Key ?? '').toUpperCase() === 'PRI',
|
|
99
111
|
}));
|
|
100
112
|
}
|
|
101
|
-
async query(sql) {
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
async query(sql, limit) {
|
|
114
|
+
if (limit === undefined || limit <= 0) {
|
|
115
|
+
const [rows] = await this.pool.query(sql);
|
|
116
|
+
return rowsToColumns(rows);
|
|
117
|
+
}
|
|
118
|
+
const corePool = this.pool.pool;
|
|
119
|
+
return await new Promise((resolve, reject) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
let columns = [];
|
|
122
|
+
const rows = [];
|
|
123
|
+
const stream = corePool.query(sql).stream({ highWaterMark: 64 });
|
|
124
|
+
const finish = () => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
resolve({ columns, rows });
|
|
129
|
+
};
|
|
130
|
+
stream.on('fields', (fields) => {
|
|
131
|
+
columns = fields.map((field) => field.name);
|
|
132
|
+
});
|
|
133
|
+
stream.on('result', (row) => {
|
|
134
|
+
if (columns.length === 0)
|
|
135
|
+
columns = Object.keys(row);
|
|
136
|
+
rows.push(columns.map((name) => toValue(row[name])));
|
|
137
|
+
if (rows.length >= limit)
|
|
138
|
+
stream.destroy();
|
|
139
|
+
});
|
|
140
|
+
stream.on('end', finish);
|
|
141
|
+
stream.on('close', finish);
|
|
142
|
+
stream.on('error', (error) => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
settled = true;
|
|
146
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
104
149
|
}
|
|
105
150
|
async exec(sql) {
|
|
106
151
|
const [result] = await this.pool.query(sql);
|
|
@@ -133,16 +178,36 @@ class PostgresAdapter {
|
|
|
133
178
|
}
|
|
134
179
|
async describeTable(table) {
|
|
135
180
|
const name = assertIdentifier(table, '表名');
|
|
136
|
-
const result = await this.pool.query(
|
|
181
|
+
const result = await this.pool.query(`SELECT c.column_name, c.data_type, c.is_nullable,
|
|
182
|
+
EXISTS (
|
|
183
|
+
SELECT 1
|
|
184
|
+
FROM information_schema.table_constraints tc
|
|
185
|
+
JOIN information_schema.key_column_usage kcu
|
|
186
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
187
|
+
AND tc.table_schema = kcu.table_schema
|
|
188
|
+
WHERE tc.table_schema = c.table_schema
|
|
189
|
+
AND tc.table_name = c.table_name
|
|
190
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
191
|
+
AND kcu.column_name = c.column_name
|
|
192
|
+
) AS is_primary
|
|
193
|
+
FROM information_schema.columns c
|
|
194
|
+
WHERE c.table_schema = 'public'
|
|
195
|
+
AND c.table_name = $1
|
|
196
|
+
ORDER BY c.ordinal_position`, [name]);
|
|
137
197
|
return result.rows.map((row) => ({
|
|
138
198
|
name: String(row.column_name),
|
|
139
199
|
type: String(row.data_type ?? ''),
|
|
140
200
|
notNull: String(row.is_nullable) === 'NO',
|
|
141
|
-
primaryKey:
|
|
201
|
+
primaryKey: row.is_primary === true,
|
|
142
202
|
}));
|
|
143
203
|
}
|
|
144
|
-
async query(sql) {
|
|
145
|
-
|
|
204
|
+
async query(sql, limit) {
|
|
205
|
+
if (limit === undefined || limit <= 0) {
|
|
206
|
+
const result = await this.pool.query(sql);
|
|
207
|
+
const rows = result.rows;
|
|
208
|
+
return rowsToColumns(rows);
|
|
209
|
+
}
|
|
210
|
+
const result = await this.pool.query({ text: sql, values: [], rows: limit });
|
|
146
211
|
const rows = result.rows;
|
|
147
212
|
return rowsToColumns(rows);
|
|
148
213
|
}
|
package/lib/tools.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface SqlToolDefinition {
|
|
|
27
27
|
gate?(exec: unknown, next: () => Promise<unknown>): Promise<unknown>;
|
|
28
28
|
timeoutMs?: number;
|
|
29
29
|
}
|
|
30
|
+
/** 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句。 */
|
|
31
|
+
export declare function assertReadQuery(sql: string): string;
|
|
30
32
|
/** 审批执行上下文的最小面。 */
|
|
31
33
|
export interface SqlExecGateContext {
|
|
32
34
|
agent?: unknown;
|
package/lib/tools.js
CHANGED
|
@@ -34,16 +34,128 @@ function requiredString(args, key, label) {
|
|
|
34
34
|
}
|
|
35
35
|
/** 只读语句关键字白名单。 */
|
|
36
36
|
const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
|
|
37
|
-
/**
|
|
38
|
-
function
|
|
37
|
+
/** 去掉字符串、引号标识符与注释,保留真实 SQL 关键字与分号。 */
|
|
38
|
+
function stripSqlNoise(sql) {
|
|
39
|
+
let out = '';
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < sql.length) {
|
|
42
|
+
const ch = sql[i];
|
|
43
|
+
const next = sql[i + 1];
|
|
44
|
+
if (ch === '-' && next === '-') {
|
|
45
|
+
i += 2;
|
|
46
|
+
while (i < sql.length && sql[i] !== '\n')
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '/' && next === '*') {
|
|
51
|
+
i += 2;
|
|
52
|
+
while (i + 1 < sql.length && !(sql[i] === '*' && sql[i + 1] === '/'))
|
|
53
|
+
i += 1;
|
|
54
|
+
i += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '#' && (i === 0 || /\s/.test(sql[i - 1]))) {
|
|
58
|
+
if (/^#(?:>>?|-)/.test(sql.slice(i))) {
|
|
59
|
+
out += ch;
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
i += 1;
|
|
64
|
+
while (i < sql.length && sql[i] !== '\n')
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === "'") {
|
|
69
|
+
out += ' ';
|
|
70
|
+
i += 1;
|
|
71
|
+
while (i < sql.length) {
|
|
72
|
+
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
73
|
+
i += 2;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (sql[i] === "'") {
|
|
77
|
+
i += 1;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (sql[i] === '\\') {
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
i += 1;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === '"' || ch === '`') {
|
|
89
|
+
out += ' ';
|
|
90
|
+
i += 1;
|
|
91
|
+
while (i < sql.length) {
|
|
92
|
+
if (sql[i] === ch) {
|
|
93
|
+
i += 1;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (sql[i] === '\\') {
|
|
97
|
+
i += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
i += 1;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === '[') {
|
|
105
|
+
out += ' ';
|
|
106
|
+
i += 1;
|
|
107
|
+
while (i < sql.length && sql[i] !== ']')
|
|
108
|
+
i += 1;
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ch === '$') {
|
|
113
|
+
const dollar = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
|
|
114
|
+
if (dollar !== null) {
|
|
115
|
+
out += ' ';
|
|
116
|
+
i += dollar[0].length;
|
|
117
|
+
const end = sql.indexOf(dollar[0], i);
|
|
118
|
+
i = end === -1 ? sql.length : end + dollar[0].length;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
out += ch;
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** 写操作关键字:出在 SELECT/EXPLAIN/WITH 语句里即拒绝。 */
|
|
128
|
+
const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/gi;
|
|
129
|
+
/** 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句。 */
|
|
130
|
+
export function assertReadQuery(sql) {
|
|
39
131
|
const trimmed = sql.trim();
|
|
40
|
-
|
|
132
|
+
const clean = stripSqlNoise(trimmed);
|
|
133
|
+
const first = /^[a-z]+/i.exec(clean.trim())?.[0]?.toLowerCase() ?? '';
|
|
134
|
+
if (!READ_KEYWORDS.test(clean.trim())) {
|
|
41
135
|
throw new Error('sql_query 只接受只读语句(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。写操作请用 sql_exec。');
|
|
42
136
|
}
|
|
43
|
-
const statements =
|
|
137
|
+
const statements = clean.split(';').filter((part) => part.trim() !== '');
|
|
44
138
|
if (statements.length > 1)
|
|
45
139
|
throw new Error('sql_query 一次只允许一条语句。');
|
|
46
|
-
|
|
140
|
+
const single = statements[0]?.trim() ?? '';
|
|
141
|
+
if (first === 'pragma') {
|
|
142
|
+
if (/=/.test(single))
|
|
143
|
+
throw new Error('sql_query 不接受带赋值参数的 PRAGMA 写操作(如 PRAGMA journal_mode=WAL),请用 sql_exec。');
|
|
144
|
+
}
|
|
145
|
+
else if (first === 'show' || first === 'describe' || first === 'desc') {
|
|
146
|
+
// SHOW / DESCRIBE 本身只读,不再扫写关键字(避免误伤 SHOW CREATE TABLE)。
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
if (/\binto\b/i.test(single))
|
|
150
|
+
throw new Error('sql_query 不接受 SELECT INTO(写表或 OUTFILE),请用 sql_exec。');
|
|
151
|
+
if (/\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/i.test(single))
|
|
152
|
+
throw new Error('sql_query 不接受 FOR UPDATE/FOR SHARE 行锁查询,请用 sql_exec。');
|
|
153
|
+
const hit = WRITE_KEYWORDS.exec(single);
|
|
154
|
+
if (hit !== null) {
|
|
155
|
+
throw new Error('检测到写操作关键字 ' + hit[0].toUpperCase() + ',sql_query 只接受只读查询。写操作请用 sql_exec。');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return trimmed.replace(/;+\s*$/, '').trim();
|
|
47
159
|
}
|
|
48
160
|
const querySchema = {
|
|
49
161
|
type: 'object',
|
|
@@ -159,7 +271,7 @@ export function buildSqlTools(config) {
|
|
|
159
271
|
};
|
|
160
272
|
const sqlQuery = {
|
|
161
273
|
name: 'sql_query',
|
|
162
|
-
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH
|
|
274
|
+
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验会拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、PRAGMA 赋值与多语句。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
|
|
163
275
|
parameters: compileParameters({
|
|
164
276
|
sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
|
|
165
277
|
connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
|
|
@@ -182,7 +294,7 @@ export function buildSqlTools(config) {
|
|
|
182
294
|
const args = asRecord(rawArgs);
|
|
183
295
|
const sql = assertReadQuery(requiredString(args, 'sql', 'SQL 语句'));
|
|
184
296
|
const { adapter, name } = getAdapter(optionalString(args, 'connection'));
|
|
185
|
-
const result = await adapter.query(sql);
|
|
297
|
+
const result = await adapter.query(sql, cfg.maxRows + 1);
|
|
186
298
|
const total = result.rows.length;
|
|
187
299
|
const rows = result.rows.slice(0, cfg.maxRows);
|
|
188
300
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH 工程师级数据库插件:sql_list/query/exec/schema 四工具,SQLite/MySQL/PostgreSQL 三引擎、多连接、只读模式与写操作审批门、词法级只读保护与流式行数钳制。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|