dsh-switchman 1.3.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.
@@ -0,0 +1,137 @@
1
+ <!-- [2026-09-05]-[translated to English (skill is now English-only)]-[content unchanged] -->
2
+ # MySQL Syntax Matrix & Query Recipes
3
+
4
+ ## Allowed / Rejected Statement Matrix
5
+
6
+ ### ✅ Allowed
7
+
8
+ | Statement type | Example | Notes |
9
+ |----------|------|------|
10
+ | `SELECT` | `SELECT id, name FROM users WHERE id = 42` | Most common |
11
+ | `WITH...SELECT` | `WITH c AS (SELECT 1) SELECT * FROM c` | CTE supported |
12
+ | `SHOW` | `SHOW TABLES`, `SHOW CREATE TABLE t` | Metadata queries |
13
+ | `DESCRIBE` / `DESC` | `DESC users`, `DESCRIBE users name` | Table structure |
14
+ | `EXPLAIN SELECT` | `EXPLAIN SELECT * FROM users` | EXPLAIN SELECT only, no ANALYZE |
15
+
16
+ ### ❌ Rejected
17
+
18
+ | Statement type | Rejection reason |
19
+ |----------|----------|
20
+ | `INSERT / UPDATE / DELETE / REPLACE` | Write operations |
21
+ | `DROP / ALTER / CREATE / TRUNCATE / RENAME` | DDL |
22
+ | `GRANT / REVOKE` | Privilege management |
23
+ | `SET` | Session variable changes |
24
+ | `LOCK / UNLOCK TABLES` | Explicit locks |
25
+ | `CALL / EXECUTE` | Stored procedures |
26
+ | `LOAD DATA / INTO OUTFILE` | File operations |
27
+ | `START TRANSACTION / COMMIT / ROLLBACK` | Transaction control is managed by the script |
28
+ | `HANDLER` | Low-level table access interface |
29
+ | All comments (`#`, `-- `, `/* */`, `/*! */`, `/*+ */`) | Can nest malicious SQL |
30
+ | Backslash escapes (`\`) | Can craft dangerous syntax |
31
+ | Multiple statements (`;` separated) | Prevents concatenation injection |
32
+ | `FOR UPDATE` / `LOCK IN SHARE MODE` | Explicit locking |
33
+ | `SLEEP()` / `BENCHMARK()` / `GET_LOCK()` | Potential DoS |
34
+ | `INTO OUTFILE` | Server-side file writes |
35
+ | `EXPLAIN ANALYZE` | Actually executes the query; blocked as a security trade-off |
36
+
37
+ ### Common "legal" forms that get rejected, and alternatives
38
+
39
+ | Intent | Rejected form | Alternative |
40
+ |--------|-------------|----------|
41
+ | Query SQL with comments | `SELECT 1 /* comment */` | Remove the comment |
42
+ | Backticks + trailing comment | `` SELECT `id` FROM `t` -- comment `` | Remove the comment |
43
+ | Dynamic LIMIT (variables) | `PREPARE` + `EXECUTE` | Write a literal number |
44
+ | Execution analysis | `EXPLAIN ANALYZE SELECT ...` | Use `EXPLAIN SELECT ...` (no execution) |
45
+ | Multiple statements at once | `SELECT 1; SELECT 2` | Split into separate calls |
46
+ | Write inside a subquery | `SELECT (UPDATE ...)` | Not allowed; all writes are refused |
47
+
48
+ ## Common read-only recipes
49
+
50
+ ### Single record by primary key
51
+
52
+ ```bash
53
+ ./scripts/mysql-query.js --user=reader --database=mydb \
54
+ 'SELECT id, username, email, status FROM users WHERE id = 42'
55
+ ```
56
+
57
+ ### Recent changes by time range
58
+
59
+ ```bash
60
+ ./scripts/mysql-query.js --user=reader --database=mydb --max-rows=50 \
61
+ "SELECT id, status, updated_at FROM orders WHERE updated_at >= '2024-06-01 00:00:00' ORDER BY updated_at DESC"
62
+ ```
63
+
64
+ ### Aggregate counts (grouped by status)
65
+
66
+ ```bash
67
+ ./scripts/mysql-query.js --user=reader --database=mydb \
68
+ 'SELECT status, COUNT(*) AS cnt FROM users GROUP BY status ORDER BY cnt DESC'
69
+ ```
70
+
71
+ ### JOIN to verify related data
72
+
73
+ ```bash
74
+ # Verify order ↔ user consistency
75
+ ./scripts/mysql-query.js --user=reader --database=mydb --max-rows=30 \
76
+ 'SELECT o.id AS order_id, o.user_id, u.username, o.amount FROM orders o JOIN users u ON o.user_id = u.id WHERE o.status = "pending" ORDER BY o.created_at DESC'
77
+ ```
78
+
79
+ ### Execution plan
80
+
81
+ ```bash
82
+ ./scripts/mysql-query.js --user=reader --database=mydb \
83
+ 'EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND created_at >= "2024-01-01"'
84
+ ```
85
+
86
+ ### Table structure
87
+
88
+ ```bash
89
+ ./scripts/mysql-query.js --user=reader --database=mydb 'DESCRIBE orders'
90
+
91
+ # or use SHOW
92
+ ./scripts/mysql-query.js --user=reader --database=mydb 'SHOW CREATE TABLE orders'
93
+ ```
94
+
95
+ ### Connectivity test
96
+
97
+ ```bash
98
+ ./scripts/mysql-query.js --user=reader --database=mydb 'SELECT 1'
99
+ ```
100
+
101
+ ## LIMIT & truncation behavior
102
+
103
+ | Scenario | Behavior |
104
+ |------|------|
105
+ | SQL has no `LIMIT` clause | Script auto-injects `LIMIT <max-rows>` (default 200) |
106
+ | SQL has `LIMIT n` with `n <= max-rows` | Executed as-is |
107
+ | SQL has `LIMIT n` with `n > max-rows` | Validation rejects it: LIMIT exceeds the cap |
108
+ | Actual rows reach `max-rows` | Trailing notice "result truncated"; refine the query and re-run |
109
+
110
+ > ⚠️ The auto-injected LIMIT applies to the outermost query. Subqueries and UNION internals are unaffected (the outer LIMIT still protects).
111
+
112
+ ## Field type output conventions
113
+
114
+ | MySQL type | Output form | Notes |
115
+ |------------|----------|------|
116
+ | INT / TINYINT / SMALLINT / MEDIUMINT | Number | Normal range |
117
+ | BIGINT | String | Avoids exceeding JS Number.MAX_SAFE_INTEGER |
118
+ | DECIMAL / NUMERIC | String | Avoids floating-point precision loss |
119
+ | FLOAT / DOUBLE | Number | Precision loss possible |
120
+ | DATE / DATETIME / TIMESTAMP | String | `dateStrings` mode, no timezone conversion |
121
+ | CHAR / VARCHAR / TEXT | String | As-is |
122
+ | JSON | Formatted string | Pretty-printed after JSON.parse |
123
+ | BLOB / BINARY | Base64 string | Binary-safe representation |
124
+ | NULL | `null` | null in JSON, NULL in table output |
125
+
126
+ ## Offline validation
127
+
128
+ Use `--validate-only` to check SQL safety without connecting:
129
+
130
+ ```bash
131
+ # Validate first, then execute
132
+ ./scripts/mysql-query.js --validate-only 'SELECT id FROM users WHERE id = 42'
133
+ # Once it passes, drop --validate-only and execute
134
+ ./scripts/mysql-query.js --user=reader --database=mydb 'SELECT id FROM users WHERE id = 42'
135
+ ```
136
+
137
+ Validation covers: statement type allowlist, comments/backslash/multi-statement/dangerous functions, and LIMIT compliance.
@@ -0,0 +1,213 @@
1
+ <!-- [2026-09-05]-[translated to English (skill is now English-only)]-[content unchanged] -->
2
+ # Redis Command Whitelist & Query Patterns
3
+
4
+ ## Full command-args-return reference
5
+
6
+ ### General / key operations
7
+
8
+ | Command | Args | Return form | Notes |
9
+ |------|------|----------|------|
10
+ | `PING` | none | `PONG` (string) | Connectivity test |
11
+ | `GET key` | key | String value or `(nil)` | Read a String value |
12
+ | `MGET key [key ...]` | 1+ keys | Array of values (in order) | Batch read Strings |
13
+ | `EXISTS key [key ...]` | 1+ keys | Count of existing keys | Check key existence |
14
+ | `TYPE key` | key | Type string | Key's data type |
15
+ | `TTL key` | key | Remaining seconds (integer) | Time until expiry |
16
+ | `PTTL key` | key | Remaining milliseconds (integer) | Expiry in ms precision |
17
+ | `STRLEN key` | key | String length | Byte length of String value |
18
+ | `SCAN cursor [MATCH pattern] [COUNT count]` | cursor, optional args | Cursor + key list | Incremental key iteration |
19
+ | `KEYS pattern`† | pattern | Key list | Full key match (blocking risk) |
20
+ | `DBSIZE` | none | Total key count | Key count in current db |
21
+ | `TIME` | none | Unix timestamp (sec.µs) | Server current time |
22
+
23
+ ### Hash operations
24
+
25
+ | Command | Args | Return form | Notes |
26
+ |------|------|----------|------|
27
+ | `HGET key field` | key, field | Field value or `(nil)` | Read a single field |
28
+ | `HMGET key field [field ...]` | key, 1+ fields | Array of values | Batch read fields |
29
+ | `HEXISTS key field` | key, field | 1 (exists) / 0 (missing) | Check field existence |
30
+ | `HLEN key` | key | Field count | Number of hash fields |
31
+ | `HGETALL key`† | key | Alternating field-value array | Full dump (blocking risk) |
32
+ | `HKEYS key`† | key | Field name array | All field names (blocking risk) |
33
+ | `HVALS key`† | key | Value array | All values (blocking risk) |
34
+ | `HSCAN key cursor [MATCH pattern] [COUNT count]` | key, cursor, optional | Cursor + field-value pairs | Incremental hash iteration |
35
+
36
+ ### List operations
37
+
38
+ | Command | Args | Return form | Notes |
39
+ |------|------|----------|------|
40
+ | `LINDEX key index` | key, index | Element or `(nil)` | Element by index |
41
+ | `LLEN key` | key | List length | Element count |
42
+ | `LRANGE key start stop` | key, start/stop indexes | Element array | Elements in range |
43
+
44
+ ### Set operations
45
+
46
+ | Command | Args | Return form | Notes |
47
+ |------|------|----------|------|
48
+ | `SISMEMBER key member` | key, member | 1 (member) / 0 (not) | Membership check |
49
+ | `SCARD key` | key | Member count | Set size |
50
+ | `SMEMBERS key`† | key | Member array | Full dump (blocking risk) |
51
+ | `SSCAN key cursor [MATCH pattern] [COUNT count]` | key, cursor, optional | Cursor + member list | Incremental set iteration |
52
+
53
+ ### ZSet operations
54
+
55
+ | Command | Args | Return form | Notes |
56
+ |------|------|----------|------|
57
+ | `ZCARD key` | key | Member count | ZSet size |
58
+ | `ZCOUNT key min max` | key, score range | Count | Count by score range |
59
+ | `ZSCORE key member` | key, member | Score or `(nil)` | Member's score |
60
+ | `ZRANK key member` | key, member | Rank or `(nil)` | Ascending rank (0-based) |
61
+ | `ZREVRANK key member` | key, member | Rank or `(nil)` | Descending rank (0-based) |
62
+ | `ZRANGE key start stop [WITHSCORES]` | key, start/stop, optional | Member (+score) array | Query by rank range |
63
+ | `ZRANGEBYSCORE key min max [WITHSCORES]` | key, score range, optional | Member (+score) array | Query by score range |
64
+ | `ZSCAN key cursor [MATCH pattern] [COUNT count]` | key, cursor, optional | Cursor + member-score pairs | Incremental zset iteration |
65
+
66
+ ### Server info
67
+
68
+ | Command | Args | Return form | Notes |
69
+ |------|------|----------|------|
70
+ | `INFO [section]` | optional section | Info text block | Server info & stats |
71
+ | `CLIENT LIST` | none | Client connection list | Current connections |
72
+ | `CONFIG GET parameter`† | Parameter name | Parameter value | Read config (safe items only) |
73
+
74
+ > † Requires the `--allow-unbounded` flag.
75
+
76
+ ## SCAN-family cursor usage
77
+
78
+ SCAN-family commands iterate incrementally with a cursor and never block Redis.
79
+
80
+ ### Basic pattern
81
+
82
+ ```bash
83
+ # SCAN instead of KEYS to iterate keys
84
+ ./scripts/redis-query.js -- SCAN 0 MATCH user:* COUNT 100
85
+ # Returns: next cursor + key list
86
+ # Cursor 0 means iteration is complete
87
+
88
+ # Multi-round iteration (manual loop)
89
+ ./scripts/redis-query.js -- SCAN 0 MATCH user:* COUNT 100
90
+ # Returns cursor=42, keys=[user:1, user:2, ...]
91
+ ./scripts/redis-query.js -- SCAN 42 MATCH user:* COUNT 100
92
+ # Returns cursor=0 (done)
93
+ ```
94
+
95
+ ### Per-type SCAN
96
+
97
+ ```bash
98
+ # Incremental hash iteration
99
+ ./scripts/redis-query.js -- HSCAN session:abc 0 COUNT 50
100
+ ./scripts/redis-query.js -- HSCAN session:abc 128 COUNT 50 # next round
101
+
102
+ # Incremental set iteration
103
+ ./scripts/redis-query.js -- SSCAN online:users 0 COUNT 100
104
+
105
+ # Incremental zset iteration
106
+ ./scripts/redis-query.js -- ZSCAN leaderboard 0 COUNT 100
107
+ ```
108
+
109
+ ### SCAN caveats
110
+
111
+ - `COUNT` is a hint; actual batch sizes may differ (Redis internals).
112
+ - Cursor `0` means done, but keys added/removed during iteration may be missed or repeated.
113
+ - `MATCH` filters server-side: it reduces network transfer, not traversal work.
114
+
115
+ ## TTL / PTTL return values
116
+
117
+ | Return | Meaning | Notes |
118
+ |--------|------|------|
119
+ | `>= 0` | Remaining lifetime | TTL in seconds, PTTL in milliseconds |
120
+ | `-1` | No expiry | Key exists without a TTL set |
121
+ | `-2` | Key missing | Expired and removed, or never created |
122
+
123
+ ```bash
124
+ ./scripts/redis-query.js -- TTL cache:homepage
125
+ # 300 → expires in 5 minutes
126
+ # -1 → no expiry
127
+ # -2 → key missing
128
+ ```
129
+
130
+ ## Safe query steps per data type
131
+
132
+ Recommended pattern: **run `TYPE` first, then pick the matching command**.
133
+
134
+ ### String
135
+
136
+ ```bash
137
+ ./scripts/redis-query.js -- TYPE user:42 # confirm it's a string
138
+ ./scripts/redis-query.js -- GET user:42 # read value
139
+ ./scripts/redis-query.js -- TTL user:42 # check expiry
140
+ ./scripts/redis-query.js -- STRLEN user:42 # check length
141
+ ```
142
+
143
+ ### Hash
144
+
145
+ ```bash
146
+ ./scripts/redis-query.js -- TYPE user:42 # confirm it's a hash
147
+ ./scripts/redis-query.js -- HLEN user:42 # field count (size hint)
148
+ ./scripts/redis-query.js -- HGET user:42 name # single field
149
+ ./scripts/redis-query.js -- HMGET user:42 name email status # batch fields
150
+ ./scripts/redis-query.js -- HSCAN user:42 0 COUNT 100 # batched iteration (recommended)
151
+ # For small keys, HGETALL works (needs --allow-unbounded)
152
+ ./scripts/redis-query.js --allow-unbounded -- HGETALL user:42
153
+ ```
154
+
155
+ ### List
156
+
157
+ ```bash
158
+ ./scripts/redis-query.js -- TYPE task:queue # confirm it's a list
159
+ ./scripts/redis-query.js -- LLEN task:queue # length
160
+ ./scripts/redis-query.js -- LRANGE task:queue 0 9 # first 10 (non-negative indexes, range ≤ max-items)
161
+ # For the tail: LLEN first, then compute a non-negative index; negative-index LRANGE ranges are unpredictable and need --allow-unbounded
162
+ ./scripts/redis-query.js -- LLEN task:queue
163
+ ./scripts/redis-query.js --allow-unbounded -- LRANGE task:queue -10 -1 # explicitly allowed negative range
164
+ ./scripts/redis-query.js -- LINDEX task:queue -1 # last element (single value; negative index unrestricted)
165
+ ```
166
+
167
+ ### Set
168
+
169
+ ```bash
170
+ ./scripts/redis-query.js -- TYPE online:users # confirm it's a set
171
+ ./scripts/redis-query.js -- SCARD online:users # member count
172
+ ./scripts/redis-query.js -- SISMEMBER online:users user:42 # membership check
173
+ ./scripts/redis-query.js -- SSCAN online:users 0 COUNT 100 # batched iteration
174
+ ```
175
+
176
+ ### ZSet
177
+
178
+ ```bash
179
+ ./scripts/redis-query.js -- TYPE leaderboard # confirm it's a zset
180
+ ./scripts/redis-query.js -- ZCARD leaderboard # member count
181
+ ./scripts/redis-query.js -- ZSCORE leaderboard user:42 # score lookup
182
+ ./scripts/redis-query.js -- ZRANK leaderboard user:42 # ascending rank
183
+ ./scripts/redis-query.js -- ZRANGEBYSCORE leaderboard 100 999 WITHSCORES # score range
184
+ ./scripts/redis-query.js -- ZSCAN leaderboard 0 COUNT 100 # batched iteration
185
+ ```
186
+
187
+ ## Unbounded command risks & SCAN alternatives
188
+
189
+ These commands can block Redis for a long time on big keys (single-threaded model) and **require `--allow-unbounded` by default**:
190
+
191
+ | Dangerous command | Reason | SCAN alternative |
192
+ |----------|------|-----------|
193
+ | `KEYS pattern` | O(N) over all keys | `SCAN 0 MATCH pattern COUNT 100` |
194
+ | `HGETALL key` | O(N) returns all fields | `HSCAN key 0 COUNT 100` |
195
+ | `HKEYS key` | O(N) returns all field names | `HSCAN key 0 COUNT 100` (ignore values) |
196
+ | `HVALS key` | O(N) returns all values | `HSCAN key 0 COUNT 100` (ignore field names) |
197
+ | `SMEMBERS key` | O(N) returns all members | `SSCAN key 0 COUNT 100` |
198
+
199
+ **Production principle**: prefer SCAN; use `--allow-unbounded` only when the key is known to be small.
200
+
201
+ ## CONFIG GET safe-parameter whitelist
202
+
203
+ `CONFIG GET` only allows these parameters (no credential leaks, no service behavior impact):
204
+
205
+ | Parameter | Notes |
206
+ |------|------|
207
+ | `maxmemory` | Max memory limit |
208
+ | `maxmemory-policy` | Eviction policy |
209
+ | `timeout` | Client idle timeout |
210
+ | `tcp-keepalive` | TCP keepalive interval |
211
+ | `databases` | Number of databases |
212
+
213
+ Querying other parameters (e.g. `requirepass`, `masterauth`, `save`) is rejected to prevent leaking sensitive config.
@@ -0,0 +1,176 @@
1
+ <!-- [2026-09-05]-[translated to English (skill is now English-only)]-[content unchanged] -->
2
+ # Read-Only Security Model
3
+
4
+ Security core of this skill: **three layers of defense guarantee read-only; any write operation is intercepted before it reaches the database**.
5
+ Application-layer interception cannot replace database privileges, though — production must use a dedicated read-only account.
6
+
7
+ ## Threat model & attack surface
8
+
9
+ ### Who can trigger this skill
10
+
11
+ - A user asking (via conversation) to query the database → the agent invokes the script
12
+ - A user unintentionally or deliberately passing SQL/commands containing writes
13
+
14
+ ### Attack surface
15
+
16
+ 1. **SQL injection**: achieving writes via SQL syntax (nested comments, newlines inside strings, etc.)
17
+ 2. **Dangerous functions**: calling `SLEEP()`, `BENCHMARK()`, `GET_LOCK()` etc. through a legal SELECT (DoS)
18
+ 3. **Multi-statement injection**: concatenating write statements with semicolons
19
+ 4. **Redis command injection**: executing write commands via arguments or variants
20
+
21
+ ## MySQL three-layer defense
22
+
23
+ ### Layer 1: SQL lexical scanner
24
+
25
+ Before connecting, the script lexically scans the raw SQL text.
26
+
27
+ **Allowed statement types:**
28
+ - `SELECT ...`
29
+ - `WITH ... SELECT ...` (CTE)
30
+ - `SHOW ...`
31
+ - `DESCRIBE` / `DESC ...`
32
+ - `EXPLAIN SELECT ...`
33
+
34
+ **Rejected writes:** INSERT / UPDATE / DELETE / REPLACE / DROP / ALTER / CREATE / TRUNCATE / RENAME / GRANT / REVOKE / SET / LOCK / UNLOCK / CALL / LOAD DATA etc.
35
+
36
+ **Rejected comment forms (all banned, including legal SQL inside comments):**
37
+ - `#` single-line comment
38
+ - `-- ` (double dash + space) single-line comment
39
+ - `/* */` multi-line comment
40
+ - `/*! */` MySQL version comment
41
+ - `/*+ */` optimizer hint
42
+
43
+ > **Why ban comments?** Comments can nest malicious SQL (e.g. `SELECT 1; /* */ DROP TABLE x;`) and hide version-specific syntax. A lexical scanner cannot safely parse comment contents, so they are rejected outright.
44
+
45
+ **Additional restrictions:**
46
+ - No backslash escapes (`\`) — prevents crafting legal-but-dangerous syntax via escapes
47
+ - No multi-statements (`;` separated) — prevents concatenated writes
48
+ - No `INTO OUTFILE` (exports to the server filesystem)
49
+ - No `FOR UPDATE` / `LOCK IN SHARE MODE` (explicit locking)
50
+ - No potential DoS functions like `SLEEP()` / `BENCHMARK()` / `GET_LOCK()`
51
+ - Auto-inject a top-level `LIMIT` when none is present (prevents long-blocking full scans)
52
+
53
+ > **Why a lexical scanner instead of regex?** Regex cannot reliably handle SQL string literals, identifier quoting (backticks), and operator ambiguity. A token-by-token scanner is more reliable than regex, yet it is still not a full SQL parser — complex but legal SQL may be rejected. That is a security trade-off.
54
+
55
+ ### Layer 2: connection-level protection
56
+
57
+ Once connected:
58
+ - `multipleStatements: false`: the mysql2 driver explicitly disables multi-statement execution; even if the scanner misses a semicolon, subsequent statements never run
59
+ - `START TRANSACTION READ ONLY`: an explicit read-only transaction; the MySQL server itself rejects writes
60
+ - Always `ROLLBACK` after the query; never `COMMIT`
61
+
62
+ > **Why START TRANSACTION READ ONLY + ROLLBACK?** Defense in depth: even if the lexical scanner were bypassed (theoretically possible), the read-only transaction intercepts writes server-side, and ROLLBACK guarantees no side effects.
63
+
64
+ ### Layer 3: account-level privileges
65
+
66
+ **Strongly recommended**: a dedicated read-only MySQL account granted only SELECT and SHOW VIEW.
67
+ This is the final boundary — even if both layers above fail, account privileges still block writes.
68
+
69
+ > ⚠️ **The admin commands below are for DBAs only; this skill's scripts never execute them:**
70
+
71
+ ```sql
72
+ -- Read-only account setup example (for reference only)
73
+ CREATE USER 'db_query_reader'@'%' IDENTIFIED BY 'strong-password';
74
+ GRANT SELECT, SHOW VIEW ON `mydb`.* TO 'db_query_reader'@'%';
75
+ FLUSH PRIVILEGES;
76
+ ```
77
+
78
+ ## Redis security model
79
+
80
+ ### Whitelist design
81
+
82
+ Redis uses a **command whitelist**, not a blacklist.
83
+
84
+ **Why a whitelist?**
85
+ - A blacklist must enumerate every dangerous command, but Redis keeps adding new ones and Redis Modules can register custom commands. A whitelist rejects unknown commands by default (fail-closed) — safer.
86
+ - Blacklists are easily bypassed by new/module commands.
87
+
88
+ ### Allowed commands (full list)
89
+
90
+ | Category | Commands | Notes |
91
+ |------|------|------|
92
+ | **General** | PING, GET, MGET, EXISTS, TYPE, TTL, PTTL, STRLEN, SCAN, KEYS†, DBSIZE, TIME | |
93
+ | **Hash** | HGET, HMGET, HEXISTS, HLEN, HGETALL†, HKEYS†, HVALS†, HSCAN | |
94
+ | **List** | LINDEX, LLEN, LRANGE | |
95
+ | **Set** | SISMEMBER, SCARD, SMEMBERS†, SSCAN | |
96
+ | **ZSet** | ZCARD, ZCOUNT, ZSCORE, ZRANK, ZREVRANK, ZRANGE, ZRANGEBYSCORE, ZSCAN | |
97
+ | **Server info** | INFO, CLIENT LIST, CONFIG GET† | CONFIG GET limited to safe items |
98
+
99
+ > † Requires `--allow-unbounded`. These can block Redis on big keys; prefer SCAN in production.
100
+
101
+ ### Rejected commands
102
+
103
+ Everything not on the whitelist is rejected (fail-closed), including but not limited to:
104
+ - **Write commands**: SET, DEL, EXPIRE, INCR, DECR, HSET, LPUSH, SADD, ZADD, PFADD etc.
105
+ - **Dangerous commands**: FLUSHALL, FLUSHDB, SHUTDOWN, DEBUG, EVAL, EVALSHA, SCRIPT
106
+ - **Admin commands**: CONFIG SET, RENAME, MOVE, RESTORE, MIGRATE, SORT (may write to external storage)
107
+ - **Unrecognized commands**: including those registered by Redis Modules
108
+
109
+ ### db is chosen via connection config
110
+
111
+ The Redis `db` number can only be set through the `--db` connection option; the **`SELECT` command is not exposed**.
112
+ This prevents switching databases mid-query and avoids touching the wrong db.
113
+
114
+ ### Gate mechanism for KEYS and other big commands
115
+
116
+ `KEYS`, `HGETALL`, `HKEYS`, `HVALS`, `SMEMBERS` can block Redis for a long time on big keys (single-threaded model).
117
+ They are rejected by default and require an explicit `--allow-unbounded`, forcing awareness of the risk.
118
+
119
+ **Production alternative — SCAN-family commands:**
120
+ - `KEYS pattern` → `SCAN 0 MATCH pattern COUNT 100`
121
+ - `HGETALL key` → `HSCAN key 0 COUNT 100`
122
+ - `SMEMBERS key` → `SSCAN key 0 COUNT 100`
123
+
124
+ ### CONFIG GET safe-parameter whitelist
125
+
126
+ `CONFIG GET` only allows these safe parameters (read-only config that doesn't affect service behavior):
127
+ - `maxmemory`
128
+ - `maxmemory-policy`
129
+ - `timeout`
130
+ - `tcp-keepalive`
131
+ - `databases`
132
+
133
+ Other config items (e.g. `requirepass`, `masterauth`) may leak credentials and are always rejected.
134
+
135
+ ### Redis ACL as the final server-side boundary
136
+
137
+ **Strongly recommended**: a dedicated Redis ACL user for queries:
138
+ ```
139
+ # Redis ACL example (for DBAs/ops only)
140
+ ACL SETUSER db_query_reader on >password ~* +@read +ping +info +client|list +config|get
141
+ ```
142
+
143
+ > ⚠️ A client-side timeout (`--timeout-ms`) only closes the client socket; it **does not cancel the command running server-side**. If a command takes too long on the server (e.g. `KEYS *` over a million keys), Redis keeps executing it after the client disconnects, blocking other requests meanwhile. This is inherent to Redis's single-threaded model, which is why the script gates dangerous commands client-side.
144
+
145
+ ## Credentials & TLS conventions
146
+
147
+ ### Password safety
148
+
149
+ - **No CLI `--password`**: command-line arguments land in shell history (`~/.bash_history` / `~/.zsh_history`) and may be readable by others.
150
+ - **Recommended**:
151
+ 1. Environment variables (`DB_QUERY_MYSQL_PASSWORD` / `DB_QUERY_REDIS_PASSWORD`)
152
+ 2. Env file (`--env-file=<path>`) with `0600` permissions
153
+ - Scripts never echo passwords in output.
154
+
155
+ ### TLS
156
+
157
+ - Both MySQL and Redis support `--tls` for encrypted connections.
158
+ - `--ca-file` specifies a CA certificate for server identity verification.
159
+ - **Enable TLS** for cross-network connections in production.
160
+
161
+ ## Failure mode: fail-closed
162
+
163
+ All safety checks follow the **fail-closed** principle:
164
+ - Unknown token in the SQL lexical scan → reject
165
+ - Unknown Redis command → reject
166
+ - Timeout → disconnect, never continue
167
+ - No "lenient mode" or fallback — a rejection is a rejection
168
+
169
+ ## Known limitations
170
+
171
+ | Limitation | Impact | Mitigation |
172
+ |------|------|------|
173
+ | Lexer is not a full SQL parser | Complex but legal SQL (comments, backslash escapes, dynamic LIMIT, EXPLAIN ANALYZE) is rejected | Security trade-off; no lenient fallback added |
174
+ | MySQL `MAX_EXECUTION_TIME` only on 5.7.8+/8.0 | Query timeout not guaranteed on MariaDB | Dedicated read-only account + connection-level timeout as backstop |
175
+ | Redis client timeout doesn't cancel server-side commands | Big-key commands can keep blocking Redis | Gate dangerous commands client-side; restrict via ACL server-side |
176
+ | Application-layer checks can't replace DB privileges | A script bug could bypass client-side checks | Dedicated read-only account + ACL mandatory in production/shared environments |
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 严格 CLI 参数解析器
5
+ * 支持 --key=value、--key value、布尔 flag、-- 分隔 SQL/Redis 命令
6
+ * 未知选项、重复冲突、越界数字 → 立即失败(fail-closed)
7
+ */
8
+
9
+ class CliError extends Error {
10
+ constructor(msg) { super(msg); this.name = 'CliError'; }
11
+ }
12
+
13
+ /**
14
+ * 解析 CLI 参数,返回 { options: {}, rest: string[] }
15
+ * @param {string[]} argv - process.argv.slice(2)
16
+ * @param {object} spec - 参数规格 { name: { type, default, min, max, alias } }
17
+ */
18
+ function parseCli(argv, spec) {
19
+ const options = {};
20
+ const rest = [];
21
+ const seen = new Set();
22
+
23
+ // 填充默认值
24
+ for (const [key, def] of Object.entries(spec)) {
25
+ if (def.default !== undefined) options[key] = def.default;
26
+ }
27
+
28
+ let i = 0;
29
+ while (i < argv.length) {
30
+ const arg = argv[i];
31
+
32
+ // -- 分隔符:后续全部作为位置参数
33
+ if (arg === '--') {
34
+ rest.push(...argv.slice(i + 1));
35
+ break;
36
+ }
37
+
38
+ // --option=value 或 --option value 形式
39
+ if (arg.startsWith('--')) {
40
+ let key, value;
41
+ const eqIdx = arg.indexOf('=');
42
+ if (eqIdx !== -1) {
43
+ key = arg.slice(2, eqIdx);
44
+ value = arg.slice(eqIdx + 1);
45
+ } else {
46
+ key = arg.slice(2);
47
+ // 查找规格中该选项的类型
48
+ const s = findSpec(spec, key);
49
+ if (!s) throw new CliError(`未知选项: --${key}`);
50
+ if (s.type === 'boolean') {
51
+ value = true;
52
+ } else {
53
+ i++;
54
+ if (i >= argv.length) throw new CliError(`--${key} 需要值`);
55
+ value = argv[i];
56
+ }
57
+ }
58
+
59
+ const resolved = resolveAlias(spec, key);
60
+ if (!resolved) throw new CliError(`未知选项: --${key}`);
61
+
62
+ // 重复选项检查
63
+ if (seen.has(resolved)) throw new CliError(`重复选项: --${resolved}`);
64
+ seen.add(resolved);
65
+
66
+ const s = spec[resolved];
67
+ options[resolved] = coerceValue(resolved, value, s);
68
+ } else {
69
+ // 非选项参数 → 位置参数
70
+ rest.push(arg);
71
+ }
72
+ i++;
73
+ }
74
+
75
+ return { options, rest };
76
+ }
77
+
78
+ /** 在 spec 或 alias 中查找选项名 */
79
+ function findSpec(spec, key) {
80
+ if (spec[key]) return spec[key];
81
+ for (const s of Object.values(spec)) {
82
+ if (s.alias === key) return s;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** 解析别名到规范名 */
88
+ function resolveAlias(spec, key) {
89
+ if (spec[key]) return key;
90
+ for (const [name, s] of Object.entries(spec)) {
91
+ if (s.alias === key) return name;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** 类型转换 + 边界检查 */
97
+ function coerceValue(key, raw, spec) {
98
+ if (spec.type === 'boolean') {
99
+ if (raw === 'true' || raw === true) return true;
100
+ if (raw === 'false' || raw === false) return false;
101
+ throw new CliError(`--${key} 期望布尔值,收到: ${raw}`);
102
+ }
103
+ if (spec.type === 'integer') {
104
+ const n = Number(raw);
105
+ if (!Number.isInteger(n)) throw new CliError(`--${key} 期望整数,收到: ${raw}`);
106
+ if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key} 最小值 ${spec.min},收到: ${n}`);
107
+ if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key} 最大值 ${spec.max},收到: ${n}`);
108
+ return n;
109
+ }
110
+ if (spec.type === 'string' && spec.choices) {
111
+ const lower = String(raw).toLowerCase();
112
+ if (!spec.choices.includes(lower)) throw new CliError(`--${key} 必须为 ${spec.choices.join('|')} 之一,收到: ${raw}`);
113
+ return lower;
114
+ }
115
+ return String(raw);
116
+ }
117
+
118
+ module.exports = { parseCli, CliError };