@zicolasjac-ai/remote-readonly-ssh 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 readonly-ssh contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # rrs
2
+
3
+ 只读 SSH 运维 CLI —— 通过堡垒机(JumpServer 式菜单)在服务器上执行**白名单强制的只读查询**。设计上零写能力。
4
+
5
+ 把"只读"从行为自律变成**技术强制**,适合 AI 助手、脚本、团队成员以最小权限做服务器排查。
6
+
7
+ ## 为什么需要它
8
+
9
+ - **技术强制只读**:命令前缀白名单 + 禁止模式 + 参数防注入 + 目标主机白名单,多层校验。
10
+ - **审计留痕**:每次调用(含被拒绝的记录)写入本地审计日志,可随时审查。
11
+ - **零依赖**:只依赖系统自带 `ssh` 客户端,无 npm 依赖树。
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ npm install -g @zicolasjac-ai/remote-readonly-ssh
17
+ ```
18
+
19
+ 要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
20
+
21
+ ## 快速开始
22
+
23
+ ```bash
24
+ rrs init # 生成配置模板 ~/.rrs/config.json
25
+ # 编辑配置: 填 jump_server / private_key_path / target_hosts
26
+ rrs list-tools # 查看白名单
27
+ rrs sys-info
28
+ ```
29
+
30
+ ## 用法
31
+
32
+ ```
33
+ rrs <tool> [arg] [--host <ip>] [--config <file>]
34
+ ```
35
+
36
+ | 工具 | 说明 |
37
+ |------|------|
38
+ | `init` | 生成配置模板 `~/.rrs/config.json` |
39
+ | `list-tools` | 查看工具、白名单、配置路径 |
40
+ | `sys-info` | 主机名 / 系统版本 / 内核 / 运行时间 |
41
+ | `proc <pattern>` | 进程查询(严格字符校验,防注入) |
42
+ | `ports [filter]` | 监听端口查询(`ss -tln`) |
43
+ | `read-file <path>` | 读文件(路径白名单 + 敏感黑名单) |
44
+ | `dir <path>` | 列目录(同路径白名单) |
45
+ | `bin-version <bin>` | 查二进制版本/编译参数(二进制白名单) |
46
+ | `exec-only <cmd>` | 通用只读命令(前缀白名单 + 禁止模式) |
47
+
48
+ ## 配置
49
+
50
+ 文件:`~/.rrs/config.json`(可用 `--config <file>` 或环境变量 `RRS_CONFIG` 覆盖)
51
+
52
+ | 键 | 说明 |
53
+ |-----|------|
54
+ | `jump_server` | 堡垒机地址 / 端口 / 用户名 |
55
+ | `private_key_path` | SSH 私钥路径(Windows 建议放 `~/.ssh/locked/` 收紧 ACL;Linux/Mac 用 `chmod 600`) |
56
+ | `target_hosts` | 允许访问的目标主机白名单 `{ip: 堡垒机搜索关键字}` |
57
+ | `allowed_version_bins` | 允许执行 `-v/-V` 的二进制白名单 |
58
+ | `allowed_command_prefixes` | 只读命令前缀白名单(cat/ls/grep/ps/ss/tail...) |
59
+ | `allowed_path_prefixes` | 允许读取的路径前缀 |
60
+ | `blocked_paths` | 敏感路径黑名单(shadow/passwd/密钥/家目录) |
61
+
62
+ 审计日志:`~/.rrs/audit.log`
63
+
64
+ ## 安全模型
65
+
66
+ - **双层校验**:每条命令必须以允许前缀开头,且不得含禁止模式(rm/mv/vi/systemctl/sudo/重定向到文件/shadow/ssh 密钥等),否则一律拒绝。
67
+ - **防绕过加固**:路径 `..` 穿越规范化、反斜杠转义、命令替换(`$()`、反引号)、分隔符(`;` `&` `&&`)等攻击面全部封堵。
68
+ - **参数防注入**:`proc`/`ports`/`bin-version` 参数做严格字符集校验。
69
+ - **主机白名单**:未登记 IP 一律拒绝。
70
+ - **无写原语**:工具集中不存在任何写操作路径;写操作由人工执行,本工具只读。
71
+
72
+ ## 许可证
73
+
74
+ MIT
package/bin/rrs.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { loadConf, configDir, audit } = require("../src/config");
7
+ const { JumpShell, cleanAnsi } = require("../src/jumpshell");
8
+ const {
9
+ validateCmd, validatePath, validateHost, validateArg, validateBin,
10
+ } = require("../src/whitelist");
11
+
12
+ const PACKAGE_ROOT = path.join(__dirname, "..");
13
+
14
+ function printUsage() {
15
+ const cmds = [
16
+ ["init", "生成配置模板到 ~/.rrs/config.json"],
17
+ ["list-tools", "查看工具、白名单、配置"],
18
+ ["sys-info", "主机名/OS/内核/uptime"],
19
+ ["proc <pattern>", "进程查询(防注入参数校验)"],
20
+ ["ports [filter]", "监听端口查询"],
21
+ ["read-file <path>", "读文件(路径白名单)"],
22
+ ["dir <path>", "列目录(路径白名单)"],
23
+ ["bin-version <bin>", "查二进制版本(二进制白名单)"],
24
+ ["exec-only <cmd>", "通用只读命令(前缀白名单+禁止模式)"],
25
+ ];
26
+ console.log("rrs — 只读 SSH 运维 CLI(白名单强制,零写能力)\n");
27
+ console.log("用法: rrs <工具> [参数] [--host <ip>] [--config <file>]\n");
28
+ for (const [k, v] of cmds) {
29
+ console.log(` ${k.padEnd(22)} ${v}`);
30
+ }
31
+ }
32
+
33
+ function parseArgs(argv) {
34
+ const opts = { tool: null, arg: null, host: null, config: null };
35
+ const rest = [];
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ if (a === "--host") opts.host = argv[++i];
39
+ else if (a === "--config") opts.config = argv[++i];
40
+ else if (a === "-h" || a === "--help") { opts.tool = "help"; }
41
+ else rest.push(a);
42
+ }
43
+ opts.tool = opts.tool || rest[0] || "help";
44
+ opts.arg = rest[1] || null;
45
+ return opts;
46
+ }
47
+
48
+ function initConfig() {
49
+ const dir = configDir();
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ const dest = path.join(dir, "config.json");
52
+ if (fs.existsSync(dest)) {
53
+ console.log(`配置已存在: ${dest}(未覆盖,如需重置请手动删除)`);
54
+ return;
55
+ }
56
+ const tpl = fs.readFileSync(path.join(PACKAGE_ROOT, "config.example.json"), "utf8");
57
+ fs.writeFileSync(dest, tpl, "utf8");
58
+ console.log(`已生成配置模板: ${dest}`);
59
+ console.log("请填写 jump_server(跳板机)、private_key_path(私钥)、target_hosts(目标主机)后使用。");
60
+ }
61
+
62
+ function buildCmds(tool, arg, conf) {
63
+ switch (tool) {
64
+ case "sys-info":
65
+ return [
66
+ "echo ===HOST===; hostname; whoami; id",
67
+ "echo ===OS===; head -5 /etc/os-release 2>&1",
68
+ "echo ===KERNEL===; uname -a",
69
+ "echo ===UPTIME===; uptime",
70
+ ];
71
+ case "proc":
72
+ return [`ps -ef | grep -E '${validateArg(arg, "进程名")}' | grep -v grep`];
73
+ case "ports":
74
+ return arg
75
+ ? [`ss -tln | grep '${validateArg(arg, "端口过滤")}'`]
76
+ : ["ss -tln"];
77
+ case "read-file":
78
+ return [`cat ${validatePath(arg, conf)}`];
79
+ case "dir":
80
+ return [`ls -la ${validatePath(arg, conf)}`];
81
+ case "bin-version":
82
+ return [`${validateBin(arg, conf)} -v 2>&1`, `${validateBin(arg, conf)} -V 2>&1`];
83
+ case "exec-only":
84
+ return [validateCmd(arg, conf)];
85
+ default:
86
+ throw new Error(`未知工具 [${tool}],用 list-tools 查看`);
87
+ }
88
+ }
89
+
90
+ function listTools(conf) {
91
+ console.log("可用只读工具:");
92
+ const tools = {
93
+ "sys-info": "主机名/OS/内核/uptime",
94
+ "proc <pattern>": "进程查询",
95
+ "ports [filter]": "端口查询",
96
+ "read-file <path>": "读文件(路径白名单)",
97
+ "dir <path>": "列目录",
98
+ "bin-version <bin>": "查版本(二进制白名单)",
99
+ "exec-only <cmd>": "通用只读命令(白名单校验)",
100
+ };
101
+ for (const [k, v] of Object.entries(tools)) console.log(` ${k.padEnd(20)} ${v}`);
102
+ console.log(`\n允许主机: ${Object.keys(conf.target_hosts || {}).join(", ")}`);
103
+ console.log(`允许查版本: ${(conf.allowed_version_bins || []).join(", ")}`);
104
+ console.log(`允许读路径前缀: ${(conf.allowed_path_prefixes || []).join(", ")}`);
105
+ console.log(`配置文件: ${process.env.READONLY_SSH_CONFIG || path.join(configDir(), "config.json")}`);
106
+ console.log(`审计日志: ${path.join(configDir(), "audit.log")}`);
107
+ }
108
+
109
+ async function main() {
110
+ const opts = parseArgs(process.argv.slice(2));
111
+ if (opts.tool === "help" || opts.tool === "--help") { printUsage(); return; }
112
+ if (opts.tool === "init") { initConfig(); return; }
113
+
114
+ let conf;
115
+ try {
116
+ conf = loadConf(opts.config);
117
+ } catch (e) {
118
+ console.error(`[错误] ${e.message}`);
119
+ process.exit(e.exitCode || 1);
120
+ }
121
+
122
+ if (opts.tool === "list-tools") { listTools(conf); return; }
123
+
124
+ let host, cmds;
125
+ try {
126
+ host = validateHost(opts.host || Object.keys(conf.target_hosts || {})[0], conf);
127
+ cmds = buildCmds(opts.tool, opts.arg, conf);
128
+ } catch (e) {
129
+ audit(configDir(), opts.tool, opts.host || "", [], false, e.message);
130
+ console.error(`[拒绝] ${e.message}`);
131
+ process.exit(2);
132
+ }
133
+
134
+ const shell = new JumpShell(conf, host);
135
+ try {
136
+ shell.connect();
137
+ await shell.login();
138
+ const out = await shell.runCmds(cmds);
139
+ console.log(cleanAnsi(out));
140
+ audit(configDir(), opts.tool, host, cmds, true);
141
+ } catch (e) {
142
+ audit(configDir(), opts.tool, host, cmds, false, e.message);
143
+ console.error(`[错误] ${e.message}`);
144
+ process.exit(1);
145
+ } finally {
146
+ shell.close();
147
+ }
148
+ }
149
+
150
+ main();
@@ -0,0 +1,65 @@
1
+ {
2
+ "jump_server": {
3
+ "host": "YOUR_BASTION_IP",
4
+ "port": 2222,
5
+ "username": "YOUR_USERNAME"
6
+ },
7
+ "private_key_path": "C:\\Users\\YOURNAME\\.ssh\\id_rsa",
8
+ "target_hosts": {
9
+ "10.0.0.1": "10.0.0",
10
+ "10.0.0.2": "10.0.0"
11
+ },
12
+ "allowed_version_bins": [
13
+ "/usr/local/nginx/sbin/nginx",
14
+ "nginx",
15
+ "java",
16
+ "openssl",
17
+ "python3",
18
+ "redis-server",
19
+ "mysqld",
20
+ "curl"
21
+ ],
22
+ "allowed_command_prefixes": [
23
+ "echo ",
24
+ "cat ",
25
+ "ls ",
26
+ "head ",
27
+ "tail ",
28
+ "grep ",
29
+ "ps ",
30
+ "ss ",
31
+ "stat ",
32
+ "md5sum ",
33
+ "hostname",
34
+ "whoami",
35
+ "id",
36
+ "uname ",
37
+ "date",
38
+ "uptime",
39
+ "free ",
40
+ "df ",
41
+ "du ",
42
+ "/usr/local/nginx/sbin/nginx -",
43
+ "nginx -"
44
+ ],
45
+ "allowed_command_patterns": [
46
+ "^diff(\\s+-[A-Za-z]+)?\\s+/[^\\s]+\\s+/[^\\s]+\\s*$",
47
+ "^find\\s+/"
48
+ ],
49
+ "allowed_path_prefixes": [
50
+ "/usr/local/nginx/",
51
+ "/etc/nginx/",
52
+ "/usr/local/",
53
+ "/opt/",
54
+ "/etc/",
55
+ "/var/log/"
56
+ ],
57
+ "blocked_paths": [
58
+ "/etc/shadow",
59
+ "/etc/passwd",
60
+ "/etc/sudoers",
61
+ "/root/",
62
+ "/home/",
63
+ "/etc/ssh/"
64
+ ]
65
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@zicolasjac-ai/remote-readonly-ssh",
3
+ "version": "1.0.2",
4
+ "description": "Read-only SSH ops CLI: whitelist-enforced, read-only queries on servers via a bastion host. Zero write capability by design.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "rrs": "bin/rrs.js"
8
+ },
9
+ "main": "src/whitelist.js",
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "config.example.json",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "ssh",
22
+ "readonly",
23
+ "ops",
24
+ "bastion",
25
+ "jumpserver",
26
+ "whitelist",
27
+ "audit",
28
+ "security",
29
+ "cli"
30
+ ],
31
+ "dependencies": {},
32
+ "scripts": {
33
+ "test": "node --test test/"
34
+ }
35
+ }
package/src/config.js ADDED
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ function configDir() {
8
+ return path.join(os.homedir(), ".rrs");
9
+ }
10
+
11
+ function configFile() {
12
+ return path.join(configDir(), "config.json");
13
+ }
14
+
15
+ function auditFile() {
16
+ return path.join(configDir(), "audit.log");
17
+ }
18
+
19
+ function loadConf(confPath) {
20
+ const resolved = confPath || process.env.RRS_CONFIG || configFile();
21
+ if (!fs.existsSync(resolved)) {
22
+ const err = new Error(
23
+ `找不到配置文件 ${resolved}\n` +
24
+ `请先运行 readonly-ssh init 生成模板,再填入您的跳板机/资产/密钥信息。`
25
+ );
26
+ err.exitCode = 3;
27
+ throw err;
28
+ }
29
+ try {
30
+ return JSON.parse(fs.readFileSync(resolved, "utf8"));
31
+ } catch (e) {
32
+ throw new Error(`配置文件解析失败 ${resolved}: ${e.message}`);
33
+ }
34
+ }
35
+
36
+ const TOOL_PURPOSE = {
37
+ "sys-info": "系统信息巡检",
38
+ "proc": "进程状态巡检",
39
+ "ports": "服务监听端口巡检",
40
+ "read-file": "配置文件核对",
41
+ "dir": "目录结构核对",
42
+ "bin-version": "软件版本核对",
43
+ "exec-only": "只读命令核查",
44
+ };
45
+
46
+ function describeCmd(tool, cmd) {
47
+ switch (tool) {
48
+ case "sys-info":
49
+ if (cmd.includes("hostname")) return "查看主机名与当前用户";
50
+ if (cmd.includes("os-release")) return "查看操作系统版本";
51
+ if (cmd.includes("uname")) return "查看内核版本";
52
+ if (cmd.includes("uptime")) return "查看运行时长与负载";
53
+ return "查看系统基础信息";
54
+ case "proc": {
55
+ const m = cmd.match(/grep -E '([^']+)'/);
56
+ return m ? `确认 ${m[1]} 相关进程状态` : "确认进程运行状态";
57
+ }
58
+ case "ports":
59
+ return cmd.includes("grep") ? "确认过滤后的监听端口" : "查看全部监听端口";
60
+ case "read-file":
61
+ return "读取配置文件(只读)";
62
+ case "dir":
63
+ return "列出目录内容(只读)";
64
+ case "bin-version": {
65
+ const m = cmd.match(/^([^\s]+)/);
66
+ return m ? `核对 ${m[1]} 版本信息` : "核对版本信息";
67
+ }
68
+ default:
69
+ return TOOL_PURPOSE[tool] || "只读核查";
70
+ }
71
+ }
72
+
73
+ function audit(confDir, tool, host, cmds, ok, note) {
74
+ try {
75
+ fs.mkdirSync(confDir, { recursive: true });
76
+ const d = new Date();
77
+ const pad = (n) => String(n).padStart(2, "0");
78
+ const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
79
+ const bar = "─".repeat(62);
80
+ const lines = [
81
+ bar,
82
+ `[巡检记录] ${local}`,
83
+ ` 主机: ${host}`,
84
+ ` 巡检项: ${TOOL_PURPOSE[tool] || tool}`,
85
+ ` 结果: ${ok ? "成功" : "失败"}${note ? `(${note})` : ""}`,
86
+ ` 命令明细:`,
87
+ ];
88
+ cmds.forEach((c, i) => {
89
+ lines.push(` ${i + 1}. ${c}`);
90
+ lines.push(` 用途: ${describeCmd(tool, c)}`);
91
+ });
92
+ lines.push(bar, "");
93
+ fs.appendFileSync(path.join(confDir, "audit.log"), lines.join("\n"), "utf8");
94
+ } catch {
95
+ // 审计失败不阻断主流程
96
+ }
97
+ }
98
+
99
+ module.exports = { configDir, configFile, auditFile, loadConf, audit };
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+
3
+ const { spawn } = require("child_process");
4
+
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ /**
8
+ * 通过系统 ssh 驱动 JumpServer 堡垒机菜单,登录目标资产并执行命令。
9
+ * 仅负责"连接与收发",命令校验在调用方(whitelist.js)完成。
10
+ */
11
+ class JumpShell {
12
+ constructor(conf, host) {
13
+ this.searchKeyword = conf.target_hosts[host];
14
+ this.host = host;
15
+ this.conf = conf;
16
+ this.proc = null;
17
+ this.buf = "";
18
+ this.stopped = false;
19
+ }
20
+
21
+ connect() {
22
+ const js = this.conf.jump_server;
23
+ const args = [
24
+ "-i", this.conf.private_key_path,
25
+ "-p", String(js.port),
26
+ "-o", "StrictHostKeyChecking=no",
27
+ "-o", "HostKeyAlgorithms=+ssh-rsa",
28
+ "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
29
+ "-tt", `${js.username}@${js.host}`,
30
+ ];
31
+ this.proc = spawn("ssh", args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
32
+ this.proc.stdout.on("data", (d) => {
33
+ this.buf += d.toString("utf8");
34
+ });
35
+ this.proc.stderr.on("data", (d) => {
36
+ this.buf += d.toString("utf8");
37
+ });
38
+ }
39
+
40
+ send(s) {
41
+ if (this.proc && this.proc.stdin && !this.proc.stdin.destroyed) {
42
+ this.proc.stdin.write(s + "\r");
43
+ }
44
+ }
45
+
46
+ async waitFor(marker, secs) {
47
+ const deadline = Date.now() + secs * 1000;
48
+ while (Date.now() < deadline) {
49
+ if (this.buf.includes(marker)) return true;
50
+ await sleep(150);
51
+ }
52
+ return false;
53
+ }
54
+
55
+ async login() {
56
+ if (!(await this.waitFor("Opt>", 25))) throw new Error("跳板机菜单未出现");
57
+ await sleep(1000);
58
+ this.send("");
59
+ await sleep(2000);
60
+ this.send(this.searchKeyword);
61
+ if (!(await this.waitFor(this.host, 15))) throw new Error(`未找到目标资产 ${this.host}`);
62
+ if (!(await this.waitFor("~", 15))) throw new Error("未能进入 shell");
63
+ await sleep(500);
64
+ }
65
+
66
+ async runCmds(cmds, waitSecs = 4) {
67
+ const start = this.buf.length;
68
+ for (const c of cmds) {
69
+ this.send(c);
70
+ await sleep(waitSecs * 1000);
71
+ }
72
+ return this.buf.slice(start);
73
+ }
74
+
75
+ close() {
76
+ this.stopped = true;
77
+ try {
78
+ this.send("exit");
79
+ setTimeout(() => {
80
+ try { this.send("exit"); } catch {}
81
+ setTimeout(() => {
82
+ try { this.proc.kill(); } catch {}
83
+ }, 1000);
84
+ }, 1000);
85
+ } catch {}
86
+ }
87
+ }
88
+
89
+ function cleanAnsi(t) {
90
+ return t.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
91
+ }
92
+
93
+ module.exports = { JumpShell, cleanAnsi, sleep };
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+
5
+ const FORBIDDEN_PATTERNS = [
6
+ />\s*[^|&]/, // output redirection to file (2>&1 stream redirect is allowed)
7
+ /\b(rm|mv|cp|tar|install|yum|dnf|apt-get|vi|vim|nano|mkdir|touch|chmod|chown|useradd|userdel|passwd|sudo|su|reboot|shutdown|systemctl|kill|pkill|wget|curl|nc)\b/,
8
+ /nginx\s+-s\b/, // nginx signal controls (stop/quit/reload/reopen) are mutating
9
+ /sed\s+-i\b/, // sed in-place edit is mutating (defense in depth)
10
+ /-exec\b|-execdir\b|-ok\b|-okdir\b/, // find exec actions run arbitrary commands
11
+ /-delete\b/, // find delete action removes files
12
+ /-fprint\b|-fprintf\b/,// find print-to-file actions write files
13
+ /\b--output\b/, // diff --output writes files
14
+ /shadow/,
15
+ /\.ssh/,
16
+ /id_rsa|id_ed25519|\.pem|\.key/,
17
+ /\bsource\b|\.\s*\//,
18
+ /history/,
19
+ // ---- anti-bypass hardening ----
20
+ /\\/, // backslash escapes (r\m == rm)
21
+ /\$\(/, // command substitution
22
+ /`/, // backtick substitution
23
+ /\$\{/, // parameter expansion (blocked to be safe)
24
+ /;/, // command separator
25
+ /(?<![>])&|&(?![0-9&])/, // single & / && (allows only >&N redirect form)
26
+ /\|\|/, // or operator
27
+ /[\r\n]/, // embedded newline separators
28
+ ];
29
+
30
+ const ARG_RE = /^[A-Za-z0-9._\-/:*]+$/;
31
+
32
+ function validateCmd(cmd, conf) {
33
+ const c = String(cmd).trim();
34
+ if (!c) throw new Error("空命令");
35
+ if (/^(sudo |su )/.test(c)) throw new Error(`拒绝: 不允许提权命令 [${c}]`);
36
+ const prefixes = conf.allowed_command_prefixes || [];
37
+ const patterns = conf.allowed_command_patterns || [];
38
+ const okPrefix = prefixes.some((p) => c.startsWith(p));
39
+ const okPattern = patterns.some((p) => new RegExp(p).test(c));
40
+ if (!okPrefix && !okPattern) {
41
+ throw new Error(`拒绝: 命令不在白名单 [${c}]`);
42
+ }
43
+ for (const pat of FORBIDDEN_PATTERNS) {
44
+ if (pat.test(c)) throw new Error(`拒绝: 命中禁止模式 [${pat}] 命令 [${c}]`);
45
+ }
46
+ return c;
47
+ }
48
+
49
+ function validatePath(pathArg, conf) {
50
+ const raw = String(pathArg).trim();
51
+ if (!raw.startsWith("/")) throw new Error(`拒绝: 仅允许绝对路径 [${raw}]`);
52
+ if (raw.includes("\0")) throw new Error(`拒绝: 非法字符 [${raw}]`);
53
+ // normalize resolves any . or .. segments BEFORE whitelist checks
54
+ const p = path.posix.normalize(raw);
55
+ const prefixes = conf.allowed_path_prefixes || [];
56
+ if (!prefixes.some((pfx) => p.startsWith(pfx))) {
57
+ throw new Error(`拒绝: 路径不在只读白名单 [${p}]`);
58
+ }
59
+ for (const sp of conf.blocked_paths || []) {
60
+ if (p.startsWith(sp)) throw new Error(`拒绝: 敏感路径 [${p}]`);
61
+ }
62
+ return p;
63
+ }
64
+
65
+ function validateHost(host, conf) {
66
+ if (!(conf.target_hosts && Object.prototype.hasOwnProperty.call(conf.target_hosts, host))) {
67
+ const allowed = Object.keys(conf.target_hosts || {});
68
+ throw new Error(`拒绝: 目标主机不在白名单 [${host}],允许: ${allowed.join(", ")}(可在配置文件修改)`);
69
+ }
70
+ return host;
71
+ }
72
+
73
+ function validateArg(arg, what) {
74
+ if (!arg || !ARG_RE.test(arg)) {
75
+ throw new Error(`拒绝: ${what} 含非法字符 [${arg}]`);
76
+ }
77
+ return arg;
78
+ }
79
+
80
+ function validateBin(bin, conf) {
81
+ const b = validateArg(bin, "二进制名");
82
+ const bins = conf.allowed_version_bins || [];
83
+ if (!bins.includes(b)) {
84
+ throw new Error(`拒绝: 二进制 [${b}] 不在白名单 ${JSON.stringify(bins)}(可在配置文件修改)`);
85
+ }
86
+ return b;
87
+ }
88
+
89
+ module.exports = { validateCmd, validatePath, validateHost, validateArg, validateBin };