dsh-db-tool 0.1.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.
Files changed (41) hide show
  1. package/README.md +66 -0
  2. package/client/README.md +53 -0
  3. package/client/client.js +1128 -0
  4. package/cordis.patch.yml +6 -0
  5. package/dist/adapters/dmdb/index.js +326 -0
  6. package/dist/adapters/gaussdb/index.js +32 -0
  7. package/dist/adapters/index.js +41 -0
  8. package/dist/adapters/mongodb/index.js +508 -0
  9. package/dist/adapters/mysql/index.js +222 -0
  10. package/dist/adapters/oracle/index.js +369 -0
  11. package/dist/adapters/postgresql/index.js +15 -0
  12. package/dist/adapters/redis/index.js +492 -0
  13. package/dist/adapters/sql-shared/common.js +69 -0
  14. package/dist/adapters/sql-shared/pg-like.js +170 -0
  15. package/dist/adapters/sqlite/index.js +106 -0
  16. package/dist/adapters/types.js +1 -0
  17. package/dist/guard/index.js +221 -0
  18. package/dist/http/index.js +396 -0
  19. package/dist/index.js +130 -0
  20. package/dist/manager.js +469 -0
  21. package/dist/script/runner.js +147 -0
  22. package/dist/script/worker.cjs +104 -0
  23. package/dist/store/audit.js +41 -0
  24. package/dist/store/connections.js +167 -0
  25. package/dist/store/grants.js +82 -0
  26. package/dist/store/index.js +41 -0
  27. package/dist/store/io.js +66 -0
  28. package/dist/store/normalize.js +22 -0
  29. package/dist/store/secrets.js +42 -0
  30. package/docs/api-contract.md +76 -0
  31. package/docs/apple-redesign-spec.md +80 -0
  32. package/docs/dsh-market-submission/mengqi1436__dsh-db-tool.yml +6 -0
  33. package/docs/install.md +75 -0
  34. package/docs/review-findings.md +95 -0
  35. package/docs/skill.md +48 -0
  36. package/package.json +70 -0
  37. package/scripts/build-gaussdb.ps1 +72 -0
  38. package/scripts/build-gaussdb.sh +53 -0
  39. package/scripts/diag-profile.mjs +21 -0
  40. package/scripts/verify-host.mjs +53 -0
  41. package/skills/db-admin/SKILL.md +138 -0
