@hedeqiang/dsh-nl2sql 0.12.0 → 0.12.2

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 CHANGED
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.12.2] - 2026-11-12
9
+
10
+ ### Security
11
+ - Row-level rules could be bypassed with MySQL `--` arithmetic (e.g. `WHERE 1--1) OR (1`): the filter injector now uses the dialect-aware lexer and refuses any rewrite where the injected rule is not a top-level conjunct.
12
+ - The SQL guard followed JavaScript's notion of whitespace after `--`; it now follows MySQL exactly, and SQL containing control characters or non-ASCII whitespace outside literals is refused (closed table-policy and system-catalog bypasses).
13
+ - `nl2sql_overview` ran its queries without the caller's row-level rules; it now runs as the caller.
14
+ - A result filtered for one member could be reused (re-charted, drilled, sent) by a member with different row-level rules; results now carry a signature of the rules applied.
15
+ - The bot's data source catalog listed tables hidden by the access policy.
16
+ - Data values could inject mentions (`<at id=all>`) or links into Lark cards and other IM messages; data-derived text is now escaped per platform.
17
+
18
+ ### Fixed
19
+ - Previewing an alert while another alert was saved could delete saved alerts; an alert edited during a check lost its state and re-fired every minute.
20
+ - Bot commands matched any question starting with words such as 自动, 数据源, 切换 or help; commands must now be the whole message. English aliases: `help`, `sources`, `switch <name>`, `auto`, `new`.
21
+ - Bots no longer change the page's current data source when they switch sources.
22
+ - Deleting a channel left alerts, the schema-change notice, scheduled reports and bot conversations pointing at it.
23
+ - Sending failed for titles longer than 100 characters; NULL numbers showed as 0; line breaks inside cells broke tables; changing a channel's type asked for unchanged secrets again; Excel sheet names with emoji could exceed Excel's limit; daily alerts handled daylight-saving transitions wrongly.
24
+ - New file data sources had the description "上传的文件:undefined".
25
+ - Page: the knowledge editor could save into another data source after switching; the Ask page briefly offered the previous data source's questions; dialogs and toasts did not appear inside conversation tool views; free-text dashboard filters could not take a comma; double clicks created duplicate widgets, alerts and knowledge items; unsaved settings were lost without warning; several actions failed silently; number formatting showed 0 for empty values and `10000万` / `1000K`; history labels for Telegram / WeCom / DingTalk bots; dates follow the UI language.
26
+ - Accessibility: focus stays inside dialogs; policy rows work with the keyboard; Chinese punctuation no longer appears in the English UI.
27
+
28
+ ## [0.12.1] - 2026-11-12
29
+
30
+ ### Fixed
31
+ - History & audit: the per-row delete button overlapped the row count and duration columns; it now has its own column.
32
+
8
33
  ## [0.12.0] - 2026-11-12
9
34
 
10
35
  ### Added
@@ -81,7 +106,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
81
106
 
82
107
  - Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
83
108
 
