koishi-plugin-spawn-modified 1.2.4 → 1.2.5

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/lib/index.js CHANGED
@@ -52,6 +52,7 @@ exports.inject = exports.name = exports.Config = void 0;
52
52
  exports.apply = apply;
53
53
  var child_process_1 = require("child_process");
54
54
  var koishi_1 = require("koishi");
55
+ var os_1 = __importDefault(require("os"));
55
56
  var path_1 = __importDefault(require("path"));
56
57
  var url_1 = require("url");
57
58
  var ansi_to_html_1 = __importDefault(require("ansi-to-html"));
@@ -66,23 +67,7 @@ exports.Config = koishi_1.Schema.object({
66
67
  restrictDirectory: koishi_1.Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
67
68
  authority: koishi_1.Schema.number().description('exec 命令所需权限等级。').default(4),
68
69
  commandFilterMode: koishi_1.Schema.union(['blacklist', 'whitelist']).description('命令过滤模式:blacklist/whitelist').default('blacklist'),
69
- commandList: koishi_1.Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([
70
- '^(?:^|[;&|\\n])\\s*(?:\\.?\\.\\/[^;&|\\s]+\\.sh\\b|(?:sh|bash|zsh|ksh|dash)\\s+[^;&|\\s]+\\.sh\\b)',
71
- '^(?:^|[;&|\\n])\\s*chmod\\s+\\+x\\b',
72
- '^\\s*sudo\\s+rm\\s+-rf\\b',
73
- '^\\s*rm\\s+-rf\\b',
74
- '^\\s*rm\\s+-rf\\s+/',
75
- '^\\s*rm\\s+-rf\\s+/\\*',
76
- '^\\s*mkfs(\\.\\w+)?\\b',
77
- '^\\s*dd\\b.*\\bof=\\/dev\\/(sd|nvme|mmcblk)',
78
- '^\\s*wipefs\\b',
79
- '^\\s*(parted|fdisk|cfdisk)\\b',
80
- '^\\s*lvremove\\b|^\\s*vgremove\\b',
81
- '^\\s*cryptsetup\\b.*(erase|format)',
82
- '^\\s*shutdown\\b|^\\s*poweroff\\b|^\\s*reboot\\b',
83
- '^\\s*chmod\\s+[-+]?(777|666)\\b',
84
- '^\\s*echo\\s+.+\\s*>\\s*/etc/(passwd|shadow|sudoers)\\b',
85
- ]),
70
+ commandList: koishi_1.Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([]),
86
71
  });
87
72
  exports.name = 'spawn';
88
73
  exports.inject = {
@@ -116,11 +101,101 @@ function isCommandBlocked(command, mode, list) {
116
101
  });
117
102
  return mode === 'blacklist' ? hit : !hit;
118
103
  }
