yearning-cli 0.1.5__py3-none-any.whl

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.
mysql_proxy/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # MySQL 协议代理
2
+
3
+ MySQL 协议代理模块,让 DBeaver、Navicat、MySQL CLI 等客户端通过 Yearning 审计平台执行查询。
4
+
5
+ 通过 `sql proxy` 子命令启动。详见 [主 README](../README.md#mysql-协议代理)。
6
+
7
+ ## 原理
8
+
9
+ ```
10
+ ┌──────────────┐ MySQL协议 ┌──────────────┐ HTTP/WS ┌──────────────┐
11
+ │ MySQL 客户端 │ ────────────▶ │ sql proxy │ ────────────▶ │ Yearning │
12
+ │ (Navicat等) │ │ (3307端口) │ │ (审计平台) │
13
+ └──────────────┘ └──────────────┘ └──────────────┘
14
+ ```
15
+
16
+ 代理服务器默认监听 `0.0.0.0:3307`,将 MySQL 协议转换为 Yearning API 调用。需要仅允许本机访问时可启动 `sql proxy --host 127.0.0.1`;需要 IPv6 全网卡监听时可使用 `sql proxy --host ::`。
17
+
18
+ ## 数据源切换
19
+
20
+ 连接用户名 = 数据源名称。免密码登录,自动解析数据源:
21
+
22
+ | 用户名 | 行为 |
23
+ |--------|------|
24
+ | `数据源名称` | 精确匹配该数据源 |
25
+ | `root` / 任意不存在的名称 | 回退到 `~/.sqlrc` 配置的默认数据源 |
26
+
27
+ 密码字段无需填写,任意值均可。
28
+
29
+ ## 前提条件
30
+
31
+ 1. 已运行 `sql login` 登录 Yearning,或已配置 `YEARNING_USER`/`YEARNING_PASS`、`~/.sqlrc` 的 `yearning_user`/`yearning_pass` 供 token 失效时自动登录
32
+ 2. `~/.sqlrc` 配置了默认数据源(用于用户名不匹配时的回退)
@@ -0,0 +1 @@
1
+ """MySQL protocol proxy — connect MySQL clients to Yearning audit platform."""
@@ -0,0 +1,342 @@
1
+ """MySQL protocol encoding/decoding utilities."""
2
+
3
+ import hashlib
4
+ import struct
5
+
6
+ # MySQL column types
7
+ MYSQL_TYPE_VARCHAR = 0xFC
8
+ MYSQL_TYPE_LONG = 0x03
9
+ MYSQL_TYPE_LONGLONG = 0x08
10
+ MYSQL_TYPE_DOUBLE = 0x05
11
+ MYSQL_TYPE_DECIMAL = 0x00
12
+ MYSQL_TYPE_DATETIME = 0x0C
13
+ MYSQL_TYPE_DATE = 0x0A
14
+ MYSQL_TYPE_TIMESTAMP = 0x07
15
+ MYSQL_TYPE_VAR_STRING = 0xFD
16
+ MYSQL_TYPE_STRING = 0xFE
17
+ MYSQL_TYPE_BLOB = 0xFC
18
+ MYSQL_TYPE_NULL = 0x06
19
+
20
+ # Capability flags
21
+ CLIENT_LONG_PASSWORD = 0x00000001
22
+ CLIENT_FOUND_ROWS = 0x00000002
23
+ CLIENT_LONG_FLAG = 0x00000004
24
+ CLIENT_CONNECT_WITH_DB = 0x00000008
25
+ CLIENT_PROTOCOL_41 = 0x00000200
26
+ CLIENT_INTERACTIVE = 0x00000400
27
+ CLIENT_TRANSACTIONS = 0x00002000
28
+ CLIENT_SECURE_CONNECTION = 0x00008000
29
+ CLIENT_MULTI_STATEMENTS = 0x00010000
30
+ CLIENT_MULTI_RESULTS = 0x00020000
31
+ CLIENT_PLUGIN_AUTH = 0x00080000
32
+ CLIENT_CONNECT_ATTRS = 0x00100000
33
+ CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000
34
+
35
+ SERVER_STATUS_AUTOCOMMIT = 0x0002
36
+ SERVER_STATUS_NO_INDEX_USED = 0x0010
37
+
38
+ DEFAULT_CAPABILITIES = (
39
+ CLIENT_LONG_PASSWORD
40
+ | CLIENT_FOUND_ROWS
41
+ | CLIENT_LONG_FLAG
42
+ | CLIENT_CONNECT_WITH_DB
43
+ | CLIENT_PROTOCOL_41
44
+ | CLIENT_INTERACTIVE
45
+ | CLIENT_TRANSACTIONS
46
+ | CLIENT_SECURE_CONNECTION
47
+ | CLIENT_MULTI_STATEMENTS
48
+ | CLIENT_MULTI_RESULTS
49
+ | CLIENT_PLUGIN_AUTH
50
+ | CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA
51
+ )
52
+
53
+
54
+ def make_packet(payload: bytes, seq: int) -> bytes:
55
+ """Wrap payload in MySQL packet format: 3-byte length + 1-byte seq + payload.
56
+
57
+ Sequence number wraps at 256 per MySQL protocol spec.
58
+ """
59
+ length = len(payload)
60
+ header = struct.pack("<I", length)[:3] + struct.pack("B", seq % 256)
61
+ return header + payload
62
+
63
+
64
+ def parse_packet(data: bytes):
65
+ """Parse MySQL packet. Returns (payload, seq_id, remaining_data)."""
66
+ if len(data) < 4:
67
+ return None, None, data
68
+ length = struct.unpack("<I", data[:3] + b"\x00")[0]
69
+ seq = data[3]
70
+ if len(data) < 4 + length:
71
+ return None, None, data
72
+ payload = data[4 : 4 + length]
73
+ remaining = data[4 + length :]
74
+ return payload, seq, remaining
75
+
76
+
77
+ def read_null_terminated_string(data: bytes, offset: int = 0):
78
+ """Read a null-terminated string from bytes."""
79
+ end = data.index(b"\x00", offset)
80
+ return data[offset:end].decode("utf-8", errors="replace"), end + 1
81
+
82
+
83
+ def read_lenenc_int(data: bytes, offset: int = 0):
84
+ """Read a length-encoded integer."""
85
+ first = data[offset]
86
+ if first < 0xFB:
87
+ return first, offset + 1
88
+ elif first == 0xFC:
89
+ return struct.unpack("<H", data[offset + 1 : offset + 3])[0], offset + 3
90
+ elif first == 0xFD:
91
+ return struct.unpack("<I", data[offset + 1 : offset + 4] + b"\x00")[0], offset + 4
92
+ elif first == 0xFE:
93
+ return struct.unpack("<Q", data[offset + 1 : offset + 9])[0], offset + 9
94
+ else:
95
+ return 0, offset + 1
96
+
97
+
98
+ def write_lenenc_int(value: int) -> bytes:
99
+ """Encode an integer as length-encoded."""
100
+ if value < 0xFB:
101
+ return struct.pack("B", value)
102
+ elif value < 0x10000:
103
+ return b"\xfc" + struct.pack("<H", value)
104
+ elif value < 0x1000000:
105
+ return b"\xfd" + struct.pack("<I", value)[:3]
106
+ else:
107
+ return b"\xfe" + struct.pack("<Q", value)
108
+
109
+
110
+ def write_lenenc_str(value: bytes) -> bytes:
111
+ """Encode bytes as length-encoded string."""
112
+ return write_lenenc_int(len(value)) + value
113
+
114
+
115
+ def make_handshake(connection_id: int, salt: bytes, server_version: str = "8.0.0-proxy") -> bytes:
116
+ """Create MySQL handshake V10 packet."""
117
+ payload = bytearray()
118
+ payload.append(0x0A) # protocol version
119
+ payload.extend(server_version.encode("utf-8") + b"\x00")
120
+ payload.extend(struct.pack("<I", connection_id))
121
+ payload.extend(salt[:8]) # auth_plugin_data_part_1
122
+ payload.append(0x00) # filler
123
+
124
+ cap_lower = DEFAULT_CAPABILITIES & 0xFFFF
125
+ cap_upper = (DEFAULT_CAPABILITIES >> 16) & 0xFFFF
126
+ payload.extend(struct.pack("<H", cap_lower))
127
+ payload.append(0x21) # utf8mb4 charset
128
+ payload.extend(struct.pack("<H", SERVER_STATUS_AUTOCOMMIT))
129
+ payload.extend(struct.pack("<H", cap_upper))
130
+ payload.append(21) # auth_plugin_data_length (8 + 12 + 1 null = 21)
131
+ payload.extend(b"\x00" * 10) # reserved
132
+
133
+ # auth_plugin_data_part_2 (12 bytes + null terminator)
134
+ payload.extend(salt[8:20] + b"\x00")
135
+ payload.extend(b"mysql_native_password\x00")
136
+
137
+ return make_packet(bytes(payload), 0)
138
+
139
+
140
+ def make_ok(affected_rows=0, last_id=0, status=SERVER_STATUS_AUTOCOMMIT, warnings=0, message="", seq=1):
141
+ """Create OK packet."""
142
+ payload = bytearray()
143
+ payload.append(0x00)
144
+ payload.extend(write_lenenc_int(affected_rows))
145
+ payload.extend(write_lenenc_int(last_id))
146
+ payload.extend(struct.pack("<H", status))
147
+ payload.extend(struct.pack("<H", warnings))
148
+ payload.extend(message.encode("utf-8"))
149
+ return make_packet(bytes(payload), seq)
150
+
151
+
152
+ def make_err(error_code=1045, message="Access denied", sql_state="HY000", seq=1):
153
+ """Create ERR packet."""
154
+ payload = bytearray()
155
+ payload.append(0xFF)
156
+ payload.extend(struct.pack("<H", error_code))
157
+ payload.append(0x23) # '#' marker
158
+ payload.extend(sql_state.encode("utf-8"))
159
+ payload.extend(message.encode("utf-8"))
160
+ return make_packet(bytes(payload), seq)
161
+
162
+
163
+ def make_eof(warnings=0, status=SERVER_STATUS_AUTOCOMMIT, seq=1):
164
+ """Create EOF packet."""
165
+ payload = bytearray()
166
+ payload.append(0xFE)
167
+ payload.extend(struct.pack("<H", warnings))
168
+ payload.extend(struct.pack("<H", status))
169
+ return make_packet(bytes(payload), seq)
170
+
171
+
172
+ def make_column_def(name: str, col_type=MYSQL_TYPE_VARCHAR, seq=0):
173
+ """Create column definition packet."""
174
+ payload = bytearray()
175
+ payload.extend(write_lenenc_str(b"def")) # catalog
176
+ payload.extend(write_lenenc_str(b"")) # schema
177
+ payload.extend(write_lenenc_str(b"")) # table
178
+ payload.extend(write_lenenc_str(b"")) # org_table
179
+ payload.extend(write_lenenc_str(name.encode("utf-8"))) # name
180
+ payload.extend(write_lenenc_str(name.encode("utf-8"))) # org_name
181
+ payload.append(0x0C) # next_length (fixed 12)
182
+ payload.extend(struct.pack("<H", 0x21)) # utf8mb4 charset
183
+ payload.extend(struct.pack("<I", 256)) # column length
184
+ payload.append(col_type)
185
+ payload.extend(struct.pack("<H", 0x0000)) # flags
186
+ payload.append(0x00) # decimals
187
+ payload.extend(b"\x00\x00") # filler
188
+ return make_packet(bytes(payload), seq)
189
+
190
+
191
+ def make_row(values: list, seq=0):
192
+ """Create row data packet. Each value is lenenc-string or NULL (0xFB)."""
193
+ payload = bytearray()
194
+ for v in values:
195
+ if v is None:
196
+ payload.append(0xFB)
197
+ else:
198
+ s = str(v).encode("utf-8")
199
+ payload.extend(write_lenenc_str(s))
200
+ return make_packet(bytes(payload), seq)
201
+
202
+
203
+ def parse_handshake_response(payload: bytes):
204
+ """Parse client handshake response. Returns (username, auth_response, database)."""
205
+ offset = 0
206
+ # capability flags (4 bytes)
207
+ capabilities = struct.unpack("<I", payload[offset : offset + 4])[0]
208
+ offset += 4
209
+ # max packet size (4 bytes)
210
+ offset += 4
211
+ # character set (1 byte)
212
+ offset += 1
213
+ # reserved (23 bytes)
214
+ offset += 23
215
+ # username (null-terminated)
216
+ username, offset = read_null_terminated_string(payload, offset)
217
+ # auth response
218
+ auth_response = b""
219
+ if capabilities & CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA:
220
+ auth_len, offset = read_lenenc_int(payload, offset)
221
+ auth_response = payload[offset : offset + auth_len]
222
+ offset += auth_len
223
+ elif capabilities & CLIENT_SECURE_CONNECTION:
224
+ auth_len = payload[offset]
225
+ offset += 1
226
+ auth_response = payload[offset : offset + auth_len]
227
+ offset += auth_len
228
+ # database
229
+ database = ""
230
+ if capabilities & CLIENT_CONNECT_WITH_DB and offset < len(payload):
231
+ try:
232
+ database, offset = read_null_terminated_string(payload, offset)
233
+ except ValueError:
234
+ pass
235
+ return username, auth_response, database
236
+
237
+
238
+ def verify_password(salt: bytes, auth_response: bytes, password: str) -> bool:
239
+ """Verify mysql_native_password authentication."""
240
+ if not password:
241
+ return auth_response == b""
242
+ stage1 = hashlib.sha1(password.encode("utf-8")).digest()
243
+ stage2 = hashlib.sha1(stage1).digest()
244
+ combined = hashlib.sha1(salt + stage2).digest()
245
+ expected = bytes(a ^ b for a, b in zip(combined, stage1))
246
+ return expected == auth_response
247
+
248
+
249
+ def infer_column_type(value) -> int:
250
+ """Infer MySQL column type from Python value."""
251
+ if value is None:
252
+ return MYSQL_TYPE_NULL
253
+ if isinstance(value, bool):
254
+ return MYSQL_TYPE_LONG
255
+ if isinstance(value, int):
256
+ return MYSQL_TYPE_LONGLONG
257
+ if isinstance(value, float):
258
+ return MYSQL_TYPE_DOUBLE
259
+ return MYSQL_TYPE_VARCHAR
260
+
261
+
262
+ def make_result_set(columns: list, rows: list, seq_start=1):
263
+ """Build complete result set packets. Returns list of (packet_bytes, seq).
264
+
265
+ Kept for backward compatibility (small result sets, simple results).
266
+ For large result sets, use stream_result_set instead.
267
+ """
268
+ packets = []
269
+ seq = seq_start
270
+
271
+ # Column count
272
+ packets.append((make_packet(write_lenenc_int(len(columns)), seq), seq))
273
+ seq += 1
274
+
275
+ # Column definitions
276
+ for i, col in enumerate(columns):
277
+ col_name = str(col)
278
+ # Infer type from first row
279
+ col_type = MYSQL_TYPE_VARCHAR
280
+ if rows:
281
+ val = rows[0][i] if i < len(rows[0]) else None
282
+ col_type = infer_column_type(val)
283
+ packets.append((make_column_def(col_name, col_type, seq), seq))
284
+ seq += 1
285
+
286
+ # EOF after columns
287
+ packets.append((make_eof(seq=seq), seq))
288
+ seq += 1
289
+
290
+ # Row data
291
+ for row in rows:
292
+ values = [row[i] if i < len(row) else None for i in range(len(columns))]
293
+ packets.append((make_row(values, seq), seq))
294
+ seq += 1
295
+
296
+ # EOF after rows
297
+ status = SERVER_STATUS_AUTOCOMMIT | SERVER_STATUS_NO_INDEX_USED
298
+ packets.append((make_eof(status=status, seq=seq), seq))
299
+ seq += 1
300
+
301
+ return packets
302
+
303
+
304
+ def make_result_header(columns: list, rows_sample: list, seq_start=1):
305
+ """Build column count + column definitions + EOF header packets.
306
+
307
+ Returns (list_of_packets, next_seq). Use with stream_rows for large result sets.
308
+ """
309
+ packets = []
310
+ seq = seq_start
311
+
312
+ packets.append((make_packet(write_lenenc_int(len(columns)), seq), seq))
313
+ seq += 1
314
+
315
+ for i, col in enumerate(columns):
316
+ col_name = str(col)
317
+ col_type = MYSQL_TYPE_VARCHAR
318
+ if rows_sample:
319
+ val = rows_sample[0][i] if i < len(rows_sample[0]) else None
320
+ col_type = infer_column_type(val)
321
+ packets.append((make_column_def(col_name, col_type, seq), seq))
322
+ seq += 1
323
+
324
+ packets.append((make_eof(seq=seq), seq))
325
+ seq += 1
326
+
327
+ return packets, seq
328
+
329
+
330
+ def stream_rows(columns: list, rows: list, seq_start: int):
331
+ """Yield row packets one by one, then final EOF packet.
332
+
333
+ This avoids building all row packets in memory at once.
334
+ """
335
+ seq = seq_start
336
+ for row in rows:
337
+ values = [row[i] if i < len(row) else None for i in range(len(columns))]
338
+ yield make_row(values, seq), seq
339
+ seq += 1
340
+
341
+ status = SERVER_STATUS_AUTOCOMMIT | SERVER_STATUS_NO_INDEX_USED
342
+ yield make_eof(status=status, seq=seq), seq