dsh-server-connector 0.2.3 → 0.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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1728
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/policy.js
CHANGED
|
@@ -1,590 +1,590 @@
|
|
|
1
|
-
// dsh-server-connector — built-in high-risk command policy engine.
|
|
2
|
-
//
|
|
3
|
-
// A single pure module: it never touches the network, the file system, the clock
|
|
4
|
-
// or DSH. `srv_policy_check` and `srv_exec` both call `check()`, so the pre-flight
|
|
5
|
-
// verdict and the execution verdict can never disagree.
|
|
6
|
-
//
|
|
7
|
-
// This is containment, not a security boundary. Regexes cannot see through
|
|
8
|
-
// `python -c "shutil.rmtree('/')"` or `base64 -d | sh`; the real boundary is the
|
|
9
|
-
// unprivileged remote user plus a sudo allowlist. The contract here is only that
|
|
10
|
-
// known destructive patterns are blocked or require confirmation.
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* One decision level.
|
|
14
|
-
* @typedef {'allow' | 'confirm' | 'block'} Decision
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* One built-in rule. Either `pattern` or `wholePattern` is present; see
|
|
19
|
-
* {@link RULES} for which one applies when.
|
|
20
|
-
* @typedef {object} Rule
|
|
21
|
-
* @property {string} id - stable rule id, the key of a server's policy map.
|
|
22
|
-
* @property {string} label - short human-readable name, used in the approval reason.
|
|
23
|
-
* @property {Decision} defaultDecision - `block` or `confirm`; never `allow`.
|
|
24
|
-
* @property {string} description - what the rule is protecting against.
|
|
25
|
-
* @property {RegExp} [pattern] - matched per subcommand and against the whole line.
|
|
26
|
-
* @property {RegExp} [wholePattern] - matched against the whole line only.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/** A rule that matched, with the decision it resolved to for this server. */
|
|
30
|
-
/**
|
|
31
|
-
* @typedef {object} RuleMatch
|
|
32
|
-
* @property {string} id
|
|
33
|
-
* @property {string} label
|
|
34
|
-
* @property {Decision} defaultDecision
|
|
35
|
-
* @property {string} description
|
|
36
|
-
* @property {Decision} decision
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
/** The three decision levels, ordered from most to least permissive. */
|
|
40
|
-
export const DECISIONS = Object.freeze({
|
|
41
|
-
ALLOW: /** @type {Decision} */ ('allow'),
|
|
42
|
-
CONFIRM: /** @type {Decision} */ ('confirm'),
|
|
43
|
-
BLOCK: /** @type {Decision} */ ('block'),
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
/** Severity rank: the strictest matching rule wins. `block > confirm > allow`. */
|
|
47
|
-
const RANK = Object.freeze({ allow: 0, confirm: 1, block: 2 })
|
|
48
|
-
|
|
49
|
-
/** Characters a single subcommand pattern may consume; separators are hard stops. */
|
|
50
|
-
const SEG = '[^\\n;|&]*'
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Verbs and redirections that modify the file they are told to modify: the
|
|
54
|
-
* named path may appear anywhere after them. Used by the rules that block or
|
|
55
|
-
* confirm a *write* to a system path while leaving a read (`cat /etc/passwd`)
|
|
56
|
-
* alone.
|
|
57
|
-
*/
|
|
58
|
-
const WRITE_VERBS =
|
|
59
|
-
'(?:>>?\\s*|\\btee\\b|\\brm\\b|' +
|
|
60
|
-
`\\bsed\\b${SEG}\\s-i|\\b(?:vi|vim|nano|emacs|ed)\\b|\\btruncate\\b|\\bshred\\b)`
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Verbs whose *last* argument is the destination, so the protected path must be
|
|
64
|
-
* preceded by at least one argument. Without that guard `cp /etc/passwd
|
|
65
|
-
* /root/bak` — a backup, i.e. a read — would be reported as a write.
|
|
66
|
-
*/
|
|
67
|
-
const COPY_VERBS = '(?:\\bcp\\b|\\bmv\\b|\\binstall\\b|\\bln\\b)'
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* A "writes this path" pattern: a write verb or redirection aimed at a path
|
|
71
|
-
* matching `pathSource`. A copy verb counts only when the path is not its
|
|
72
|
-
* first argument.
|
|
73
|
-
* @param {string} pathSource - Regex source for the target path.
|
|
74
|
-
* @returns {RegExp} compiled, case-insensitive pattern.
|
|
75
|
-
*/
|
|
76
|
-
function writePattern(pathSource) {
|
|
77
|
-
return new RegExp(
|
|
78
|
-
`(?:${WRITE_VERBS}${SEG}${pathSource})|(?:${COPY_VERBS}[^\\n;|&]*\\S\\s+${pathSource})`,
|
|
79
|
-
'i',
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The built-in rule catalog (doc §4.1). Exactly 20 rules; `defaultDecision` is
|
|
85
|
-
* always `block` or `confirm`, never `allow`. Every rule is pure data: an id, a
|
|
86
|
-
* label and description for the approval reason and panel, the default
|
|
87
|
-
* decision, and the regexes it is matched with.
|
|
88
|
-
*
|
|
89
|
-
* `pattern` is tested against each subcommand (and the whole command line, which
|
|
90
|
-
* is harmless because the patterns stop at separators). `wholePattern` is tested
|
|
91
|
-
* against the whole command line only: the fork bomb and `curl … | sh` contain
|
|
92
|
-
* separators themselves, so per-subcommand matching would never see them.
|
|
93
|
-
*
|
|
94
|
-
* The catalog is read-only: server policy can override a decision or disable a
|
|
95
|
-
* rule, but never add to or edit the catalog.
|
|
96
|
-
*/
|
|
97
|
-
export const RULES = /** @type {readonly Rule[]} */ (Object.freeze([
|
|
98
|
-
Object.freeze({
|
|
99
|
-
id: 'rm-recursive',
|
|
100
|
-
label: '递归删除',
|
|
101
|
-
defaultDecision: 'block',
|
|
102
|
-
description: 'rm 的递归删除(-r / -R / --recursive)会不可逆地删除整个目录树。',
|
|
103
|
-
// `git rm -r` is left out: it removes tracked paths and stays recoverable
|
|
104
|
-
// from the object database, so it is a VCS operation rather than a wipe.
|
|
105
|
-
pattern: /(?<!git )\brm\b[^\n;|&]*(?:\s-[a-zA-Z]*r[a-zA-Z]*\b|\s--recursive\b)/i,
|
|
106
|
-
}),
|
|
107
|
-
Object.freeze({
|
|
108
|
-
id: 'dd-write',
|
|
109
|
-
label: '裸设备写入 / 格式化',
|
|
110
|
-
defaultDecision: 'block',
|
|
111
|
-
description: 'dd 直接写块设备或 mkfs 格式化会毁掉整块磁盘上的数据。',
|
|
112
|
-
pattern: /\bdd\b[^\n;|&]*\bof=\s*\/dev\/|\bmkfs(?:\.[a-z0-9]+)?\b/i,
|
|
113
|
-
}),
|
|
114
|
-
Object.freeze({
|
|
115
|
-
id: 'db-drop',
|
|
116
|
-
label: '删库 / 清表',
|
|
117
|
-
defaultDecision: 'block',
|
|
118
|
-
description: 'DROP DATABASE / DROP TABLE / TRUNCATE 会不可逆地删除数据库或表数据。',
|
|
119
|
-
// `truncate` alone is the unix command that zeroes a FILE (see truncate-log),
|
|
120
|
-
// so as SQL it needs `TABLE` or a client that runs it (`psql -c "truncate t"`).
|
|
121
|
-
pattern:
|
|
122
|
-
/\bdrop\s+(?:database|schema|table)\b|\btruncate\s+table\b|\b(?:mysql|mariadb|psql|sqlite3|mongosh?|clickhouse-client|redis-cli)\b[^\n;|&]*\btruncate\b/i,
|
|
123
|
-
}),
|
|
124
|
-
Object.freeze({
|
|
125
|
-
id: 'fork-bomb',
|
|
126
|
-
label: 'fork 炸弹',
|
|
127
|
-
defaultDecision: 'block',
|
|
128
|
-
description: '递归自复制的 fork 炸弹会耗尽远端进程表,导致机器失联。',
|
|
129
|
-
wholePattern: /:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:[^}]*\}/i,
|
|
130
|
-
}),
|
|
131
|
-
Object.freeze({
|
|
132
|
-
id: 'shutdown',
|
|
133
|
-
label: '关机',
|
|
134
|
-
defaultDecision: 'block',
|
|
135
|
-
description: 'shutdown / poweroff 会直接关掉远端主机。',
|
|
136
|
-
pattern: /\b(?:shutdown|poweroff)\b/i,
|
|
137
|
-
}),
|
|
138
|
-
Object.freeze({
|
|
139
|
-
id: 'reboot',
|
|
140
|
-
label: '重启',
|
|
141
|
-
defaultDecision: 'block',
|
|
142
|
-
description: 'reboot 会重启远端主机,中断其上所有服务。',
|
|
143
|
-
pattern: /\breboot\b/i,
|
|
144
|
-
}),
|
|
145
|
-
Object.freeze({
|
|
146
|
-
id: 'chmod-root',
|
|
147
|
-
label: '递归改系统路径权限',
|
|
148
|
-
defaultDecision: 'confirm',
|
|
149
|
-
description: '对 /etc、/usr、/bin 等系统路径递归 chmod 会破坏系统权限。',
|
|
150
|
-
// A system path is one the distribution owns. `/home` and `/srv` are left
|
|
151
|
-
// out on purpose: they hold user data and the default work dir of an app
|
|
152
|
-
// (`/srv/app` in doc §3.1), so recursive chmod there is routine.
|
|
153
|
-
pattern:
|
|
154
|
-
/\bchmod\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
|
|
155
|
-
}),
|
|
156
|
-
Object.freeze({
|
|
157
|
-
id: 'chown-root',
|
|
158
|
-
label: '递归改系统路径属主',
|
|
159
|
-
defaultDecision: 'confirm',
|
|
160
|
-
description: '对 /etc、/usr、/var 等系统路径递归 chown 会破坏系统文件属主。',
|
|
161
|
-
pattern:
|
|
162
|
-
/\bchown\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
|
|
163
|
-
}),
|
|
164
|
-
Object.freeze({
|
|
165
|
-
id: 'service-stop',
|
|
166
|
-
label: '停用服务',
|
|
167
|
-
defaultDecision: 'confirm',
|
|
168
|
-
description: 'systemctl stop / disable 会停掉或取消自启服务,可能造成业务中断。',
|
|
169
|
-
pattern: /\bsystemctl\b[^\n;|&]*\b(?:stop|disable)\b/i,
|
|
170
|
-
}),
|
|
171
|
-
Object.freeze({
|
|
172
|
-
id: 'service-restart',
|
|
173
|
-
label: '重启服务',
|
|
174
|
-
defaultDecision: 'confirm',
|
|
175
|
-
description: 'systemctl restart / docker restart 会短暂中断服务。',
|
|
176
|
-
pattern: /\bsystemctl\b[^\n;|&]*\brestart\b|\bdocker\s+(?:container\s+)?restart\b/i,
|
|
177
|
-
}),
|
|
178
|
-
Object.freeze({
|
|
179
|
-
id: 'docker-prune',
|
|
180
|
-
label: '清理 / 强删容器',
|
|
181
|
-
defaultDecision: 'confirm',
|
|
182
|
-
description: 'docker prune / docker rm -f 会清掉镜像缓存或强删容器,可能丢数据。',
|
|
183
|
-
pattern:
|
|
184
|
-
/\bdocker\s+(?:system\s+)?prune\b|\bdocker\s+(?:container\s+)?rm\b[^\n;|&]*(?:\s-[a-zA-Z]*f\b|\s--force\b)/i,
|
|
185
|
-
}),
|
|
186
|
-
Object.freeze({
|
|
187
|
-
id: 'curl-pipe-sh',
|
|
188
|
-
label: 'curl 管道执行',
|
|
189
|
-
defaultDecision: 'confirm',
|
|
190
|
-
description: 'curl … | sh 会把远端脚本直接交给 shell 执行,内容不可预审。',
|
|
191
|
-
wholePattern: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|da|z|k)?sh\b/i,
|
|
192
|
-
}),
|
|
193
|
-
Object.freeze({
|
|
194
|
-
id: 'kill-all',
|
|
195
|
-
label: '杀全部进程',
|
|
196
|
-
defaultDecision: 'confirm',
|
|
197
|
-
description: 'kill -9 -1 / pkill -9 会杀掉该用户能杀的所有进程。',
|
|
198
|
-
pattern:
|
|
199
|
-
/\bkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)[^\n;|&]*\s-1\b|\bpkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)/i,
|
|
200
|
-
}),
|
|
201
|
-
Object.freeze({
|
|
202
|
-
id: 'passwd-edit',
|
|
203
|
-
label: '改账号数据库',
|
|
204
|
-
defaultDecision: 'block',
|
|
205
|
-
description: '写 /etc/passwd 或 /etc/shadow 容易把账号系统改坏甚至锁死 root。',
|
|
206
|
-
pattern: writePattern('\\/etc\\/(?:passwd|shadow)\\b'),
|
|
207
|
-
}),
|
|
208
|
-
Object.freeze({
|
|
209
|
-
id: 'crontab-edit',
|
|
210
|
-
label: '删改计划任务',
|
|
211
|
-
defaultDecision: 'confirm',
|
|
212
|
-
description: 'crontab -r 或写 /etc/cron* 会删除或植入定时任务。',
|
|
213
|
-
pattern: new RegExp(
|
|
214
|
-
`\\bcrontab\\s+-r\\b|${WRITE_VERBS}${SEG}\\/etc\\/cron`,
|
|
215
|
-
'i',
|
|
216
|
-
),
|
|
217
|
-
}),
|
|
218
|
-
Object.freeze({
|
|
219
|
-
id: 'iptables-flush',
|
|
220
|
-
label: '清空防火墙',
|
|
221
|
-
defaultDecision: 'confirm',
|
|
222
|
-
description: 'iptables -F 会清空全部防火墙规则,可能把机器暴露出去。',
|
|
223
|
-
pattern: /\biptables\b[^\n;|&]*\s(?:-F|--flush)\b/i,
|
|
224
|
-
}),
|
|
225
|
-
Object.freeze({
|
|
226
|
-
id: 'git-force-push',
|
|
227
|
-
label: '强制推送',
|
|
228
|
-
defaultDecision: 'confirm',
|
|
229
|
-
description: 'git push --force / -f 会覆盖远端历史(--force-with-lease 是安全变体,不在此列)。',
|
|
230
|
-
// `--force-with-lease` is the guarded form: it only overwrites a branch the
|
|
231
|
-
// pusher has already seen, so it is deliberately not matched.
|
|
232
|
-
pattern: /\bgit\s+push\b[^\n;|&]*(?:\s--force(?!-with-lease)\b|\s-[a-zA-Z]*f[a-zA-Z]*\b)/i,
|
|
233
|
-
}),
|
|
234
|
-
Object.freeze({
|
|
235
|
-
id: 'history-clear',
|
|
236
|
-
label: '清空命令历史',
|
|
237
|
-
defaultDecision: 'confirm',
|
|
238
|
-
description: 'history -c 或清空 ~/.bash_history 会抹掉审计线索。',
|
|
239
|
-
pattern:
|
|
240
|
-
/\bhistory\s+-c\b|(?:>>?|\b(?:rm|shred|truncate|tee)\b)[^\n;|&]*\.[a-z_]*history\b/i,
|
|
241
|
-
}),
|
|
242
|
-
Object.freeze({
|
|
243
|
-
id: 'truncate-log',
|
|
244
|
-
label: '清空日志',
|
|
245
|
-
defaultDecision: 'confirm',
|
|
246
|
-
description: '重定向 /var/log/、把文件截断为 0 字节(truncate 无 -s 时即截断为 0)会清空日志文件。',
|
|
247
|
-
pattern:
|
|
248
|
-
/(?:>>?\s*\/var\/log\/)|\btruncate\b[^\n;|&]*(?:\s-s\s*0\b|\s--size\s*=?\s*0\b)|\btruncate\s+(?!--?[a-z])/i,
|
|
249
|
-
}),
|
|
250
|
-
Object.freeze({
|
|
251
|
-
id: 'sudoers-edit',
|
|
252
|
-
label: '改 sudo 配置',
|
|
253
|
-
defaultDecision: 'block',
|
|
254
|
-
description: '写 /etc/sudoers* 可能直接丢失 sudo 权限或开出无密码提权。',
|
|
255
|
-
pattern: writePattern('\\/etc\\/sudoers'),
|
|
256
|
-
}),
|
|
257
|
-
]))
|
|
258
|
-
|
|
259
|
-
/** Catalog indexed by rule id. */
|
|
260
|
-
export const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule]))
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Whether `value` is one of the three decisions.
|
|
264
|
-
* @param {unknown} value
|
|
265
|
-
* @returns {value is Decision}
|
|
266
|
-
*/
|
|
267
|
-
function isDecision(value) {
|
|
268
|
-
return value === 'allow' || value === 'confirm' || value === 'block'
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Compare two decisions by severity.
|
|
273
|
-
* @param {Decision} a
|
|
274
|
-
* @param {Decision} b
|
|
275
|
-
* @returns {number} positive when `a` is stricter than `b`, 0 when equal.
|
|
276
|
-
*/
|
|
277
|
-
export function compareDecisions(a, b) {
|
|
278
|
-
return RANK[a] - RANK[b]
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ------------------------------------------------- custom rules (doc §4.4) --
|
|
282
|
-
|
|
283
|
-
/** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
|
|
284
|
-
export const MAX_CUSTOM_PATTERN_LENGTH = 512
|
|
285
|
-
|
|
286
|
-
/** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
|
|
287
|
-
export const MAX_CUSTOM_RULES = 50
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Shapes that invite catastrophic backtracking: a quantified group whose body is
|
|
291
|
-
* itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
|
|
292
|
-
* rejects a few harmless forms rather than admitting a ReDoS.
|
|
293
|
-
*/
|
|
294
|
-
const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
|
|
295
|
-
|
|
296
|
-
/** One custom rule as stored on a server record (doc §3.1). */
|
|
297
|
-
/**
|
|
298
|
-
* @typedef {object} CustomRule
|
|
299
|
-
* @property {string} id
|
|
300
|
-
* @property {string} label
|
|
301
|
-
* @property {string} pattern - regex source, compiled on read
|
|
302
|
-
* @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
|
|
303
|
-
* @property {string} description
|
|
304
|
-
*/
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* A custom rule that failed validation. The route layer turns this into a 400.
|
|
308
|
-
*/
|
|
309
|
-
export class RuleError extends Error {
|
|
310
|
-
/**
|
|
311
|
-
* @param {string} code - machine-readable reason
|
|
312
|
-
* @param {string} message - human-readable, shown in the panel
|
|
313
|
-
*/
|
|
314
|
-
constructor(code, message) {
|
|
315
|
-
super(message)
|
|
316
|
-
this.name = 'RuleError'
|
|
317
|
-
this.code = code
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Validate and compile one custom rule (doc §4.4.2). Every check runs before the
|
|
323
|
-
* rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
|
|
324
|
-
* never stored.
|
|
325
|
-
*
|
|
326
|
-
* `samples` is optional: when given, at least one must match, which is the
|
|
327
|
-
* check that catches "I wrote a regex that never fires".
|
|
328
|
-
* @param {unknown} value - the rule as it arrived (already id-assigned)
|
|
329
|
-
* @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
|
|
330
|
-
* @returns {CustomRule}
|
|
331
|
-
*/
|
|
332
|
-
export function validateCustomRule(value, options = {}) {
|
|
333
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
334
|
-
throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
|
|
335
|
-
}
|
|
336
|
-
const rule = /** @type {Record<string, unknown>} */ (value)
|
|
337
|
-
const label = typeof rule.label === 'string' ? rule.label.trim() : ''
|
|
338
|
-
if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
|
|
339
|
-
if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
|
|
340
|
-
const description = typeof rule.description === 'string' ? rule.description.trim() : ''
|
|
341
|
-
if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
|
|
342
|
-
if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
|
|
343
|
-
// `allow` is refused outright: a rule that permits what the engine already
|
|
344
|
-
// permits buys nothing, and it is the only way to fake a loosening.
|
|
345
|
-
throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
|
|
346
|
-
}
|
|
347
|
-
if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
|
|
348
|
-
throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
|
|
349
|
-
}
|
|
350
|
-
const pattern = rule.pattern.trim()
|
|
351
|
-
if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
352
|
-
throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
|
|
353
|
-
}
|
|
354
|
-
if (NESTED_QUANTIFIER.test(pattern)) {
|
|
355
|
-
throw new RuleError(
|
|
356
|
-
'INVALID_RULE',
|
|
357
|
-
'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
|
|
358
|
-
)
|
|
359
|
-
}
|
|
360
|
-
/** @type {RegExp} */
|
|
361
|
-
let compiled
|
|
362
|
-
try {
|
|
363
|
-
compiled = new RegExp(pattern, 'i')
|
|
364
|
-
} catch (error) {
|
|
365
|
-
throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
|
|
366
|
-
}
|
|
367
|
-
// A pattern that matches the empty string matches every command, which would
|
|
368
|
-
// block or confirm everything on that server.
|
|
369
|
-
if (compiled.test('')) {
|
|
370
|
-
throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
|
|
371
|
-
}
|
|
372
|
-
const id = typeof rule.id === 'string' ? rule.id.trim() : ''
|
|
373
|
-
if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
|
|
374
|
-
const taken = new Set(options.takenIds ?? [])
|
|
375
|
-
if (RULES_BY_ID.has(id) || taken.has(id)) {
|
|
376
|
-
throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
|
|
377
|
-
}
|
|
378
|
-
const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
|
|
379
|
-
if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
|
|
380
|
-
throw new RuleError(
|
|
381
|
-
'RULE_NEVER_MATCHES',
|
|
382
|
-
'none of the sample commands matched this pattern; it would never fire.',
|
|
383
|
-
)
|
|
384
|
-
}
|
|
385
|
-
return Object.freeze({
|
|
386
|
-
id,
|
|
387
|
-
label,
|
|
388
|
-
pattern,
|
|
389
|
-
defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
|
|
390
|
-
description,
|
|
391
|
-
})
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Compile a stored custom rule for matching. A record that fails to compile is
|
|
396
|
-
* skipped rather than thrown: `servers.json` may predate a stricter validator,
|
|
397
|
-
* and one bad rule must not take down every command on that server.
|
|
398
|
-
* @param {CustomRule} rule
|
|
399
|
-
* @returns {Rule|null}
|
|
400
|
-
*/
|
|
401
|
-
function compileCustomRule(rule) {
|
|
402
|
-
/** @type {RegExp} */
|
|
403
|
-
let pattern
|
|
404
|
-
try {
|
|
405
|
-
pattern = new RegExp(rule.pattern, 'i')
|
|
406
|
-
} catch {
|
|
407
|
-
return null
|
|
408
|
-
}
|
|
409
|
-
return Object.freeze({
|
|
410
|
-
id: rule.id,
|
|
411
|
-
label: rule.label,
|
|
412
|
-
defaultDecision: rule.defaultDecision,
|
|
413
|
-
description: rule.description,
|
|
414
|
-
pattern,
|
|
415
|
-
custom: true,
|
|
416
|
-
})
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* The rule set that applies to one server: the built-in catalog, then that
|
|
421
|
-
* server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
|
|
422
|
-
* through the same match and strictest-wins logic.
|
|
423
|
-
* @param {ReadonlyArray<CustomRule>|undefined} customRules
|
|
424
|
-
* @returns {ReadonlyArray<Rule>}
|
|
425
|
-
*/
|
|
426
|
-
export function rulesFor(customRules) {
|
|
427
|
-
if (!Array.isArray(customRules) || customRules.length === 0) return RULES
|
|
428
|
-
const extra = []
|
|
429
|
-
for (const rule of customRules) {
|
|
430
|
-
if (rule === null || typeof rule !== 'object') continue
|
|
431
|
-
const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
|
|
432
|
-
if (compiled !== null) extra.push(compiled)
|
|
433
|
-
}
|
|
434
|
-
return extra.length === 0 ? RULES : [...RULES, ...extra]
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Split a command line into the subcommands every rule must be checked against.
|
|
439
|
-
*
|
|
440
|
-
* Splits on `&&`, `||`, `;`, `|` and newlines (doc §4.2.1). Without this,
|
|
441
|
-
* `echo ok && rm -rf /` would only be matched as a whole and the destructive
|
|
442
|
-
* half could slip past a rule anchored at the start of the line.
|
|
443
|
-
*
|
|
444
|
-
* @param {string} command - the raw command line.
|
|
445
|
-
* @returns {string[]} trimmed, non-empty subcommands.
|
|
446
|
-
* @throws {TypeError} when `command` is not a string.
|
|
447
|
-
*/
|
|
448
|
-
export function splitSubcommands(command) {
|
|
449
|
-
if (typeof command !== 'string') throw new TypeError('command must be a string')
|
|
450
|
-
return command
|
|
451
|
-
.split(/&&|\|\||;|\||\r?\n/)
|
|
452
|
-
.map((segment) => segment.trim())
|
|
453
|
-
.filter((segment) => segment !== '')
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Separate the two accepted second-argument shapes into overrides plus disabled
|
|
458
|
-
* rule ids:
|
|
459
|
-
* - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
|
|
460
|
-
* - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
|
|
461
|
-
*
|
|
462
|
-
* A third argument may carry `disabledRules` alongside a bare override map.
|
|
463
|
-
* @param {Record<string, unknown>|undefined} serverPolicy
|
|
464
|
-
* @param {string[]|undefined} disabledRules
|
|
465
|
-
* @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
|
|
466
|
-
*/
|
|
467
|
-
function normalizeServerPolicy(serverPolicy, disabledRules) {
|
|
468
|
-
const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
|
|
469
|
-
if (serverPolicy === undefined || serverPolicy === null) {
|
|
470
|
-
return { overrides: {}, disabled, customRules: [] }
|
|
471
|
-
}
|
|
472
|
-
if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
|
|
473
|
-
if (Array.isArray(serverPolicy.disabledRules)) {
|
|
474
|
-
for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
|
|
475
|
-
}
|
|
476
|
-
const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
|
|
477
|
-
const customRules = Array.isArray(fields.customRules)
|
|
478
|
-
? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
|
|
479
|
-
: []
|
|
480
|
-
const record = 'policy' in fields || 'disabledRules' in fields
|
|
481
|
-
const policy = fields.policy
|
|
482
|
-
/** @type {Record<string, unknown>} */
|
|
483
|
-
const overrides = record
|
|
484
|
-
? policy !== null && typeof policy === 'object'
|
|
485
|
-
? /** @type {Record<string, unknown>} */ (policy)
|
|
486
|
-
: {}
|
|
487
|
-
: fields
|
|
488
|
-
return { overrides, disabled, customRules }
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Resolve one rule's effective decision under a server's overrides (doc §4.2.3).
|
|
493
|
-
*
|
|
494
|
-
* A server may tighten any rule, loosen a `confirm` rule to `allow`, or lower a
|
|
495
|
-
* `block` rule to `confirm`. It may **not** lower a `block` rule straight to
|
|
496
|
-
* `allow`: that value is coerced to `confirm`, the loosest sanctioned setting.
|
|
497
|
-
* Unknown ids and non-decision values fall back to the rule's default.
|
|
498
|
-
*
|
|
499
|
-
* @param {Rule} rule
|
|
500
|
-
* @param {Record<string, unknown>} [overrides]
|
|
501
|
-
* @returns {Decision}
|
|
502
|
-
*/
|
|
503
|
-
export function effectiveDecision(rule, overrides = {}) {
|
|
504
|
-
const configured = overrides?.[rule.id]
|
|
505
|
-
if (!isDecision(configured)) return rule.defaultDecision
|
|
506
|
-
if (rule.defaultDecision === 'block' && configured === 'allow') return 'confirm'
|
|
507
|
-
return configured
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Whether one rule matches the command line. A rule carries exactly one of
|
|
512
|
-
* `pattern` / `wholePattern`; the other is undefined.
|
|
513
|
-
* @param {Rule} rule
|
|
514
|
-
* @param {string} command
|
|
515
|
-
* @param {string[]} segments
|
|
516
|
-
* @returns {boolean}
|
|
517
|
-
*/
|
|
518
|
-
function ruleMatches(rule, command, segments) {
|
|
519
|
-
if (rule.wholePattern !== undefined) return rule.wholePattern.test(command)
|
|
520
|
-
const pattern = /** @type {RegExp} */ (rule.pattern)
|
|
521
|
-
return pattern.test(command) || segments.some((segment) => pattern.test(segment))
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* The policy engine (doc §4.2): a pure function from
|
|
526
|
-
* `(command, serverPolicy) → { decision, matchedRule, … }`.
|
|
527
|
-
*
|
|
528
|
-
* Semantics:
|
|
529
|
-
* 1. the command line is split into subcommands and every subcommand is checked
|
|
530
|
-
* against every rule;
|
|
531
|
-
* 2. the strictest matching decision wins (`block > confirm > allow`);
|
|
532
|
-
* 3. `disabledRules` removes a rule entirely, so `srv_policy_check` and
|
|
533
|
-
* `srv_exec` agree on what exists;
|
|
534
|
-
* 4. no match is `allow`.
|
|
535
|
-
*
|
|
536
|
-
* @param {string} command - the raw command line.
|
|
537
|
-
* @param {{policy?: Record<string, unknown>, disabledRules?: string[]}|Record<string, unknown>} [serverPolicy]
|
|
538
|
-
* a server record (`{policy, disabledRules}`) or a bare override map.
|
|
539
|
-
* @param {string[]} [disabledRules] - extra disabled ids when the second argument
|
|
540
|
-
* is a bare override map.
|
|
541
|
-
* @returns {{
|
|
542
|
-
* decision: Decision,
|
|
543
|
-
* matchedRule: RuleMatch | null,
|
|
544
|
-
* ruleId: string | null,
|
|
545
|
-
* matches: RuleMatch[],
|
|
546
|
-
* segments: string[],
|
|
547
|
-
* }}
|
|
548
|
-
* @throws {TypeError} for a non-string command or a non-object policy argument.
|
|
549
|
-
*/
|
|
550
|
-
export function check(command, serverPolicy = {}, disabledRules) {
|
|
551
|
-
const segments = splitSubcommands(command)
|
|
552
|
-
const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
|
|
553
|
-
|
|
554
|
-
const matches = []
|
|
555
|
-
// Built-ins first, then this server's custom rules (doc §4.2.5): the custom
|
|
556
|
-
// set is part of the same sweep, so strictest-wins needs no special case.
|
|
557
|
-
for (const rule of rulesFor(customRules)) {
|
|
558
|
-
if (disabled.has(rule.id)) continue
|
|
559
|
-
if (!ruleMatches(rule, command, segments)) continue
|
|
560
|
-
matches.push({
|
|
561
|
-
id: rule.id,
|
|
562
|
-
label: rule.label,
|
|
563
|
-
defaultDecision: rule.defaultDecision,
|
|
564
|
-
description: rule.description,
|
|
565
|
-
decision: effectiveDecision(rule, overrides),
|
|
566
|
-
})
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/** @type {Decision} */
|
|
570
|
-
let decision = 'allow'
|
|
571
|
-
/** @type {RuleMatch | null} */
|
|
572
|
-
let matchedRule = null
|
|
573
|
-
for (const match of matches) {
|
|
574
|
-
if (RANK[match.decision] > RANK[decision]) {
|
|
575
|
-
decision = match.decision
|
|
576
|
-
matchedRule = match
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
decision,
|
|
582
|
-
matchedRule,
|
|
583
|
-
ruleId: matchedRule === null ? null : matchedRule.id,
|
|
584
|
-
matches,
|
|
585
|
-
segments,
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Alias of {@link check} for callers that prefer an explicit verb. */
|
|
590
|
-
export const checkCommand = check
|
|
1
|
+
// dsh-server-connector — built-in high-risk command policy engine.
|
|
2
|
+
//
|
|
3
|
+
// A single pure module: it never touches the network, the file system, the clock
|
|
4
|
+
// or DSH. `srv_policy_check` and `srv_exec` both call `check()`, so the pre-flight
|
|
5
|
+
// verdict and the execution verdict can never disagree.
|
|
6
|
+
//
|
|
7
|
+
// This is containment, not a security boundary. Regexes cannot see through
|
|
8
|
+
// `python -c "shutil.rmtree('/')"` or `base64 -d | sh`; the real boundary is the
|
|
9
|
+
// unprivileged remote user plus a sudo allowlist. The contract here is only that
|
|
10
|
+
// known destructive patterns are blocked or require confirmation.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One decision level.
|
|
14
|
+
* @typedef {'allow' | 'confirm' | 'block'} Decision
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One built-in rule. Either `pattern` or `wholePattern` is present; see
|
|
19
|
+
* {@link RULES} for which one applies when.
|
|
20
|
+
* @typedef {object} Rule
|
|
21
|
+
* @property {string} id - stable rule id, the key of a server's policy map.
|
|
22
|
+
* @property {string} label - short human-readable name, used in the approval reason.
|
|
23
|
+
* @property {Decision} defaultDecision - `block` or `confirm`; never `allow`.
|
|
24
|
+
* @property {string} description - what the rule is protecting against.
|
|
25
|
+
* @property {RegExp} [pattern] - matched per subcommand and against the whole line.
|
|
26
|
+
* @property {RegExp} [wholePattern] - matched against the whole line only.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A rule that matched, with the decision it resolved to for this server. */
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} RuleMatch
|
|
32
|
+
* @property {string} id
|
|
33
|
+
* @property {string} label
|
|
34
|
+
* @property {Decision} defaultDecision
|
|
35
|
+
* @property {string} description
|
|
36
|
+
* @property {Decision} decision
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The three decision levels, ordered from most to least permissive. */
|
|
40
|
+
export const DECISIONS = Object.freeze({
|
|
41
|
+
ALLOW: /** @type {Decision} */ ('allow'),
|
|
42
|
+
CONFIRM: /** @type {Decision} */ ('confirm'),
|
|
43
|
+
BLOCK: /** @type {Decision} */ ('block'),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** Severity rank: the strictest matching rule wins. `block > confirm > allow`. */
|
|
47
|
+
const RANK = Object.freeze({ allow: 0, confirm: 1, block: 2 })
|
|
48
|
+
|
|
49
|
+
/** Characters a single subcommand pattern may consume; separators are hard stops. */
|
|
50
|
+
const SEG = '[^\\n;|&]*'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Verbs and redirections that modify the file they are told to modify: the
|
|
54
|
+
* named path may appear anywhere after them. Used by the rules that block or
|
|
55
|
+
* confirm a *write* to a system path while leaving a read (`cat /etc/passwd`)
|
|
56
|
+
* alone.
|
|
57
|
+
*/
|
|
58
|
+
const WRITE_VERBS =
|
|
59
|
+
'(?:>>?\\s*|\\btee\\b|\\brm\\b|' +
|
|
60
|
+
`\\bsed\\b${SEG}\\s-i|\\b(?:vi|vim|nano|emacs|ed)\\b|\\btruncate\\b|\\bshred\\b)`
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Verbs whose *last* argument is the destination, so the protected path must be
|
|
64
|
+
* preceded by at least one argument. Without that guard `cp /etc/passwd
|
|
65
|
+
* /root/bak` — a backup, i.e. a read — would be reported as a write.
|
|
66
|
+
*/
|
|
67
|
+
const COPY_VERBS = '(?:\\bcp\\b|\\bmv\\b|\\binstall\\b|\\bln\\b)'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A "writes this path" pattern: a write verb or redirection aimed at a path
|
|
71
|
+
* matching `pathSource`. A copy verb counts only when the path is not its
|
|
72
|
+
* first argument.
|
|
73
|
+
* @param {string} pathSource - Regex source for the target path.
|
|
74
|
+
* @returns {RegExp} compiled, case-insensitive pattern.
|
|
75
|
+
*/
|
|
76
|
+
function writePattern(pathSource) {
|
|
77
|
+
return new RegExp(
|
|
78
|
+
`(?:${WRITE_VERBS}${SEG}${pathSource})|(?:${COPY_VERBS}[^\\n;|&]*\\S\\s+${pathSource})`,
|
|
79
|
+
'i',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The built-in rule catalog (doc §4.1). Exactly 20 rules; `defaultDecision` is
|
|
85
|
+
* always `block` or `confirm`, never `allow`. Every rule is pure data: an id, a
|
|
86
|
+
* label and description for the approval reason and panel, the default
|
|
87
|
+
* decision, and the regexes it is matched with.
|
|
88
|
+
*
|
|
89
|
+
* `pattern` is tested against each subcommand (and the whole command line, which
|
|
90
|
+
* is harmless because the patterns stop at separators). `wholePattern` is tested
|
|
91
|
+
* against the whole command line only: the fork bomb and `curl … | sh` contain
|
|
92
|
+
* separators themselves, so per-subcommand matching would never see them.
|
|
93
|
+
*
|
|
94
|
+
* The catalog is read-only: server policy can override a decision or disable a
|
|
95
|
+
* rule, but never add to or edit the catalog.
|
|
96
|
+
*/
|
|
97
|
+
export const RULES = /** @type {readonly Rule[]} */ (Object.freeze([
|
|
98
|
+
Object.freeze({
|
|
99
|
+
id: 'rm-recursive',
|
|
100
|
+
label: '递归删除',
|
|
101
|
+
defaultDecision: 'block',
|
|
102
|
+
description: 'rm 的递归删除(-r / -R / --recursive)会不可逆地删除整个目录树。',
|
|
103
|
+
// `git rm -r` is left out: it removes tracked paths and stays recoverable
|
|
104
|
+
// from the object database, so it is a VCS operation rather than a wipe.
|
|
105
|
+
pattern: /(?<!git )\brm\b[^\n;|&]*(?:\s-[a-zA-Z]*r[a-zA-Z]*\b|\s--recursive\b)/i,
|
|
106
|
+
}),
|
|
107
|
+
Object.freeze({
|
|
108
|
+
id: 'dd-write',
|
|
109
|
+
label: '裸设备写入 / 格式化',
|
|
110
|
+
defaultDecision: 'block',
|
|
111
|
+
description: 'dd 直接写块设备或 mkfs 格式化会毁掉整块磁盘上的数据。',
|
|
112
|
+
pattern: /\bdd\b[^\n;|&]*\bof=\s*\/dev\/|\bmkfs(?:\.[a-z0-9]+)?\b/i,
|
|
113
|
+
}),
|
|
114
|
+
Object.freeze({
|
|
115
|
+
id: 'db-drop',
|
|
116
|
+
label: '删库 / 清表',
|
|
117
|
+
defaultDecision: 'block',
|
|
118
|
+
description: 'DROP DATABASE / DROP TABLE / TRUNCATE 会不可逆地删除数据库或表数据。',
|
|
119
|
+
// `truncate` alone is the unix command that zeroes a FILE (see truncate-log),
|
|
120
|
+
// so as SQL it needs `TABLE` or a client that runs it (`psql -c "truncate t"`).
|
|
121
|
+
pattern:
|
|
122
|
+
/\bdrop\s+(?:database|schema|table)\b|\btruncate\s+table\b|\b(?:mysql|mariadb|psql|sqlite3|mongosh?|clickhouse-client|redis-cli)\b[^\n;|&]*\btruncate\b/i,
|
|
123
|
+
}),
|
|
124
|
+
Object.freeze({
|
|
125
|
+
id: 'fork-bomb',
|
|
126
|
+
label: 'fork 炸弹',
|
|
127
|
+
defaultDecision: 'block',
|
|
128
|
+
description: '递归自复制的 fork 炸弹会耗尽远端进程表,导致机器失联。',
|
|
129
|
+
wholePattern: /:\s*\(\s*\)\s*\{[^}]*:\s*\|\s*:[^}]*\}/i,
|
|
130
|
+
}),
|
|
131
|
+
Object.freeze({
|
|
132
|
+
id: 'shutdown',
|
|
133
|
+
label: '关机',
|
|
134
|
+
defaultDecision: 'block',
|
|
135
|
+
description: 'shutdown / poweroff 会直接关掉远端主机。',
|
|
136
|
+
pattern: /\b(?:shutdown|poweroff)\b/i,
|
|
137
|
+
}),
|
|
138
|
+
Object.freeze({
|
|
139
|
+
id: 'reboot',
|
|
140
|
+
label: '重启',
|
|
141
|
+
defaultDecision: 'block',
|
|
142
|
+
description: 'reboot 会重启远端主机,中断其上所有服务。',
|
|
143
|
+
pattern: /\breboot\b/i,
|
|
144
|
+
}),
|
|
145
|
+
Object.freeze({
|
|
146
|
+
id: 'chmod-root',
|
|
147
|
+
label: '递归改系统路径权限',
|
|
148
|
+
defaultDecision: 'confirm',
|
|
149
|
+
description: '对 /etc、/usr、/bin 等系统路径递归 chmod 会破坏系统权限。',
|
|
150
|
+
// A system path is one the distribution owns. `/home` and `/srv` are left
|
|
151
|
+
// out on purpose: they hold user data and the default work dir of an app
|
|
152
|
+
// (`/srv/app` in doc §3.1), so recursive chmod there is routine.
|
|
153
|
+
pattern:
|
|
154
|
+
/\bchmod\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
|
|
155
|
+
}),
|
|
156
|
+
Object.freeze({
|
|
157
|
+
id: 'chown-root',
|
|
158
|
+
label: '递归改系统路径属主',
|
|
159
|
+
defaultDecision: 'confirm',
|
|
160
|
+
description: '对 /etc、/usr、/var 等系统路径递归 chown 会破坏系统文件属主。',
|
|
161
|
+
pattern:
|
|
162
|
+
/\bchown\b[^\n;|&]*(?:\s-[a-zA-Z]*R[a-zA-Z]*\b|\s--recursive\b)[^\n;|&]*\/(?:etc|usr|bin|sbin|lib|lib32|lib64|boot|var|opt|root|proc|sys|dev)\b/i,
|
|
163
|
+
}),
|
|
164
|
+
Object.freeze({
|
|
165
|
+
id: 'service-stop',
|
|
166
|
+
label: '停用服务',
|
|
167
|
+
defaultDecision: 'confirm',
|
|
168
|
+
description: 'systemctl stop / disable 会停掉或取消自启服务,可能造成业务中断。',
|
|
169
|
+
pattern: /\bsystemctl\b[^\n;|&]*\b(?:stop|disable)\b/i,
|
|
170
|
+
}),
|
|
171
|
+
Object.freeze({
|
|
172
|
+
id: 'service-restart',
|
|
173
|
+
label: '重启服务',
|
|
174
|
+
defaultDecision: 'confirm',
|
|
175
|
+
description: 'systemctl restart / docker restart 会短暂中断服务。',
|
|
176
|
+
pattern: /\bsystemctl\b[^\n;|&]*\brestart\b|\bdocker\s+(?:container\s+)?restart\b/i,
|
|
177
|
+
}),
|
|
178
|
+
Object.freeze({
|
|
179
|
+
id: 'docker-prune',
|
|
180
|
+
label: '清理 / 强删容器',
|
|
181
|
+
defaultDecision: 'confirm',
|
|
182
|
+
description: 'docker prune / docker rm -f 会清掉镜像缓存或强删容器,可能丢数据。',
|
|
183
|
+
pattern:
|
|
184
|
+
/\bdocker\s+(?:system\s+)?prune\b|\bdocker\s+(?:container\s+)?rm\b[^\n;|&]*(?:\s-[a-zA-Z]*f\b|\s--force\b)/i,
|
|
185
|
+
}),
|
|
186
|
+
Object.freeze({
|
|
187
|
+
id: 'curl-pipe-sh',
|
|
188
|
+
label: 'curl 管道执行',
|
|
189
|
+
defaultDecision: 'confirm',
|
|
190
|
+
description: 'curl … | sh 会把远端脚本直接交给 shell 执行,内容不可预审。',
|
|
191
|
+
wholePattern: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|da|z|k)?sh\b/i,
|
|
192
|
+
}),
|
|
193
|
+
Object.freeze({
|
|
194
|
+
id: 'kill-all',
|
|
195
|
+
label: '杀全部进程',
|
|
196
|
+
defaultDecision: 'confirm',
|
|
197
|
+
description: 'kill -9 -1 / pkill -9 会杀掉该用户能杀的所有进程。',
|
|
198
|
+
pattern:
|
|
199
|
+
/\bkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)[^\n;|&]*\s-1\b|\bpkill\b[^\n;|&]*(?:\s-9\b|\s-KILL\b|\s-s\s*(?:9|KILL)\b)/i,
|
|
200
|
+
}),
|
|
201
|
+
Object.freeze({
|
|
202
|
+
id: 'passwd-edit',
|
|
203
|
+
label: '改账号数据库',
|
|
204
|
+
defaultDecision: 'block',
|
|
205
|
+
description: '写 /etc/passwd 或 /etc/shadow 容易把账号系统改坏甚至锁死 root。',
|
|
206
|
+
pattern: writePattern('\\/etc\\/(?:passwd|shadow)\\b'),
|
|
207
|
+
}),
|
|
208
|
+
Object.freeze({
|
|
209
|
+
id: 'crontab-edit',
|
|
210
|
+
label: '删改计划任务',
|
|
211
|
+
defaultDecision: 'confirm',
|
|
212
|
+
description: 'crontab -r 或写 /etc/cron* 会删除或植入定时任务。',
|
|
213
|
+
pattern: new RegExp(
|
|
214
|
+
`\\bcrontab\\s+-r\\b|${WRITE_VERBS}${SEG}\\/etc\\/cron`,
|
|
215
|
+
'i',
|
|
216
|
+
),
|
|
217
|
+
}),
|
|
218
|
+
Object.freeze({
|
|
219
|
+
id: 'iptables-flush',
|
|
220
|
+
label: '清空防火墙',
|
|
221
|
+
defaultDecision: 'confirm',
|
|
222
|
+
description: 'iptables -F 会清空全部防火墙规则,可能把机器暴露出去。',
|
|
223
|
+
pattern: /\biptables\b[^\n;|&]*\s(?:-F|--flush)\b/i,
|
|
224
|
+
}),
|
|
225
|
+
Object.freeze({
|
|
226
|
+
id: 'git-force-push',
|
|
227
|
+
label: '强制推送',
|
|
228
|
+
defaultDecision: 'confirm',
|
|
229
|
+
description: 'git push --force / -f 会覆盖远端历史(--force-with-lease 是安全变体,不在此列)。',
|
|
230
|
+
// `--force-with-lease` is the guarded form: it only overwrites a branch the
|
|
231
|
+
// pusher has already seen, so it is deliberately not matched.
|
|
232
|
+
pattern: /\bgit\s+push\b[^\n;|&]*(?:\s--force(?!-with-lease)\b|\s-[a-zA-Z]*f[a-zA-Z]*\b)/i,
|
|
233
|
+
}),
|
|
234
|
+
Object.freeze({
|
|
235
|
+
id: 'history-clear',
|
|
236
|
+
label: '清空命令历史',
|
|
237
|
+
defaultDecision: 'confirm',
|
|
238
|
+
description: 'history -c 或清空 ~/.bash_history 会抹掉审计线索。',
|
|
239
|
+
pattern:
|
|
240
|
+
/\bhistory\s+-c\b|(?:>>?|\b(?:rm|shred|truncate|tee)\b)[^\n;|&]*\.[a-z_]*history\b/i,
|
|
241
|
+
}),
|
|
242
|
+
Object.freeze({
|
|
243
|
+
id: 'truncate-log',
|
|
244
|
+
label: '清空日志',
|
|
245
|
+
defaultDecision: 'confirm',
|
|
246
|
+
description: '重定向 /var/log/、把文件截断为 0 字节(truncate 无 -s 时即截断为 0)会清空日志文件。',
|
|
247
|
+
pattern:
|
|
248
|
+
/(?:>>?\s*\/var\/log\/)|\btruncate\b[^\n;|&]*(?:\s-s\s*0\b|\s--size\s*=?\s*0\b)|\btruncate\s+(?!--?[a-z])/i,
|
|
249
|
+
}),
|
|
250
|
+
Object.freeze({
|
|
251
|
+
id: 'sudoers-edit',
|
|
252
|
+
label: '改 sudo 配置',
|
|
253
|
+
defaultDecision: 'block',
|
|
254
|
+
description: '写 /etc/sudoers* 可能直接丢失 sudo 权限或开出无密码提权。',
|
|
255
|
+
pattern: writePattern('\\/etc\\/sudoers'),
|
|
256
|
+
}),
|
|
257
|
+
]))
|
|
258
|
+
|
|
259
|
+
/** Catalog indexed by rule id. */
|
|
260
|
+
export const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule]))
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether `value` is one of the three decisions.
|
|
264
|
+
* @param {unknown} value
|
|
265
|
+
* @returns {value is Decision}
|
|
266
|
+
*/
|
|
267
|
+
function isDecision(value) {
|
|
268
|
+
return value === 'allow' || value === 'confirm' || value === 'block'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compare two decisions by severity.
|
|
273
|
+
* @param {Decision} a
|
|
274
|
+
* @param {Decision} b
|
|
275
|
+
* @returns {number} positive when `a` is stricter than `b`, 0 when equal.
|
|
276
|
+
*/
|
|
277
|
+
export function compareDecisions(a, b) {
|
|
278
|
+
return RANK[a] - RANK[b]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------- custom rules (doc §4.4) --
|
|
282
|
+
|
|
283
|
+
/** Longest accepted `pattern` source. Past this the rule is almost certainly a paste accident. */
|
|
284
|
+
export const MAX_CUSTOM_PATTERN_LENGTH = 512
|
|
285
|
+
|
|
286
|
+
/** Most custom rules one server may carry. Bounds both the UI and the regex sweep. */
|
|
287
|
+
export const MAX_CUSTOM_RULES = 50
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Shapes that invite catastrophic backtracking: a quantified group whose body is
|
|
291
|
+
* itself quantified (`(a+)+`, `(a*)*`, `(.*)+`). Conservative on purpose — it
|
|
292
|
+
* rejects a few harmless forms rather than admitting a ReDoS.
|
|
293
|
+
*/
|
|
294
|
+
const NESTED_QUANTIFIER = /\(\s*[^()]*[+*]\s*\)\s*[+*]/
|
|
295
|
+
|
|
296
|
+
/** One custom rule as stored on a server record (doc §3.1). */
|
|
297
|
+
/**
|
|
298
|
+
* @typedef {object} CustomRule
|
|
299
|
+
* @property {string} id
|
|
300
|
+
* @property {string} label
|
|
301
|
+
* @property {string} pattern - regex source, compiled on read
|
|
302
|
+
* @property {Decision} defaultDecision - `confirm` or `block`; never `allow`
|
|
303
|
+
* @property {string} description
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A custom rule that failed validation. The route layer turns this into a 400.
|
|
308
|
+
*/
|
|
309
|
+
export class RuleError extends Error {
|
|
310
|
+
/**
|
|
311
|
+
* @param {string} code - machine-readable reason
|
|
312
|
+
* @param {string} message - human-readable, shown in the panel
|
|
313
|
+
*/
|
|
314
|
+
constructor(code, message) {
|
|
315
|
+
super(message)
|
|
316
|
+
this.name = 'RuleError'
|
|
317
|
+
this.code = code
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Validate and compile one custom rule (doc §4.4.2). Every check runs before the
|
|
323
|
+
* rule is allowed to touch `servers.json`, so a rule that cannot be honoured is
|
|
324
|
+
* never stored.
|
|
325
|
+
*
|
|
326
|
+
* `samples` is optional: when given, at least one must match, which is the
|
|
327
|
+
* check that catches "I wrote a regex that never fires".
|
|
328
|
+
* @param {unknown} value - the rule as it arrived (already id-assigned)
|
|
329
|
+
* @param {{samples?: string[], takenIds?: Iterable<string>}} [options]
|
|
330
|
+
* @returns {CustomRule}
|
|
331
|
+
*/
|
|
332
|
+
export function validateCustomRule(value, options = {}) {
|
|
333
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
334
|
+
throw new RuleError('INVALID_RULE', 'a custom rule must be an object.')
|
|
335
|
+
}
|
|
336
|
+
const rule = /** @type {Record<string, unknown>} */ (value)
|
|
337
|
+
const label = typeof rule.label === 'string' ? rule.label.trim() : ''
|
|
338
|
+
if (label === '') throw new RuleError('INVALID_RULE', 'label must be a non-empty string.')
|
|
339
|
+
if (label.length > 80) throw new RuleError('INVALID_RULE', 'label must be at most 80 characters.')
|
|
340
|
+
const description = typeof rule.description === 'string' ? rule.description.trim() : ''
|
|
341
|
+
if (description.length > 400) throw new RuleError('INVALID_RULE', 'description must be at most 400 characters.')
|
|
342
|
+
if (rule.defaultDecision !== 'confirm' && rule.defaultDecision !== 'block') {
|
|
343
|
+
// `allow` is refused outright: a rule that permits what the engine already
|
|
344
|
+
// permits buys nothing, and it is the only way to fake a loosening.
|
|
345
|
+
throw new RuleError('INVALID_RULE', 'defaultDecision must be "confirm" or "block".')
|
|
346
|
+
}
|
|
347
|
+
if (typeof rule.pattern !== 'string' || rule.pattern.trim() === '') {
|
|
348
|
+
throw new RuleError('INVALID_RULE', 'pattern must be a non-empty regex source.')
|
|
349
|
+
}
|
|
350
|
+
const pattern = rule.pattern.trim()
|
|
351
|
+
if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
|
|
352
|
+
throw new RuleError('INVALID_RULE', `pattern must be at most ${MAX_CUSTOM_PATTERN_LENGTH} characters.`)
|
|
353
|
+
}
|
|
354
|
+
if (NESTED_QUANTIFIER.test(pattern)) {
|
|
355
|
+
throw new RuleError(
|
|
356
|
+
'INVALID_RULE',
|
|
357
|
+
'pattern contains a nested quantifier like (a+)+, which can backtrack catastrophically. Rewrite it without the inner repetition.',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
/** @type {RegExp} */
|
|
361
|
+
let compiled
|
|
362
|
+
try {
|
|
363
|
+
compiled = new RegExp(pattern, 'i')
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw new RuleError('INVALID_RULE', `pattern is not a valid regular expression: ${/** @type {Error} */ (error).message}`)
|
|
366
|
+
}
|
|
367
|
+
// A pattern that matches the empty string matches every command, which would
|
|
368
|
+
// block or confirm everything on that server.
|
|
369
|
+
if (compiled.test('')) {
|
|
370
|
+
throw new RuleError('INVALID_RULE', 'pattern matches the empty string, so it would match every command.')
|
|
371
|
+
}
|
|
372
|
+
const id = typeof rule.id === 'string' ? rule.id.trim() : ''
|
|
373
|
+
if (id === '') throw new RuleError('INVALID_RULE', 'id must be a non-empty string.')
|
|
374
|
+
const taken = new Set(options.takenIds ?? [])
|
|
375
|
+
if (RULES_BY_ID.has(id) || taken.has(id)) {
|
|
376
|
+
throw new RuleError('DUPLICATE_RULE', `rule id "${id}" is already in use on this server.`)
|
|
377
|
+
}
|
|
378
|
+
const samples = Array.isArray(options.samples) ? options.samples.filter((s) => typeof s === 'string' && s.trim() !== '') : []
|
|
379
|
+
if (samples.length > 0 && !samples.some((sample) => compiled.test(sample))) {
|
|
380
|
+
throw new RuleError(
|
|
381
|
+
'RULE_NEVER_MATCHES',
|
|
382
|
+
'none of the sample commands matched this pattern; it would never fire.',
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return Object.freeze({
|
|
386
|
+
id,
|
|
387
|
+
label,
|
|
388
|
+
pattern,
|
|
389
|
+
defaultDecision: /** @type {Decision} */ (rule.defaultDecision),
|
|
390
|
+
description,
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Compile a stored custom rule for matching. A record that fails to compile is
|
|
396
|
+
* skipped rather than thrown: `servers.json` may predate a stricter validator,
|
|
397
|
+
* and one bad rule must not take down every command on that server.
|
|
398
|
+
* @param {CustomRule} rule
|
|
399
|
+
* @returns {Rule|null}
|
|
400
|
+
*/
|
|
401
|
+
function compileCustomRule(rule) {
|
|
402
|
+
/** @type {RegExp} */
|
|
403
|
+
let pattern
|
|
404
|
+
try {
|
|
405
|
+
pattern = new RegExp(rule.pattern, 'i')
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
return Object.freeze({
|
|
410
|
+
id: rule.id,
|
|
411
|
+
label: rule.label,
|
|
412
|
+
defaultDecision: rule.defaultDecision,
|
|
413
|
+
description: rule.description,
|
|
414
|
+
pattern,
|
|
415
|
+
custom: true,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The rule set that applies to one server: the built-in catalog, then that
|
|
421
|
+
* server's custom rules (doc §4.2.5). Custom rules enjoy no privilege — they run
|
|
422
|
+
* through the same match and strictest-wins logic.
|
|
423
|
+
* @param {ReadonlyArray<CustomRule>|undefined} customRules
|
|
424
|
+
* @returns {ReadonlyArray<Rule>}
|
|
425
|
+
*/
|
|
426
|
+
export function rulesFor(customRules) {
|
|
427
|
+
if (!Array.isArray(customRules) || customRules.length === 0) return RULES
|
|
428
|
+
const extra = []
|
|
429
|
+
for (const rule of customRules) {
|
|
430
|
+
if (rule === null || typeof rule !== 'object') continue
|
|
431
|
+
const compiled = compileCustomRule(/** @type {CustomRule} */ (rule))
|
|
432
|
+
if (compiled !== null) extra.push(compiled)
|
|
433
|
+
}
|
|
434
|
+
return extra.length === 0 ? RULES : [...RULES, ...extra]
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Split a command line into the subcommands every rule must be checked against.
|
|
439
|
+
*
|
|
440
|
+
* Splits on `&&`, `||`, `;`, `|` and newlines (doc §4.2.1). Without this,
|
|
441
|
+
* `echo ok && rm -rf /` would only be matched as a whole and the destructive
|
|
442
|
+
* half could slip past a rule anchored at the start of the line.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} command - the raw command line.
|
|
445
|
+
* @returns {string[]} trimmed, non-empty subcommands.
|
|
446
|
+
* @throws {TypeError} when `command` is not a string.
|
|
447
|
+
*/
|
|
448
|
+
export function splitSubcommands(command) {
|
|
449
|
+
if (typeof command !== 'string') throw new TypeError('command must be a string')
|
|
450
|
+
return command
|
|
451
|
+
.split(/&&|\|\||;|\||\r?\n/)
|
|
452
|
+
.map((segment) => segment.trim())
|
|
453
|
+
.filter((segment) => segment !== '')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Separate the two accepted second-argument shapes into overrides plus disabled
|
|
458
|
+
* rule ids:
|
|
459
|
+
* - a server record (`{ policy, disabledRules, customRules }`, doc §3.1), or
|
|
460
|
+
* - a bare per-rule override map (`{ 'rm-recursive': 'confirm' }`).
|
|
461
|
+
*
|
|
462
|
+
* A third argument may carry `disabledRules` alongside a bare override map.
|
|
463
|
+
* @param {Record<string, unknown>|undefined} serverPolicy
|
|
464
|
+
* @param {string[]|undefined} disabledRules
|
|
465
|
+
* @returns {{overrides: Record<string, unknown>, disabled: Set<string>, customRules: CustomRule[]}}
|
|
466
|
+
*/
|
|
467
|
+
function normalizeServerPolicy(serverPolicy, disabledRules) {
|
|
468
|
+
const disabled = new Set(Array.isArray(disabledRules) ? disabledRules : [])
|
|
469
|
+
if (serverPolicy === undefined || serverPolicy === null) {
|
|
470
|
+
return { overrides: {}, disabled, customRules: [] }
|
|
471
|
+
}
|
|
472
|
+
if (typeof serverPolicy !== 'object') throw new TypeError('serverPolicy must be an object')
|
|
473
|
+
if (Array.isArray(serverPolicy.disabledRules)) {
|
|
474
|
+
for (const id of serverPolicy.disabledRules) if (typeof id === 'string') disabled.add(id)
|
|
475
|
+
}
|
|
476
|
+
const fields = /** @type {Record<string, unknown>} */ (serverPolicy)
|
|
477
|
+
const customRules = Array.isArray(fields.customRules)
|
|
478
|
+
? /** @type {CustomRule[]} */ (fields.customRules.filter((rule) => rule !== null && typeof rule === 'object'))
|
|
479
|
+
: []
|
|
480
|
+
const record = 'policy' in fields || 'disabledRules' in fields
|
|
481
|
+
const policy = fields.policy
|
|
482
|
+
/** @type {Record<string, unknown>} */
|
|
483
|
+
const overrides = record
|
|
484
|
+
? policy !== null && typeof policy === 'object'
|
|
485
|
+
? /** @type {Record<string, unknown>} */ (policy)
|
|
486
|
+
: {}
|
|
487
|
+
: fields
|
|
488
|
+
return { overrides, disabled, customRules }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Resolve one rule's effective decision under a server's overrides (doc §4.2.3).
|
|
493
|
+
*
|
|
494
|
+
* A server may tighten any rule, loosen a `confirm` rule to `allow`, or lower a
|
|
495
|
+
* `block` rule to `confirm`. It may **not** lower a `block` rule straight to
|
|
496
|
+
* `allow`: that value is coerced to `confirm`, the loosest sanctioned setting.
|
|
497
|
+
* Unknown ids and non-decision values fall back to the rule's default.
|
|
498
|
+
*
|
|
499
|
+
* @param {Rule} rule
|
|
500
|
+
* @param {Record<string, unknown>} [overrides]
|
|
501
|
+
* @returns {Decision}
|
|
502
|
+
*/
|
|
503
|
+
export function effectiveDecision(rule, overrides = {}) {
|
|
504
|
+
const configured = overrides?.[rule.id]
|
|
505
|
+
if (!isDecision(configured)) return rule.defaultDecision
|
|
506
|
+
if (rule.defaultDecision === 'block' && configured === 'allow') return 'confirm'
|
|
507
|
+
return configured
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Whether one rule matches the command line. A rule carries exactly one of
|
|
512
|
+
* `pattern` / `wholePattern`; the other is undefined.
|
|
513
|
+
* @param {Rule} rule
|
|
514
|
+
* @param {string} command
|
|
515
|
+
* @param {string[]} segments
|
|
516
|
+
* @returns {boolean}
|
|
517
|
+
*/
|
|
518
|
+
function ruleMatches(rule, command, segments) {
|
|
519
|
+
if (rule.wholePattern !== undefined) return rule.wholePattern.test(command)
|
|
520
|
+
const pattern = /** @type {RegExp} */ (rule.pattern)
|
|
521
|
+
return pattern.test(command) || segments.some((segment) => pattern.test(segment))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The policy engine (doc §4.2): a pure function from
|
|
526
|
+
* `(command, serverPolicy) → { decision, matchedRule, … }`.
|
|
527
|
+
*
|
|
528
|
+
* Semantics:
|
|
529
|
+
* 1. the command line is split into subcommands and every subcommand is checked
|
|
530
|
+
* against every rule;
|
|
531
|
+
* 2. the strictest matching decision wins (`block > confirm > allow`);
|
|
532
|
+
* 3. `disabledRules` removes a rule entirely, so `srv_policy_check` and
|
|
533
|
+
* `srv_exec` agree on what exists;
|
|
534
|
+
* 4. no match is `allow`.
|
|
535
|
+
*
|
|
536
|
+
* @param {string} command - the raw command line.
|
|
537
|
+
* @param {{policy?: Record<string, unknown>, disabledRules?: string[]}|Record<string, unknown>} [serverPolicy]
|
|
538
|
+
* a server record (`{policy, disabledRules}`) or a bare override map.
|
|
539
|
+
* @param {string[]} [disabledRules] - extra disabled ids when the second argument
|
|
540
|
+
* is a bare override map.
|
|
541
|
+
* @returns {{
|
|
542
|
+
* decision: Decision,
|
|
543
|
+
* matchedRule: RuleMatch | null,
|
|
544
|
+
* ruleId: string | null,
|
|
545
|
+
* matches: RuleMatch[],
|
|
546
|
+
* segments: string[],
|
|
547
|
+
* }}
|
|
548
|
+
* @throws {TypeError} for a non-string command or a non-object policy argument.
|
|
549
|
+
*/
|
|
550
|
+
export function check(command, serverPolicy = {}, disabledRules) {
|
|
551
|
+
const segments = splitSubcommands(command)
|
|
552
|
+
const { overrides, disabled, customRules } = normalizeServerPolicy(serverPolicy, disabledRules)
|
|
553
|
+
|
|
554
|
+
const matches = []
|
|
555
|
+
// Built-ins first, then this server's custom rules (doc §4.2.5): the custom
|
|
556
|
+
// set is part of the same sweep, so strictest-wins needs no special case.
|
|
557
|
+
for (const rule of rulesFor(customRules)) {
|
|
558
|
+
if (disabled.has(rule.id)) continue
|
|
559
|
+
if (!ruleMatches(rule, command, segments)) continue
|
|
560
|
+
matches.push({
|
|
561
|
+
id: rule.id,
|
|
562
|
+
label: rule.label,
|
|
563
|
+
defaultDecision: rule.defaultDecision,
|
|
564
|
+
description: rule.description,
|
|
565
|
+
decision: effectiveDecision(rule, overrides),
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** @type {Decision} */
|
|
570
|
+
let decision = 'allow'
|
|
571
|
+
/** @type {RuleMatch | null} */
|
|
572
|
+
let matchedRule = null
|
|
573
|
+
for (const match of matches) {
|
|
574
|
+
if (RANK[match.decision] > RANK[decision]) {
|
|
575
|
+
decision = match.decision
|
|
576
|
+
matchedRule = match
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
decision,
|
|
582
|
+
matchedRule,
|
|
583
|
+
ruleId: matchedRule === null ? null : matchedRule.id,
|
|
584
|
+
matches,
|
|
585
|
+
segments,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Alias of {@link check} for callers that prefer an explicit verb. */
|
|
590
|
+
export const checkCommand = check
|