1. 追踪调试 set -x
一句话总结:
set -x打印每条命令的实际执行轨迹,PS4 定制前缀,配合-v与局部开启,是 Shell 调试的第一利器。
# 全程追踪
bash -x script.sh
# 脚本内局部开启
set -x # 打开追踪
# ... 要跟踪的段落
set +x # 关闭追踪
# 定制追踪前缀:显示行号与函数名
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
1.1 追踪输出解读
| 前缀 | 含义 |
|---|---|
+ | 展开后的命令 |
+ set -x | 命令本身 |
| 变量值 | 展开结果直接可见 |
| 函数名 | FUNCNAME 显示调用链 |
# 示例:看变量展开真相
x="a b"
set -x
echo "$x" # + echo 'a b' → 一眼看出引号是否生效
set +x
一句话总结: 变量"凭空消失"、命令"莫名失败"时,set -x 能把展开后的真相打出来——多数引号问题一眼就能定位。
2. 陷阱调试与断点
一句话总结:
trap ... DEBUG可以在每条命令前插入钩子,配合环境变量实现"条件断点",是脚本里的 gdb。
# 每执行一条命令前打印行号与当前内容
trap 'printf "-> %s:%s %s\n" "$BASH_SOURCE" "$LINENO" "$BASH_COMMAND"' DEBUG
# 条件断点:某个条件成立才停
trap 'if [[ $count -eq 10 ]]; then set -x; fi' DEBUG
2.1 调试工具对照
| 工具 | 作用 | 场景 |
|---|---|---|
set -x | 命令级追踪 | 通用第一步 |
set -v | 输入原文回显 | 看源行 |
bash -n | 语法检查 | 起步必跑 |
bash -x | 追踪执行 | 运行时问题 |
trap DEBUG | 每命令钩子 | 断点 |
bash -o functrace | 函数内也追踪 | 深层函数 |
# 语法检查先行
bash -n script.sh || echo "有语法错误"
# 只跑某个函数(提取测试)
source lib.sh && parse_version "v1.2"
一句话总结: 调试顺序建议:
bash -n排语法 →set -x看展开 →trap DEBUG放断点,由浅入深不盲猜。
3. 常见运行时错误定位
一句话总结: 未定义变量、引号缺失、分词错位、命令找不到,四类高频错误各有明显的追踪特征。
# 未定义变量(set -u 下)
unset_var # 报 unbound variable
# 引号缺失导致参数错位
args="a b c"
call "$args" # 变成 3 个参数
# 命令找不到
foocommand # command not found
# 逻辑错误:空格判断
[[ $x = "a b" ]] # 没引号会被分词
3.1 高频错误对照表
| 症状 | 根因 | 定位方法 |
|---|---|---|
unbound variable | 用了未定义变量 | set -u + set -x |
| 参数个数不对 | 忘记加引号 | set -x 看展开 |
command not found | 拼错/路径不在 PATH | type cmd |
| 静默失败 | -e 被 if 掩盖 | 去掉 if 重跑 |
| 行为时好时坏 | 环境变量不同 | env 对比 |
# 排查技巧
type -a awk # 看命令解析到哪个路径
command -v jq # 是否存在
env | grep -i proxy # 环境变量干扰排查
一句话总结: 排查错误时先问三个问题:这行真的执行了吗、变量展开成了什么、退出码是多少。set -x 加
; echo rc=$?能同时回答三问。
4. 引号注入防护
一句话总结: 外部输入拼进命令时,不引号就会被词分裂/命令替换劫持——引号注入是 Shell 脚本最常见的注入点。
# 危险:eval 会二次解析展开后的文本,$var 里的命令被真正执行
var='x; rm -rf /'
eval "echo $var" # ❌ 注入!等价于执行: echo x; rm -rf /
eval "echo ${var}" # ❌ 同样注入,eval 绝不能接外部数据
# 安全:普通展开不二次解析,双引号只作字面量
echo "$var" # ✅ 打印字面量 x; rm -rf /
echo "${var}" # ✅ 同上
# 危险:不加引号时通配符与分词生效,可能误伤
pattern='*.log'
rm $pattern # ❌ 通配符展开,可能删除意料之外的文件
rm "$pattern" # ✅ 按字面量处理
引号缺失并不等于"执行了分号后面的命令"——Shell 不会对展开结果二次分词成新命令;真正的执行型注入只在 eval、
$()拼接、或把展开结果当命令名时发生。但这不代表裸变量安全:词分裂与通配符展开同样会造成行为漂移,所以一律引号仍然是铁律。
4.1 注入场景对照
| 输入内容 | 不引号 | 双引号 |
|---|---|---|
a b c | 拆成 3 词 | 1 词 |
$(rm -rf /) | 执行命令替换 | 字面量 |
a; b | 分号再执行 b | 字面量 |
`ls` | 反引号执行 | 字面量 |
* | 通配符展开 | 字面量 |
# 危险场景:把变量塞进 eval
eval "echo $var" # var 含 ; 或 $() 即注入 → 禁用 eval
# 安全替代:不要 eval,直接展开
echo "$var"
# 通配符注入:禁止 glob 展开
set -f # 关闭文件名展开
echo "$pattern"
set +f
一句话总结: 安全铁律:所有外部输入一律双引号;
eval默认禁用;需要通配时用显式的 glob 逻辑而非裸变量。ShellCheck 的 SC2086 就是在帮你防这件事。
5. 变量安全与数据清洗
一句话总结: 进入脚本的每一条外部数据都要按"信任边界"清洗:数字验正则、枚举入白名单、路径拒绝特殊字符。
# 数字白名单
[[ "$input" =~ ^[0-9]+$ ]] || die "非数字: $input"
# 枚举白名单
case "$env" in dev|test|prod) ;; *) die "非法环境";; esac
# 路径消毒:拒绝 ../ 与特殊字符
[[ "$path" =~ ^[a-zA-Z0-9_./-]+$ ]] || die "非法路径"
# 长度限制
[[ ${#input} -le 64 ]] || die "输入过长"
5.1 数据清洗原则
| 输入类型 | 校验策略 | 示例 |
|---|---|---|
| 数字 | 正则 ^[0-9]+$ | 端口号 |
| 枚举 | case 白名单 | 环境名 |
| 文件路径 | 字符白名单 + 禁止 .. | 配置路径 |
| 邮件 | 正则 + 长度 | 通知收件人 |
# 从文件读取也要防:IFS 保留整行防换行注入
while IFS= read -r line; do
[[ "$line" =~ ^[A-Za-z0-9]+$ ]] || continue
echo "处理: $line"
done < user_list.txt
一句话总结: 清洗输入的本质是缩小信任边界:能枚举的用白名单,能正则的用正则,其余一律当作不可信字面量只读不执行。
6. 临时文件与权限安全
一句话总结: mktemp 生成不可预测的临时路径、脚本启动设 umask、关键文件校验属主,能防掉临时文件投毒与越权读。
# 安全临时文件
tmpdir=$(mktemp -d) # /tmp/tmp.XXXXXX 不可预测
tmpfile=$(mktemp) # 普通临时文件
trap 'rm -rf "$tmpdir"' EXIT # 用完必清
# 更安全的做法:工作目录放 /tmp 之外的私有目录
# 但 mktemp 已足够抵御常见攻击
6.1 权限加固清单
| 事项 | 做法 |
|---|---|
| umask | 脚本开头 umask 077 |
| 临时目录 | mktemp -d + EXIT trap |
| 敏感文件 | chmod 600 |
| 脚本自身 | 校验属主与权限 |
| 目录穿越 | 路径白名单校验 |
umask 077
secret_file=$(mktemp)
echo "$password" > "$secret_file" # 只有自己能读
chmod 600 "$secret_file"
# 检查脚本是否被篡改:仅允许特定属主执行
owner=$(stat -c %U "$0")
[[ "$owner" == "root" ]] || { echo "脚本属主异常"; exit 1; }
一句话总结: 临时文件必须
mktemp(路径不可预测),用完trap EXIT清理;落盘敏感信息默认umask 077。这两条成本极低,收益极高。
7. 日志、审计与敏感信息
一句话总结: 密码、令牌、密钥绝不落日志;记录谁在何时执行了什么,审计脚本的关键执行点。
# 绝不把密码打印到日志
# 错误:log "password=$PASS"
# 正确:脱敏
log "连接数据库(已用环境变量注入)"
# 审计关键动作
audit() {
printf '%s %s 执行: %s\n' "$(date '+%F %T')" "${SUDO_USER:-$USER}" "$*" >> /var/log/audit.log
}
audit "deploy" "$env" "$app"
7.1 敏感信息防护对照
| 敏感类型 | 错误做法 | 正确做法 |
|---|---|---|
| 密码 | 写进脚本参数 | 环境变量/密钥库 |
| 令牌 | 打进日志 | 脱敏/不打印 |
| 路径 | 拼接字符串 | 只读 + 白名单 |
| 证书 | 仓库明文 | 密钥管理服务 |
# 环境变量注入而非命令行参数(避免 ps 泄露)
export DB_PASS="${DB_PASS:?}"
psql -h "$DB_HOST" -U "$DB_USER" -c "SELECT 1" # 密码不进 ps
# 命令行里避免明文
# 坏:mysql -p secret
# 好:mysql -p"$MYSQL_PWD" 或 .my.cnf(600)
一句话总结: 密钥从"环境变量或密钥库"注入,永不进命令行参数、永不进日志。审计日志记录"谁在何时做了什么",是安全事件响应的第一现场。
8. 总结
| 环节 | 要点 |
|---|---|
| 追踪 | set -x + PS4 前缀看展开真相 |
| 断点 | trap DEBUG 条件钩子 |
| 定位 | bash -n → set -x → 三问排查法 |
| 注入 | 外部输入一律双引号,禁用 eval |
| 清洗 | 数字正则、枚举白名单、路径白名单 |
| 权限 | mktemp + umask 077 + EXIT trap 清理 |
| 审计 | 密钥不入日志,关键动作留痕 |
调试与安全是脚本"上线"前的最后一道关。调试图快、安全图稳:set -x 解决"为什么错",引号与白名单解决"会不会被黑"。这套能力与前面的变量、文本处理、进程、参数、工程化合在一起,就是一个完整的 Shell 工程能力栈。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。