@zicolasjac-ai/remote-readonly-ssh 1.1.1 → 1.1.3
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 +25 -7
- package/bin/postinstall.js +27 -0
- package/bin/rrs.js +6 -3
- package/package.json +2 -1
- package/src/init.js +142 -0
- package/src/whitelist.js +53 -8
package/README.md
CHANGED
|
@@ -19,19 +19,28 @@ npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
|
19
19
|
|
|
20
20
|
要求 Node.js >= 18,系统已装 OpenSSH 客户端(Windows 10+ / Linux / macOS 自带)。
|
|
21
21
|
|
|
22
|
+
> **npx 一行命令**:`npx -y @zicolasjac-ai/remote-readonly-ssh init` 也可直接启动初始化向导(无需先 `npm install`)。
|
|
23
|
+
>
|
|
22
24
|
> 从旧包名 `remote-readonly-ssh`(v1.0.0)升级的用户,请先 `npm uninstall -g remote-readonly-ssh` 再安装,否则会报 `EEXIST: file already exists`。
|
|
23
25
|
|
|
26
|
+
> **postinstall 自动引导**:全局安装完成后,TTY 环境下会自动进入初始化向导;非交互式安装(如 CI)会打印三行下一步提示,不会阻塞安装。
|
|
27
|
+
|
|
24
28
|
## 快速开始
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
|
-
npm install -g @zicolasjac-ai/remote-readonly-ssh
|
|
28
|
-
|
|
29
|
-
rrs setup skills # 把 SKILL.md 安装到 opencode / claude / agents 等目录
|
|
30
|
-
# 编辑配置: 填 jump_server / private_key_path / target_hosts
|
|
31
|
-
rrs list-tools
|
|
32
|
-
rrs sys-info --json
|
|
31
|
+
npm install -g @zicolasjac-ai/remote-readonly-ssh # 装完自动进向导
|
|
32
|
+
# 或 npx -y @zicolasjac-ai/remote-readonly-ssh init
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
向导会依次询问:
|
|
36
|
+
|
|
37
|
+
1. 是否覆盖已有 `~/.rrs/config.json`(首次安装跳过此步)
|
|
38
|
+
2. 堡垒机主机 / 端口 / 用户名 / SSH 私钥路径
|
|
39
|
+
3. 目标主机白名单(每行 `ip=堡垒机搜索关键字`)
|
|
40
|
+
4. 是否把 SKILL.md 安装到 opencode / Claude / Cursor 等 AI 助手
|
|
41
|
+
|
|
42
|
+
向导非交互式环境(CI、管道)会静默生成配置模板,不阻塞安装。
|
|
43
|
+
|
|
35
44
|
## 用法
|
|
36
45
|
|
|
37
46
|
```
|
|
@@ -99,11 +108,20 @@ npm test # 运行单元测试(node --test)
|
|
|
99
108
|
## 安全模型
|
|
100
109
|
|
|
101
110
|
- **双层校验**:每条命令必须以允许前缀开头,且不得含禁止模式(rm/mv/vi/systemctl/sudo/重定向到文件/shadow/ssh 密钥等),否则一律拒绝。
|
|
102
|
-
-
|
|
111
|
+
- **管道分段校验**:命令按 `|` 分段(引号内的 `|` 不分割),每个管道段都必须独立通过白名单——`... | sh`、`... | base64 -d` 等尾段注入全部拒绝。
|
|
112
|
+
- **重定向白名单化**:仅放行 `2>&1` 形式的流重定向,`>`、`>>`、`>|`(强制覆盖)、`>&file` 等任何写文件形态一律拒绝。
|
|
113
|
+
- **路径黑名单全局生效**:`exec-only` / `diff` / `find` 命令中出现的所有绝对路径(含规范化后的 `//x`、`/./x`、`/a/../b` 形态)都与 `read-file`/`dir` 受同一份 `blocked_paths` 约束。
|
|
114
|
+
- **防绕过加固**:路径 `..` 穿越规范化、通配符 glob(`/etc/*/shadow`)、反斜杠转义、命令替换(`$()`、反引号)、变量展开(`$HOME`)、波浪号(`~`)、分隔符(`;` `&` `&&`)、换行注入等攻击面全部封堵;`hostname <name>`、`date -s` 等状态变更命令拒绝。
|
|
103
115
|
- **参数防注入**:`proc`/`ports`/`bin-version` 参数做严格字符集校验。
|
|
104
116
|
- **主机白名单**:未登记 IP 一律拒绝。
|
|
105
117
|
- **无写原语**:工具集中不存在任何写操作路径;写操作由人工执行,本工具只读。
|
|
106
118
|
|
|
119
|
+
**已知边界**(设计取舍):
|
|
120
|
+
|
|
121
|
+
- `--config` / 环境变量 `RRS_CONFIG` 可指向任意配置文件——本工具防护的是"AI 误发危险命令",不防护对本地配置的恶意篡改(本地已有写权限的攻击者不在威胁模型内)。
|
|
122
|
+
- `exec-only` 的相对路径参数(如 `cat file.txt`)不经过路径白名单(无法可靠区分文件名与普通参数);家目录敏感文件已由禁词表(`.ssh`、`id_rsa`、`history` 等)与 `blocked_paths` 尽可能覆盖。
|
|
123
|
+
- `exec-only` 中 `$`(含 grep 行尾锚 `'$'`)被整体禁用——需要正则锚的场景请用 `proc` 工具。
|
|
124
|
+
|
|
107
125
|
## 许可证
|
|
108
126
|
|
|
109
127
|
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const initWizard = require("../src/init");
|
|
5
|
+
const setupSkill = require("../src/setup");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
(async () => {
|
|
11
|
+
const tty = process.stdin.isTTY && process.stdout.isTTY;
|
|
12
|
+
if (tty) {
|
|
13
|
+
try {
|
|
14
|
+
await initWizard.runInteractive({ packageRoot: PACKAGE_ROOT });
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(`[rrs postinstall] 向导失败: ${e.message}`);
|
|
17
|
+
console.error("可稍后手动执行: rrs init");
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log("rrs 安装完成。下一步:");
|
|
22
|
+
console.log(" rrs init # 交互式初始化 + 安装 AI skill(推荐)");
|
|
23
|
+
console.log(" rrs setup skills # 仅安装 AI skill 到 opencode / claude 等");
|
|
24
|
+
console.log(" rrs list-tools # 查看白名单与配置");
|
|
25
|
+
console.log("");
|
|
26
|
+
}
|
|
27
|
+
})();
|
package/bin/rrs.js
CHANGED
|
@@ -9,12 +9,13 @@ const {
|
|
|
9
9
|
validateCmd, validatePath, validateHost, validateArg, validateBin,
|
|
10
10
|
} = require("../src/whitelist");
|
|
11
11
|
const setupSkill = require("../src/setup");
|
|
12
|
+
const initWizard = require("../src/init");
|
|
12
13
|
|
|
13
14
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
14
15
|
|
|
15
16
|
function printUsage() {
|
|
16
17
|
const cmds = [
|
|
17
|
-
["init", "
|
|
18
|
+
["init", "交互式初始化向导(TTY 下)或生成配置模板"],
|
|
18
19
|
["list-tools", "查看工具、白名单、配置"],
|
|
19
20
|
["sys-info", "主机名/OS/内核/uptime"],
|
|
20
21
|
["proc <pattern>", "进程查询(防注入参数校验)"],
|
|
@@ -101,8 +102,10 @@ function runSetup(opts) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
function initConfig() {
|
|
105
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
106
|
+
return initWizard.runInteractive();
|
|
107
|
+
}
|
|
104
108
|
const dir = configDir();
|
|
105
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
106
109
|
const dest = path.join(dir, "config.json");
|
|
107
110
|
if (fs.existsSync(dest)) {
|
|
108
111
|
console.log(`配置已存在: ${dest}(未覆盖,如需重置请手动删除)`);
|
|
@@ -218,4 +221,4 @@ if (require.main === module) {
|
|
|
218
221
|
main();
|
|
219
222
|
}
|
|
220
223
|
|
|
221
|
-
module.exports = { parseArgs, buildCmds, printUsage, runSetup };
|
|
224
|
+
module.exports = { parseArgs, buildCmds, printUsage, runSetup, initConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zicolasjac-ai/remote-readonly-ssh",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "只读 SSH 运维 CLI 与 Agent Skill:通过 JumpServer 堡垒机白名单强制执行只读查询;附 SKILL.md 让 opencode / Claude Code / Cursor 等 AI 助手自动发现并正确调用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"dependencies": {},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"test": "node --test",
|
|
39
|
+
"postinstall": "node bin/postinstall.js",
|
|
39
40
|
"preuninstall": "node -e \"try { require('./src/setup').uninstall({agent:'all'}) } catch(_) {}\""
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const readline = require("node:readline/promises");
|
|
7
|
+
const setupSkill = require("./setup");
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function templatePath() {
|
|
12
|
+
return path.join(PACKAGE_ROOT, "config.example.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readTemplate() {
|
|
16
|
+
return JSON.parse(fs.readFileSync(templatePath(), "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeTemplate(destDir) {
|
|
20
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
21
|
+
const dest = path.join(destDir, "config.json");
|
|
22
|
+
if (fs.existsSync(dest)) return { dest, existed: true };
|
|
23
|
+
fs.writeFileSync(dest, JSON.stringify(readTemplate(), null, 2), "utf8");
|
|
24
|
+
return { dest, existed: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseTargets(text) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (!t || t.startsWith("#")) continue;
|
|
32
|
+
const eq = t.indexOf("=");
|
|
33
|
+
if (eq <= 0) continue;
|
|
34
|
+
const ip = t.slice(0, eq).trim();
|
|
35
|
+
const kw = t.slice(eq + 1).trim();
|
|
36
|
+
if (ip && kw) out[ip] = kw;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function applyAnswers(cfg, answers) {
|
|
42
|
+
const a = answers || {};
|
|
43
|
+
if (a.jumpHost) cfg.jump_server.host = a.jumpHost;
|
|
44
|
+
if (a.jumpPort) cfg.jump_server.port = Number(a.jumpPort) || cfg.jump_server.port;
|
|
45
|
+
if (a.jumpUser) cfg.jump_server.username = a.jumpUser;
|
|
46
|
+
if (a.privateKeyPath) cfg.private_key_path = a.privateKeyPath;
|
|
47
|
+
if (a.targetHosts && Object.keys(a.targetHosts).length) cfg.target_hosts = a.targetHosts;
|
|
48
|
+
return cfg;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultAnswersFor(cfg) {
|
|
52
|
+
return {
|
|
53
|
+
jumpHost: cfg.jump_server && cfg.jump_server.host ? "" : "",
|
|
54
|
+
jumpPort: cfg.jump_server && cfg.jump_server.port ? String(cfg.jump_server.port) : "22",
|
|
55
|
+
jumpUser: cfg.jump_server && cfg.jump_server.username ? cfg.jump_server.username : "",
|
|
56
|
+
privateKeyPath: cfg.private_key_path && !cfg.private_key_path.includes("YOURNAME") ? cfg.private_key_path : "",
|
|
57
|
+
targetHosts: cfg.target_hosts || {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function collectAnswers(rl, cfg, prompt) {
|
|
62
|
+
const ask = async (q, def) => {
|
|
63
|
+
const label = def ? `${q} [${def}]` : q;
|
|
64
|
+
const ans = (await rl.question(`${label}: `)).trim();
|
|
65
|
+
return ans || def || "";
|
|
66
|
+
};
|
|
67
|
+
const usePrompt = prompt && typeof prompt === "object";
|
|
68
|
+
const ans = {};
|
|
69
|
+
ans.jumpHost = usePrompt && prompt.jumpHost
|
|
70
|
+
? await prompt.jumpHost(ask, cfg)
|
|
71
|
+
: await ask("堡垒机主机 IP / 域名", "");
|
|
72
|
+
if (!ans.jumpHost) {
|
|
73
|
+
throw new Error("跳过:未填写堡垒机主机。请稍后编辑 " + path.join(os.homedir(), ".rrs", "config.json"));
|
|
74
|
+
}
|
|
75
|
+
ans.jumpPort = usePrompt && prompt.jumpPort
|
|
76
|
+
? await prompt.jumpPort(ask, cfg)
|
|
77
|
+
: await ask("堡垒机端口", "22");
|
|
78
|
+
ans.jumpUser = usePrompt && prompt.jumpUser
|
|
79
|
+
? await prompt.jumpUser(ask, cfg)
|
|
80
|
+
: await ask("堡垒机用户名", "");
|
|
81
|
+
ans.privateKeyPath = usePrompt && prompt.privateKeyPath
|
|
82
|
+
? await prompt.privateKeyPath(ask, cfg)
|
|
83
|
+
: await ask("SSH 私钥绝对路径", path.join(os.homedir(), ".ssh", "id_rsa"));
|
|
84
|
+
const targetText = usePrompt && prompt.targetHosts
|
|
85
|
+
? await prompt.targetHosts(ask, cfg)
|
|
86
|
+
: await ask("目标主机(每行 ip=堡垒机搜索关键字,空行结束)", "");
|
|
87
|
+
ans.targetHosts = parseTargets(targetText);
|
|
88
|
+
return ans;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runInteractive({ input = process.stdin, output = process.stdout, packageRoot = PACKAGE_ROOT, prompt, installSkills = true, rlFactory } = {}) {
|
|
92
|
+
const destDir = path.join(os.homedir(), ".rrs");
|
|
93
|
+
const dest = path.join(destDir, "config.json");
|
|
94
|
+
const existed = fs.existsSync(dest);
|
|
95
|
+
|
|
96
|
+
const rl = rlFactory
|
|
97
|
+
? rlFactory()
|
|
98
|
+
: readline.createInterface({ input, output });
|
|
99
|
+
try {
|
|
100
|
+
let overwrite = "n";
|
|
101
|
+
if (existed) {
|
|
102
|
+
const ans = (await rl.question(`检测到已存在配置 ${dest}\n (n) 跳过配置,仅安装 skill (b) 备份后重写 (y) 直接覆盖 [n]: `)).trim().toLowerCase();
|
|
103
|
+
overwrite = ans || "n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!existed || overwrite === "y" || overwrite === "b") {
|
|
107
|
+
if (existed && overwrite === "b") {
|
|
108
|
+
fs.copyFileSync(dest, dest + ".bak");
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
111
|
+
const cfg = readTemplate();
|
|
112
|
+
const answers = await collectAnswers(rl, defaultAnswersFor(cfg), prompt);
|
|
113
|
+
const filled = applyAnswers(cfg, answers);
|
|
114
|
+
fs.writeFileSync(dest, JSON.stringify(filled, null, 2), "utf8");
|
|
115
|
+
output.write(`已写入配置: ${dest}\n`);
|
|
116
|
+
} else {
|
|
117
|
+
output.write(`保留现有配置: ${dest}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let installedSkills = false;
|
|
121
|
+
if (installSkills) {
|
|
122
|
+
const ans = (await rl.question("安装 AI skill 到 opencode / claude / agents 目录? [Y/n]: ")).trim().toLowerCase();
|
|
123
|
+
const install = ans || "y";
|
|
124
|
+
if (install !== "n") {
|
|
125
|
+
const results = setupSkill.install(packageRoot, { agent: "all" });
|
|
126
|
+
for (const r of results) {
|
|
127
|
+
output.write(` [${r.agent}] ${r.action === "written" ? "已安装" : r.action} ${r.dir}\n`);
|
|
128
|
+
}
|
|
129
|
+
installedSkills = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
output.write("\n下一步:\n");
|
|
134
|
+
output.write(" rrs list-tools # 查看白名单与配置\n");
|
|
135
|
+
output.write(" rrs sys-info --json # 试跑一次只读巡检\n");
|
|
136
|
+
return { dest, overwrite, installedSkills };
|
|
137
|
+
} finally {
|
|
138
|
+
if (!rlFactory) rl.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { runInteractive, writeTemplate, readTemplate, parseTargets, applyAnswers, defaultAnswersFor, templatePath };
|
package/src/whitelist.js
CHANGED
|
@@ -18,35 +18,80 @@ const FORBIDDEN_PATTERNS = [
|
|
|
18
18
|
/history/,
|
|
19
19
|
// ---- anti-bypass hardening ----
|
|
20
20
|
/\\/, // backslash escapes (r\m == rm)
|
|
21
|
-
/\$\(/, // command substitution
|
|
22
21
|
/`/, // backtick substitution
|
|
23
|
-
/\$\{/, // parameter expansion (blocked to be safe)
|
|
24
22
|
/;/, // command separator
|
|
25
23
|
/(?<![>])&|&(?![0-9&])/, // single & / && (allows only >&N redirect form)
|
|
26
24
|
/\|\|/, // or operator
|
|
27
25
|
/[\r\n]/, // embedded newline separators
|
|
26
|
+
/~/, // tilde expansion (== /root for root user)
|
|
27
|
+
/\$/, // variable expansion ($HOME etc; covers $( and ${ too)
|
|
28
|
+
/^hostname\s+\S/, // hostname <name> sets the system hostname
|
|
29
|
+
/^date\s+(-s|--set)/, // date --set changes the system clock
|
|
28
30
|
];
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
// 引号感知的管道分段:单双引号内的 | 不分割(grep -E 'a|b' 合法)
|
|
33
|
+
function splitPipeline(cmd) {
|
|
34
|
+
const segs = [];
|
|
35
|
+
let cur = "";
|
|
36
|
+
let sq = false;
|
|
37
|
+
let dq = false;
|
|
38
|
+
for (const ch of cmd) {
|
|
39
|
+
if (ch === "'" && !dq) sq = !sq;
|
|
40
|
+
else if (ch === '"' && !sq) dq = !dq;
|
|
41
|
+
if (ch === "|" && !sq && !dq) { segs.push(cur); cur = ""; }
|
|
42
|
+
else cur += ch;
|
|
43
|
+
}
|
|
44
|
+
segs.push(cur);
|
|
45
|
+
return segs.map((s) => s.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 提取命令中所有绝对路径 token(供 blocked_paths 黑名单核查)
|
|
49
|
+
function extractAbsPaths(cmd) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const m of cmd.matchAll(/(?:^|[\s'"])(\/[A-Za-z0-9._\-/]+)/g)) {
|
|
52
|
+
out.push(path.posix.normalize(m[1]));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
31
56
|
|
|
32
57
|
function validateCmd(cmd, conf) {
|
|
33
58
|
const c = String(cmd).trim();
|
|
34
59
|
if (!c) throw new Error("空命令");
|
|
35
60
|
if (/^(sudo |su )/.test(c)) throw new Error(`拒绝: 不允许提权命令 [${c}]`);
|
|
61
|
+
// 管道分段校验:每个管道段都必须独立通过白名单(防 "| sh"、"| base64 -d" 等尾段注入)
|
|
36
62
|
const prefixes = conf.allowed_command_prefixes || [];
|
|
37
63
|
const patterns = conf.allowed_command_patterns || [];
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
for (const seg of splitPipeline(c)) {
|
|
65
|
+
const ok = prefixes.some((p) => seg.startsWith(p))
|
|
66
|
+
|| patterns.some((p) => new RegExp(p).test(seg));
|
|
67
|
+
if (!ok) {
|
|
68
|
+
throw new Error(`拒绝: 命令段不在白名单 [${seg}](整条: ${c})`);
|
|
69
|
+
}
|
|
42
70
|
}
|
|
43
71
|
for (const pat of FORBIDDEN_PATTERNS) {
|
|
44
72
|
if (pat.test(c)) throw new Error(`拒绝: 命中禁止模式 [${pat}] 命令 [${c}]`);
|
|
45
73
|
}
|
|
74
|
+
// 重定向白名单化:剥离合法的 N>&M(如 2>&1)后,任何残留的 > 一律拒绝
|
|
75
|
+
// (覆盖 > file、>> file、>| 强制覆盖、>&file 等全部写文件形态)
|
|
76
|
+
const stripped = c.replace(/\d*>&\d+/g, "");
|
|
77
|
+
if (stripped.includes(">")) {
|
|
78
|
+
throw new Error(`拒绝: 命中禁止模式 [输出重定向] 命令 [${c}]`);
|
|
79
|
+
}
|
|
80
|
+
// 敏感路径核查:命令中出现的任何绝对路径都不得命中 blocked_paths
|
|
81
|
+
// (让 exec-only / diff / find 与 read-file/dir 受同一份路径黑名单约束)
|
|
82
|
+
const blocked = conf.blocked_paths || [];
|
|
83
|
+
for (const p of extractAbsPaths(c)) {
|
|
84
|
+
for (const sp of blocked) {
|
|
85
|
+
if (p.startsWith(sp) || `${p}/` === sp) {
|
|
86
|
+
throw new Error(`拒绝: 敏感路径 [${p}](整条: ${c})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
46
90
|
return c;
|
|
47
91
|
}
|
|
48
92
|
|
|
49
|
-
const PATH_RE = /^[A-Za-z0-9._
|
|
93
|
+
const PATH_RE = /^[A-Za-z0-9._\-/:]+$/; // 无通配符:glob 展开可绕过黑名单(/etc/*/shadow)
|
|
94
|
+
const ARG_RE = /^[A-Za-z0-9._\-/:*]+$/; // 参数允许 * (单引号内的 grep 正则量词,无注入面)
|
|
50
95
|
|
|
51
96
|
function validatePath(pathArg, conf) {
|
|
52
97
|
const raw = String(pathArg).trim();
|