@hedeqiang/dsh-nl2sql 0.14.1 → 0.14.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,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.2] - 2026-11-13
9
+
10
+ ### Fixed
11
+ - A column named just `token` was always masked, but in crypto / payment databases it is the currency (USDT, BTC…). It is now masked only in credential tables (sessions, oauth, api keys…) or when its comment says it is a credential; credential names such as `access_token`, `api_token` and `token_hash` are still masked. Mask any other column explicitly with the data source's access policy.
12
+ - `tel`, `mail` and `mobile` no longer match inside other words (`whitelist_status`, `hotel`, `telegram_id`, `mailbox`).
13
+ - Cached schema analyses are re-classified with the new rules on load (no re-analysis needed), and cached query results masked under the old rules are not reused.
14
+
8
15
  ## [0.14.1] - 2026-11-13
9
16
 
10
17
  ### Fixed
@@ -144,7 +151,8 @@ These features are tested with local databases and fake IM / Lark servers; real
144
151
 
145
152
  - 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.
146
153
 
147
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.1...HEAD
154
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.2...HEAD
155
+ [0.14.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.1...v0.14.2
148
156
  [0.14.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...v0.14.1
149
157
  [0.14.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.1...v0.14.0
150
158
  [0.13.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.0...v0.13.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
6
  "bin": {
package/service/query.js CHANGED
@@ -9,7 +9,7 @@ import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
9
9
  import { cacheKey } from '../store.js'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { writeXlsx } from '../xlsx.js'
12
- import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
12
+ import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, SENSITIVE_RULES_VERSION, isCredentialToken, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
13
13
 
14
14
  export const methods = {
15
15
  // ---------------------------------------------------------------- query
@@ -87,7 +87,7 @@ export const methods = {
87
87
  }
88
88
  // Query cache: dashboards, reruns and reports reuse a recent identical result.
89
89
  const ttlMs = (settings.cacheTtlSec ?? 0) * 1000
90
- const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
90
+ const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, SENSITIVE_RULES_VERSION, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
91
91
  if (key) {
92
92
  const hit = this.store.cached(key, ttlMs)
93
93
  if (hit && (hit.rowRulesSig ?? undefined) === rowRulesSig) {
@@ -142,7 +142,7 @@ export const methods = {
142
142
  if (settings.maskSensitive) {
143
143
  res.columns.forEach((f, i) => {
144
144
  const col = f.table ? schema?.tables.find(t => t.name === f.table)?.columns.find(c => c.name === f.column) : undefined
145
- if (col?.roles.includes('sensitive') || SENSITIVE_NAME.test(f.name)) sensitive.add(i)
145
+ if (col?.roles.includes('sensitive') || SENSITIVE_NAME.test(f.name) || (!col && isCredentialToken(f.name, f.table))) sensitive.add(i)
146
146
  })
147
147
  }
148
148
  for (const row of cells) for (const i of sensitive) row[i] = maskValue(res.columns[i].name, row[i])
package/service/schema.js CHANGED
@@ -7,7 +7,7 @@ import { annotateSchema, columnMasked, filterSchema, normalizePolicy, policyActi
7
7
  import { admitSql } from '../sql-guard.js'
8
8
  import { readFile, writeFile } from 'node:fs/promises'
9
9
  import { join } from 'node:path'
10
- import { DIALECT_HINTS, UserError, classifyColumn, classifyTable, inferRelations, maskValue, newId, requireText, singular, upsert } from './shared.js'
10
+ import { DIALECT_HINTS, SENSITIVE_RULES_VERSION, UserError, classifyColumn, classifyTable, inferRelations, maskValue, newId, requireText, singular, upsert } from './shared.js'
11
11
 
12
12
  /** Character bigrams (whitespace removed, works for Chinese) plus ASCII words of 3+ characters. */
13
13
  export function textGrams(text) {
@@ -18,6 +18,23 @@ export function textGrams(text) {
18
18
  return out
19
19
  }
20
20
 
21
+ /**
22
+ * Re-apply the current sensitive-column rules to a cached schema (rules change between
23
+ * versions, e.g. a bare `token` currency column is no longer sensitive). Only the
24
+ * 'sensitive' role is touched; other roles stay as analysed.
25
+ */
26
+ export function reclassifySensitive(schema) {
27
+ if (!schema?.tables || schema.sensitiveRules === SENSITIVE_RULES_VERSION) return schema
28
+ for (const t of schema.tables) for (const c of t.columns ?? []) {
29
+ const want = classifyColumn(c, t).includes('sensitive')
30
+ const has = (c.roles ?? []).includes('sensitive')
31
+ if (want && !has) c.roles = [...(c.roles ?? []), 'sensitive']
32
+ if (!want && has) c.roles = c.roles.filter(r => r !== 'sensitive')
33
+ }
34
+ schema.sensitiveRules = SENSITIVE_RULES_VERSION
35
+ return schema
36
+ }
37
+
21
38
  export const methods = {
22
39
  // --------------------------------------------------------------- schema
23
40
 
@@ -87,6 +104,7 @@ export const methods = {
87
104
  if (previous && settings.schemaWatch !== false) {
88
105
  try { this.recordSchemaChange(ds, previous, schema) } catch (error) { console.warn('nl2sql: schema diff failed: %s', error.message) }
89
106
  }
107
+ schema.sensitiveRules = SENSITIVE_RULES_VERSION
90
108
  this.schemas.set(ds.id, schema)
91
109
  ds.schemaAnalyzedAt = schema.analyzedAt
92
110
  if (raw.version && !ds.serverVersion) ds.serverVersion = raw.version
@@ -97,7 +115,7 @@ export const methods = {
97
115
 
98
116
  async readSchemaFile(id) {
99
117
  try {
100
- return JSON.parse(await readFile(join(this.dir, `schema-${id}.json`), 'utf8'))
118
+ return reclassifySensitive(JSON.parse(await readFile(join(this.dir, `schema-${id}.json`), 'utf8')))
101
119
  } catch {
102
120
  return undefined
103
121
  }
package/service/shared.js CHANGED
@@ -57,14 +57,24 @@ export function pick(obj, keys) {
57
57
  return out
58
58
  }
59
59
 
60
- // "token" alone is ambiguous (crypto token_code / token_price are business data):
61
- // only credential-like token names count as sensitive.
62
- export const SENSITIVE_NAME = /(phone|mobile|tel|email|mail|id_?card|idno|identity|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|^token$|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
60
+ // "token" alone is ambiguous: in crypto / payment schemas it is the currency (USDT, BTC…), so a
61
+ // bare `token` column is NOT sensitive by name. Credential-like names (access_token, api_token,
62
+ // token_hash, …) are; a bare `token` counts only in credential tables or with a credential comment
63
+ // (see isCredentialToken). Mask anything else explicitly with the access policy's masked columns.
64
+ export const SENSITIVE_NAME = /(phone|(?:^|_)mobile(?:_?(?:no|num|number))?(?:_|$)|(?:^|_)tel(?:ephone)?(?:_?(?:no|num|number))?(?:_|$)|e_?mail|(?:^|_)mail(?:_?addr(?:ess)?)?(?:_|$)|id_?card|idno|identity_?(?:no|num|number|card)|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
65
+ const CREDENTIAL_TABLE = /(session|oauth|auth|login|credential|api_?key|access_?key|personal_?access|user_?token|refresh_?token|jwt|sso)/i
66
+ const CREDENTIAL_COMMENT = /(令牌|凭证|凭据|密钥|访问码|登录态|credential|access token|auth token|bearer|jwt)/i
67
+ /** A bare `token` column that holds a credential (by table name or comment), not a currency. */
68
+ export function isCredentialToken(name, table, comment) {
69
+ return /^token$/i.test(String(name ?? '')) && (CREDENTIAL_TABLE.test(String(table ?? '')) || CREDENTIAL_COMMENT.test(String(comment ?? '')))
70
+ }
71
+ /** Bumped whenever the sensitive-column rules change (part of result cache keys). */
72
+ export const SENSITIVE_RULES_VERSION = 3
63
73
 
64
74
  export function maskValue(column, value) {
65
75
  if (value === null || value === undefined) return value
66
76
  const s = String(value)
67
- if (/(password|passwd|pwd|secret|token|密码)/i.test(column)) return '******'
77
+ if (/(password|passwd|pwd|secret|密码)/i.test(column) || /(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|token_?(?:hash|secret|key)$|^token$/i.test(column)) return '******'
68
78
  if (/(email|mail|邮箱)/i.test(column) && s.includes('@')) {
69
79
  const [u, d] = s.split('@')
70
80
  return `${u.slice(0, 1)}***@${d}`
@@ -91,10 +101,9 @@ export function classifyColumn(col, table) {
91
101
  if (t === 'enum' || t === 'set' || (ENUM_NAME.test(name) && (t === 'tinyint' || t === 'smallint' || t === 'char' || t === 'varchar' || t === 'int'))) roles.push('enum')
92
102
  if (NUMERIC_TYPES.has(t) && !roles.includes('id') && !roles.includes('enum') && !roles.includes('time') && !/^is_|^has_/.test(name)) roles.push('measure')
93
103
  if (TEXT_TYPES.has(t) && GEO_NAME.test(name)) roles.push('geo')
94
- if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '')) roles.push('sensitive')
104
+ if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '') || isCredentialToken(name, typeof table === 'string' ? table : table?.name, col.comment)) roles.push('sensitive')
95
105
  if (/^is_|^has_/.test(name) || col.columnType === 'tinyint(1)' || t === 'bit') roles.push('flag')
96
106
  if (roles.length === 0 && TEXT_TYPES.has(t)) roles.push('text')
97
- void table
98
107
  return roles
99
108
  }
100
109