84
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.0...HEAD
109
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...HEAD
110
+ [0.12.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...v0.12.2
111
+ [0.12.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.0...v0.12.1
85
112
  [0.12.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.2...v0.12.0
86
113
  [0.11.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.1...v0.11.2
87
114
  [0.11.1]: https://github.com/hedeqiang/dsh-nl2sql/releases/tag/v0.11.1
package/analysis.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { timeBounds, TIME_RANGES } from './filters.js'
12
+ import { dashDashCommentFollower } from './sql-guard.js'
12
13
 
13
14
  // ------------------------------------------------------------ quoting
14
15
 
@@ -83,7 +84,9 @@ function tokenize(sql, { dialect } = {}) {
83
84
  const c = sql[i]
84
85
  const n = sql[i + 1]
85
86
  if (/\s/.test(c)) { i++; continue }
86
- if ((c === '-' && n === '-') || (c === '#' && dialect !== 'postgres')) { while (i < sql.length && sql[i] !== '\n') i++; continue }
87
+ // MySQL: `--` starts a comment only when followed by a control/space character (or end); other dialects: always.
88
+ const dashComment = c === '-' && n === '-' && (dialect === 'postgres' || dialect === 'sqlite' || dialect === 'elasticsearch' || dashDashCommentFollower(sql[i + 2]))
89
+ if (dashComment || (c === '#' && dialect !== 'postgres' && dialect !== 'sqlite' && dialect !== 'elasticsearch')) { while (i < sql.length && sql[i] !== '\n') i++; continue }
87
90
  if (c === '/' && n === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; continue }
88
91
  if (c === '\'' || c === '"' || c === '`') {
89
92
  const start = i
@@ -48,7 +48,7 @@
48
48
  */
49
49
  import { createHmac } from 'node:crypto'
50
50
  import { ChannelError, postJson, request } from './http.js'
51
- import { resultMeta, isKpi, kpiText, fmtNum, cellText, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
51
+ import { resultMeta, isKpi, kpiText, fmtNum, cellText, defangMd, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
52
52
 
53
53
  export const OAPI = 'https://oapi.dingtalk.com'
54
54
  export const API = 'https://api.dingtalk.com'
@@ -153,11 +153,12 @@ export function dingApiError(json, status) {
153
153
  // ------------------------------------------------------------- rendering
154
154
 
155
155
  /** A result as text lines (DingTalk markdown has no tables). */
156
- export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
156
+ export function resultAsDingText(r, maxRows = 10, maxCols = 6, { escape = defangMd } = {}) {
157
157
  const cols = r.columns.slice(0, maxCols)
158
158
  const rows = r.rows.slice(0, maxRows)
159
- const cell = (c, v) => (c.kind === 'number' ? fmtNum(v) : cellText(v, 30)).replace(/\|/g, '|')
160
- const out = [`**${cols.map(c => c.name).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
159
+ // NULL → '—' (not 0); line breaks would split a numbered row; data must not form links / tags.
160
+ const cell = (c, v) => escape((c.kind === 'number' ? fmtNum(v) : cellText(v, 30)) || '—').replace(/\|/g, '|')
161
+ const out = [`**${cols.map(c => escape(c.name)).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
161
162
  const total = r.rowCount ?? r.rows.length
162
163
  if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行;完整数据可在数据问答页面查看或导出 Excel`)
163
164
  return out.join('\n')
@@ -167,7 +168,8 @@ export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
167
168
  export function stripTables(md) {
168
169
  return String(md ?? '').replace(MD_TABLE, (m, lead, block) => {
169
170
  const t = parseMdTable(block)
170
- return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8)}\n`
171
+ // Tables authored in the message text keep their Markdown (links etc.).
172
+ return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8, { escape: (x) => String(x ?? '') })}\n`
171
173
  })
172
174
  }
173
175
 
@@ -179,18 +181,18 @@ export function toDingMarkdown(md) {
179
181
  /** OutboundMessage → { title, text } for msgtype markdown / sampleMarkdown. */
180
182
  export function renderMarkdown(message, { maxRows = 10 } = {}) {
181
183
  const parts = []
182
- if (message.title) parts.push(`### ${message.title}`)
184
+ if (message.title) parts.push(`### ${defangMd(message.title)}`)
183
185
  if (message.text) parts.push(toDingMarkdown(message.text))
184
186
  for (const r of (message.results ?? []).slice(0, 10)) {
185
- const lines = [`#### ${r.title || '查询结果'}`, `> ${resultMeta(r)}`, '']
186
- if (r.error) lines.push(r.error)
187
+ const lines = [`#### ${defangMd(r.title || '查询结果')}`, `> ${resultMeta(r)}`, '']
188
+ if (r.error) lines.push(defangMd(r.error))
187
189
  else if (!r.rows?.length) lines.push('无数据')
188
190
  else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n\n' }))
189
191
  else lines.push(resultAsDingText(r, maxRows))
190
192
  if (r.sql && message.includeSql) lines.push('', '```sql\n' + r.sql.slice(0, 1500) + '\n```')
191
193
  parts.push(lines.join('\n'))
192
194
  }
193
- if (message.footer) parts.push(toDingMarkdown(message.footer))
195
+ if (message.footer) parts.push(defangMd(toDingMarkdown(message.footer)))
194
196
  const all = message.mentionAll || mentionsAll(message.text) || mentionsAll(message.footer)
195
197
  if (all && !parts.some(p => p.includes('@所有人'))) parts.push('@所有人')
196
198
  const title = String(message.title || '数据问答').slice(0, 60)
package/channels/lark.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { createHash, createHmac } from 'node:crypto'
18
18
  import { ChannelError, request } from './http.js'
19
- import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
19
+ import { MD_TABLE, cellText, chartOption, chartable, dualAxis, escapeLarkMd, kpiText, isKpi, needsAttachment, num, oneLine, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
20
 
21
21
  export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
22
 
@@ -704,7 +704,13 @@ function tableElement(result, maxRows) {
704
704
  row_height: 'low',
705
705
  header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
706
706
  columns: cols.map((c, i) => ({ name: `c${i}`, display_name: c.name, data_type: c.kind === 'number' ? 'number' : 'text', ...(c.kind === 'number' ? { format: { separator: true } } : {}), width: 'auto' })),
707
- rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.map((c, i) => [`c${i}`, c.kind === 'number' && Number.isFinite(Number(r[i])) ? Number(r[i]) : cellText(r[i])]))),
707
+ // NULL / '' numbers stay empty (Number(null) would show 0); line breaks would break rows.
708
+ rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.flatMap((c, i) => {
709
+ const v = r[i]
710
+ if (c.kind !== 'number') return [[`c${i}`, oneLine(cellText(v))]]
711
+ if (v === null || v === undefined || v === '') return [] // empty cell, not 0
712
+ return [[`c${i}`, Number.isFinite(Number(v)) ? Number(v) : oneLine(cellText(v))]]
713
+ }))),
708
714
  }
709
715
  }
710
716
 
@@ -840,15 +846,17 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
840
846
  for (const r of message.results ?? []) {
841
847
  if (elements.length) elements.push({ tag: 'hr' })
842
848
  if (r.error) {
843
- elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
849
+ elements.push({ tag: 'markdown', content: `**${escapeLarkMd(r.title || '查询')}**\n<font color='red'>${escapeLarkMd(r.error)}</font>` })
844
850
  continue
845
851
  }
846
- elements.push({ tag: 'markdown', content: [`**${r.title || '查询结果'}**`, `<font color='grey'>${resultMeta(r)}</font>`].join(' ') })
852
+ // Everything data-derived (titles, values, errors, footer) is escaped: a cell like
853
+ // `<at id=all></at>` or `[x](https://…)` must show as text, not mention or link.
854
+ elements.push({ tag: 'markdown', content: [`**${escapeLarkMd(r.title || '查询结果')}**`, `<font color='grey'>${resultMeta(r, { escape: escapeLarkMd })}</font>`].join(' ') })
847
855
  const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
848
856
  if (r.rows.length === 0) {
849
857
  elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
850
858
  } else if (isKpi(r)) {
851
- elements.push({ tag: 'markdown', content: kpiText(r) })
859
+ elements.push({ tag: 'markdown', content: kpiText(r, { escape: escapeLarkMd }) })
852
860
  } else if (nativeChart) {
853
861
  elements.push(nativeChart)
854
862
  chartCount++
@@ -860,7 +868,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
860
868
  const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
861
869
  if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
862
870
  } else {
863
- elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
871
+ elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => escapeLarkMd(cellText(v))).join(' | ')).join('\n') })
864
872
  const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
865
873
  if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
866
874
  }
@@ -871,7 +879,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
871
879
  }
872
880
  if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
873
881
  }
874
- if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
882
+ if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${escapeLarkMd(message.footer)}</font>`, text_size: 'notation' })
875
883
  const card = {
876
884
  schema: '2.0',
877
885
  config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
@@ -894,10 +902,10 @@ export function legacyCard(card) {
894
902
  const md = card.body.elements.map(e => {
895
903
  if (e.tag === 'markdown') return e.content
896
904
  if (e.tag === 'hr') return '---'
897
- if (e.tag === 'table') return [e.columns.map(c => c.display_name).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => cellText(r[c.name])).join(' | '))].join('\n')
905
+ if (e.tag === 'table') return [e.columns.map(c => escapeLarkMd(c.display_name)).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => escapeLarkMd(cellText(r[c.name]))).join(' | '))].join('\n')
898
906
  if (e.tag === 'chart') {
899
907
  const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
900
- return v.slice(0, 10).map(x => Object.values(x).map(y => cellText(y)).join(' | ')).join('\n')
908
+ return v.slice(0, 10).map(x => Object.values(x).map(y => escapeLarkMd(cellText(y))).join(' | ')).join('\n')
901
909
  }
902
910
  return ''
903
911
  }).filter(Boolean).join('\n\n')
@@ -14,8 +14,9 @@ export function num(v) {
14
14
  return v === null || v === undefined || v === '' ? null : Number(v)
15
15
  }
16
16
 
17
- /** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. */
17
+ /** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. NULL / '' → ''. */
18
18
  export function fmtNum(v) {
19
+ if (v === null || v === undefined || v === '') return ''
19
20
  const n = Number(v)
20
21
  if (!Number.isFinite(n)) return String(v ?? '')
21
22
  if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
@@ -30,10 +31,44 @@ export function cellText(v, max = 80) {
30
31
  return s.length > max ? `${s.slice(0, max - 1)}…` : s
31
32
  }
32
33
 
33
- /** A KPI-style result (one row) as `**col**:value | …` Markdown. */
34
- export function kpiText(result, { separator = ' | ', bold = true } = {}) {
34
+ /** Line breaks (CR, LF, CRLF) → one space: keeps table rows and inline Markdown on one line. */
35
+ export function oneLine(s) {
36
+ return String(s ?? '').replace(/\r\n?|\n/g, ' ')
37
+ }
38
+
39
+ /**
40
+ * Lark card Markdown: data-derived text (cell values, column names, result titles,
41
+ * errors, footers) as literal text. Lark documents HTML numeric entities for
42
+ * characters that would trigger Markdown / tags (`<at>`, `<font>`, links, emphasis).
43
+ * https://open.larksuite.com/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/rich-text
44
+ */
45
+ export function escapeLarkMd(s) {
46
+ return oneLine(s)
47
+ .replace(/&(?=#?\w+;)/g, '&#38;')
48
+ .replace(/[<>[\]*_~`\\]/g, ch => `&#${ch.charCodeAt(0)};`)
49
+ }
50
+
51
+ /**
52
+ * Neutralize link / tag syntax in data-derived text for platforms whose Markdown
53
+ * has no reliable escape (WeCom, DingTalk): `<` `>` `[` `]` become full-width look-alikes,
54
+ * so values cannot create links, images, `<@user>` mentions or `<font>` tags.
55
+ */
56
+ export function defangMd(s) {
57
+ return oneLine(s).replace(/[<>[\]]/g, ch => ({ '<': '<', '>': '>', '[': '[', ']': ']' })[ch])
58
+ }
59
+
60
+ /**
61
+ * A KPI-style result (one row) as `**col**:value | …` Markdown. NULL shows as '—'.
62
+ * `escape` is applied to data-derived text (column names and values).
63
+ */
64
+ export function kpiText(result, { separator = ' | ', bold = true, escape = defangMd } = {}) {
35
65
  const row = result.rows?.[0] ?? []
36
- return result.columns.map((c, i) => `${bold ? `**${c.name}**` : c.name}:${c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])}`).join(separator)
66
+ return result.columns.map((c, i) => {
67
+ const raw = c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])
68
+ const value = raw === '' ? '—' : escape(oneLine(raw))
69
+ const name = escape(oneLine(c.name))
70
+ return `${bold ? `**${name}**` : name}:${value}`
71
+ }).join(separator)
37
72
  }
