一次危险的命令拦截,背后藏着哪些 Bash 语法细节?
在日常的自动化运维或 AI 辅助编程中,我们经常需要写一些"安全钩子"------比如在执行用户命令之前,检查它是否包含危险模式(rm -rf /、mkfs、dd 等)。下面的这段代码就是一个典型的预执行检查器:
bash
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
echo "BLOCKED: Command matches dangerous pattern: $pattern" >&2
cat <<EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked dangerous pattern: $pattern"
}
}
EOF
exit 2
fi
done
这段脚本只有十几行,却浓缩了 Shell 编程中数组遍历 、模式匹配 、多行输出 、重定向 和退出码五大核心知识。本文将逐层拆解,并补充日常脚本中极易混淆的细节。
一、数组与循环:"${ARRAY[@]}" 为什么必须加引号?
bash
DANGEROUS_PATTERNS=("rm -rf" "mkfs" "dd if=/dev/zero")
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
...
done
"${DANGEROUS_PATTERNS[@]}":将数组每个元素展开为独立的单词,且保留元素内部的空格(例如"rm -rf"不会被拆成rm和-rf)。- 如果写成
${DANGEROUS_PATTERNS[@]}(无引号),数组元素会经历 单词分割 和 路径扩展,包含空格的元素就会被错误拆开。 - 经验法则 :展开数组时,始终使用双引号 +
[@]。
二、模式匹配:[[ "$COMMAND" == *"$pattern"* ]] 是正则吗?
不是 。== 在 [[ ]] 中执行的是 通配符模式匹配(globbing),而非正则表达式。
*表示"任意字符串"(包括空串)。*"$pattern"*的含义:只要$COMMAND包含$pattern这个子串就匹配成功。- 这与
grep -F(固定字符串)类似,但不支持.、^、$等正则元字符。
局限 :无法防范变种攻击。比如想禁止 rm -rf /,但用户输入 rm -rf / 时确实会拦截;若用户输入 rm -rf /* 则仍会匹配(因为子串 rm -rf / 存在)。但如果用户写成 rm -rf "${HOME}",模式 rm -rf / 就不能匹配了------这需要更复杂的解析器,简单的子串匹配做不到。
三、输出错误信息:>&2 是什么?
标准输出(文件描述符 1)和标准错误(文件描述符 2)默认都显示在终端,但它们在概念上是分开的流。
-
echo "..." >&2:将原本输出到标准输出的文本 重定向 到标准错误。 -
为什么这样做?因为脚本的正常结果(例如后面的 JSON)应该输出到 stdout,而错误/诊断信息应该走 stderr,便于调用者区分。例如:
bashresult=$(./check_command.sh 2>/dev/null) # 忽略错误输出,只取正确 JSON
>&2 中的 2 是文件描述符编号,不是退出码。它不会终止脚本,只是改变输出流向。
四、多行文本输出:echo vs cat <<EOF
用 echo 输出多行 JSON(痛苦版)
bash
echo "{\n \"hookSpecificOutput\": {\n \"permissionDecision\": \"deny\"\n }\n}"
需要手动转义换行符和双引号,极易出错。
用 here-document(优雅版)
bash
cat <<EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny"
}
}
EOF
Here-document 语法 <<EOF ... EOF 的作用:将中间的多行文本直接作为 cat 的标准输入。其中的变量(如 $pattern)会被正常展开。如果想禁止变量展开,使用 <<"EOF" 或 <<'EOF'。
何时用 echo,何时用 cat?
- 短字符串、单行 →
echo - 多行、含大量引号/花括号/换行 →
cat <<EOF
五、exit 2:数字 2 的含义完全不同
exit 2 表示以退出码 2 终止脚本。退出码 0 代表成功,非 0 代表失败。具体的非零值可由脚本自定义(1 表示一般错误,2 表示参数错误,126 表示命令不可执行等)。
在本文开头的代码中,exit 2 的作用是:
- 立即终止脚本,不再检查后续模式。
- 告诉父进程"因为危险模式而拒绝执行"(退出码 2 是一种约定)。
对比:
| 语法 | 作用 | 是否终止脚本 |
|---|---|---|
>&2 |
重定向标准输出到标准错误 | 否 |
exit 2 |
退出脚本,返回码 2 | 是 |
二者常配合使用:echo "错误" >&2 输出消息,然后 exit 2 报告失败状态。
六、整合:一个完整的命令拦截器示例
bash
#!/bin/bash
# 定义危险模式数组
DANGEROUS_PATTERNS=(
"rm -rf /"
"mkfs"
"dd if=/dev/zero"
":(){ :|:& };:" # fork 炸弹的简单特征
)
# 待检查的命令(通常来自用户输入)
COMMAND="$1"
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
# 错误信息送 stderr
echo "BLOCKED: Command matches dangerous pattern: $pattern" >&2
# 结构化输出(例如 JSON)送 stdout,供上层工具解析
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked dangerous pattern: $pattern. This command could cause irreversible damage."
}
}
EOF
exit 2
fi
done
# 如果没有匹配,放行
echo "Command allowed: $COMMAND"
exit 0
执行效果:
bash
$ ./check.sh "sudo rm -rf /home"
BLOCKED: Command matches dangerous pattern: rm -rf /
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked dangerous pattern: rm -rf /. This command could cause irreversible damage."
}
}
$ echo $?
2
七、总结:一张表记住所有疑难点
| 语法 | 含义 | 常见错误 |
|---|---|---|
"${ARRAY[@]}" |
安全地展开数组所有元素 | 漏掉引号导致元素被拆分 |
[[ "$s" == *"$p"* ]] |
子串包含匹配(通配符) | 误认为支持正则表达式 |
>&2 |
重定向标准输出到标准错误 | 误以为是退出码 |
cat <<EOF |
原样输出多行文本(支持变量展开) | 结束符 EOF 前有空格或缩进 |
exit 2 |
以退出码 2 终止脚本 | 与 >&2 中的 2 混淆 |
Shell 脚本的这些细节看似琐碎,但每个都是前辈在实践中踩过的坑。掌握它们,你不仅能写出安全的命令拦截器,还能更自信地阅读和修改生产环境中的 Bash 代码。
最后留一个思考题 :如果用户输入的命令是 rm -rf /home/user,上面的模式 rm -rf / 会匹配成功吗?为什么?欢迎在评论区讨论。