@@ -0,0 +1,170 @@
1
+ import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from './common.js';
2
+ function poolConfig(conn) {
3
+ const base = {
4
+ max: 10,
5
+ idleTimeoutMillis: 30000,
6
+ connectionTimeoutMillis: 5000,
7
+ };
8
+ if (conn.url) {
9
+ base.connectionString = conn.url;
10
+ }
11
+ else if (conn.fields) {
12
+ const f = conn.fields;
13
+ base.host = String(f.host ?? '127.0.0.1');
14
+ base.port = Number(f.port ?? 5432);
15
+ base.user = String(f.user ?? f.username ?? 'postgres');
16
+ if (f.password != null)
17
+ base.password = String(f.password);
18
+ if (f.database != null)
19
+ base.database = String(f.database);
20
+ }
21
+ else {
22
+ throw new Error(`${conn.meta.kind} 连接缺少 url 或 fields 配置`);
23
+ }
24
+ if (conn.ssl)
25
+ base.ssl = { rejectUnauthorized: false };
26
+ return base;
27
+ }
28
+ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
29
+ const pool = new Driver.Pool(poolConfig(conn));
30
+ const readOnly = opts?.mode === 'ro';
31
+ if (readOnly) {
32
+ // 服务器级 ro 强制(官方手段):每个新连接会话设为只读事务。
33
+ // fail-closed:SET 失败时 release(err) 让驱动销毁该连接、等待的 acquire 收到错误——
34
+ // 会话级只读是 ro 的最后防线,不允许静默降级成可写连接。
35
+ pool.on('connect', (c) => {
36
+ void c.query('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY').catch((err) => {
37
+ c.release(err instanceof Error ? err : new Error(String(err)));
38
+ });
39
+ });
40
+ // pg 约定:release(err) 若无等待者会 emit pool 'error',不监听会崩进程
41
+ pool.on('error', () => { });
42
+ }
43
+ // 事务激活期间所有 query/execute 路由到同一 client
44
+ let txClient = null;
45
+ const run = (sql, params) => txClient ? txClient.query(sql, params) : pool.query(sql, params);
46
+ const defaultSchema = () => {
47
+ const f = conn.fields;
48
+ return f && typeof f.schema === 'string' && f.schema ? f.schema : 'public';
49
+ };
50
+ function toQueryResult(res) {
51
+ let columns = (res.fields ?? []).map((f) => f.name);
52
+ if (columns.length === 0 && res.rows.length > 0) {
53
+ columns = Object.keys(res.rows[0]);
54
+ }
55
+ const rows = res.rows.map((r) => columns.map((c) => normalizeCell(r[c])));
56
+ return { columns, rows, rowCount: rows.length };
57
+ }
58
+ const tx = {
59
+ begin: () => humanize(`${kind} 事务开启`, async () => {
60
+ if (txClient)
61
+ throw new Error('事务已开启,请勿重复 begin');
62
+ txClient = await pool.connect();
63
+ try {
64
+ await txClient.query('BEGIN');
65
+ }
66
+ catch (e) {
67
+ txClient.release();
68
+ txClient = null;
69
+ throw e;
70
+ }
71
+ }),
72
+ commit: () => humanize(`${kind} 事务提交`, async () => {
73
+ const c = txClient;
74
+ if (!c)
75
+ throw new Error('没有进行中的事务');
76
+ txClient = null;
77
+ try {
78
+ await c.query('COMMIT');
79
+ }
80
+ finally {
81
+ c.release();
82
+ }
83
+ }),
84
+ rollback: () => humanize(`${kind} 事务回滚`, async () => {
85
+ const c = txClient;
86
+ if (!c)
87
+ throw new Error('没有进行中的事务');
88
+ txClient = null;
89
+ try {
90
+ await c.query('ROLLBACK');
91
+ }
92
+ finally {
93
+ c.release();
94
+ }
95
+ }),
96
+ };
97
+ return {
98
+ kind,
99
+ connId: conn.meta.id,
100
+ tx,
101
+ testConnect: () => humanize(`${kind} 连接测试`, async () => {
102
+ const res = await pool.query('SELECT version() AS v');
103
+ const v = res.rows[0]?.v;
104
+ return { ok: true, serverInfo: v == null ? kind : String(v) };
105
+ }),
106
+ query: (sql, params) => humanize(`${kind} 查询失败`, async () => toQueryResult(await run(sql, params ?? []))),
107
+ execute: (statement, params) => humanize(`${kind} 执行失败`, async () => {
108
+ const res = await run(statement, params ?? []);
109
+ const n = res.rowCount;
110
+ return {
111
+ affectedRows: typeof n === 'number' ? n : undefined,
112
+ message: typeof n === 'number' ? `执行成功,受影响 ${n} 行` : '执行成功',
113
+ };
114
+ }),
115
+ listDatabases: () => humanize(`${kind} 列出数据库`, async () => {
116
+ const res = await pool.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname');
117
+ return res.rows.map((r) => String(r.datname));
118
+ }),
119
+ listTables: (database) => humanize(`${kind} 列出表`, async () => {
120
+ const schema = database ? assertIdent(database, 'schema') : defaultSchema();
121
+ const res = await pool.query(`SELECT table_name, table_type FROM information_schema.tables
122
+ WHERE table_schema = $1 ORDER BY table_name`, [schema]);
123
+ return res.rows.map((r) => {
124
+ const rawType = String(r.table_type);
125
+ return {
126
+ name: String(r.table_name),
127
+ type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
128
+ database: schema,
129
+ };
130
+ });
131
+ }),
132
+ describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
133
+ const schema = database ? assertIdent(database, 'schema') : defaultSchema();
134
+ const tbl = assertIdent(table, '表名');
135
+ const res = await pool.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
136
+ col_description((quote_ident($1) || '.' || quote_ident($2))::regclass, c.ordinal_position) AS col_comment
137
+ FROM information_schema.columns c
138
+ WHERE c.table_schema = $1 AND c.table_name = $2
139
+ ORDER BY c.ordinal_position`, [schema, tbl]);
140
+ if (res.rows.length === 0)
141
+ throw new Error(`表不存在: ${schema}.${tbl}`);
142
+ const pks = await pool.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
143
+ JOIN information_schema.key_column_usage kcu
144
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
145
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`, [schema, tbl]);
146
+ const pkSet = new Set(pks.rows.map((r) => String(r.column_name)));
147
+ return res.rows.map((r) => {
148
+ const len = r.character_maximum_length;
149
+ const base = String(r.data_type);
150
+ return {
151
+ name: String(r.column_name),
152
+ dataType: len != null ? `${base}(${String(len)})` : base,
153
+ nullable: r.is_nullable === 'YES',
154
+ key: pkSet.has(String(r.column_name)) ? 'PRI' : undefined,
155
+ default: r.column_default == null ? null : String(r.column_default),
156
+ comment: r.col_comment == null ? undefined : String(r.col_comment),
157
+ };
158
+ });
159
+ }),
160
+ previewRows: (table, limit, database, offset) => humanize(`${kind} 预览行`, async () => {
161
+ const schema = database ? assertIdent(database, 'schema') : defaultSchema();
162
+ const tbl = assertIdent(table, '表名');
163
+ const lim = clampLimit(limit);
164
+ const off = clampOffset(offset);
165
+ const res = await run(`SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`, [lim, off]);
166
+ return toQueryResult(res);
167
+ }),
168
+ close: () => humanize(`${kind} 关闭连接`, () => pool.end()),
169
+ };
170
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * SQLite 适配器(better-sqlite3,同步 API 包装 Promise)。
3
+ * fields.database / fields.path 为文件路径;ro 模式 readonly + fileMustExist 打开。
4
+ */
5
+ import Database from 'better-sqlite3';
6
+ import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent } from '../sql-shared/common.js';
7
+ function resolveFile(conn) {
8
+ if (conn.fields) {
9
+ const f = conn.fields;
10
+ const path = f.database ?? f.path ?? f.file;
11
+ if (path != null && String(path) !== '')
12
+ return String(path);
13
+ }
14
+ if (conn.url) {
15
+ return conn.url.startsWith('file://') ? conn.url.slice('file://'.length) : conn.url;
16
+ }
17
+ throw new Error('sqlite 连接缺少文件路径(fields.database / fields.path 或 url)');
18
+ }
19
+ function toQueryResult(raw, columns) {
20
+ return {
21
+ columns,
22
+ rows: raw.map((r) => columns.map((c) => normalizeCell(r[c]))),
23
+ rowCount: raw.length,
24
+ };
25
+ }
26
+ export async function createSqliteAdapter(conn, opts) {
27
+ const file = resolveFile(conn);
28
+ const readOnly = opts?.mode === 'ro';
29
+ let db;
30
+ try {
31
+ db = readOnly
32
+ ? new Database(file, { readonly: true, fileMustExist: true })
33
+ : new Database(file);
34
+ }
35
+ catch (e) {
36
+ const msg = e instanceof Error ? e.message : String(e);
37
+ throw new Error(`sqlite 打开失败 (${file}): ${msg}`);
38
+ }
39
+ return {
40
+ kind: 'sqlite',
41
+ connId: conn.meta.id,
42
+ testConnect: () => humanize('sqlite 连接测试', async () => {
43
+ const row = db.prepare('SELECT sqlite_version() AS v').get();
44
+ return { ok: true, serverInfo: `SQLite ${String(row?.v ?? '')}` };
45
+ }),
46
+ query: (sql, params) => humanize('sqlite 查询失败', async () => {
47
+ const stmt = db.prepare(sql);
48
+ const columns = stmt.columns().map((c) => c.name);
49
+ const raw = stmt.all(...(params ?? []));
50
+ return toQueryResult(raw, columns);
51
+ }),
52
+ execute: (statement, params) => humanize('sqlite 执行失败', async () => {
53
+ const stmt = db.prepare(statement);
54
+ const info = stmt.run(...(params ?? []));
55
+ const n = Number(info.changes);
56
+ return {
57
+ affectedRows: Number.isFinite(n) ? n : undefined,
58
+ message: `执行成功,受影响 ${n} 行`,
59
+ };
60
+ }),
61
+ listDatabases: () => humanize('sqlite 列出数据库', async () => ['main']),
62
+ listTables: () => humanize('sqlite 列出表', async () => {
63
+ const raw = db
64
+ .prepare(`SELECT name, type FROM sqlite_master
65
+ WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name`)
66
+ .all();
67
+ return raw.map((r) => ({
68
+ name: String(r.name),
69
+ type: String(r.type).toUpperCase(),
70
+ }));
71
+ }),
72
+ describeTable: (table) => humanize('sqlite 查看表结构', async () => {
73
+ const tbl = assertIdent(table, '表名');
74
+ const raw = db
75
+ .prepare(`PRAGMA table_info(${quoteIdent(tbl)})`)
76
+ .all();
77
+ if (raw.length === 0)
78
+ throw new Error(`表不存在: ${tbl}`);
79
+ return raw.map((r) => {
80
+ const dflt = r.dflt_value;
81
+ const pk = Number(r.pk) > 0;
82
+ return {
83
+ name: String(r.name),
84
+ dataType: String(r.type ?? ''),
85
+ // 主键按非空展示(SQLite INTEGER PRIMARY KEY 即便 notnull=0 也不可存 NULL)
86
+ nullable: Number(r.notnull) === 0 && !pk,
87
+ key: pk ? 'PRI' : undefined,
88
+ default: dflt == null ? null : String(dflt),
89
+ };
90
+ });
91
+ }),
92
+ previewRows: (table, limit, _database, offset) => humanize('sqlite 预览行', async () => {
93
+ const tbl = quoteIdent(assertIdent(table, '表名'));
94
+ const lim = clampLimit(limit);
95
+ const off = clampOffset(offset);
96
+ const stmt = db.prepare(`SELECT * FROM ${tbl} LIMIT ? OFFSET ?`);
97
+ const columns = stmt.columns().map((c) => c.name);
98
+ const raw = stmt.all(lim, off);
99
+ return toQueryResult(raw, columns);
100
+ }),
101
+ close: () => humanize('sqlite 关闭连接', async () => {
102
+ db.close();
103
+ }),
104
+ };
105
+ }
106
+ export const factory = (conn) => createSqliteAdapter(conn);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 语句风险分级 + 一次性挑战(用户确认)凭据存储。
3
+ *
4
+ * classifyStatement:把任意 kind 的一条语句分为 'danger' | 'warning' | 'none',
5
+ * 并给出中文原因。Redis/Mongo 危险清单直接复用各适配器导出的官方清单;
6
+ * SQL 系按首个关键字 + 语句内危险片段识别。
7
+ */
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { DANGEROUS_COMMANDS, READ_COMMANDS, WRITE_COMMANDS, parseRedisCommand } from '../adapters/redis/index.js';
10
+ import { DANGEROUS_OPS, READ_OPS } from '../adapters/mongodb/index.js';
11
+ /** SQL 读取类首词白名单(大小写不敏感;首词必须命中,否则视为非读) */
12
+ const SQL_READ_HEADS = new Set([
13
+ 'select', 'with', 'show', 'describe', 'desc', 'explain', 'use', 'set', 'help', 'table',
14
+ ]);
15
+ /** DDL:隐式提交、不可回滚 */
16
+ const SQL_DDL_RE = /^\s*(drop|truncate|alter|create|rename|comment|grant|revoke|flashback|purge)\b/i;
17
+ /** DML:修改数据 */
18
+ const SQL_DML_RE = /^\s*(insert|update|delete|merge|replace|upsert|call|do)\b/i;
19
+ /** 维护类(oracle/dm 等:shutdown/startup/analyze 等) */
20
+ const SQL_MAINT_RE = /^\s*(shutdown|startup|archive\s+log|recover|analyze|optimize|check\s+table|repair|lock|unlock|kill|flush)\b/i;
21
+ /** 读取语句内出现即 danger 的片段(多语句、文件读写、全局设置、跨行锁读) */
22
+ const SQL_HIDDEN_DANGER_RE = /[;]\s*\S|into\s+(outfile|dumpfile)|load_file\s*\(|set\s+(global|persist)|select\s+[\s\S]*\bfor\s+update\b/i;
23
+ /** 读语句体内出现写关键字即 danger(WITH...DELETE / EXPLAIN ANALYZE DELETE 等) */
24
+ const SQL_WRITE_BODY_RE = /\b(insert|update|delete|merge|drop|alter|truncate)\b/i;
25
+ /** MongoDB 读操作白名单(复用 mongodb 适配器导出的 READ_OPS,单一事实来源) */
26
+ const MONGO_READ_OPS = READ_OPS;
27
+ /** MongoDB 写操作清单(适配器未导出,此处按 DANGEROUS_OPS 之外的写命令复述;含命令文档形式 insert/update/delete/create) */
28
+ const MONGO_WRITE_OPS = new Set([
29
+ 'insert', 'insertOne', 'insertMany',
30
+ 'updateOne', 'updateMany', 'update', 'replaceOne', 'findOneAndUpdate', 'findAndModify', 'bulkWrite',
31
+ 'deleteOne', 'deleteMany', 'delete',
32
+ 'createCollection', 'create', 'renameCollection', 'mapReduce',
33
+ ]);
34
+ /**
35
+ * 命令文档形式的删集合:{ drop: 'coll' } 语义等同 dropCollection(删整个集合),
36
+ * 但适配器导出的 DANGEROUS_OPS 只含 helper 形式(dropCollection),故此处按 danger 补位。
37
+ */
38
+ const MONGO_DANGEROUS_EXTRA = new Set(['drop']);
39
+ /** 剥掉块注释/行注释(sqlHead 与语句体分析共用;写词藏在注释里不应触发分级) */
40
+ function stripSqlComments(statement) {
41
+ return statement
42
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
43
+ .replace(/--[^\n]*/g, ' ')
44
+ .replace(/#[^\n]*/g, ' ');
45
+ }
46
+ /** 首个 SQL 关键字(剥掉注释与括号) */
47
+ export function sqlHead(statement) {
48
+ const s = stripSqlComments(statement).trim();
49
+ const m = /^[\s(]*([a-zA-Z_]+)/.exec(s);
50
+ return m?.[1]?.toLowerCase() ?? '';
51
+ }
52
+ /** 语句 SHA256(challenge 绑定用;trim 后哈希) */
53
+ export function statementHash(statement) {
54
+ return createHash('sha256').update(statement.trim(), 'utf8').digest('hex');
55
+ }
56
+ /**
57
+ * 语句风险分级。op 指明调用通道:query 通道收到任何写语句一律 danger
58
+ * (只读通道混入写);execute 通道 SELECT 类放行(如 SELECT ... INTO 之外
59
+ * 的合法场景由各适配器会话级只读兜底)。
60
+ */
61
+ export function classifyStatement(kind, statement, op) {
62
+ if (kind === 'redis')
63
+ return classifyRedis(statement, op);
64
+ if (kind === 'mongodb')
65
+ return classifyMongo(statement, op);
66
+ return classifySql(statement, op);
67
+ }
68
+ /* ---------------- SQL 系(mysql/postgresql/gaussdb/sqlite/oracle/dmdb) ---------------- */
69
+ function classifySql(statement, op) {
70
+ const head = sqlHead(statement);
71
+ if (head === '')
72
+ return { level: 'danger', reason: '无法解析语句关键字,按危险语句处理' };
73
+ // 语句体分析一律基于剥注释后的文本:写词藏在注释里不应触发分级(误报),
74
+ // 前导注释也不能使 DDL/DML 锚定失效(漏报——否则 /* x */ DROP 绕过确认)
75
+ const body = stripSqlComments(statement);
76
+ const isRead = SQL_READ_HEADS.has(head);
77
+ if (isRead) {
78
+ if (SQL_HIDDEN_DANGER_RE.test(body)) {
79
+ return { level: 'danger', reason: '读语句包含危险片段(多语句 / 文件读写 / 全局设置 / 锁读),需要确认' };
80
+ }
81
+ // 读头不等于只读:WITH...DELETE / EXPLAIN ANALYZE DELETE 语句体内含写关键字
82
+ if (SQL_WRITE_BODY_RE.test(body)) {
83
+ return { level: 'danger', reason: '读语句体内包含写操作关键字(如 WITH...DELETE / EXPLAIN 写语句),需要确认' };
84
+ }
85
+ return { level: 'none' };
86
+ }
87
+ if (SQL_DDL_RE.test(body))
88
+ return { level: 'danger', reason: 'DDL 不可回滚(隐式提交),可能破坏表结构或数据' };
89
+ if (SQL_DML_RE.test(body)) {
90
+ if (op === 'query')
91
+ return { level: 'danger', reason: '只读通道(query)出现写语句,需要确认' };
92
+ return { level: 'warning', reason: '写操作将修改数据' };
93
+ }
94
+ if (SQL_MAINT_RE.test(body))
95
+ return { level: 'danger', reason: '维护/管理命令影响服务器状态,需要确认' };
96
+ // 未知关键字:execute 通道 warning(适配器只读兜底),query 通道 danger
97
+ if (op === 'query')
98
+ return { level: 'danger', reason: `只读通道(query)出现非读取语句(${head}),需要确认` };
99
+ return { level: 'warning', reason: `未识别的语句类型(${head}),请确认后执行` };
100
+ }
101
+ /* ---------------- Redis ---------------- */
102
+ function classifyRedis(statement, op) {
103
+ const parts = parseRedisCommand(statement);
104
+ const head = (parts[0] ?? '').toUpperCase();
105
+ if (head === '')
106
+ return { level: 'danger', reason: '无法解析 Redis 命令,按危险命令处理' };
107
+ if (DANGEROUS_COMMANDS.has(head)) {
108
+ return { level: 'danger', reason: `Redis 危险命令 ${head},可能清空/重配置实例,需要确认` };
109
+ }
110
+ if (WRITE_COMMANDS.has(head)) {
111
+ if (op === 'query')
112
+ return { level: 'danger', reason: `只读通道(query)出现 Redis 写命令 ${head},需要确认` };
113
+ return { level: 'warning', reason: `Redis 写命令 ${head} 将修改数据` };
114
+ }
115
+ if (READ_COMMANDS.has(head))
116
+ return { level: 'none' };
117
+ if (op === 'query')
118
+ return { level: 'danger', reason: `只读通道(query)出现未识别的 Redis 命令(${head}),需要确认` };
119
+ return { level: 'warning', reason: `未识别的 Redis 命令(${head}),请确认后执行` };
120
+ }
121
+ /* ---------------- MongoDB ---------------- */
122
+ /** MongoDB 命令:JSON 对象,首键为操作名 */
123
+ function classifyMongo(statement, op) {
124
+ let head = '';
125
+ try {
126
+ const parsed = JSON.parse(statement);
127
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
128
+ head = Object.keys(parsed)[0] ?? '';
129
+ }
130
+ }
131
+ catch {
132
+ return { level: 'danger', reason: 'MongoDB 命令不是合法 JSON 对象,按危险命令处理' };
133
+ }
134
+ if (head === '')
135
+ return { level: 'danger', reason: '无法解析 MongoDB 命令,按危险命令处理' };
136
+ if (DANGEROUS_OPS.has(head)) {
137
+ return { level: 'danger', reason: `MongoDB 危险操作 ${head},可能删库/删集合,需要确认` };
138
+ }
139
+ if (MONGO_DANGEROUS_EXTRA.has(head)) {
140
+ return { level: 'danger', reason: `MongoDB 危险操作 ${head}(删除整个集合,等同 dropCollection),需要确认` };
141
+ }
142
+ if (MONGO_WRITE_OPS.has(head)) {
143
+ if (op === 'query')
144
+ return { level: 'danger', reason: `只读通道(query)出现 MongoDB 写操作 ${head},需要确认` };
145
+ return { level: 'warning', reason: `MongoDB 写操作 ${head} 将修改数据` };
146
+ }
147
+ if (MONGO_READ_OPS.has(head))
148
+ return { level: 'none' };
149
+ if (op === 'query')
150
+ return { level: 'danger', reason: `只读通道(query)出现未识别的 MongoDB 操作(${head}),需要确认` };
151
+ // fail-closed:未识别的操作在 execute 通道也不静默放行(与 SQL/Redis 未识别语义对齐)
152
+ return { level: 'warning', reason: `未识别的 MongoDB 操作(${head}),请确认后执行` };
153
+ }
154
+ /**
155
+ * 一次性确认凭据:绑定语句 SHA256、5 分钟过期、定期清理。
156
+ * consume(id, statement):存在 + 未过期 + hash 一致才通过,且立即失效(一次性)。
157
+ */
158
+ export class ChallengeStore {
159
+ ttl;
160
+ nowFn;
161
+ map = new Map();
162
+ timer;
163
+ disposed = false;
164
+ constructor(opts) {
165
+ this.ttl = opts?.ttlMs ?? 5 * 60 * 1000;
166
+ this.nowFn = opts?.now ?? (() => Date.now());
167
+ const interval = opts?.sweepIntervalMs ?? 60 * 1000;
168
+ if (interval > 0) {
169
+ this.timer = setInterval(() => this.sweep(), interval);
170
+ this.timer.unref?.();
171
+ }
172
+ }
173
+ /** 生成绑定语句+作用域的一次性 challenge */
174
+ create(statement, scope) {
175
+ if (this.disposed)
176
+ throw new Error('ChallengeStore 已关闭');
177
+ const now = this.nowFn();
178
+ const ch = {
179
+ id: 'c_' + randomBytes(12).toString('hex'),
180
+ statementHash: statementHash(statement),
181
+ createdAt: now,
182
+ expiresAt: now + this.ttl,
183
+ ...(scope ? { connId: scope.connId, projectKey: scope.projectKey } : {}),
184
+ };
185
+ this.map.set(ch.id, ch);
186
+ return ch;
187
+ }
188
+ /** 一次性消费:存在 + 未过期 + hash 与作用域一致才通过,且立即失效;否则 false(不改状态) */
189
+ consume(id, statement, scope) {
190
+ const ch = this.map.get(id);
191
+ if (!ch)
192
+ return false;
193
+ this.map.delete(id); // 无论成败都取走,防重放探测
194
+ if (this.nowFn() > ch.expiresAt)
195
+ return false;
196
+ if (ch.statementHash !== statementHash(statement))
197
+ return false;
198
+ // 作用域绑定:创建时带 scope 则消费时必须完全一致(防同语句跨连接/跨项目重放)
199
+ if (ch.connId !== undefined && ch.connId !== scope?.connId)
200
+ return false;
201
+ if (ch.projectKey !== undefined && ch.projectKey !== scope?.projectKey)
202
+ return false;
203
+ return true;
204
+ }
205
+ /** 清理过期项 */
206
+ sweep() {
207
+ const now = this.nowFn();
208
+ for (const [id, ch] of this.map) {
209
+ if (now > ch.expiresAt)
210
+ this.map.delete(id);
211
+ }
212
+ }
213
+ dispose() {
214
+ this.disposed = true;
215
+ if (this.timer) {
216
+ clearInterval(this.timer);
217
+ this.timer = undefined;
218
+ }
219
+ this.map.clear();
220
+ }
221
+ }