38
73
 
39
74
  /** True when the result should be shown as KPI text rather than a table/chart. */
@@ -59,8 +94,8 @@ export function needsAttachment(result, shown) {
59
94
  return (result.rowCount ?? result.rows.length) > shown
60
95
  }
61
96
 
62
- export function resultMeta(result) {
63
- return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
97
+ export function resultMeta(result, { escape = defangMd } = {}) {
98
+ return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => escape(f.description)).join(';')}` : ''}`
64
99
  }
65
100
 
66
101
  // ------------------------------------------------------- chart rendering
@@ -207,7 +242,7 @@ export function toPlainText(md) {
207
242
  }
208
243
 
209
244
  function mdCell(v, max = 40) {
210
- return cellText(v, max).replace(/\|/g, '\\|').replace(/\n/g, ' ')
245
+ return defangMd(cellText(v, max)).replace(/\|/g, '\\|')
211
246
  }
212
247
 
213
248
  /** A result as a Markdown table (≤ maxRows rows, ≤ maxCols columns), with a "… N more" note when cut. */
@@ -229,7 +264,7 @@ export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}
229
264
  export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
230
265
  const cols = (result.columns ?? []).slice(0, maxCols)
231
266
  const rows = (result.rows ?? []).slice(0, maxRows)
232
- const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => (c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
267
+ const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => oneLine(c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
233
268
  const total = result.rowCount ?? result.rows?.length ?? 0
234
269
  if (total > rows.length) lines.push(`…共 ${total} 行`)
235
270
  return lines.join('\n')
@@ -243,11 +278,12 @@ export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
243
278
  export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, sqlMax = 1500 } = {}) {
244
279
  const clean = markdown === 'full' ? normalizeMarkdown : markdown === 'none' ? toPlainText : toPlainMarkdown
245
280
  const parts = []
246
- if (message.title) parts.push(markdown === 'none' ? String(message.title) : `**${message.title}**`)
281
+ if (message.title) parts.push(markdown === 'none' ? oneLine(message.title) : `**${defangMd(message.title)}**`)
247
282
  if (message.text) parts.push(clean(message.text))
248
283
  for (const r of message.results ?? []) {
249
- const head = markdown === 'none' ? `${r.title || '查询结果'}` : `**${r.title || '查询结果'}**`
250
- if (r.error) { parts.push(`${head}\n${r.error}`); continue }
284
+ // Result titles, errors and cells are data-derived: no Markdown / tags from them.
285
+ const head = markdown === 'none' ? oneLine(r.title || '查询结果') : `**${defangMd(r.title || '查询结果')}**`
286
+ if (r.error) { parts.push(`${head}\n${markdown === 'none' ? r.error : defangMd(r.error)}`); continue }
251
287
  const lines = [`${head} ${resultMeta(r)}`]
252
288
  if (!r.rows?.length) lines.push('无数据')
253
289
  else if (isKpi(r)) lines.push(kpiText(r, { bold: markdown !== 'none' }))
@@ -255,7 +291,7 @@ export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, s
255
291
  if (r.sql && message.includeSql) lines.push(markdown === 'none' ? r.sql.slice(0, sqlMax) : `\`\`\`sql\n${r.sql.slice(0, sqlMax)}\n\`\`\``)
256
292
  parts.push(lines.join('\n'))
257
293
  }
258
- if (message.footer) parts.push(clean(message.footer))
294
+ if (message.footer) parts.push(markdown === 'none' ? toPlainText(message.footer) : defangMd(toPlainMarkdown(message.footer)))
259
295
  return parts.join('\n\n')
260
296
  }
