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 +32 -0
- mysql_proxy/__init__.py +1 -0
- mysql_proxy/protocol.py +342 -0
- mysql_proxy/server.py +661 -0
- pyproject.toml +33 -0
- yearning_cli/__init__.py +1 -0
- yearning_cli/__main__.py +3 -0
- yearning_cli/cli.py +459 -0
- yearning_cli/client.py +636 -0
- yearning_cli-0.1.5.dist-info/METADATA +192 -0
- yearning_cli-0.1.5.dist-info/RECORD +13 -0
- yearning_cli-0.1.5.dist-info/WHEEL +4 -0
- yearning_cli-0.1.5.dist-info/entry_points.txt +2 -0
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` 配置了默认数据源(用于用户名不匹配时的回退)
|
mysql_proxy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MySQL protocol proxy — connect MySQL clients to Yearning audit platform."""
|
mysql_proxy/protocol.py
ADDED
|
@@ -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
|