@zicolasjac-ai/remote-readonly-ssh 1.3.0 → 1.4.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/README.md +2 -0
- package/config.example.json +7 -0
- package/package.json +1 -1
- package/skills/remote-readonly-ssh/SKILL.md +2 -0
- package/src/config.js +21 -0
- package/src/jumpshell.js +60 -5
- package/src/whitelist.js +100 -2
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
82
82
|
| `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
|
|
83
83
|
| `allowed_path_prefixes` | 允许读取的路径前缀 |
|
|
84
84
|
| `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
|
|
85
|
+
| `container_exec` | 可选:`docker exec` 容器只读策略(允许子命令、路径前缀、递归路径前缀、超时、输出上限) |
|
|
85
86
|
|
|
86
87
|
> **凭据安全提示**:密码为明文存放于本机 `config.json`(与私钥文件同级信任,仅本机可读)。Windows 建议收紧 ACL,Linux/macOS 建议 `chmod 600 ~/.rrs/config.json`。
|
|
87
88
|
>
|
|
@@ -138,6 +139,7 @@ npm test # 运行单元测试(node --test)
|
|
|
138
139
|
- **主机密钥 TOFU**:堡垒机公钥指纹首见记录(`~/.rrs/known_hosts.json`)、每次连接比对,变化即拒绝(防中间人窃取凭据/会话);连接错误消息中的凭据类内容自动脱敏。
|
|
139
140
|
- **凭据不落命令行**:认证走 ssh2 库内存参数,密码/私钥不出现在进程参数与环境变量中;审计日志只记命令,不含凭据。
|
|
140
141
|
- **直连模式同样受只读强制**:`direct: true` 仅跳过堡垒机菜单选资产环节,命令前缀白名单 / 禁止模式 / 路径黑名单 / 主机白名单 / 审计 / TOFU 对直连主机完全等效——防护不依赖堡垒机这道外部闸门。
|
|
142
|
+
- **容器只读命令加固**:`docker exec` 受 `container_exec` 限制,仅允许配置内子命令与路径前缀;递归扫描只允许日志目录;实际执行自动包 `timeout`、`head -c` 输出截断和完成 marker,避免无输出命令卡住会话。
|
|
141
143
|
- **无写原语**:工具集中不存在任何写操作路径;写操作由人工执行,本工具只读。
|
|
142
144
|
|
|
143
145
|
**已知边界**(设计取舍):
|
package/config.example.json
CHANGED
|
@@ -75,6 +75,13 @@
|
|
|
75
75
|
"^diff(\\s+-[A-Za-z]+)?\\s+/[^\\s]+\\s+/[^\\s]+\\s*$",
|
|
76
76
|
"^find\\s+/"
|
|
77
77
|
],
|
|
78
|
+
"container_exec": {
|
|
79
|
+
"allowed_subcommands": ["ps", "nvidia-smi", "ls", "grep", "head", "tail", "cat"],
|
|
80
|
+
"allowed_path_prefixes": ["/workspace/ds-dev"],
|
|
81
|
+
"recursive_path_prefixes": ["/workspace/ds-dev/logs"],
|
|
82
|
+
"timeout_seconds": 10,
|
|
83
|
+
"max_output_bytes": 500000
|
|
84
|
+
},
|
|
78
85
|
"allowed_path_prefixes": [
|
|
79
86
|
"/usr/local/nginx/",
|
|
80
87
|
"/etc/nginx/",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;认证组支持 SSH 私钥/密码两种模式与按 IP 分组;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -95,6 +95,8 @@ rrs <tool> [arg] [--host <ip>] [--config <file>] [--json]
|
|
|
95
95
|
2. **永远不要**用 `read-file` 读 `blocked_paths`(`/etc/passwd`、`/etc/shadow`、`~/.ssh/id_*` 等)
|
|
96
96
|
3. **永远不要**指定未在 `target_hosts` 注册的 IP
|
|
97
97
|
4. **不要**试图把 `;` `&&` `|` `>` `$()` 拼到参数里——所有路径和参数都做严格字符集校验
|
|
98
|
+
5. **容器命令只走受控路径**:若使用 `docker exec`,必须遵守 `container_exec.allowed_path_prefixes`;默认只允许 `/workspace/ds-dev` 这类明确前缀,`grep -r` 只允许递归配置中的日志目录,禁止 `bash/sh`、`tail -f`、`ls -R`、相对路径和越界路径。
|
|
99
|
+
6. **修改 `~/.rrs/config.json` 前必须先征得用户确认**(`~/.rrs/` 下的所有文件均如此,含白名单、target_hosts、认证组、known_hosts)。被拒绝时不要直接动手改配置:先把需要的路径/主机/变更内容展示给用户,等用户明确同意后再编辑。配置文件属于用户资产,不在工具的只读授权范围内自行变更。
|
|
98
100
|
|
|
99
101
|
被拒绝的尝试一样会进审计日志。
|
|
100
102
|
|
package/src/config.js
CHANGED
|
@@ -102,6 +102,27 @@ function validateConfig(conf) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
const exec = conf.container_exec;
|
|
106
|
+
if (exec !== undefined) {
|
|
107
|
+
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
|
|
108
|
+
errors.push("container_exec 必须是对象 {allowed_subcommands, allowed_path_prefixes, recursive_path_prefixes, timeout_seconds, max_output_bytes}");
|
|
109
|
+
} else {
|
|
110
|
+
for (const key of ["allowed_subcommands", "allowed_path_prefixes", "recursive_path_prefixes"]) {
|
|
111
|
+
if (exec[key] !== undefined) {
|
|
112
|
+
if (!Array.isArray(exec[key]) || !exec[key].every((x) => typeof x === "string" && x)) {
|
|
113
|
+
errors.push(`container_exec.${key} 必须是非空字符串数组`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (exec.timeout_seconds !== undefined && (!Number.isInteger(exec.timeout_seconds) || exec.timeout_seconds < 1 || exec.timeout_seconds > 30)) {
|
|
118
|
+
errors.push(`container_exec.timeout_seconds 必须是 1-30 的整数,当前: ${exec.timeout_seconds}`);
|
|
119
|
+
}
|
|
120
|
+
if (exec.max_output_bytes !== undefined && (!Number.isInteger(exec.max_output_bytes) || exec.max_output_bytes < 1024 || exec.max_output_bytes > 2000000)) {
|
|
121
|
+
errors.push(`container_exec.max_output_bytes 必须是 1024-2000000 的整数,当前: ${exec.max_output_bytes}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
if (errors.length) throw cfgError(errors.join(";"));
|
|
106
127
|
return true;
|
|
107
128
|
}
|
package/src/jumpshell.js
CHANGED
|
@@ -5,9 +5,42 @@ const path = require("path");
|
|
|
5
5
|
const crypto = require("crypto");
|
|
6
6
|
const { Client } = require("ssh2");
|
|
7
7
|
const { configDir } = require("./config");
|
|
8
|
+
const { containerExecConf } = require("./whitelist");
|
|
8
9
|
|
|
9
10
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
11
|
|
|
12
|
+
function shellQuote(s) {
|
|
13
|
+
return "'" + String(s).replace(/'/g, "'\"'\"'") + "'";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function randomMarker() {
|
|
17
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildGuardedCommand(cmd, opts = {}) {
|
|
21
|
+
const timeoutSecs = Number.isInteger(opts.timeout_seconds) ? opts.timeout_seconds : 10;
|
|
22
|
+
const outputLimit = Number.isInteger(opts.max_output_bytes) ? opts.max_output_bytes : 500000;
|
|
23
|
+
const marker = randomMarker();
|
|
24
|
+
const a = marker.slice(0, Math.max(0, marker.length - 4));
|
|
25
|
+
const b = marker.slice(-4);
|
|
26
|
+
const q = shellQuote(cmd);
|
|
27
|
+
const line =
|
|
28
|
+
`RRS_MARKER_A=${a}; RRS_MARKER_B=${b}; ` +
|
|
29
|
+
`{ if command -v timeout >/dev/null 2>&1; then timeout -k 1 -s KILL ${timeoutSecs} sh -c ${q}; ` +
|
|
30
|
+
`else sh -c ${q}; fi; } | head -c ${outputLimit}; ` +
|
|
31
|
+
'echo "${RRS_MARKER_A}${RRS_MARKER_B}"';
|
|
32
|
+
return { line, marker };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripGuardedEcho(text) {
|
|
36
|
+
const tail = 'echo "${RRS_MARKER_A}${RRS_MARKER_B}"';
|
|
37
|
+
const idx = text.indexOf(tail);
|
|
38
|
+
if (idx < 0) return text;
|
|
39
|
+
const nl = text.indexOf("\n", idx);
|
|
40
|
+
if (nl < 0) return text.slice(0, idx);
|
|
41
|
+
return text.slice(nl + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
// 行尾 shell prompt($/#/% 可带 ~ 家目录前缀,如 "user@host:~$ ")
|
|
12
45
|
// 注意: 不能用 ">",会与 JumpServer 菜单行 "Opt>" 冲突
|
|
13
46
|
const PROMPT_RE = /(?:^|\r?\n)[^\r\n]*~?[$#%]\s*$/;
|
|
@@ -250,6 +283,16 @@ class JumpShell {
|
|
|
250
283
|
return false;
|
|
251
284
|
}
|
|
252
285
|
|
|
286
|
+
async waitForMarker(marker, deadline) {
|
|
287
|
+
while (Date.now() < deadline) {
|
|
288
|
+
if (this.terminated) throw new Error("SSH 连接已断开");
|
|
289
|
+
this.checkErr();
|
|
290
|
+
if (this.out.includes(marker)) return true;
|
|
291
|
+
await sleep(150);
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
253
296
|
async login(timeoutSecs = 30) {
|
|
254
297
|
const deadline = Date.now() + timeoutSecs * 1000;
|
|
255
298
|
if (this.direct) {
|
|
@@ -270,14 +313,25 @@ class JumpShell {
|
|
|
270
313
|
|
|
271
314
|
async runCmds(cmds, timeoutSecs = 30) {
|
|
272
315
|
const deadline = Date.now() + timeoutSecs * 1000;
|
|
273
|
-
const
|
|
316
|
+
const execConf = containerExecConf(this.conf);
|
|
317
|
+
let result = "";
|
|
274
318
|
for (const c of cmds) {
|
|
275
319
|
if (Date.now() >= deadline) throw new Error("命令执行总超时");
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
320
|
+
const remaining = Math.max(1, Math.floor((deadline - Date.now()) / 1000));
|
|
321
|
+
const innerTimeout = Math.max(1, Math.min(execConf.timeout_seconds, remaining - 1));
|
|
322
|
+
const { line, marker } = buildGuardedCommand(c, {
|
|
323
|
+
timeout_seconds: innerTimeout,
|
|
324
|
+
max_output_bytes: execConf.max_output_bytes,
|
|
325
|
+
});
|
|
326
|
+
const start = this.out.length;
|
|
327
|
+
this.send(line);
|
|
328
|
+
if (!(await this.waitForMarker(marker, deadline))) throw new Error(`等待命令执行完成超时: ${c}`);
|
|
329
|
+
const chunk = this.out.slice(start);
|
|
330
|
+
const markerIdx = chunk.indexOf(marker);
|
|
331
|
+
const output = markerIdx >= 0 ? chunk.slice(0, markerIdx) : chunk;
|
|
332
|
+
result += stripGuardedEcho(output);
|
|
279
333
|
}
|
|
280
|
-
return
|
|
334
|
+
return result;
|
|
281
335
|
}
|
|
282
336
|
|
|
283
337
|
close() {
|
|
@@ -297,4 +351,5 @@ module.exports = {
|
|
|
297
351
|
JumpShell, cleanAnsi, sleep, PROMPT_RE,
|
|
298
352
|
resolveHostEntry, resolveProfile, mapConnError,
|
|
299
353
|
knownHostsPath, loadKnownHosts, saveKnownHosts, hostKeyFingerprint, checkHostKey,
|
|
354
|
+
shellQuote, buildGuardedCommand, stripGuardedEcho,
|
|
300
355
|
};
|
package/src/whitelist.js
CHANGED
|
@@ -48,12 +48,99 @@ function splitPipeline(cmd) {
|
|
|
48
48
|
// 提取命令中所有绝对路径 token(供 blocked_paths 黑名单核查)
|
|
49
49
|
function extractAbsPaths(cmd) {
|
|
50
50
|
const out = [];
|
|
51
|
-
for (const m of cmd.matchAll(/(?:^|[\s'"])(\/[A-Za-z0-9._\-/]
|
|
51
|
+
for (const m of cmd.matchAll(/(?:^|[\s'"])(\/[A-Za-z0-9._\-/]*)/g)) {
|
|
52
52
|
out.push(path.posix.normalize(m[1]));
|
|
53
53
|
}
|
|
54
54
|
return out;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const DEFAULT_CONTAINER_EXEC = {
|
|
58
|
+
allowed_subcommands: ["ps", "nvidia-smi", "ls", "grep", "head", "tail", "cat"],
|
|
59
|
+
allowed_path_prefixes: ["/workspace/ds-dev"],
|
|
60
|
+
recursive_path_prefixes: ["/workspace/ds-dev/logs"],
|
|
61
|
+
timeout_seconds: 10,
|
|
62
|
+
max_output_bytes: 500000,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function containerExecConf(conf) {
|
|
66
|
+
const c = conf.container_exec || {};
|
|
67
|
+
return {
|
|
68
|
+
allowed_subcommands: Array.isArray(c.allowed_subcommands) && c.allowed_subcommands.length
|
|
69
|
+
? new Set(c.allowed_subcommands)
|
|
70
|
+
: new Set(DEFAULT_CONTAINER_EXEC.allowed_subcommands),
|
|
71
|
+
allowed_path_prefixes: Array.isArray(c.allowed_path_prefixes) && c.allowed_path_prefixes.length
|
|
72
|
+
? c.allowed_path_prefixes
|
|
73
|
+
: DEFAULT_CONTAINER_EXEC.allowed_path_prefixes,
|
|
74
|
+
recursive_path_prefixes: Array.isArray(c.recursive_path_prefixes) && c.recursive_path_prefixes.length
|
|
75
|
+
? c.recursive_path_prefixes
|
|
76
|
+
: DEFAULT_CONTAINER_EXEC.recursive_path_prefixes,
|
|
77
|
+
timeout_seconds: Number.isInteger(c.timeout_seconds) ? c.timeout_seconds : DEFAULT_CONTAINER_EXEC.timeout_seconds,
|
|
78
|
+
max_output_bytes: Number.isInteger(c.max_output_bytes) ? c.max_output_bytes : DEFAULT_CONTAINER_EXEC.max_output_bytes,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stripContainerExecPrefixes(seg) {
|
|
83
|
+
return String(seg).trim().replace(
|
|
84
|
+
/^(?:timeout\s+\d+\s+|nice\s+-n\s+\d+\s+|ionice\s+-c\s+\d+\s+)+/,
|
|
85
|
+
""
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseContainerExec(seg) {
|
|
90
|
+
const stripped = stripContainerExecPrefixes(seg);
|
|
91
|
+
const m = stripped.match(/^docker\s+exec\s+([A-Za-z0-9._-]+)\s+(.*)$/s);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const args = m[2].trim();
|
|
94
|
+
const sub = args.split(/\s+/)[0] || "";
|
|
95
|
+
return { container: m[1], args, sub };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasPathPrefix(p, prefixes) {
|
|
99
|
+
return prefixes.some((pref) => p === pref || p.startsWith(`${pref}/`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertPathsAllowed(args, prefixes, cmdLine, what) {
|
|
103
|
+
const paths = extractAbsPaths(args);
|
|
104
|
+
if (paths.length === 0) {
|
|
105
|
+
throw new Error(`拒绝: ${what} 必须显式给出白名单目录下的绝对路径 [${cmdLine}]`);
|
|
106
|
+
}
|
|
107
|
+
for (const p of paths) {
|
|
108
|
+
if (!hasPathPrefix(p, prefixes)) {
|
|
109
|
+
throw new Error(`拒绝: ${what} 路径不在容器只读白名单 [${p}](整条: ${cmdLine})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateContainerExecSeg(seg, conf) {
|
|
115
|
+
const parsed = parseContainerExec(seg);
|
|
116
|
+
if (!parsed) return;
|
|
117
|
+
const cfg = containerExecConf(conf);
|
|
118
|
+
const { args, sub } = parsed;
|
|
119
|
+
if (!cfg.allowed_subcommands.has(sub)) {
|
|
120
|
+
throw new Error(`拒绝: 容器内子命令不在白名单 [${sub}](整条: ${seg})`);
|
|
121
|
+
}
|
|
122
|
+
if (sub === "ps") {
|
|
123
|
+
if (args !== "ps -ef") throw new Error(`拒绝: 容器内 ps 仅允许固定形式 [docker exec ... ps -ef](整条: ${seg})`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (sub === "nvidia-smi") {
|
|
127
|
+
if (args !== "nvidia-smi") throw new Error(`拒绝: 容器内 nvidia-smi 不允许附加参数(整条: ${seg})`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (sub === "ls" && (/(?:^|\s)-[A-Za-z]*R[A-Za-z]*(?=\s|$)/.test(args) || /--recursive/.test(args))) {
|
|
131
|
+
throw new Error(`拒绝: 容器内禁止递归 ls(整条: ${seg})`);
|
|
132
|
+
}
|
|
133
|
+
if (sub === "tail" && (/(?:^|\s)-f(?:\s|$)|--follow/.test(args))) {
|
|
134
|
+
throw new Error(`拒绝: 容器内 tail 禁止 -f/--follow(整条: ${seg})`);
|
|
135
|
+
}
|
|
136
|
+
if (sub === "head" && (/(?:^|\s)-f(?:\s|$)|--follow/.test(args))) {
|
|
137
|
+
throw new Error(`拒绝: 容器内 head 禁止 -f/--follow(整条: ${seg})`);
|
|
138
|
+
}
|
|
139
|
+
const isRecursive = (/(?:^|\s)-[A-Za-z]*[rR][A-Za-z]*(?=\s|$)/.test(args) || /--recursive/.test(args));
|
|
140
|
+
const pathPrefixes = isRecursive ? cfg.recursive_path_prefixes : cfg.allowed_path_prefixes;
|
|
141
|
+
assertPathsAllowed(args, pathPrefixes, seg, sub);
|
|
142
|
+
}
|
|
143
|
+
|
|
57
144
|
function validateCmd(cmd, conf) {
|
|
58
145
|
const c = String(cmd).trim();
|
|
59
146
|
if (!c) throw new Error("空命令");
|
|
@@ -68,6 +155,9 @@ function validateCmd(cmd, conf) {
|
|
|
68
155
|
throw new Error(`拒绝: 命令段不在白名单 [${seg}](整条: ${c})`);
|
|
69
156
|
}
|
|
70
157
|
}
|
|
158
|
+
for (const seg of splitPipeline(c)) {
|
|
159
|
+
validateContainerExecSeg(seg, conf);
|
|
160
|
+
}
|
|
71
161
|
for (const pat of FORBIDDEN_PATTERNS) {
|
|
72
162
|
if (pat.test(c)) throw new Error(`拒绝: 命中禁止模式 [${pat}] 命令 [${c}]`);
|
|
73
163
|
}
|
|
@@ -134,4 +224,12 @@ function validateBin(bin, conf) {
|
|
|
134
224
|
return b;
|
|
135
225
|
}
|
|
136
226
|
|
|
137
|
-
module.exports = {
|
|
227
|
+
module.exports = {
|
|
228
|
+
validateCmd,
|
|
229
|
+
validatePath,
|
|
230
|
+
validateHost,
|
|
231
|
+
validateArg,
|
|
232
|
+
validateBin,
|
|
233
|
+
containerExecConf,
|
|
234
|
+
DEFAULT_CONTAINER_EXEC,
|
|
235
|
+
};
|