@nsyan/db 1.0.0 → 1.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.
- package/README.md +55 -9
- package/index.ts +208 -31
- package/package.json +5 -2
- package/src/config.ts +72 -8
- package/src/core/audit.ts +67 -0
- package/src/core/policy.ts +13 -0
- package/src/core/scan/candidates.ts +11 -5
- package/src/core/scan/parsers.ts +1 -1
- package/src/core/scan/spring.ts +5 -2
- package/src/core/types.ts +13 -2
- package/src/dialects/document-dialect.ts +273 -0
- package/src/dialects/index.ts +1 -0
- package/src/dialects/mongodb.ts +334 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsyan/db",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI 接入数据库扩展 ——
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "AI 接入数据库扩展 —— 五大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/MongoDB/Hive/Spark 九种数据库,提供查询/表结构/扫描建连/连接清单工具给 LLM",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
7
7
|
"pi-package",
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"dm",
|
|
14
14
|
"redis",
|
|
15
15
|
"elasticsearch",
|
|
16
|
+
"mongodb",
|
|
17
|
+
"mongo",
|
|
16
18
|
"hive",
|
|
17
19
|
"spark"
|
|
18
20
|
],
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
"es7": "npm:@elastic/elasticsearch@7",
|
|
44
46
|
"hive-driver": "^1.0.1",
|
|
45
47
|
"ioredis": "^6.0.0",
|
|
48
|
+
"mongodb": "^6.21.0",
|
|
46
49
|
"mysql2": "^3.23.1",
|
|
47
50
|
"oracledb": "^7.0.1",
|
|
48
51
|
"pg": "^8.22.0"
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// config.ts —— 连接配置与插件配置读写(含旧文件名兼容 + mtime 内存缓存)
|
|
2
2
|
// 注:本模块只做 JSON 文件 IO,不依赖任何 pi 运行时模块
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, statSync, chmodSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { registry } from "./dialects/index.js";
|
|
7
|
-
import type { ConnConfig, DbTypeId } from "./core/types.js";
|
|
7
|
+
import type { ConnConfig, DbTypeId, TestConnectionResult } from "./core/types.js";
|
|
8
8
|
|
|
9
9
|
// ── 路径 ──────────────────────────────────────────
|
|
10
10
|
|
|
@@ -23,6 +23,8 @@ export interface PluginConfig {
|
|
|
23
23
|
max_rows: number;
|
|
24
24
|
/** 单条 SQL 超时秒数 */
|
|
25
25
|
query_timeout: number;
|
|
26
|
+
/** 写操作审计日志(v1.1 UX 共识 Q4 修订:默认关闭,/db config 开启;reason 强校验不受此开关影响) */
|
|
27
|
+
audit_enabled: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
@@ -30,6 +32,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
|
|
|
30
32
|
confirm_before_exec: "write",
|
|
31
33
|
max_rows: 100,
|
|
32
34
|
query_timeout: 30,
|
|
35
|
+
audit_enabled: false,
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
// ── mtime 内存缓存 ────────────────────────────────
|
|
@@ -78,6 +81,8 @@ export function loadConfigs(): ConnConfig[] {
|
|
|
78
81
|
|
|
79
82
|
export function saveConfigs(configs: ConnConfig[]): void {
|
|
80
83
|
writeFileSync(CONFIG_FILE, JSON.stringify(configs, null, 2));
|
|
84
|
+
// 降险(v1.1 UX 共识 Q4):配置含明文密码,限制为仅当前用户可读写
|
|
85
|
+
try { chmodSync(CONFIG_FILE, 0o600); } catch { /* 只读文件系统等场景容错 */ }
|
|
81
86
|
invalidateConfigCache(CONFIG_FILE);
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -119,6 +124,7 @@ export function getConfigSummary(cfg: PluginConfig): string {
|
|
|
119
124
|
`执行确认: ${confirmLabel}`,
|
|
120
125
|
`最大行数: ${cfg.max_rows}`,
|
|
121
126
|
`查询超时: ${cfg.query_timeout}s`,
|
|
127
|
+
`审计日志: ${cfg.audit_enabled ? "开" : "关"}`,
|
|
122
128
|
].join("\n");
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -140,6 +146,8 @@ export interface ParsedConnectionString {
|
|
|
140
146
|
password?: string;
|
|
141
147
|
database?: string;
|
|
142
148
|
dbIndex?: number;
|
|
149
|
+
/** URI 查询参数(MongoDB: authSource/replicaSet/tls...) */
|
|
150
|
+
options?: Record<string, string>;
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
export function parseConnectionString(input: string): ParsedConnectionString | null {
|
|
@@ -154,11 +162,70 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
|
|
154
162
|
if (p.password !== undefined) out.password = p.password;
|
|
155
163
|
if (p.database !== undefined) out.database = p.database;
|
|
156
164
|
if (p.dbIndex !== undefined) out.dbIndex = p.dbIndex;
|
|
165
|
+
if (p.options !== undefined) out.options = p.options;
|
|
157
166
|
return out;
|
|
158
167
|
}
|
|
159
168
|
return null;
|
|
160
169
|
}
|
|
161
170
|
|
|
171
|
+
// ── 使用/测试记录(v1.1 UX 共识 Q1:连接列表摘要的数据源)───
|
|
172
|
+
|
|
173
|
+
/** 回写最近使用时间(查询/列表/表结构成功后调用;找不到连接时静默跳过) */
|
|
174
|
+
export function recordLastUsed(configName: string): void {
|
|
175
|
+
const all = loadConfigs();
|
|
176
|
+
const c = all.find((x) => x.name === configName);
|
|
177
|
+
if (!c) return;
|
|
178
|
+
c.lastUsedAt = new Date().toISOString();
|
|
179
|
+
saveConfigs(all);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 回写最近一次测试连接结果(测试连接/编辑后自动测试/向导建连成功时调用) */
|
|
183
|
+
export function recordTestResult(configName: string, r: TestConnectionResult): void {
|
|
184
|
+
const all = loadConfigs();
|
|
185
|
+
const c = all.find((x) => x.name === configName);
|
|
186
|
+
if (!c) return;
|
|
187
|
+
c.lastTest = { ok: r.success, latency: r.latency, version: r.version, at: new Date().toISOString() };
|
|
188
|
+
saveConfigs(all);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 相对时间文案:刚刚 / N 分钟前 / N 小时前 / N 天前 / YYYY-MM-DD */
|
|
192
|
+
export function formatRelativeTime(iso: string): string {
|
|
193
|
+
const t = new Date(iso).getTime();
|
|
194
|
+
if (!Number.isFinite(t)) return "";
|
|
195
|
+
const diff = Date.now() - t;
|
|
196
|
+
const min = Math.floor(diff / 60_000);
|
|
197
|
+
if (min < 1) return "刚刚";
|
|
198
|
+
if (min < 60) return `${min} 分钟前`;
|
|
199
|
+
const hour = Math.floor(min / 60);
|
|
200
|
+
if (hour < 24) return `${hour} 小时前`;
|
|
201
|
+
const day = Math.floor(hour / 24);
|
|
202
|
+
if (day < 30) return `${day} 天前`;
|
|
203
|
+
return iso.slice(0, 10);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 环境标签片段:[prod·强制只读] / [dev];无标签返回空串 */
|
|
207
|
+
export function envTagLabel(c: Pick<ConnConfig, "envTag" | "forceReadonly">): string {
|
|
208
|
+
if (!c.envTag) return "";
|
|
209
|
+
return `[${c.envTag}${c.forceReadonly ? "·强制只读" : ""}]`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 连接摘要行(选择列表/查看列表共用):名称 [类型] ⭐默认 [prod·强制只读] · 上次使用 … · 测试 … */
|
|
213
|
+
export function connSummaryLine(c: ConnConfig): string {
|
|
214
|
+
const parts = [`${c.name} [${shortTypeLabel(c.type)}]`];
|
|
215
|
+
if (c.isDefault) parts.push("⭐默认");
|
|
216
|
+
const tag = envTagLabel(c);
|
|
217
|
+
if (tag) parts.push(tag);
|
|
218
|
+
if (c.lastUsedAt) {
|
|
219
|
+
const rel = formatRelativeTime(c.lastUsedAt);
|
|
220
|
+
if (rel) parts.push(`上次使用 ${rel}`);
|
|
221
|
+
}
|
|
222
|
+
if (c.lastTest) {
|
|
223
|
+
parts.push(`测试 ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ?? ""}`);
|
|
224
|
+
}
|
|
225
|
+
const desc = c.description ? ` - ${c.description}` : "";
|
|
226
|
+
return parts.join(" · ") + desc;
|
|
227
|
+
}
|
|
228
|
+
|
|
162
229
|
// ── 默认连接(Spec §11.1 P0:database 参数可选,缺省走 isDefault 标记的连接)───
|
|
163
230
|
|
|
164
231
|
export function getDefaultConfig(configs: ConnConfig[]): ConnConfig | undefined {
|
|
@@ -171,9 +238,6 @@ export function setDefaultConfig(configs: ConnConfig[], id: string): ConnConfig[
|
|
|
171
238
|
|
|
172
239
|
// ── 查询结果导出(Spec §11.1 P0:长结果落盘不糊上下文)───
|
|
173
240
|
|
|
174
|
-
import { writeFileSync } from "node:fs";
|
|
175
|
-
import { join } from "node:path";
|
|
176
|
-
import { tmpdir } from "node:os";
|
|
177
241
|
import { toCsv } from "./core/export.js";
|
|
178
242
|
|
|
179
243
|
export interface QueryExport {
|
|
@@ -212,9 +276,9 @@ export function toRuntimeConfig(c: ConnConfig, defaultPort: number): ConnConfig
|
|
|
212
276
|
|
|
213
277
|
// 供 UI 层展示类型短标签(原 index.ts 内 5 处重复映射的收敛点之一)
|
|
214
278
|
export function shortTypeLabel(type: DbTypeId): string {
|
|
215
|
-
return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
|
|
279
|
+
return ({ postgresql: "PG", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB" } as Record<string, string>)[type] ?? type;
|
|
216
280
|
}
|
|
217
281
|
|
|
218
282
|
export function fullTypeLabel(type: DbTypeId): string {
|
|
219
|
-
return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle" } as Record<string, string>)[type] ?? type;
|
|
283
|
+
return ({ postgresql: "PostgreSQL", mysql: "MySQL", oracle: "Oracle", mongodb: "MongoDB" } as Record<string, string>)[type] ?? type;
|
|
220
284
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// core/audit.ts —— 写操作审计日志(v1.1 UX 共识 Q4 + 轮转共识:按本地日期一天一个 .jsonl,SQL 存完整原文不截断)
|
|
2
|
+
// 注:本模块只做文件 IO,不依赖任何 pi 运行时模块;审计失败静默降级,绝不阻断查询主流程
|
|
3
|
+
|
|
4
|
+
import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { DbTypeId } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** 审计目录:~/.pi/agent/db-audit/,按天一个 <YYYY-MM-DD>.jsonl */
|
|
10
|
+
export const AUDIT_DIR = join(homedir(), ".pi", "agent", "db-audit");
|
|
11
|
+
|
|
12
|
+
export interface AuditEntry {
|
|
13
|
+
/** ISO 时间 */
|
|
14
|
+
time: string;
|
|
15
|
+
/** 执行时的项目路径(process.cwd()),跨项目区分用 */
|
|
16
|
+
project: string;
|
|
17
|
+
/** 连接名 */
|
|
18
|
+
connection: string;
|
|
19
|
+
/** 数据库类型 */
|
|
20
|
+
type: DbTypeId;
|
|
21
|
+
/** verdict.summary(如 "UPDATE orders(共 1 条语句)") */
|
|
22
|
+
summary: string;
|
|
23
|
+
/** AI 提供的执行理由(全文) */
|
|
24
|
+
reason: string;
|
|
25
|
+
/** 完整 SQL(共识:不截断) */
|
|
26
|
+
sql: string;
|
|
27
|
+
/** 执行时的生效只读标记(写成功恒为 false,保留字段以备语义扩展) */
|
|
28
|
+
readonly: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 本地日期 → YYYY-MM-DD(审计文件按本地天轮转) */
|
|
32
|
+
export function localDateStr(d: Date): string {
|
|
33
|
+
const y = d.getFullYear();
|
|
34
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
35
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
36
|
+
return `${y}-${m}-${day}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 审计文件路径:<dir>/<YYYY-MM-DD>.jsonl(按 time 的本地日期取天) */
|
|
40
|
+
export function auditFilePath(time: Date, dir: string = AUDIT_DIR): string {
|
|
41
|
+
return join(dir, `${localDateStr(time)}.jsonl`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 目录 mkdir / 文件 chmod 仅进程生命周期内首次执行(后续每次追加只剩 1 次 appendFileSync syscall)
|
|
45
|
+
const ensuredDirs = new Set<string>();
|
|
46
|
+
const chmoddedFiles = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 追加一条审计记录到按天轮转的 JSONL 文件(一行一条)。
|
|
50
|
+
* 同步写入保证返回结果前已落盘(单次开销亚毫秒级);失败静默降级不阻断主流程。
|
|
51
|
+
* @param entry 审计条目
|
|
52
|
+
* @param dir 审计目录,默认 AUDIT_DIR(测试可注入临时目录)
|
|
53
|
+
*/
|
|
54
|
+
export function appendAuditLog(entry: AuditEntry, dir: string = AUDIT_DIR): void {
|
|
55
|
+
try {
|
|
56
|
+
if (!ensuredDirs.has(dir)) {
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
ensuredDirs.add(dir);
|
|
59
|
+
}
|
|
60
|
+
const file = auditFilePath(new Date(entry.time), dir);
|
|
61
|
+
appendFileSync(file, JSON.stringify(entry) + "\n", "utf-8");
|
|
62
|
+
if (!chmoddedFiles.has(file)) {
|
|
63
|
+
try { chmodSync(file, 0o600); } catch { /* 平台不支持等场景容错 */ }
|
|
64
|
+
chmoddedFiles.add(file);
|
|
65
|
+
}
|
|
66
|
+
} catch { /* 审计失败不影响主流程 */ }
|
|
67
|
+
}
|
package/src/core/policy.ts
CHANGED
|
@@ -9,3 +9,16 @@ export function decide(verdict: Verdict, _readonly: boolean, confirm: ConfirmMod
|
|
|
9
9
|
if (confirm === "write" && verdict.isWrite) return "confirm";
|
|
10
10
|
return "run";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3;isAllowed/ExecOpts 均用此值) */
|
|
14
|
+
export function effectiveReadonly(aiReadonly: boolean, config: { forceReadonly?: boolean }): boolean {
|
|
15
|
+
return aiReadonly || config.forceReadonly === true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 写操作是否缺失执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦,isWrite 即要求)。
|
|
20
|
+
* 命中时工具层应拒绝执行并引导 AI 补充 reason(动机+影响范围)。
|
|
21
|
+
*/
|
|
22
|
+
export function writeRequiresReason(verdict: Verdict, reason?: string): boolean {
|
|
23
|
+
return verdict.ok === true && verdict.isWrite === true && !(typeof reason === "string" && reason.trim() !== "");
|
|
24
|
+
}
|
|
@@ -20,6 +20,7 @@ interface FieldBag {
|
|
|
20
20
|
username?: string; password?: string;
|
|
21
21
|
database?: string; dbIndex?: number;
|
|
22
22
|
ssl?: boolean;
|
|
23
|
+
options?: Record<string, string>;
|
|
23
24
|
url?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -41,6 +42,7 @@ const REQUIRED: Record<DbTypeId, string[]> = {
|
|
|
41
42
|
spark: ["host", "port", "database", "username"],
|
|
42
43
|
redis: ["host", "port", "password"],
|
|
43
44
|
elasticsearch: ["host", "port"],
|
|
45
|
+
mongodb: ["host", "port"], // 账号/工作库可选(本地无认证常见)
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
// ── 工具函数 ──────────────────────────────────────
|
|
@@ -78,6 +80,7 @@ function dialectFromImage(image: string): DbTypeId | null {
|
|
|
78
80
|
const i = image.toLowerCase();
|
|
79
81
|
if (/postgres/.test(i)) return "postgresql";
|
|
80
82
|
if (/(^|\/)(mysql|mariadb)/.test(i)) return "mysql";
|
|
83
|
+
if (/(^|\/)mongo/.test(i)) return "mongodb"; // mongo / mongodb 镜像(mongo-express 误报可忍变)
|
|
81
84
|
if (/redis/.test(i)) return "redis";
|
|
82
85
|
if (/elasticsearch/.test(i)) return "elasticsearch";
|
|
83
86
|
if (/dm8|dameng/.test(i)) return "dm";
|
|
@@ -109,7 +112,9 @@ export async function scanProject(
|
|
|
109
112
|
const dirEnvCache = new Map<string, Record<string, string> | undefined>();
|
|
110
113
|
|
|
111
114
|
const pushUrl = (url: string, file: string, profile: string, confidence: number, keyFields?: Partial<RawDbConfig>): void => {
|
|
112
|
-
|
|
115
|
+
// 先用完整 URL 路由(MongoDB 的 authSource/replicaSet 等在 query 里,不能剥);
|
|
116
|
+
// 失败再回退剥离 query 的旧路径(兼容 JDBC 带查询参数时各 parseUrl 的锚点匹配)
|
|
117
|
+
const parsed = parseUrlViaRegistry(url) ?? parseUrlViaRegistry(stripQuery(url));
|
|
113
118
|
if (!parsed) return;
|
|
114
119
|
raws.push({
|
|
115
120
|
dialectId: parsed.dialectId,
|
|
@@ -123,6 +128,7 @@ export async function scanProject(
|
|
|
123
128
|
database: parsed.database,
|
|
124
129
|
dbIndex: parsed.dbIndex,
|
|
125
130
|
ssl: parsed.ssl,
|
|
131
|
+
options: parsed.options,
|
|
126
132
|
},
|
|
127
133
|
file, profile, confidence,
|
|
128
134
|
});
|
|
@@ -160,7 +166,7 @@ export async function scanProject(
|
|
|
160
166
|
if (base === ".env" || base.startsWith(".env.")) {
|
|
161
167
|
const env = parseEnv(text);
|
|
162
168
|
for (const [k, v] of Object.entries(env)) {
|
|
163
|
-
if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
|
|
169
|
+
if (/(^|_)(DATABASE_URL|DATASOURCE_URL|REDIS_URL|ELASTICSEARCH_URL|MONGODB_URI|MONGO_URL|DB_URL|JDBC_URL)$|_URL$/i.test(k)) {
|
|
164
170
|
pushUrl(v, file, profile, weight);
|
|
165
171
|
}
|
|
166
172
|
}
|
|
@@ -175,9 +181,9 @@ export async function scanProject(
|
|
|
175
181
|
const bag: FieldBag = {
|
|
176
182
|
host: svc.name, // compose 网络内服务名即主机名
|
|
177
183
|
port: hostPortOf(svc.ports),
|
|
178
|
-
username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"],
|
|
179
|
-
password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"],
|
|
180
|
-
database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"],
|
|
184
|
+
username: svc.env["POSTGRES_USER"] ?? svc.env["MYSQL_USER"] ?? svc.env["ES_USERNAME"] ?? svc.env["MONGO_INITDB_ROOT_USERNAME"],
|
|
185
|
+
password: svc.env["POSTGRES_PASSWORD"] ?? svc.env["MYSQL_ROOT_PASSWORD"] ?? svc.env["MYSQL_PASSWORD"] ?? svc.env["REDIS_PASSWORD"] ?? svc.env["ELASTIC_PASSWORD"] ?? svc.env["MONGO_INITDB_ROOT_PASSWORD"],
|
|
186
|
+
database: svc.env["POSTGRES_DB"] ?? svc.env["MYSQL_DATABASE"] ?? svc.env["MONGO_INITDB_DATABASE"],
|
|
181
187
|
};
|
|
182
188
|
raws.push({ dialectId, bag, file, profile, confidence: weight });
|
|
183
189
|
// compose 里也可能带完整 Spring URL
|
package/src/core/scan/parsers.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function parseCompose(text: string): ComposeService[] {
|
|
|
138
138
|
|
|
139
139
|
// ── 通用 URL 正则(全文件扫描兜底)──────────────────
|
|
140
140
|
|
|
141
|
-
const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
|
|
141
|
+
const URL_RE = /(?:jdbc:(?:postgresql|mysql|oracle|dm|hive2)|rediss?|mongodb\+srv|mongodb|postgresql|mysql):\/\/[^\s"'<>`]+|https?:\/\/[^\s"'<>`]*:9200[^\s"'<>`]*/g;
|
|
142
142
|
|
|
143
143
|
export function extractUrls(text: string): string[] {
|
|
144
144
|
const out = new Set<string>();
|
package/src/core/scan/spring.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch 键映射 + profile 分组
|
|
1
|
+
// scan/spring.ts —— Spring 专属:datasource / data.redis / elasticsearch / data.mongodb 键映射 + profile 分组
|
|
2
2
|
import type { DbTypeId } from "../types.js";
|
|
3
3
|
|
|
4
|
-
export type SpringGroup = "datasource" | "redis" | "es";
|
|
4
|
+
export type SpringGroup = "datasource" | "redis" | "es" | "mongo";
|
|
5
5
|
|
|
6
6
|
export interface RawDbConfig {
|
|
7
7
|
group: SpringGroup;
|
|
@@ -45,11 +45,14 @@ const SPRING_KEYS: Array<[string, Field]> = [
|
|
|
45
45
|
["spring.data.elasticsearch.uris", "url"],
|
|
46
46
|
["spring.data.elasticsearch.username", "username"],
|
|
47
47
|
["spring.data.elasticsearch.password", "password"],
|
|
48
|
+
["spring.data.mongodb.uri", "url"], // Spring Boot 2.x+(含 3.x)
|
|
49
|
+
["spring.mongodb.uri", "url"], // Spring Boot 1.x 旧前缀
|
|
48
50
|
];
|
|
49
51
|
|
|
50
52
|
function groupOf(key: string): SpringGroup {
|
|
51
53
|
if (key.includes("redis")) return "redis";
|
|
52
54
|
if (key.includes("elasticsearch")) return "es";
|
|
55
|
+
if (key.includes("mongodb")) return "mongo"; // 独立分组:避免与 datasource 的 url 字段互相覆盖
|
|
53
56
|
return "datasource";
|
|
54
57
|
}
|
|
55
58
|
|
package/src/core/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// core/types.ts
|
|
2
2
|
export type DbTypeId = "postgresql" | "mysql" | "oracle" | "dm"
|
|
3
|
-
| "redis" | "elasticsearch" | "hive" | "spark";
|
|
4
|
-
export type DbFamily = "relational" | "kv" | "search" | "bigdata";
|
|
3
|
+
| "redis" | "elasticsearch" | "mongodb" | "hive" | "spark";
|
|
4
|
+
export type DbFamily = "relational" | "kv" | "search" | "document" | "bigdata";
|
|
5
5
|
|
|
6
6
|
export interface ConnConfig {
|
|
7
7
|
id: string; name: string; type: DbTypeId;
|
|
@@ -13,7 +13,16 @@ export interface ConnConfig {
|
|
|
13
13
|
dbIndex?: number; // Redis 库号
|
|
14
14
|
apiKey?: string; // ES 预留(二期)
|
|
15
15
|
options?: Record<string, string>; // Hive/Spark 会话变量等(旧文件 extraParams 读取时并入此字段)
|
|
16
|
+
// MongoDB: authSource/replicaSet/tls/authMechanism 等(options.srv=true 标记 SRV 连接)
|
|
16
17
|
isDefault?: boolean; // 默认连接标记(§11.1,G 阶段接线)
|
|
18
|
+
/** 环境标签(v1.1 UX 共识 Q3):dev/test/prod;prod 建议配 forceReadonly */
|
|
19
|
+
envTag?: "dev" | "test" | "prod";
|
|
20
|
+
/** 连接级强制只读:无视全局 ai_readonly,该连接永远只接受查询 */
|
|
21
|
+
forceReadonly?: boolean;
|
|
22
|
+
/** 最近一次成功使用时间(ISO);工具/菜单查询成功后回写 */
|
|
23
|
+
lastUsedAt?: string;
|
|
24
|
+
/** 最近一次测试连接结果(菜单/向导/编辑后自动测试时回写) */
|
|
25
|
+
lastTest?: { ok: boolean; latency?: string; version?: string; at: string };
|
|
17
26
|
createdAt: string;
|
|
18
27
|
}
|
|
19
28
|
|
|
@@ -21,6 +30,8 @@ export interface ParsedTarget {
|
|
|
21
30
|
host: string; port: number;
|
|
22
31
|
username?: string; password?: string;
|
|
23
32
|
database?: string; dbIndex?: number; ssl?: boolean;
|
|
33
|
+
/** URI 查询参数(MongoDB: authSource/replicaSet/tls...),由 parseUrl 解出、建连时回填 */
|
|
34
|
+
options?: Record<string, string>;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export interface DbConnection { type: DbTypeId; client: unknown; close(): Promise<void>; }
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// dialects/document-dialect.ts —— 文档家族基类(JSON 命令信封解析、读写分类、limit 注入、结果拍平)
|
|
2
|
+
// 注:交互语义是"发 JSON 命令文档、拿文档结果"(db.runCommand 形态),与关系型/KV/搜索的
|
|
3
|
+
// 语句·命令·DSL 语义均不同,独立成基类。首个实现为 MongoDB(Spec 共识:Q1 选 JSON 信封)。
|
|
4
|
+
|
|
5
|
+
import type { ConnConfig, DbConnection, ExecOpts, ParsedTarget,
|
|
6
|
+
QueryResult } from "../core/types.js";
|
|
7
|
+
import type { Dialect, Verdict, Fingerprints } from "./dialect.js";
|
|
8
|
+
|
|
9
|
+
// ── 信封分类(读写管控核心,Spec 共识 Q3)──────────────
|
|
10
|
+
// 读白名单 + 写需确认 + 管理/DDL/服务端 JS 恒拒 + 未知命令保守按写(ES parseDsl 同款兜底)
|
|
11
|
+
|
|
12
|
+
/** 读命令白名单(小写;find/count/distinct/aggregate 分类前会先过 JS/写回深扫) */
|
|
13
|
+
const READ_COMMANDS: ReadonlySet<string> = new Set([
|
|
14
|
+
"find", "count", "distinct", "aggregate",
|
|
15
|
+
"collstats", "dbstats", "listcollections", "listindexes", "dataSize",
|
|
16
|
+
"ping", "buildinfo", "hello", "ismaster", "isdbgrid", "serverstatus",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** 恒拒命令前缀(小写匹配;与只读开关无关):DDL/复制集/分片/运维一刀切 */
|
|
20
|
+
const DENY_PREFIXES: readonly string[] = [
|
|
21
|
+
"drop", // drop / dropDatabase / dropIndexes / dropUsers ...
|
|
22
|
+
"create", // create / createIndexes / createUser / createSearchIndexes ...
|
|
23
|
+
"replset", // replSetInitiate / replSetReconfig / replSetStepDown ...
|
|
24
|
+
"addshard", "removeshard", "balancer",
|
|
25
|
+
"eval", // eval / $eval 不走前缀也命中 DENY_COMMANDS,此处兜底变体
|
|
26
|
+
"shutdown", "kill", "fsync", "repair", "compact", "configurefailpoint",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** 恒拒命令(精确小写匹配) */
|
|
30
|
+
const DENY_COMMANDS: ReadonlySet<string> = new Set([
|
|
31
|
+
"collmod", "renamecollection", "converttocapped", "clonecollectionascapped",
|
|
32
|
+
"reindex", "setparameter", "setfeaturecompatibilityversion", "logrotate",
|
|
33
|
+
"enablesharding", "shardcollection", "movechunk", "moveprimary", "split",
|
|
34
|
+
"applyops", "currentop", "clone", "copydb", "clonecollection", "$eval",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** 服务端 JS 执行(任意深度出现即恒拒,Q3 共识) */
|
|
38
|
+
const JS_KEYS: ReadonlySet<string> = new Set(["$where", "$function", "$accumulator"]);
|
|
39
|
+
/** aggregate 写回管道阶段(任意深度出现 → 整条按写分类) */
|
|
40
|
+
const AGG_WRITE_KEYS: ReadonlySet<string> = new Set(["$out", "$merge"]);
|
|
41
|
+
|
|
42
|
+
/** 深度扫描:任意层的对象 key(小写)命中 targets 即 true */
|
|
43
|
+
export function hasAnyKey(node: unknown, targets: ReadonlySet<string>): boolean {
|
|
44
|
+
if (Array.isArray(node)) return node.some((n) => hasAnyKey(n, targets));
|
|
45
|
+
if (node !== null && typeof node === "object") {
|
|
46
|
+
return Object.entries(node as Record<string, unknown>).some(([k, v]) =>
|
|
47
|
+
targets.has(k.toLowerCase()) || hasAnyKey(v, targets));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface EnvelopeClassified {
|
|
53
|
+
envelope?: Record<string, unknown>;
|
|
54
|
+
verdict: Verdict;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解析 + 分类一条命令信封(isAllowed 与 executeOn 共用,保证裁决与执行一致) */
|
|
58
|
+
export function classifyEnvelope(sql: string): EnvelopeClassified {
|
|
59
|
+
let body: unknown;
|
|
60
|
+
try {
|
|
61
|
+
body = JSON.parse(sql.trim());
|
|
62
|
+
} catch {
|
|
63
|
+
return {
|
|
64
|
+
verdict: {
|
|
65
|
+
ok: false,
|
|
66
|
+
reason: "MongoDB 命令信封必须是合法 JSON 对象,如 {\"find\":\"users\",\"filter\":{}}(单命令一次执行,不支持多语句)",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
71
|
+
return {
|
|
72
|
+
verdict: { ok: false, reason: "命令信封必须是 JSON 对象(顶层 key 为命令名),如 {\"find\":\"users\"}" },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const envelope = body as Record<string, unknown>;
|
|
76
|
+
const cmd = Object.keys(envelope)[0] ?? "";
|
|
77
|
+
const target = envelope[cmd];
|
|
78
|
+
const summary = `${cmd}${typeof target === "string" ? " " + target.slice(0, 40) : ""}`;
|
|
79
|
+
|
|
80
|
+
// 1) 服务端 JS 恒拒(比命令分类优先:$match 里夹带 $where 也拦)
|
|
81
|
+
if (hasAnyKey(envelope, JS_KEYS)) {
|
|
82
|
+
return {
|
|
83
|
+
envelope,
|
|
84
|
+
verdict: {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "禁止服务端 JS 执行($where / $function / $accumulator),与只读开关无关",
|
|
87
|
+
isWrite: true,
|
|
88
|
+
summary: `${summary}(硬限制)`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// 2) 管理/DDL 恒拒(前缀 + 精确名单)
|
|
93
|
+
const cmdLower = cmd.toLowerCase();
|
|
94
|
+
if (DENY_PREFIXES.some((p) => cmdLower.startsWith(p)) || DENY_COMMANDS.has(cmdLower)) {
|
|
95
|
+
return {
|
|
96
|
+
envelope,
|
|
97
|
+
verdict: {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: `禁止执行管理/DDL 命令:${cmd}(硬限制)`,
|
|
100
|
+
isWrite: true,
|
|
101
|
+
summary: `${summary}(硬限制)`,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// 3) aggregate 含 $out/$merge → 整条按写(readonly 由 isAllowed 统一裁决)
|
|
106
|
+
if (cmdLower === "aggregate" && hasAnyKey(envelope, AGG_WRITE_KEYS)) {
|
|
107
|
+
return { envelope, verdict: { ok: true, isWrite: true, summary: `${summary}($out/$merge 写回)` } };
|
|
108
|
+
}
|
|
109
|
+
// 4) 读白名单
|
|
110
|
+
if (READ_COMMANDS.has(cmdLower)) {
|
|
111
|
+
return { envelope, verdict: { ok: true, isWrite: false, summary } };
|
|
112
|
+
}
|
|
113
|
+
// 5) 已知写命令与其余未知命令一律按写(未知写意图不可排除)
|
|
114
|
+
return { envelope, verdict: { ok: true, isWrite: true, summary } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── limit 注入(Spec 共识 Q8:无界查询自动封顶,bulk 上限 1000)───
|
|
118
|
+
|
|
119
|
+
/** 单条写命令文档数组上限(防手滑不防恶意;writable 模式另有确认框兜底) */
|
|
120
|
+
export const MAX_BULK_DOCS = 1000;
|
|
121
|
+
|
|
122
|
+
export function injectLimits(envelope: Record<string, unknown>, maxRows: number): void {
|
|
123
|
+
const cmd = (Object.keys(envelope)[0] ?? "").toLowerCase();
|
|
124
|
+
if (cmd === "find") {
|
|
125
|
+
// Mongo 语义:limit ≤ 0 等价于“不限”,与未提供同等对待,一并收敛到 maxRows
|
|
126
|
+
const userLimit = typeof envelope.limit === "number" && Number.isFinite(envelope.limit) && envelope.limit > 0
|
|
127
|
+
? envelope.limit : undefined;
|
|
128
|
+
const limit = Math.min(Math.max(1, Math.trunc(userLimit ?? maxRows)), maxRows);
|
|
129
|
+
envelope.limit = limit;
|
|
130
|
+
envelope.batchSize = limit; // db.command 走 firstBatch,batchSize 决定单批返回量
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (cmd === "aggregate" && Array.isArray(envelope.pipeline)) {
|
|
134
|
+
// 空 pipeline 同样需要封顶(全集合扫描)
|
|
135
|
+
const last = envelope.pipeline.length > 0
|
|
136
|
+
? envelope.pipeline[envelope.pipeline.length - 1] as Record<string, unknown> | null
|
|
137
|
+
: null;
|
|
138
|
+
const hasTailLimit = last !== null && typeof last === "object"
|
|
139
|
+
&& ("$limit" in last || "$count" in last);
|
|
140
|
+
if (!hasTailLimit) {
|
|
141
|
+
envelope.pipeline = [...(envelope.pipeline as unknown[]), { $limit: maxRows }];
|
|
142
|
+
}
|
|
143
|
+
const cursor = (envelope.cursor !== null && typeof envelope.cursor === "object"
|
|
144
|
+
? { ...(envelope.cursor as Record<string, unknown>) }
|
|
145
|
+
: {}) as Record<string, unknown>;
|
|
146
|
+
cursor.batchSize = maxRows;
|
|
147
|
+
envelope.cursor = cursor;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// 写命令文档数组封顶(insert/update/delete 的 documents 字段)
|
|
151
|
+
for (const field of ["documents", "updates", "deletes"]) {
|
|
152
|
+
const arr = envelope[field];
|
|
153
|
+
if (Array.isArray(arr) && arr.length > MAX_BULK_DOCS) {
|
|
154
|
+
envelope[field] = arr.slice(0, MAX_BULK_DOCS);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── 结果拍平(Spec 共识 Q7:顶层字段并集,上限 50 列)───
|
|
160
|
+
|
|
161
|
+
export const MAX_QUERY_COLUMNS = 50;
|
|
162
|
+
|
|
163
|
+
/** BSON 值 → 展示原语:ObjectId 取 hex、其余 BSON 优先 toJSON(Binary/UUID → base64,避免 [object Object])、Date ISO、对象/数组 JSON */
|
|
164
|
+
function cellOf(v: unknown): unknown {
|
|
165
|
+
if (v === undefined) return null;
|
|
166
|
+
if (v === null || typeof v !== "object") return v;
|
|
167
|
+
const b = v as { toHexString?: () => string; _bsontype?: string; toJSON?: () => unknown };
|
|
168
|
+
if (typeof b.toHexString === "function") return b.toHexString();
|
|
169
|
+
if (b._bsontype) {
|
|
170
|
+
const j = typeof b.toJSON === "function" ? b.toJSON() : undefined;
|
|
171
|
+
return j !== undefined && j !== null && typeof j !== "object" ? j : JSON.stringify(v);
|
|
172
|
+
}
|
|
173
|
+
if (v instanceof Date) return v.toISOString();
|
|
174
|
+
return JSON.stringify(v);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function flattenDocs(
|
|
178
|
+
docs: unknown[],
|
|
179
|
+
maxRows: number,
|
|
180
|
+
): { columns: string[]; rows: unknown[][]; rowCount: number; truncated?: boolean } {
|
|
181
|
+
const shown = docs.slice(0, maxRows);
|
|
182
|
+
// 列 = 顶层字段并集,首现顺序,封顶 MAX_QUERY_COLUMNS
|
|
183
|
+
// truncated 语义对齐 bigdata 方言:结果数达 maxRows 上限即标截断(无法区分“恰好等于”)
|
|
184
|
+
const columns: string[] = [];
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
for (const d of shown) {
|
|
187
|
+
if (columns.length >= MAX_QUERY_COLUMNS) break;
|
|
188
|
+
if (d !== null && typeof d === "object" && !Array.isArray(d)) {
|
|
189
|
+
for (const k of Object.keys(d as Record<string, unknown>)) {
|
|
190
|
+
if (!seen.has(k)) {
|
|
191
|
+
seen.add(k);
|
|
192
|
+
columns.push(k);
|
|
193
|
+
if (columns.length >= MAX_QUERY_COLUMNS) break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const rows = shown.map((d) => {
|
|
199
|
+
if (d === null || typeof d !== "object" || Array.isArray(d)) return [cellOf(d)];
|
|
200
|
+
const obj = d as Record<string, unknown>;
|
|
201
|
+
return columns.map((c) => cellOf(obj[c]));
|
|
202
|
+
});
|
|
203
|
+
return { columns, rows, rowCount: rows.length, truncated: docs.length >= maxRows };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 基类 ──────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export abstract class DocumentDialect implements Dialect {
|
|
209
|
+
abstract id: Dialect["id"];
|
|
210
|
+
abstract label: string;
|
|
211
|
+
abstract family: Dialect["family"];
|
|
212
|
+
abstract defaultPort: number;
|
|
213
|
+
abstract fingerprints: Fingerprints;
|
|
214
|
+
abstract parseUrl(url: string): ParsedTarget | null;
|
|
215
|
+
abstract displayUrl(config: ConnConfig): string;
|
|
216
|
+
abstract versionQuery(conn: DbConnection): Promise<string>;
|
|
217
|
+
protected abstract doConnect(config: ConnConfig, timeoutMs: number): Promise<DbConnection>;
|
|
218
|
+
/** 执行一条已过白名单的命令信封,返回驱动原始响应 */
|
|
219
|
+
protected abstract doCommand(client: unknown, config: ConnConfig,
|
|
220
|
+
envelope: Record<string, unknown>): Promise<unknown>;
|
|
221
|
+
/** 原始响应 → 文档数组(MongoDB 取 cursor.firstBatch / distinct.values,默认整响应单行) */
|
|
222
|
+
protected extractDocs(raw: unknown): unknown[] {
|
|
223
|
+
return [raw];
|
|
224
|
+
}
|
|
225
|
+
abstract listTables(config: ConnConfig, pattern?: string): Promise<import("../core/types.js").ListTablesResult>;
|
|
226
|
+
abstract describeTable(config: ConnConfig, target: string): Promise<import("../core/types.js").DescribeTableResult>;
|
|
227
|
+
|
|
228
|
+
isAllowed(sql: string, readonly: boolean): Verdict {
|
|
229
|
+
const { verdict } = classifyEnvelope(sql);
|
|
230
|
+
if (!verdict.ok) return verdict;
|
|
231
|
+
if (verdict.isWrite && readonly) {
|
|
232
|
+
return { ...verdict, ok: false, reason: `只读模式下不允许执行写命令:${verdict.summary}` };
|
|
233
|
+
}
|
|
234
|
+
return verdict;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async executeOn(config: ConnConfig, sql: string, opts: ExecOpts): Promise<QueryResult> {
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
const { envelope, verdict } = classifyEnvelope(sql);
|
|
240
|
+
if (!envelope || !verdict.ok) {
|
|
241
|
+
return { success: false, error: verdict.reason ?? "命令被拒绝", duration: `${Date.now() - start}ms` };
|
|
242
|
+
}
|
|
243
|
+
injectLimits(envelope, opts.maxRows);
|
|
244
|
+
try {
|
|
245
|
+
const raw = await this.withConnection(
|
|
246
|
+
config,
|
|
247
|
+
(conn) => this.doCommand(conn.client, config, envelope),
|
|
248
|
+
opts.timeoutSec * 1000,
|
|
249
|
+
);
|
|
250
|
+
const docs = this.extractDocs(raw);
|
|
251
|
+
const { columns, rows, rowCount, truncated } = flattenDocs(docs, opts.maxRows);
|
|
252
|
+
return { success: true, columns, rows, rowCount, truncated, duration: `${Date.now() - start}ms` };
|
|
253
|
+
} catch (err: unknown) {
|
|
254
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), duration: `${Date.now() - start}ms` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async withConnection<T>(config: ConnConfig, fn: (conn: DbConnection) => Promise<T>, timeoutMs = 10_000): Promise<T> {
|
|
259
|
+
const conn = await this.doConnect(config, timeoutMs);
|
|
260
|
+
try { return await fn(conn); }
|
|
261
|
+
finally { await conn.close(); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async testConnection(config: ConnConfig): Promise<import("../core/types.js").TestConnectionResult> {
|
|
265
|
+
const start = Date.now();
|
|
266
|
+
try {
|
|
267
|
+
const version = await this.withConnection(config, (conn) => this.versionQuery(conn));
|
|
268
|
+
return { success: true, version, latency: `${Date.now() - start}ms` };
|
|
269
|
+
} catch (err: unknown) {
|
|
270
|
+
return { success: false, error: err instanceof Error ? err.message : String(err), latency: `${Date.now() - start}ms` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|