104
+ function stripQuotes(text) {
105
+ return text.replace(/^['"]|['"]$/g, '');
106
+ }
107
+ function tokenizeCommand(command) {
108
+ var tokens = [];
109
+ var current = '';
110
+ var quote = null;
111
+ for (var i = 0; i < command.length; i++) {
112
+ var char = command[i];
113
+ if ((char === '"' || char === "'") && (quote === null || quote === char)) {
114
+ quote = quote ? null : char;
115
+ continue;
116
+ }
117
+ if (!quote && /\s/.test(char)) {
118
+ if (current) {
119
+ tokens.push(current);
120
+ current = '';
121
+ }
122
+ continue;
123
+ }
124
+ current += char;
125
+ }
126
+ if (current)
127
+ tokens.push(current);
128
+ return tokens;
129
+ }
130
+ function isPathLike(token) {
131
+ var trimmed = token.trim();
132
+ if (!trimmed)
133
+ return false;
134
+ if (/^[|&><]+$/.test(trimmed))
135
+ return false;
136
+ if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed))
137
+ return false;
138
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed))
139
+ return false;
140
+ var normalized = stripQuotes(trimmed);
141
+ return (/^[A-Za-z]:[\\/]/.test(normalized) ||
142
+ normalized.startsWith('/') ||
143
+ normalized.startsWith('~') ||
144
+ normalized.startsWith('..') ||
145
+ normalized.startsWith('./') ||
146
+ normalized.includes('/') ||
147
+ normalized.includes('\\'));
148
+ }
149
+ function resolveCandidatePath(candidate, currentDir) {
150
+ var _a;
151
+ var cleaned = stripQuotes(candidate.trim());
152
+ var homeDir = ((_a = os_1.default.homedir) === null || _a === void 0 ? void 0 : _a.call(os_1.default)) || '';
153
+ if (cleaned.startsWith('~')) {
154
+ var withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '');
155
+ var homeResolved = homeDir ? path_1.default.join(homeDir, withoutTilde) : cleaned;
156
+ return path_1.default.resolve(homeResolved);
157
+ }
158
+ return path_1.default.resolve(currentDir, cleaned);
159
+ }
160
+ function extractPathCandidates(command) {
161
+ var tokens = tokenizeCommand(command);
162
+ var candidates = [];
163
+ for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) {
164
+ var token = tokens_1[_i];
165
+ var normalized = stripQuotes(token);
166
+ if (isPathLike(normalized)) {
167
+ candidates.push(normalized);
168
+ continue;
169
+ }
170
+ var eqIndex = normalized.indexOf('=');
171
+ if (eqIndex > 0) {
172
+ var value = normalized.slice(eqIndex + 1);
173
+ if (isPathLike(value)) {
174
+ candidates.push(value);
175
+ }
176
+ }
177
+ }
178
+ return candidates;
179
+ }
119
180
  // 解析 cd 命令并验证路径
120
181
  function isWithinRoot(rootDir, targetPath) {
121
182
  var relative = path_1.default.relative(rootDir, targetPath);
122
183
  return relative === '' || (!relative.startsWith('..') && !path_1.default.isAbsolute(relative));
123
184
  }
