dsh-napcat-keeper 2.1.2 → 2.5.1
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 +95 -0
- package/README.md +123 -23
- package/lib/client.js +355 -30
- package/lib/index.js +1572 -67
- package/package.json +11 -1
- package/scripts/check-manifest.mjs +400 -0
- package/scripts/check-privacy.mjs +176 -0
- package/scripts/selftest-scan.mjs +69 -0
- package/scripts/selftest-setup.mjs +210 -0
- package/scripts/smoke-client.mjs +197 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-napcat-keeper",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "发现没有能够随着dsh打开自动启动napcat的插件,遂使用大肥鱼制作此插件,建议搭配Yuuyuko-uu的dsh-qq-bridge使用~",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,11 +11,21 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"lib",
|
|
14
|
+
"scripts",
|
|
14
15
|
"cordis.patch.yml",
|
|
15
16
|
"README.md",
|
|
16
17
|
"CHANGELOG.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"check": "node scripts/check-manifest.mjs && node scripts/check-privacy.mjs",
|
|
22
|
+
"check:manifest": "node scripts/check-manifest.mjs",
|
|
23
|
+
"check:privacy": "node scripts/check-privacy.mjs",
|
|
24
|
+
"check:fix": "node scripts/check-manifest.mjs --fix",
|
|
25
|
+
"smoke:client": "node scripts/smoke-client.mjs",
|
|
26
|
+
"selftest:scan": "node scripts/selftest-scan.mjs",
|
|
27
|
+
"selftest:setup": "node scripts/selftest-setup.mjs"
|
|
28
|
+
},
|
|
19
29
|
"keywords": [
|
|
20
30
|
"deepseek-harness",
|
|
21
31
|
"dsh-plugin",
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-manifest.mjs —— 插件清单体检(0 依赖,纯 Node)
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有这个:DSH 加载本地插件时对它自己的 package.json 做的是「干净」的
|
|
6
|
+
* JSON.parse,一旦文件带了 UTF-8 BOM(EF BB BF),就会当场炸:
|
|
7
|
+
* Unexpected token '', "{ "name"... is not valid JSON
|
|
8
|
+
* 插件代码根本跑不到,看门狗整个不生效,还得翻日志才知道是 BOM。
|
|
9
|
+
*
|
|
10
|
+
* v2.4.1 又踩了一次同一个位置:package.json 被 PowerShell 5.1 拿「ANSI 读 →
|
|
11
|
+
* UTF-8 写」往返了一遍,中文变成双层乱码,最后一个字的残缺字节还把收尾引号吃掉,
|
|
12
|
+
* 于是报 `Bad control character in string literal in JSON at position 899`。
|
|
13
|
+
* 所以这个脚本现在管四件事:
|
|
14
|
+
* ① BOM;② 是不是合法 UTF-8;③ 有没有双重编码(乱码);④ JSON 错在哪一行哪一列。
|
|
15
|
+
* 外加核对「清单版本」和「代码里的 export const version」是否同步。
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node scripts/check-manifest.mjs # 体检当前插件
|
|
19
|
+
* node scripts/check-manifest.mjs --fix # 顺手抹掉 BOM(只动 BOM,不动内容)
|
|
20
|
+
* node scripts/check-manifest.mjs <dir> # 体检别的插件目录
|
|
21
|
+
*
|
|
22
|
+
* 退出码:0 = 全部通过;1 = 有问题(发版前卡一道)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
26
|
+
import { join, dirname, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const FIX = args.includes('--fix');
|
|
32
|
+
const rootArg = args.find((a) => !a.startsWith('--'));
|
|
33
|
+
const ROOT = rootArg ? resolve(rootArg) : resolve(HERE, '..');
|
|
34
|
+
|
|
35
|
+
const fails = [];
|
|
36
|
+
const warns = [];
|
|
37
|
+
const oks = [];
|
|
38
|
+
const ok = (m) => oks.push(m);
|
|
39
|
+
const fail = (m) => fails.push(m);
|
|
40
|
+
const warn = (m) => warns.push(m);
|
|
41
|
+
|
|
42
|
+
// ── 0. 编码工具 ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** 豁免标记:写文档时要在注释里贴事故乱码原文,就在那一行带上它 */
|
|
45
|
+
const IGNORE_MARK = 'check-manifest:ignore-mojibake';
|
|
46
|
+
|
|
47
|
+
const utf8Strict = new TextDecoder('utf-8', { fatal: true });
|
|
48
|
+
const gbkDecoder = new TextDecoder('gbk');
|
|
49
|
+
|
|
50
|
+
const hasBom = (buf) => buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
|
|
51
|
+
/** 去掉 BOM 后重写:其余字节原样保留 */
|
|
52
|
+
const stripBom = (file, buf) => writeFileSync(file, buf.subarray(3));
|
|
53
|
+
|
|
54
|
+
/** UTF-8 字节序列里第一个非法位置的字节偏移;合法返回 -1 */
|
|
55
|
+
function firstInvalidUtf8(buf) {
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < buf.length) {
|
|
58
|
+
const b = buf[i];
|
|
59
|
+
let need = 0;
|
|
60
|
+
if (b < 0x80) need = 0;
|
|
61
|
+
else if (b >= 0xc2 && b <= 0xdf) need = 1;
|
|
62
|
+
else if (b >= 0xe0 && b <= 0xef) need = 2;
|
|
63
|
+
else if (b >= 0xf0 && b <= 0xf4) need = 3;
|
|
64
|
+
else return i;
|
|
65
|
+
if (i + need >= buf.length) return i; // 尾巴被截断了
|
|
66
|
+
for (let k = 1; k <= need; k += 1) {
|
|
67
|
+
const c = buf[i + k];
|
|
68
|
+
if (c < 0x80 || c > 0xbf) return i;
|
|
69
|
+
}
|
|
70
|
+
i += need + 1;
|
|
71
|
+
}
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 反向表:字符 → GBK 字节。Node 只有 GBK 的解码器,没有编码器,
|
|
77
|
+
* 所以这里把 0x81–0xFE / 0x40–0xFE 全解一遍倒过来建表(一次性,几十毫秒)。
|
|
78
|
+
*/
|
|
79
|
+
let gbkRev = null;
|
|
80
|
+
function gbkRevMap() {
|
|
81
|
+
if (gbkRev) return gbkRev;
|
|
82
|
+
const map = new Map();
|
|
83
|
+
for (let lead = 0x81; lead <= 0xfe; lead += 1) {
|
|
84
|
+
for (let trail = 0x40; trail <= 0xfe; trail += 1) {
|
|
85
|
+
if (trail === 0x7f) continue;
|
|
86
|
+
const ch = gbkDecoder.decode(Buffer.from([lead, trail]));
|
|
87
|
+
if (ch.length === 1 && ch !== '\uFFFD' && !map.has(ch)) map.set(ch, [lead, trail]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
gbkRev = map;
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 按 GBK 编码成字节;有编不了的字就返回 null */
|
|
95
|
+
function encodeGbk(str) {
|
|
96
|
+
const map = gbkRevMap();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const ch of str) {
|
|
99
|
+
const code = ch.codePointAt(0);
|
|
100
|
+
if (code < 0x80) {
|
|
101
|
+
out.push(code);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const pair = map.get(ch);
|
|
105
|
+
if (!pair) return null;
|
|
106
|
+
out.push(pair[0], pair[1]);
|
|
107
|
+
}
|
|
108
|
+
return Buffer.from(out);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isCjk = (ch) => {
|
|
112
|
+
const c = ch.codePointAt(0);
|
|
113
|
+
return c >= 0x4e00 && c <= 0x9fff;
|
|
114
|
+
};
|
|
115
|
+
const countCjk = (s) => {
|
|
116
|
+
let n = 0;
|
|
117
|
+
for (const ch of s) if (isCjk(ch)) n += 1;
|
|
118
|
+
return n;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function tryUtf8(bytes) {
|
|
122
|
+
try {
|
|
123
|
+
return utf8Strict.decode(bytes);
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 双重编码判定:把这段字按 GBK 编回字节,再按 UTF-8 解一次 ——
|
|
131
|
+
* 如果能解出「成串的汉字」,说明它原本是 UTF-8 的汉字,被人按 ANSI(GBK) 读过又写成 UTF-8。
|
|
132
|
+
*
|
|
133
|
+
* 三个现实细节都得容下,否则会漏报:
|
|
134
|
+
* ① 事故里最后一个字的残缺字节会把后面的字符一起吞掉,段的尾巴是「半截 UTF-8」→ 允许从尾部裁掉 1–3 字节再试;
|
|
135
|
+
* ② 有些字在 GBK 里编不回去(被替换成 U+FFFD 或 '?'),它们就是天然断点;
|
|
136
|
+
* ③ 半角标点(位置 < 0x40)不可能是 GBK 的尾字节,也可以当断点 —— 只断取样,不改原文。
|
|
137
|
+
* 正常中文过不了这一关:按 GBK 编出来的字节几乎不可能同时是合法 UTF-8。
|
|
138
|
+
*/
|
|
139
|
+
function mojibakeDecode(line) {
|
|
140
|
+
if (!line || !/[^\x00-\x7f]/.test(line)) return null;
|
|
141
|
+
// 文档里引用事故原文(比如注释/CHANGELOG 里贴那串乱码)不该被判死,带这个标记就跳过
|
|
142
|
+
if (line.includes(IGNORE_MARK)) return null;
|
|
143
|
+
const map = gbkRevMap();
|
|
144
|
+
|
|
145
|
+
const segs = [];
|
|
146
|
+
let cur = [];
|
|
147
|
+
const flush = () => {
|
|
148
|
+
if (cur.length) segs.push(cur);
|
|
149
|
+
cur = [];
|
|
150
|
+
};
|
|
151
|
+
for (const ch of line) {
|
|
152
|
+
const c = ch.codePointAt(0);
|
|
153
|
+
if (c < 0x80) {
|
|
154
|
+
if (c < 0x40) flush();
|
|
155
|
+
else cur.push(ch);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!map.has(ch)) {
|
|
159
|
+
flush();
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
cur.push(ch);
|
|
163
|
+
}
|
|
164
|
+
flush();
|
|
165
|
+
|
|
166
|
+
for (const seg of segs) {
|
|
167
|
+
const segText = seg.join('');
|
|
168
|
+
if (countCjk(segText) < 2) continue;
|
|
169
|
+
const bytes = encodeGbk(segText);
|
|
170
|
+
if (!bytes) continue;
|
|
171
|
+
for (let cut = 0; cut <= 3; cut += 1) {
|
|
172
|
+
if (cut >= bytes.length) break;
|
|
173
|
+
const text = tryUtf8(bytes.subarray(0, bytes.length - cut));
|
|
174
|
+
if (text && countCjk(text) >= 2) return text;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 把 JSON.parse 的报错变成「第几行第几列 + 那一行 + ^ 指过去」 */
|
|
181
|
+
function pinpointJson(text, error) {
|
|
182
|
+
const msg = String((error && error.message) || error);
|
|
183
|
+
let index = -1;
|
|
184
|
+
const byPos = /position (\d+)/.exec(msg);
|
|
185
|
+
const byLineCol = /line (\d+) column (\d+)/.exec(msg);
|
|
186
|
+
if (byPos) index = Number(byPos[1]);
|
|
187
|
+
else if (byLineCol) {
|
|
188
|
+
const line = Number(byLineCol[1]);
|
|
189
|
+
const col = Number(byLineCol[2]);
|
|
190
|
+
const lines = text.split('\n');
|
|
191
|
+
let offset = 0;
|
|
192
|
+
for (let i = 0; i < line - 1 && i < lines.length; i += 1) offset += lines[i].length + 1;
|
|
193
|
+
index = offset + col - 1;
|
|
194
|
+
}
|
|
195
|
+
if (index < 0 || index > text.length) return '';
|
|
196
|
+
const before = text.slice(0, index);
|
|
197
|
+
const lineNo = before.split('\n').length;
|
|
198
|
+
const colNo = index - before.lastIndexOf('\n');
|
|
199
|
+
const lineText = text.split('\n')[lineNo - 1] ?? '';
|
|
200
|
+
const caret = ' '.repeat(Math.max(0, Math.min(colNo - 1, lineText.length))) + '^';
|
|
201
|
+
const code = text.charCodeAt(index);
|
|
202
|
+
const show =
|
|
203
|
+
code < 0x20
|
|
204
|
+
? `U+${code.toString(16).toUpperCase().padStart(4, '0')}(裸控制字符,字符串里不能直接出现)`
|
|
205
|
+
: JSON.stringify(text[index]);
|
|
206
|
+
return [
|
|
207
|
+
` 出错位置:第 ${lineNo} 行第 ${colNo} 列,字符 ${show}`,
|
|
208
|
+
` ${lineText}`,
|
|
209
|
+
` ${caret}`,
|
|
210
|
+
mojibakeDecode(lineText)
|
|
211
|
+
? ' 提示:这一行像是被「ANSI 读 → UTF-8 写」往返过(双重编码),中文已经变乱码'
|
|
212
|
+
: '',
|
|
213
|
+
]
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 1. package.json:存在 + 无 BOM + 合法 UTF-8 + 可解析 ────────────────────
|
|
219
|
+
const pkgPath = join(ROOT, 'package.json');
|
|
220
|
+
let pkg = null;
|
|
221
|
+
|
|
222
|
+
if (!existsSync(pkgPath)) {
|
|
223
|
+
fail(`package.json 不存在:${pkgPath}`);
|
|
224
|
+
} else {
|
|
225
|
+
let buf = readFileSync(pkgPath);
|
|
226
|
+
if (hasBom(buf)) {
|
|
227
|
+
if (FIX) {
|
|
228
|
+
stripBom(pkgPath, buf);
|
|
229
|
+
buf = readFileSync(pkgPath);
|
|
230
|
+
ok('package.json 带 BOM —— 已抹掉(--fix)');
|
|
231
|
+
} else {
|
|
232
|
+
fail('package.json 带 UTF-8 BOM(DSH 的 JSON.parse 会失败)→ 跑 --fix,或另存为「UTF-8 无 BOM」');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const badByte = firstInvalidUtf8(buf);
|
|
237
|
+
const text = buf.toString('utf8');
|
|
238
|
+
|
|
239
|
+
if (hasBom(buf)) {
|
|
240
|
+
fail('package.json 仍带 BOM,跳过解析');
|
|
241
|
+
} else if (badByte >= 0) {
|
|
242
|
+
fail(`package.json 不是合法 UTF-8(第一个坏字节在第 ${badByte} 字节)→ 多半是被别的编码重写过,重写一遍`);
|
|
243
|
+
} else {
|
|
244
|
+
try {
|
|
245
|
+
// 故意不做 replace(/\uFEFF/):这里要模拟 DSH 的真实读法
|
|
246
|
+
pkg = JSON.parse(text);
|
|
247
|
+
ok(`package.json 可被 JSON.parse(name=${pkg.name}, version=${pkg.version})`);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
fail(`package.json 解析失败:${e.message}\n${pinpointJson(text, e)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const mojiLines = [];
|
|
254
|
+
text.split('\n').forEach((line, i) => {
|
|
255
|
+
const fixed = mojibakeDecode(line);
|
|
256
|
+
if (fixed && fixed !== line) mojiLines.push([i + 1, line.trim().slice(0, 60), fixed.trim().slice(0, 60)]);
|
|
257
|
+
});
|
|
258
|
+
if (mojiLines.length) {
|
|
259
|
+
fail(
|
|
260
|
+
`package.json 有 ${mojiLines.length} 行是双重编码乱码(被「ANSI 读 → UTF-8 写」往返过):\n` +
|
|
261
|
+
mojiLines
|
|
262
|
+
.map(([n, bad, good]) => ` - 第 ${n} 行:${bad} ← 应该是:${good}`)
|
|
263
|
+
.join('\n'),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── 2. 清单字段与实际文件对得上 ────────────────────────────────────────────
|
|
269
|
+
if (pkg) {
|
|
270
|
+
const resolveRel = (p) => join(ROOT, String(p).replace(/^\.\//, ''));
|
|
271
|
+
|
|
272
|
+
const entry = pkg.exports?.['.'] ?? pkg.main;
|
|
273
|
+
if (!entry) warn('package.json 没有 exports["."] 也没有 main —— DSH 可能找不到入口');
|
|
274
|
+
else if (existsSync(resolveRel(entry))) ok(`入口文件存在:${entry}`);
|
|
275
|
+
else fail(`入口文件不存在:${entry}`);
|
|
276
|
+
|
|
277
|
+
if (pkg.type !== 'module') warn(`type=${pkg.type ?? '(缺失)'} —— 本插件是 ESM,建议 "module"`);
|
|
278
|
+
|
|
279
|
+
const patch = pkg.dsh?.bundle?.patch;
|
|
280
|
+
if (patch) {
|
|
281
|
+
if (existsSync(resolveRel(patch))) ok(`bundle patch 存在:${patch}`);
|
|
282
|
+
else fail(`bundle patch 不存在:${patch}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const clientEntry = pkg.exports?.['./client'];
|
|
286
|
+
if (clientEntry) {
|
|
287
|
+
if (existsSync(resolveRel(clientEntry))) ok(`client 入口存在:${clientEntry}`);
|
|
288
|
+
else fail(`client 入口不存在:${clientEntry}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 版本对齐:清单写 2.4.0、代码里还留 2.3.2 这种漂移,日志和设置页会对不上号
|
|
292
|
+
if (entry && existsSync(resolveRel(entry))) {
|
|
293
|
+
const src = readFileSync(resolveRel(entry), 'utf8');
|
|
294
|
+
const m = /export\s+const\s+version\s*=\s*['"]([^'"]+)['"]/.exec(src);
|
|
295
|
+
if (!m) warn(`${entry} 里没找到 export const version —— 版本号无处核对`);
|
|
296
|
+
else if (m[1] !== pkg.version) fail(`版本不同步:package.json=${pkg.version},${entry}=${m[1]}`);
|
|
297
|
+
else ok(`版本同步:${pkg.version}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── 3. 全目录扫 BOM / 非法 UTF-8 / 双重编码 ────────────────────────────────
|
|
302
|
+
const TEXT_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|json|yml|yaml|md|txt|ps1|bat|cmd|css|html)$/i;
|
|
303
|
+
const SKIP_DIR = new Set(['node_modules', '.git']);
|
|
304
|
+
|
|
305
|
+
function walk(dir, out = []) {
|
|
306
|
+
let names;
|
|
307
|
+
try {
|
|
308
|
+
names = readdirSync(dir);
|
|
309
|
+
} catch {
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
for (const name of names) {
|
|
313
|
+
if (SKIP_DIR.has(name)) continue;
|
|
314
|
+
const full = join(dir, name);
|
|
315
|
+
let st;
|
|
316
|
+
try {
|
|
317
|
+
st = statSync(full);
|
|
318
|
+
} catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (st.isDirectory()) walk(full, out);
|
|
322
|
+
else if (st.size > 0 && TEXT_EXT.test(name)) out.push(full);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const bomFiles = [];
|
|
328
|
+
const badUtf8 = [];
|
|
329
|
+
const mojiFiles = [];
|
|
330
|
+
for (const file of walk(ROOT)) {
|
|
331
|
+
let buf;
|
|
332
|
+
try {
|
|
333
|
+
buf = readFileSync(file);
|
|
334
|
+
} catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const rel = file.slice(ROOT.length + 1);
|
|
338
|
+
|
|
339
|
+
if (hasBom(buf)) {
|
|
340
|
+
// 豁免:给 PowerShell 5.1 吃的 .ps1 / .txt(没 BOM 会按 ANSI 解,中文全乱)
|
|
341
|
+
if (/\.(ps1|txt)$/i.test(rel)) {
|
|
342
|
+
warn(`带 BOM(PS 5.1 场景可接受):${rel}`);
|
|
343
|
+
} else if (FIX) {
|
|
344
|
+
stripBom(file, buf);
|
|
345
|
+
ok(`已抹掉 BOM:${rel}`);
|
|
346
|
+
} else {
|
|
347
|
+
bomFiles.push(rel);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const body = hasBom(buf) ? buf.subarray(3) : buf;
|
|
352
|
+
const badByte = firstInvalidUtf8(body);
|
|
353
|
+
if (badByte >= 0) {
|
|
354
|
+
badUtf8.push(`${rel}(第 ${badByte} 字节起)`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const text = body.toString('utf8');
|
|
359
|
+
const hitLines = [];
|
|
360
|
+
text.split('\n').forEach((line, i) => {
|
|
361
|
+
if (mojibakeDecode(line)) hitLines.push(i + 1);
|
|
362
|
+
});
|
|
363
|
+
if (hitLines.length) {
|
|
364
|
+
mojiFiles.push(`${rel}(${hitLines.length} 行,例如第 ${hitLines.slice(0, 5).join('/')} 行)`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (bomFiles.length) {
|
|
369
|
+
fail(`${bomFiles.length} 个文件带 BOM(Node/DSH 读它们会出问题):\n - ${bomFiles.join('\n - ')}`);
|
|
370
|
+
} else {
|
|
371
|
+
ok('除豁免项外,没有带 BOM 的文本文件');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (badUtf8.length) {
|
|
375
|
+
fail(`${badUtf8.length} 个文件不是合法 UTF-8(多半是拿 ANSI 码表重写过):\n - ${badUtf8.join('\n - ')}`);
|
|
376
|
+
} else {
|
|
377
|
+
ok('所有文本文件都是合法 UTF-8');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (mojiFiles.length) {
|
|
381
|
+
fail(
|
|
382
|
+
`${mojiFiles.length} 个文件疑似双重编码乱码(UTF-8 → 按 ANSI 读 → 再写成 UTF-8):\n - ${mojiFiles.join('\n - ')}\n` +
|
|
383
|
+
' 修法:用 write/edit 工具按正确中文重写那几行,别拿 PowerShell 的 Get-Content / Set-Content 往返。',
|
|
384
|
+
);
|
|
385
|
+
} else {
|
|
386
|
+
ok('没有检测到双重编码乱码');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── 4. 报告 ───────────────────────────────────────────────────────────────
|
|
390
|
+
console.log(`\n插件体检:${ROOT}\n${'─'.repeat(64)}`);
|
|
391
|
+
for (const m of oks) console.log(` [ OK ] ${m}`);
|
|
392
|
+
for (const m of warns) console.log(` [WARN] ${m}`);
|
|
393
|
+
for (const m of fails) console.log(` [FAIL] ${m}`);
|
|
394
|
+
|
|
395
|
+
if (fails.length) {
|
|
396
|
+
console.log(`${'─'.repeat(64)}\n结论:${fails.length} 项有问题。是 BOM 的话跑:node scripts/check-manifest.mjs --fix\n`);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
console.log(`${'─'.repeat(64)}\n结论:清单健康,DSH 能正常读到这个插件。\n`);
|
|
400
|
+
process.exit(0);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-privacy.mjs —— 发布前隐私体检(0 依赖,纯 Node)
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有这个:这个插件是从一台真机上长出来的。调试用的绝对路径、fixture 里的
|
|
6
|
+
* 真号、临时贴过的令牌,最容易顺着 lib/ 和 scripts/ 一起被打进 npm 包和 GitHub。
|
|
7
|
+
* 发版前跑一遍,把这类东西拦在本地,别指望"我记得清过了"。
|
|
8
|
+
*
|
|
9
|
+
* 查什么:
|
|
10
|
+
* ① 家目录绝对路径(C:/Users/<名字>、/home/<名字>、/Users/<名字>)——
|
|
11
|
+
* 只放行文档里该用的占位名(你 / x / USERNAME…);
|
|
12
|
+
* ② 邮箱(QQ 邮箱、私人邮箱)—— 只放行 GitHub noreply 与 example.*;
|
|
13
|
+
* ③ 凭据形状(ghp_ / npm_ / sk- / AKIA、Bearer 后跟实串);
|
|
14
|
+
* ④ DSH 会话 id(session-xxxxxxxx-…);
|
|
15
|
+
* ⑤ fixture 里像真号的 launchAccount 数字 —— 只放行合成占位。
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node scripts/check-privacy.mjs # 体检当前插件
|
|
19
|
+
* node scripts/check-privacy.mjs <dir> # 体检别的插件目录
|
|
20
|
+
*
|
|
21
|
+
* 行内豁免:在那一行写 check-privacy:ignore(比如文档要举例说明什么算泄漏)。
|
|
22
|
+
* 退出码:0 = 干净;1 = 命中。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
+
import { join, dirname, resolve, extname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const ROOT_ARG = process.argv.slice(2).find((a) => !a.startsWith('--'));
|
|
31
|
+
const ROOT = ROOT_ARG ? resolve(ROOT_ARG) : resolve(HERE, '..');
|
|
32
|
+
|
|
33
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'coverage', '.turbo', '.cache']);
|
|
34
|
+
const SKIP_FILES = new Set(['.DS_Store', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
|
|
35
|
+
const SKIP_EXT = new Set([
|
|
36
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.zip', '.tgz', '.gz',
|
|
37
|
+
'.exe', '.dll', '.node', '.woff', '.woff2', '.ttf', '.mp4', '.mp3', '.pdf'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** 文档里合法的占位用户名(大小写不敏感) */
|
|
41
|
+
const PLACEHOLDER_USERS = new Set([
|
|
42
|
+
'你', '您的用户名', '用户名', '<user>', '<username>', 'username', 'user', 'yourname',
|
|
43
|
+
'x', 'abc', 'test', 'example', 'public', 'default', 'defaultuser', 'allusers', '%username%'
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** 允许出现的示例邮箱域名 */
|
|
47
|
+
const PLACEHOLDER_EMAIL_HOSTS = [
|
|
48
|
+
'users.noreply.github.com',
|
|
49
|
+
'example.com', 'example.org', 'example.net', 'test.local', 'localhost'
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** 允许出现的合成账号占位(fixture 专用) */
|
|
53
|
+
const PLACEHOLDER_ACCOUNTS = new Set(['1000000000']);
|
|
54
|
+
|
|
55
|
+
const IGNORE_MARK = 'check-privacy:ignore';
|
|
56
|
+
|
|
57
|
+
const RULES = [
|
|
58
|
+
{
|
|
59
|
+
id: 'home-path',
|
|
60
|
+
why: '家目录里带着真实用户名 —— 换成「你」/「x」这种占位',
|
|
61
|
+
re: /(?:[A-Za-z]:)[\\/]Users[\\/]([^\\/\s"'`<>|,;:)]+)/g,
|
|
62
|
+
allow: (v) => PLACEHOLDER_USERS.has(v.toLowerCase())
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'home-path-unix',
|
|
66
|
+
why: '家目录里带着真实用户名 —— 换成「你」/「x」这种占位',
|
|
67
|
+
re: /(?:^|[\s'"`(=\[])\/(?:home|Users)\/([A-Za-z0-9_.-]+)/g,
|
|
68
|
+
allow: (v) => PLACEHOLDER_USERS.has(v.toLowerCase())
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'email',
|
|
72
|
+
why: '出现邮箱地址 —— 私人邮箱(含 QQ 邮箱)别进包',
|
|
73
|
+
re: /[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/g,
|
|
74
|
+
allow: (v) => {
|
|
75
|
+
const host = v.toLowerCase();
|
|
76
|
+
return PLACEHOLDER_EMAIL_HOSTS.some((h) => host === h || host.endsWith('.' + h));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'credential',
|
|
81
|
+
why: '看起来像真实凭据/令牌',
|
|
82
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\b/g
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'bearer',
|
|
86
|
+
why: 'Bearer 后面跟了一段实串(源码里应该是 ${变量})',
|
|
87
|
+
re: /\bBearer\s+([A-Za-z0-9._~+/=-]{20,})/g
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'session-id',
|
|
91
|
+
why: '出现 DSH 会话 id',
|
|
92
|
+
re: /\bsession-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'fixture-account',
|
|
96
|
+
why: 'fixture 里像真实 QQ 号的账号 —— 换成合成占位',
|
|
97
|
+
re: /\blaunchAccount\s*:\s*['"]?([1-9]\d{8,10})\b/g,
|
|
98
|
+
allow: (v) => PLACEHOLDER_ACCOUNTS.has(v)
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function walk(dir, out) {
|
|
103
|
+
for (const name of readdirSync(dir)) {
|
|
104
|
+
const p = join(dir, name);
|
|
105
|
+
const st = statSync(p, { throwIfNoEntry: false });
|
|
106
|
+
if (!st) continue;
|
|
107
|
+
if (st.isDirectory()) {
|
|
108
|
+
if (!SKIP_DIRS.has(name)) walk(p, out);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!st.isFile()) continue;
|
|
112
|
+
if (SKIP_FILES.has(name)) continue;
|
|
113
|
+
if (SKIP_EXT.has(extname(name).toLowerCase())) continue;
|
|
114
|
+
out.push(p);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 读成文本;二进制/超大文件返回 null(不是审计对象) */
|
|
120
|
+
function asText(file) {
|
|
121
|
+
const buf = readFileSync(file);
|
|
122
|
+
if (buf.length > 4 * 1024 * 1024) return null;
|
|
123
|
+
if (buf.subarray(0, 4096).includes(0)) return null;
|
|
124
|
+
return buf.toString('utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const files = walk(ROOT, []).sort();
|
|
128
|
+
const hits = [];
|
|
129
|
+
let scanned = 0;
|
|
130
|
+
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const text = asText(file);
|
|
133
|
+
if (text == null) continue;
|
|
134
|
+
scanned += 1;
|
|
135
|
+
const lines = text.split(/\r?\n/);
|
|
136
|
+
const rel = file.slice(ROOT.length + 1) || file;
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
|
|
139
|
+
for (const rule of RULES) {
|
|
140
|
+
rule.re.lastIndex = 0;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = rule.re.exec(text)) !== null) {
|
|
143
|
+
const payload = m[1] == null ? m[0] : m[1];
|
|
144
|
+
if (rule.allow && rule.allow(payload)) continue;
|
|
145
|
+
const before = text.slice(0, m.index);
|
|
146
|
+
const lineNo = before.split('\n').length;
|
|
147
|
+
const lineText = lines[lineNo - 1] || '';
|
|
148
|
+
if (lineText.includes(IGNORE_MARK)) continue;
|
|
149
|
+
const key = rule.id + '@' + lineNo;
|
|
150
|
+
if (seen.has(key)) continue;
|
|
151
|
+
seen.add(key);
|
|
152
|
+
hits.push({
|
|
153
|
+
file: rel,
|
|
154
|
+
line: lineNo,
|
|
155
|
+
rule: rule.id,
|
|
156
|
+
why: rule.why,
|
|
157
|
+
sample: payload.length > 60 ? payload.slice(0, 57) + '…' : payload
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('隐私体检:' + ROOT);
|
|
164
|
+
for (const h of hits) {
|
|
165
|
+
console.log(' ✗ ' + h.file + ':' + h.line + ' [' + h.rule + '] ' + h.sample);
|
|
166
|
+
console.log(' → ' + h.why);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (hits.length === 0) {
|
|
170
|
+
console.log(' ✓ 干净:扫了 ' + scanned + ' 个文本文件,0 命中');
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log('');
|
|
175
|
+
console.log(' ' + hits.length + ' 处命中(扫了 ' + scanned + ' 个文本文件)。清干净再发版。');
|
|
176
|
+
process.exit(1);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 扫盘判定自测:真 Shell 与 OneKey 留下的壳模板必须分得开。
|
|
2
|
+
// 造一棵假树真跑 scanOneDrive,不碰真实磁盘上的 NapCat。
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
// 相对脚本自身定位:换台机器、换个 clone 目录都跑得起来
|
|
9
|
+
const ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'index.js')
|
|
10
|
+
const mod = await import(pathToFileURL(ENTRY).href)
|
|
11
|
+
const it = mod.__internals
|
|
12
|
+
|
|
13
|
+
let bad = 0
|
|
14
|
+
const ok = (name, cond, extra) => {
|
|
15
|
+
console.log((cond ? 'PASS ' : 'FAIL ') + name + (cond || !extra ? '' : ' → ' + extra))
|
|
16
|
+
if (!cond) bad++
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = mkdtempSync(path.join(tmpdir(), 'npk-scan-'))
|
|
20
|
+
const mk = (rel, files = [], dirs = []) => {
|
|
21
|
+
const d = path.join(root, rel)
|
|
22
|
+
mkdirSync(d, { recursive: true })
|
|
23
|
+
for (const f of files) writeFileSync(path.join(d, f), 'x')
|
|
24
|
+
for (const s of dirs) mkdirSync(path.join(d, s), { recursive: true })
|
|
25
|
+
return d
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 三种典型形态:壳模板 / 真 Shell / 精简版(名字带 napcat,没有强证据)
|
|
29
|
+
mk('OneKey/bootmain', ['NapCatWinBootMain.exe', 'NapCatWinBootHook.dll', 'napcat.bat', 'napcat.quick.bat', 'ReadMe.txt'])
|
|
30
|
+
mk('OneKey/NapCat.1.Shell', ['QQ.exe', 'NapCatWinBootMain.exe', 'napcat.quick2.bat', 'msvcp140.dll'], ['versions'])
|
|
31
|
+
mk('lite/napcat-custom', ['napcat.bat'])
|
|
32
|
+
mk('plain/whatever', ['readme.txt', 'data.bin'])
|
|
33
|
+
|
|
34
|
+
it.scan.found.length = 0
|
|
35
|
+
it.scan.visited = 0
|
|
36
|
+
await it.scanOneDrive(root, { maxDepth: 6 })
|
|
37
|
+
const found = it.scanPublic().found
|
|
38
|
+
const byName = (n) => found.find((f) => f.name === n)
|
|
39
|
+
|
|
40
|
+
ok('扫到 3 个候选', found.length === 3, JSON.stringify(found.map((f) => f.name)))
|
|
41
|
+
ok('普通目录不算候选', !found.some((f) => f.name === 'whatever'))
|
|
42
|
+
ok('三个目录都带启动脚本', found.every((f) => Boolean(f.launcher)), JSON.stringify(found.map((f) => f.launcher)))
|
|
43
|
+
|
|
44
|
+
const real = byName('NapCat.1.Shell')
|
|
45
|
+
ok('真 Shell 被认出来', Boolean(real && real.shell), JSON.stringify(real))
|
|
46
|
+
ok('真 Shell 证据含 versions/ 与 QQ.exe', Boolean(real && real.evidence.includes('versions/') && real.evidence.includes('QQ.exe')), JSON.stringify(real && real.evidence))
|
|
47
|
+
ok('真 Shell 启动脚本挑到 quick2', Boolean(real && real.launcher === 'napcat.quick2.bat'), real && real.launcher)
|
|
48
|
+
|
|
49
|
+
const shellTpl = byName('bootmain')
|
|
50
|
+
ok('壳模板不算真 Shell', Boolean(shellTpl && shellTpl.shell === false), JSON.stringify(shellTpl))
|
|
51
|
+
ok('壳模板没有强证据', Boolean(shellTpl && shellTpl.evidence.length === 0), JSON.stringify(shellTpl && shellTpl.evidence))
|
|
52
|
+
ok('壳模板仍然算「候选」(可以被人工选中)', Boolean(shellTpl && shellTpl.boot === true))
|
|
53
|
+
|
|
54
|
+
const lite = byName('napcat-custom')
|
|
55
|
+
ok('精简版名字像 napcat 也算候选', Boolean(lite))
|
|
56
|
+
ok('精简版同样标成「存疑」', Boolean(lite && lite.shell === false))
|
|
57
|
+
|
|
58
|
+
ok('真 Shell 排在壳模板前面', found[0].shell === true, found.map((f) => `${f.name}:${f.shell}`).join(' | '))
|
|
59
|
+
|
|
60
|
+
// shellEvidence 单元级:只有目录里的 versions 才认
|
|
61
|
+
const fs = await import('node:fs')
|
|
62
|
+
const evReal = it.shellEvidence(fs.readdirSync(path.join(root, 'OneKey/NapCat.1.Shell'), { withFileTypes: true }))
|
|
63
|
+
const evTpl = it.shellEvidence(fs.readdirSync(path.join(root, 'OneKey/bootmain'), { withFileTypes: true }))
|
|
64
|
+
ok('shellEvidence(真) = shell', evReal.shell === true)
|
|
65
|
+
ok('shellEvidence(壳) = 不是 shell', evTpl.shell === false)
|
|
66
|
+
|
|
67
|
+
rmSync(root, { recursive: true, force: true })
|
|
68
|
+
console.log('\n结果:' + (bad ? bad + ' 项失败' : '全部通过(' + 15 + ' 项)'))
|
|
69
|
+
process.exit(bad ? 1 : 0)
|