261
297
 
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { randomBytes } from 'node:crypto'
20
20
  import { ChannelError, request } from './http.js'
21
- import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
21
+ import { renderChartPng as defaultRenderChartPng, chartable, oneLine, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
22
22
 
23
23
  export const DEFAULT_API_BASE = 'https://api.telegram.org'
24
24
  export const TEXT_LIMIT = 4096
@@ -280,12 +280,19 @@ export function inlineKeyboard(spec) {
280
280
 
281
281
  // ------------------------------------------------------------ rendering
282
282
 
283
+ /**
284
+ * Data-derived text in Telegram Markdown: HTML is escaped by the converter anyway, so only
285
+ * link syntax and line breaks are neutralized (`[x](url)` must not become a link).
286
+ */
287
+ const defangMd = (s) => oneLine(s).replace(/[[\]]/g, ch => (ch === '[' ? '[' : ']'))
288
+
283
289
  function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
284
- const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
285
- if (r.error) return `${head}\n${r.error}`
290
+ // Data-derived text (title, error, values) is defanged: no links / tags / mentions from data.
291
+ const head = `**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}`
292
+ if (r.error) return `${head}\n${defangMd(r.error)}`
286
293
  const lines = [head]
287
294
  if (!r.rows?.length) lines.push('无数据')
288
- else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
295
+ else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n', escape: defangMd }))
289
296
  else lines.push(resultAsMarkdownTable(r, maxRows, { maxCols: 6 }))
290
297
  if (r.sql && includeSql) lines.push('```sql\n' + r.sql.slice(0, 1500) + '\n```')
291
298
  return lines.join('\n')
@@ -297,7 +304,7 @@ function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
297
304
  */
298
305
  export function planMessage(message, { buttons = true } = {}) {
299
306
  const segs = []
300
- const head = [message.title ? `**${message.title}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
307
+ const head = [message.title ? `**${defangMd(message.title)}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
301
308
  let text = head
302
309
  const flush = (keyboard) => { if (text.trim() || keyboard) segs.push({ kind: 'text', md: text, ...(keyboard ? { keyboard } : {}) }); text = '' }
303
310
  for (const r of (message.results ?? []).slice(0, 10)) {
@@ -310,7 +317,7 @@ export function planMessage(message, { buttons = true } = {}) {
310
317
  text = [text, resultMarkdown(r, { includeSql: message.includeSql })].filter(s => s.trim()).join('\n\n')
311
318
  if (keyboard) flush(keyboard)
312
319
  }
313
- if (message.footer) text = [text, message.footer].filter(s => s.trim()).join('\n\n')
320
+ if (message.footer) text = [text, defangMd(toPlainMarkdown(message.footer, { mentionAll: '' }))].filter(s => s.trim()).join('\n\n')
314
321
  flush()
315
322
  return segs
316
323
  }
package/channels/wecom.js CHANGED
@@ -36,7 +36,7 @@
36
36
  */
37
37
  import { createHash, randomBytes } from 'node:crypto'
38
38
  import { ChannelError, postJson } from './http.js'
39
- import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown, mentionsAll, splitText } from './message.js'
39
+ import { renderChartPng as defaultRenderChartPng, chartable, cellText, defangMd, fmtNum, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown, mentionsAll, splitText } from './message.js'
40
40
 
41
41
  export const WEBHOOK_HOST = 'https://qyapi.weixin.qq.com'
42
42
  export const DEFAULT_GATEWAY = 'wss://openws.work.weixin.qq.com'
@@ -95,8 +95,9 @@ export function toWecomMarkdown(md) {
95
95
  }
96
96
 
97
97
  function resultBlock(r, { includeSql, maxRows = 10, table = true } = {}) {
98
- const head = `**${r.title || '查询结果'}** ${resultMeta(r)}`
99
- if (r.error) return `${head}\n${r.error}`
98
+ // Data-derived text (title, error, values) is defanged: no links / tags / mentions from data.
99
+ const head = `**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}`
100
+ if (r.error) return `${head}\n${defangMd(r.error)}`
100
101
  const lines = [head]
101
102
  if (!r.rows?.length) lines.push('无数据')
102
103
  else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
@@ -109,7 +110,7 @@ function resultBlock(r, { includeSql, maxRows = 10, table = true } = {}) {
109
110
  function resultAsText(r, maxRows) {
110
111
  const cols = r.columns.slice(0, 6)
111
112
  const rows = r.rows.slice(0, maxRows)
112
- const out = [`> ${cols.map(c => c.name).join(' | ')}`, ...rows.map(row => `> ${cols.map((c, i) => String(row[i] ?? '')).join(' | ')}`)]
113
+ const out = [`> ${cols.map(c => defangMd(c.name)).join(' | ')}`, ...rows.map(row => `> ${cols.map((c, i) => defangMd(c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i], 60))).join(' | ')}`)]
113
114
  if ((r.rowCount ?? r.rows.length) > rows.length) out.push(`<font color="comment">…共 ${r.rowCount ?? r.rows.length} 行</font>`)
114
115
  return out.join('\n')
115
116
  }
@@ -121,19 +122,19 @@ function resultAsText(r, maxRows) {
121
122
  export function planWebhook(message) {
122
123
  const sections = []
123
124
  const images = []
124
- if (message.title) sections.push(`## ${message.title}`)
125
+ if (message.title) sections.push(`## ${defangMd(message.title)}`)
125
126
  if (message.text) sections.push(message.text)
126
127
  let hasTable = /(^|\n)\|[^\n]*\|\s*\n\|[\s:|-]+\|/.test(message.text ?? '')
127
128
  for (const r of (message.results ?? []).slice(0, 10)) {
128
129
  if (message.chartMode !== 'table' && !r.error && chartable(r)) {
129
- sections.push(`**${r.title || '查询结果'}** ${resultMeta(r)}(见下方图表)${r.sql && message.includeSql ? '\n```sql\n' + r.sql.slice(0, 1500) + '\n```' : ''}`)
130
+ sections.push(`**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}(见下方图表)${r.sql && message.includeSql ? '\n```sql\n' + r.sql.slice(0, 1500) + '\n```' : ''}`)
130
131
  images.push({ msgtype: 'image', result: r })
131
132
  continue
132
133
  }
133
134
  if (r.rows?.length && !isKpi(r) && !r.error) hasTable = true
134
135
  sections.push(resultBlock(r, { includeSql: message.includeSql }))
135
136
  }
136
- if (message.footer) sections.push(message.footer)
137
+ if (message.footer) sections.push(defangMd(toPlainMarkdown(message.footer, { mentionAll: '' })))
137
138
  const md = sections.filter(Boolean).join('\n\n')
138
139
  const msgtype = hasTable ? 'markdown_v2' : 'markdown'
139
140
  const content = msgtype === 'markdown_v2' ? toPlainMarkdown(md, { mentionAll: '' }) : toWecomMarkdown(md)
@@ -238,10 +239,10 @@ function inboundText(body) {
238
239
 
239
240
  function aibotMarkdown(message) {
240
241
  const parts = []
241
- if (message.title) parts.push(`**${message.title}**`)
242
+ if (message.title) parts.push(`**${defangMd(message.title)}**`)
242
243
  if (message.text) parts.push(toPlainMarkdown(message.text, { mentionAll: '' }))
243
244
  for (const r of (message.results ?? []).slice(0, 10)) parts.push(resultBlock(r, { includeSql: message.includeSql }))
244
- if (message.footer) parts.push(toPlainMarkdown(message.footer, { mentionAll: '' }))
245
+ if (message.footer) parts.push(defangMd(toPlainMarkdown(message.footer, { mentionAll: '' })))
245
246
  return parts.join('\n\n')
246
247
  }
247
248
 
package/filters.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * place safely (top-level UNION, no FROM) run unfiltered and report so.
12
12
  */
13
13
 
14
+ import { lexSql } from './sql-guard.js'
15
+
14
16
  export const TIME_RANGES = ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear', 'custom']
15
17
 
16
18
  export function normalizeFilters(input) {
@@ -68,50 +70,50 @@ export function describeFilter(f) {
68
70
 
69
71
  const CLAUSE_END = /^(WHERE|GROUP|HAVING|ORDER|LIMIT|WINDOW|QUALIFY|FETCH|OFFSET|UNION|INTERSECT|EXCEPT|FOR|PIVOT)$/i
70
72
 
71
- /** Top-level (depth 0) word tokens of the outermost SELECT, with offsets. */
72
- function topLevelWords(sql) {
73
+ /**
74
+ * Top-level (depth 0) word tokens of the outermost SELECT, with offsets.
75
+ * Tokenised with the dialect-exact lexer from sql-guard.js, so comments,
76
+ * strings and quoted identifiers are recognised exactly as the server does
77
+ * (e.g. MySQL `1--1` is code, not a comment).
78
+ */
79
+ function topLevelWords(sql, dialect = 'mysql') {
73
80
  const words = []
74
81
  let depth = 0
75
- for (let i = 0; i < sql.length;) {
76
- const c = sql[i]
77
- const n = sql[i + 1]
78
- if ((c === '-' && n === '-') || c === '#') { while (i < sql.length && sql[i] !== '\n') i++; continue }
79
- if (c === '/' && n === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; continue }
80
- if (c === '\'' || c === '"' || c === '`') {
81
- const q = c
82
- const start = i
83
- i++
84
- while (i < sql.length) {
85
- if (sql[i] === '\\' && q === '\'') { i += 2; continue }
86
- if (sql[i] === q) { if (sql[i + 1] === q) { i += 2; continue } break }
87
- i++
88
- }
89
- i++
90
- if (depth === 0) words.push({ kind: 'quoted', text: sql.slice(start, i), start, end: i })
82
+ const { segments } = lexSql(sql, { dialect })
83
+ for (const seg of segments) {
84
+ if (seg.kind === 'comment') continue
85
+ if (seg.kind === 'string' || seg.kind === 'ident') {
86
+ if (depth === 0) words.push({ kind: 'quoted', text: seg.text, start: seg.start, end: seg.end })
91
87
  continue
92
88
  }
93
- if (c === '(') { depth++; if (depth === 1) words.push({ kind: 'open', start: i, end: i + 1 }); i++; continue }
94
- if (c === ')') { depth--; if (depth === 0) words.push({ kind: 'close', start: i, end: i + 1 }); i++; continue }
95
- if (/[A-Za-z_]/.test(c)) {
96
- let j = i
97
- while (j < sql.length && /[\w$.\-*]/.test(sql[j]) && !(sql[j] === '-' && sql[j + 1] === '-')) j++
98
- if (depth === 0) words.push({ kind: 'word', text: sql.slice(i, j), upper: sql.slice(i, j).toUpperCase(), start: i, end: j })
99
- i = j
100
- continue
89
+ const text = seg.text
90
+ for (let k = 0; k < text.length;) {
91
+ const c = text[k]
92
+ const i = seg.start + k
93
+ if (c === '(') { depth++; if (depth === 1) words.push({ kind: 'open', start: i, end: i + 1 }); k++; continue }
94
+ if (c === ')') { depth--; if (depth === 0) words.push({ kind: 'close', start: i, end: i + 1 }); k++; continue }
95
+ if (/[A-Za-z_]/.test(c)) {
96
+ let j = k
97
+ while (j < text.length && /[\w$.\-*]/.test(text[j]) && !(text[j] === '-' && text[j + 1] === '-')) j++
98
+ if (depth === 0) words.push({ kind: 'word', text: text.slice(k, j), upper: text.slice(k, j).toUpperCase(), start: i, end: seg.start + j })
99
+ k = j
100
+ continue
101
+ }
102
+ if (c === ',' && depth === 0) words.push({ kind: 'comma', start: i, end: i + 1 })
103
+ k++
101
104
  }
102
- if (c === ',' && depth === 0) words.push({ kind: 'comma', start: i, end: i + 1 })
103
- i++
104
105
  }
105
106
  return words
106
107
  }
107
108
 
108
109
  function unquote(t) {
110
+ if (t.startsWith('[') && t.endsWith(']')) return t.slice(1, -1)
109
111
  return t.replace(/^[`"]|[`"]$/g, '').replace(/``/g, '`').replace(/""/g, '"')
110
112
  }
111
113
 
112
114
  /** Parse the outermost query: FROM items with aliases, WHERE span, insertion point. */
113
- export function parseOuterQuery(sql) {
114
- const words = topLevelWords(sql)
115
+ export function parseOuterQuery(sql, { dialect = 'mysql' } = {}) {
116
+ const words = topLevelWords(sql, dialect)
115
117
  // Skip a leading WITH ... list: the main SELECT is the first top-level SELECT after the CTE bodies.
116
118
  let selectIdx = words.findIndex(w => w.kind === 'word' && w.upper === 'SELECT')
117
119
  if (words[0]?.upper === 'WITH') {
@@ -190,7 +192,7 @@ function literalFor(value, dialect) {
190
192
  */
191
193
  export function applyFilters(sql, filters, { dialect, schema, now }) {
192
194
  if (!filters.length) return { sql, applied: [], skipped: [] }
193
- const outer = parseOuterQuery(sql)
195
+ const outer = parseOuterQuery(sql, { dialect })
194
196
  if (!outer || !outer.tables.length) return { sql, applied: [], skipped: filters.map(f => ({ ...f, reason: 'unsupported query shape' })) }
195
197
  const tables = schema?.tables ?? []
196
198
  const resolve = (ref) => {
@@ -224,14 +226,66 @@ export function applyFilters(sql, filters, { dialect, schema, now }) {
224
226
  }
225
227
  if (!predicates.length) return { sql, applied, skipped }
226
228
  const pred = predicates.join(' AND ')
229
+ // A trailing line comment would swallow whatever follows on the same line: break the line first.
230
+ const nl = (text) => (endsInLineComment(text, dialect) ? '\n' : '')
227
231
  let out
228
232
  if (outer.where) {
233
+ const head = sql.slice(0, outer.where.keywordStart)
229
234
  const existing = sql.slice(outer.where.keywordEnd, outer.where.end).trim()
230
- out = `${sql.slice(0, outer.where.keywordStart)}WHERE (${pred}) AND (${existing})${outer.where.end < sql.length ? ' ' : ''}${sql.slice(outer.where.end)}`
235
+ out = `${head}${nl(head)}WHERE (${pred}) AND (${existing}${nl(existing)})${outer.where.end < sql.length ? ' ' : ''}${sql.slice(outer.where.end)}`
231
236
  } else {
232
237
  const before = sql.slice(0, outer.insertAt).replace(/\s+$/, '')
233
238
  const after = sql.slice(outer.insertAt)
234
- out = `${before} WHERE ${pred}${after ? ` ${after}` : ''}`
239
+ out = `${before}${nl(before) || ' '}WHERE ${pred}${after ? ` ${after}` : ''}`
235
240
  }
241
+ verifyInjection(out, pred, Boolean(outer.where), outer.tables, dialect)
236
242
  return { sql: out, applied, skipped }
237
243
  }
244
+
245
+ /** Raised when an injected predicate would not be a top-level conjunct of the rewritten query. */
246
+ export class FilterInjectionError extends Error {
247
+ constructor(message) {
248
+ super(message)
249
+ this.code = 'nl2sql/filter-injection'
250
+ }
251
+ }
252
+
253
+ function endsInLineComment(text, dialect) {
254
+ const segs = lexSql(text, { dialect }).segments
255
+ const last = segs[segs.length - 1]
256
+ return Boolean(last && last.kind === 'comment' && last.end === text.length && !last.text.startsWith('/*'))
257
+ }
258
+
259
+ const tokenSig = (w) => (w.kind === 'word' ? `w:${w.upper}` : w.kind === 'quoted' ? `q:${w.text}` : w.kind)
260
+
261
+ /**
262
+ * Defence in depth: re-lex the rewritten SQL with the dialect lexer and make
263
+ * sure the injected predicate is a top-level conjunct of the outer WHERE —
264
+ * balanced parentheses, the same FROM items, and a WHERE body made of exactly
265
+ * `(pred) AND (existing)` or exactly `pred`. Anything else (a stray `)`, a
266
+ * top-level OR next to the predicate, a comment swallowing it) is refused.
267
+ */
268
+ function verifyInjection(out, pred, hadWhere, tables, dialect) {
269
+ const fail = () => { throw new FilterInjectionError('filter predicate could not be injected as a top-level condition; query refused') }
270
+ const lexed = lexSql(out, { dialect })
271
+ if (lexed.unterminated) fail()
272
+ let depth = 0
273
+ for (const seg of lexed.segments) {
274
+ if (seg.kind !== 'code') continue
275
+ for (const c of seg.text) {
276
+ if (c === '(') depth++
277
+ else if (c === ')' && --depth < 0) fail()
278
+ }
279
+ }
280
+ if (depth !== 0) fail()
281
+ const outer = parseOuterQuery(out, { dialect })
282
+ if (!outer?.where) fail()
283
+ const sameTables = outer.tables.length === tables.length && outer.tables.every((t, k) => t.name === tables[k].name && t.alias === tables[k].alias)
284
+ if (!sameTables) fail()
285
+ const body = topLevelWords(out, dialect).filter(w => w.start >= outer.where.keywordEnd && w.start < outer.where.end).map(tokenSig)
286
+ const expected = hadWhere ? ['open', 'close', 'w:AND', 'open', 'close'] : topLevelWords(pred, dialect).map(tokenSig)
287
+ if (body.length !== expected.length || body.some((t, k) => t !== expected[k])) fail()
288
+ // The predicate text must sit right after WHERE (inside the first parenthesis when ANDed with an existing condition).
289
+ const at = out.slice(outer.where.keywordEnd).replace(/^\s+/, '')
290
+ if (!at.startsWith(hadWhere ? `(${pred})` : pred)) fail()
291
+ }
package/index.js CHANGED
@@ -188,7 +188,8 @@ function registerTools(ctx, service) {
188
188
  if (!args.datasource) throw new Error('`datasource` is required for action=use')
189
189
  const ds = service.requireDatasource(args.datasource)
190
190
  service.assertAccess(actor, ds)
191
- const info = await service.bindSession(sessionId, ds.id)
191
+ // The agent tool binds only this session; the page's current data source is left alone.
192
+ const info = await service.bindSession(sessionId, ds.id, { setCurrent: false })
192
193
  return { bound: info.datasourceId, datasources: visible.map(d => pick(d, ['id', 'name', 'type', 'database', 'serverVersion'])) }
193
194
  }
194
195
  const info = sessionId ? service.sessionInfo(sessionId) : null
@@ -353,7 +354,8 @@ function registerOverviewTool(ctx, service) {
353
354
  const ds = service.datasourceForSession(exec.agent?.id, args.datasource)
354
355
  service.assertCanWriteSql(service.actorFor(exec.agent?.id), '生成自动概览')
355
356
  if (args.pin && service.actorFor(exec.agent?.id)) throw new Error('只有本机用户可以把图表固定到看板')
356
- const { table, results } = await service.tableOverview({ datasourceId: ds.id, table: args.table, pin: Boolean(args.pin) })
357
+ const sessionId = exec.agent?.id
358
+ const { table, results } = await service.tableOverview({ datasourceId: ds.id, table: args.table, pin: Boolean(args.pin), sessionId, actor: service.actorFor(sessionId), source: 'agent' })
357
359
  return {
358
360
  model: { table, pinned: Boolean(args.pin), charts: results.map(r => (r.error ? { title: r.title, error: r.error } : { title: r.title, ...service.modelView(r) })) },
359
361
  display: { overview: true, table, results: results.map(r => (r.error ? { title: r.title, sql: r.sql, error: r.error } : displayMeta(r))) },