185
+ function validatePathAccess(command, currentDir, rootDir, restrictDirectory) {
186
+ if (!restrictDirectory)
187
+ return { valid: true };
188
+ var normalizedRoot = path_1.default.resolve(rootDir);
189
+ var candidates = extractPathCandidates(command);
190
+ for (var _i = 0, candidates_1 = candidates; _i < candidates_1.length; _i++) {
191
+ var candidate = candidates_1[_i];
192
+ var resolved = resolveCandidatePath(candidate, currentDir);
193
+ if (!isWithinRoot(normalizedRoot, resolved)) {
194
+ return { valid: false, error: 'restricted-path' };
195
+ }
196
+ }
197
+ return { valid: true };
198
+ }
124
199
  function validateCdCommand(command, currentDir, rootDir, restrictDirectory) {
125
200
  if (!restrictDirectory)
126
201
  return { valid: true };
@@ -226,7 +301,7 @@ function apply(ctx, config) {
226
301
  ctx.i18n.define('zh-CN', require('./locales/zh-CN'));
227
302
  ctx.command('exec <command:text>', { authority: (_a = config.authority) !== null && _a !== void 0 ? _a : 4 })
228
303
  .action(function (_a, command_1) { return __awaiter(_this, [_a, command_1], void 0, function (_b, command) {
229
- var filterList, filterMode, sessionId, rootDir, currentDir, cdValidation, timeout, state;
304
+ var filterList, filterMode, sessionId, rootDir, currentDir, cdValidation, pathValidation, timeout, state;
230
305
  var _this = this;
231
306
  var _c;
232
307
  var session = _b.session;
@@ -249,6 +324,10 @@ function apply(ctx, config) {
249
324
  if (!cdValidation.valid) {
250
325
  return [2 /*return*/, session.text('.restricted-directory')];
251
326
  }
327
+ pathValidation = validatePathAccess(command, currentDir, rootDir, config.restrictDirectory);
328
+ if (!pathValidation.valid) {
329
+ return [2 /*return*/, session.text('.restricted-path')];
330
+ }
252
331
  timeout = config.timeout;
253
332
  state = { command: command, timeout: timeout, output: '' };
254
333
  if (!!config.renderImage) return [3 /*break*/, 2];
@@ -7,7 +7,8 @@
7
7
  "started": "[运行开始] {command}",
8
8
  "finished": "[运行完毕] {command}\n{output}",
9
9
  "blocked-command": "该命令已被禁止执行。",
10
- "restricted-directory": "不允许切换到上级或其他目录。"
10
+ "restricted-directory": "不允许切换到上级或其他目录。",
11
+ "restricted-path": "不允许访问配置目录以外的文件或目录。"
11
12
  }
12
13
  }
13
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-spawn-modified",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "Run shell commands with Koishi",
5
5
  "keywords": [
6
6
  "bot",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { exec } from 'child_process'
2
2
  import { Context, h, Schema, Time } from 'koishi'
3
+ import os from 'os'
3
4
  import path from 'path'
4
5
  import { pathToFileURL } from 'url'
5
6
  import AnsiToHtml from 'ansi-to-html'
@@ -37,23 +38,7 @@ export const Config: Schema<Config> = Schema.object({
37
38
  restrictDirectory: Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
38
39
  authority: Schema.number().description('exec 命令所需权限等级。').default(4),
39
40
  commandFilterMode: Schema.union(['blacklist', 'whitelist']).description('命令过滤模式:blacklist/whitelist').default('blacklist'),
40
- commandList: Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([
41
- '^(?:^|[;&|\\n])\\s*(?:\\.?\\.\\/[^;&|\\s]+\\.sh\\b|(?:sh|bash|zsh|ksh|dash)\\s+[^;&|\\s]+\\.sh\\b)',
42
- '^(?:^|[;&|\\n])\\s*chmod\\s+\\+x\\b',
43
- '^\\s*sudo\\s+rm\\s+-rf\\b',
44
- '^\\s*rm\\s+-rf\\b',
45
- '^\\s*rm\\s+-rf\\s+/',
46
- '^\\s*rm\\s+-rf\\s+/\\*',
47
- '^\\s*mkfs(\\.\\w+)?\\b',
48
- '^\\s*dd\\b.*\\bof=\\/dev\\/(sd|nvme|mmcblk)',
49
- '^\\s*wipefs\\b',
50
- '^\\s*(parted|fdisk|cfdisk)\\b',
51
- '^\\s*lvremove\\b|^\\s*vgremove\\b',
52
- '^\\s*cryptsetup\\b.*(erase|format)',
53
- '^\\s*shutdown\\b|^\\s*poweroff\\b|^\\s*reboot\\b',
54
- '^\\s*chmod\\s+[-+]?(777|666)\\b',
55
- '^\\s*echo\\s+.+\\s*>\\s*/etc/(passwd|shadow|sudoers)\\b',
56
- ]),
41
+ commandList: Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([]),
57
42
  })
58
43
 
59
44
  export interface State {
@@ -99,12 +84,116 @@ function isCommandBlocked(command: string, mode: 'blacklist' | 'whitelist', list
99
84
  return mode === 'blacklist' ? hit : !hit
100
85
  }
101
86
 
87
+ function stripQuotes(text: string): string {
88
+ return text.replace(/^['"]|['"]$/g, '')
89
+ }
90
+
91
+ function tokenizeCommand(command: string): string[] {
92
+ const tokens: string[] = []
93
+ let current = ''
94
+ let quote: string | null = null
95
+
96
+ for (let i = 0; i < command.length; i++) {
97
+ const char = command[i]
98
+
99
+ if ((char === '"' || char === "'") && (quote === null || quote === char)) {
100
+ quote = quote ? null : char
101
+ continue
102
+ }
103
+
104
+ if (!quote && /\s/.test(char)) {
105
+ if (current) {
106
+ tokens.push(current)
107
+ current = ''
108
+ }
109
+ continue
110
+ }
111
+
112
+ current += char
113
+ }
114
+
115
+ if (current) tokens.push(current)
116
+ return tokens
117
+ }
118
+
119
+ function isPathLike(token: string): boolean {
120
+ const trimmed = token.trim()
121
+ if (!trimmed) return false
122
+ if (/^[|&><]+$/.test(trimmed)) return false
123
+ if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed)) return false
124
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return false
125
+
126
+ const normalized = stripQuotes(trimmed)
127
+
128
+ return (
129
+ /^[A-Za-z]:[\\/]/.test(normalized) ||
130
+ normalized.startsWith('/') ||
131
+ normalized.startsWith('~') ||
132
+ normalized.startsWith('..') ||
133
+ normalized.startsWith('./') ||
134
+ normalized.includes('/') ||
135
+ normalized.includes('\\')
136
+ )
137
+ }
138
+
139
+ function resolveCandidatePath(candidate: string, currentDir: string): string {
140
+ const cleaned = stripQuotes(candidate.trim())
141
+ const homeDir = os.homedir?.() || ''
142
+
143
+ if (cleaned.startsWith('~')) {
144
+ const withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '')
145
+ const homeResolved = homeDir ? path.join(homeDir, withoutTilde) : cleaned
146
+ return path.resolve(homeResolved)
147
+ }
148
+
149
+ return path.resolve(currentDir, cleaned)
150
+ }
151
+
152
+ function extractPathCandidates(command: string): string[] {
153
+ const tokens = tokenizeCommand(command)
154
+ const candidates: string[] = []
155
+
156
+ for (const token of tokens) {
157
+ const normalized = stripQuotes(token)
158
+ if (isPathLike(normalized)) {
159
+ candidates.push(normalized)
160
+ continue
161
+ }
162
+
163
+ const eqIndex = normalized.indexOf('=')
164
+ if (eqIndex > 0) {
165
+ const value = normalized.slice(eqIndex + 1)
166
+ if (isPathLike(value)) {
167
+ candidates.push(value)
168
+ }
169
+ }
170
+ }
171
+
172
+ return candidates
173
+ }
174
+
102
175
  // 解析 cd 命令并验证路径
103
176
  function isWithinRoot(rootDir: string, targetPath: string): boolean {
104
177
  const relative = path.relative(rootDir, targetPath)
105
178
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
106
179
  }
107
180
 
181
+ function validatePathAccess(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; error?: string } {
182
+ if (!restrictDirectory) return { valid: true }
183
+
184
+ const normalizedRoot = path.resolve(rootDir)
185
+ const candidates = extractPathCandidates(command)
186
+
187
+ for (const candidate of candidates) {
188
+ const resolved = resolveCandidatePath(candidate, currentDir)
189
+ if (!isWithinRoot(normalizedRoot, resolved)) {
190
+ return { valid: false, error: 'restricted-path' }
191
+ }
192
+ }
193
+
194
+ return { valid: true }
195
+ }
196
+
108
197
  function validateCdCommand(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; newDir?: string; error?: string } {
109
198
  if (!restrictDirectory) return { valid: true }
110
199
 
@@ -342,6 +431,10 @@ export function apply(ctx: Context, config: Config) {
342
431
  if (!cdValidation.valid) {
343
432
  return session.text('.restricted-directory')
344
433
  }
434
+ const pathValidation = validatePathAccess(command, currentDir, rootDir, config.restrictDirectory)
435
+ if (!pathValidation.valid) {
436
+ return session.text('.restricted-path')
437
+ }
345
438
  const { timeout } = config
346
439
  const state: State = { command, timeout, output: '' }
347
440
  if (!config.renderImage) {
@@ -9,3 +9,4 @@ commands:
9
9
  {output}
10
10
  blocked-command: 该命令已被禁止执行。
11
11
  restricted-directory: 不允许切换到上级或其他目录。
12
+ restricted-path: 不允许访问配置目录以外的文件或目录。