1. 凭据泄露的常见路径
一句话总结: 密钥泄露几乎从不来自「被黑客攻破」,而是来自脚本自己把它写进了日志、进程列表或 Git 仓库。
先建立一个威胁模型:脚本运行时,凭据可能出现在五个地方——源码文件、命令行参数、环境变量、临时文件、日志输出。每一个都是泄露面。命令行参数最危险,因为同机器上任何用户都能通过 ps aux 看到完整命令行。
# 危险:密码出现在进程列表里
mysql -u root -pSuperSecret123 -e "SHOW DATABASES"
# 危险:密钥出现在 history 与日志中
curl -H "Authorization: Bearer sk-abcdef123456" https://api.example.com
1.1 泄露面清单
一句话总结: 源码 > 参数 > 临时文件 > 环境变量 > 日志,按修复成本从低到高逐项治理。
# 自查:扫描仓库中疑似硬编码的凭据
grep -rnE '(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["'"'"'][^"'"'"']+' \
--include='*.sh' --include='*.env' . || true
# 检查 shell 历史里的敏感命令
grep -nE '(password|token|secret)=' ~/.bash_history 2>/dev/null || true
1.2 最小权限原则
一句话总结: 每个凭据只授予完成任务所需的最小范围与最短有效期,泄露的损失才可控。
# 使用只读、限定前缀、短有效期的临时凭据
export AWS_ACCESS_KEY_ID="$TEMP_KEY"
export AWS_SECRET_ACCESS_KEY="$TEMP_SECRET"
export AWS_SESSION_TOKEN="$TEMP_TOKEN"
# 而不是使用拥有 AdministratorAccess 的长期密钥
2. 避免明文:输入与传递
一句话总结: 凭据应「按需取用、即用即弃」,从不在磁盘上以明文长期停留。
2.1 安全读取用户输入
一句话总结: 用
read -s关闭回显,避免密码出现在屏幕上与终端日志里。
# 关闭回显读取密码
read -rsp "请输入数据库密码: " DB_PASS
echo # read -s 不回显换行,手动补一个
# 用后立即清理
trap 'unset DB_PASS' EXIT
2.2 从文件描述符读取而非参数
一句话总结: 把凭据写进一个只在当前进程可见的 fd,工具从 fd 读,进程列表里什么都看不到。
# 通过 fd 3 传递配置,避免出现在 argv
mysql --defaults-extra-file=<(printf '[client]\npassword=%s\n' "$DB_PASS") \
-u app -e "SELECT 1"
# curl 从文件读 header
curl -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") https://api.example.com
2.3 环境变量与进程继承
一句话总结: 环境变量比参数安全,但仍会被子进程继承,敏感变量应在使用后立刻 unset。
# 只给单个命令注入环境变量,不污染当前 shell
env DB_PASS="$DB_PASS" ./migrate.sh
# 用完立即清除,避免被后续子进程继承
unset DB_PASS TOKEN
3. 文件权限与临时文件
一句话总结: 任何落盘的凭据都必须 0600 且由当前用户独占,临时文件要防竞态。
# 创建仅本人可读的临时凭据文件
umask 077
cred=$(mktemp)
trap 'shred -u "$cred" 2>/dev/null || rm -f "$cred"' EXIT
printf 'password=%s\n' "$DB_PASS" > "$cred"
chmod 600 "$cred"
3.1 umask 与权限修复
一句话总结: 脚本入口统一
umask 077,能一次性把后续所有新建文件的权限收紧。
#!/usr/bin/env bash
set -euo pipefail
umask 077 # 新建文件默认 600,目录默认 700
# 校验已有密钥文件权限
check_perm() {
local f="$1" want="600"
local got
got=$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f")
[[ "$got" == "$want" ]] || { echo "权限过宽: $f ($got)" >&2; return 1; }
}
3.2 安全删除
一句话总结:
rm只是解除链接,内容仍在磁盘上,敏感文件用shred覆写后再删。
# shred 覆写三次后删除
shred -u -n 3 "$cred" 2>/dev/null || rm -f "$cred"
# 无 shred(如 macOS)时降级
if ! command -v shred >/dev/null 2>&1; then
dd if=/dev/urandom of="$cred" bs=1k count=1 conv=notrunc 2>/dev/null
rm -f "$cred"
fi
4. keyring 与系统凭据存储
一句话总结: 与其自己管加密文件,不如把长期凭据交给操作系统钥匙串,脚本只负责取用。
4.1 macOS Keychain
一句话总结:
security命令读写钥匙串,凭据由系统加密保管,脚本不再持有明文。
# 写入钥匙串(-U 表示存在则更新)
security add-generic-password -a "$USER" -s myapp-db -w "$DB_PASS" -U
# 读取
DB_PASS=$(security find-generic-password -a "$USER" -s myapp-db -w)
# 使用后清理
unset DB_PASS
4.2 Linux Secret Service
一句话总结: GNOME Keyring 通过
secret-tool提供同样能力,headless 环境需先解锁 keyring。
# 写入(依赖 gnome-keyring 或 libsecret)
printf '%s' "$DB_PASS" | secret-tool store --label='myapp db' service myapp key db
# 读取
DB_PASS=$(secret-tool lookup service myapp key db)
# 检查 keyring 是否可用
if ! secret-tool lookup service myapp key db >/dev/null 2>&1; then
echo "keyring 不可用,需先解锁" >&2
fi
4.3 pass 与 age 加密
一句话总结: 无桌面环境时,
pass(基于 GPG)或age(现代加密)是纯命令行的可靠选择。
# pass:GPG 加密的密码库
pass insert -m myapp/db <<< "$DB_PASS"
DB_PASS=$(pass show myapp/db | head -n1)
# age:用公钥加密,脚本持私钥解密
age -R pubkey.txt -o secret.age <<< "$DB_PASS"
DB_PASS=$(age -d -i key.txt secret.age)
5. Vault 与动态凭据
一句话总结: Vault 类系统按需签发短时凭据,从根上消灭「长期密钥躺在服务器上」的问题。
# 登录后取临时 token
VAULT_TOKEN=$(vault login -method=oidc -format=json | jq -r '.auth.client_token')
export VAULT_TOKEN
# 读取 KV 中的静态密钥
DB_PASS=$(vault kv get -field=password secret/myapp/db)
# 申请动态数据库凭据(默认租期 1 小时)
vault read -format=json database/creds/app-role \
| jq -r '.data | "\(.username) \(.password)"'
5.1 租期与续约
一句话总结: 动态凭据有 TTL,长任务要么续约要么在租期内完成,过期后连接会被吊销。
# 查看租期
vault lease lookup "$LEASE_ID" | grep -i ttl
# 续约(在 TTL 内调用)
vault lease renew "$LEASE_ID"
5.2 失败降级
一句话总结: Vault 不可达时不要回退到硬编码密钥,宁可失败,这是安全设计的关键取舍。
# 正确的失败处理:宁可报错,也不降级到明文
if ! DB_PASS=$(vault kv get -field=password secret/myapp/db 2>/dev/null); then
echo "无法从 Vault 获取凭据,中止" >&2
exit 1
fi
# 错误示范:${DB_PASS:-hardcoded_default} —— 把后门写进了脚本
6. 日志脱敏与输出控制
一句话总结: 所有对外输出(日志、错误、调试)都要过一遍脱敏函数,绝不原样打印凭据。
# 通用脱敏:保留前 4 位,其余打码
mask() {
local s="$1"
local n=${#s}
if (( n <= 8 )); then
printf '%s' '****'
else
printf '%s****%s' "${s:0:4}" "${s: -2}"
fi
}
echo "使用 token: $(mask "$TOKEN")"
6.1 set -x 的陷阱
一句话总结:
set -x会把变量展开后的完整命令打进 stderr,调试完必须关闭,或用 PS4 做过滤。
# 危险:token 会被完整打印
set -x
curl -H "Authorization: Bearer $TOKEN" https://api.example.com
set +x
# 安全:PS4 只打印行号与函数名,不打印内容
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main} '
6.2 集中式日志脱敏
一句话总结: 把脱敏规则做成正则表,日志函数统一调用,比在每个 print 前手工处理可靠。
# 日志函数:统一过滤敏感模式
log_safe() {
sed -E \
-e 's/(password|passwd|token|secret|api[_-]?key)=[^[:space:]]+/\1=***/gI' \
-e 's/(Bearer )[A-Za-z0-9._-]+/\1***/g' \
-e 's/-----BEGIN[^-]+-----.*/***REDACTED***/g'
}
printf '连接串: %s\n' "password=hunter2 host=db" | log_safe
6.3 错误信息中的凭据
一句话总结: 工具报错常带连接串,捕获 stderr 时先脱敏再落盘。
# 捕获错误并脱敏后记录
if ! out=$(psql "$DSN" -c "SELECT 1" 2>&1); then
printf '%s\n' "$out" | log_safe >> /var/log/app-error.log
echo "数据库连接失败" >&2
exit 1
fi
7. 实战:凭据注入与生命周期管理
一句话总结: 把取用、注入、清理做成一个受控流程,用 trap 保证异常路径也清理干净。
#!/usr/bin/env bash
set -euo pipefail
umask 077
# 全局清理钩子
cleanup() {
unset DB_PASS API_TOKEN VAULT_TOKEN 2>/dev/null || true
[[ -n "${CRED_FILE:-}" ]] && shred -u "$CRED_FILE" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# 1. 取用:优先 Vault,其次 keyring,最后报错
load_secret() {
local name="$1"
if command -v vault >/dev/null 2>&1 && vault kv get "secret/myapp/$name" >/dev/null 2>&1; then
vault kv get -field=value "secret/myapp/$name"
elif command -v security >/dev/null 2>&1; then
security find-generic-password -s "myapp-$name" -w
else
return 1
fi
}
DB_PASS=$(load_secret db || { echo "无法获取 db 凭据" >&2; exit 1; })
# 2. 注入:写临时文件供工具读取,避免 argv 泄露
CRED_FILE=$(mktemp)
printf '[client]\npassword=%s\n' "$DB_PASS" > "$CRED_FILE"
chmod 600 "$CRED_FILE"
# 3. 使用
psql --defaults-extra-file="$CRED_FILE" -U app -c "SELECT now()"
# 4. 清理由 trap 负责
7.1 凭据轮换
一句话总结: 轮换脚本要先验证新凭据可用,再切换,最后吊销旧凭据,任何一步失败都可回退。
# 轮换三步:验证 -> 切换 -> 吊销
new_pass=$(vault kv get -field=new_password secret/myapp/db)
# 验证新凭据可连接
PGPASSWORD="$new_pass" psql -h db -U app -c "SELECT 1" >/dev/null \
|| { echo "新凭据验证失败,保持旧凭据" >&2; exit 1; }
# 切换并吊销旧凭据
vault kv put secret/myapp/db password="$new_pass" >/dev/null
echo "轮换完成"
7.2 审计与告警
一句话总结: 记录「谁在何时取用了哪个凭据」,异常取用模式才能被发现。
audit_secret() {
printf '%s user=%s secret=%s host=%s\n' \
"$(date -Iseconds)" "$USER" "$1" "$(hostname)" \
>> /var/log/secret-audit.log
}
audit_secret db
8. 总结
| 环节 | 要点 |
|---|---|
| 威胁模型 | 源码、参数、临时文件、环境变量、日志五处泄露面 |
| 输入 | read -s 关回显,凭据走 fd 而非 argv |
| 传递 | 环境变量优于参数,用完立即 unset |
| 文件 | umask 077、0600 权限、shred 覆写删除 |
| 存储 | Keychain、Secret Service、pass、age 按环境选型 |
| 动态凭据 | Vault 签发短时凭据,注意 TTL 与续约 |
| 脱敏 | 统一 mask 与 log_safe,set -x 要慎用 |
| 生命周期 | trap 清理 + 轮换先验证后切换 + 审计留痕 |
凭据安全的核心思想是**「凭据是流程的一部分,而不是常量」**:从取用、注入、使用到销毁,每一步都要有明确的边界与失败策略。记住那条铁律——Vault 不可达时宁可失败,也绝不回退到硬编码。凭据管好之后,下一个高频场景就是拿着它去连数据库,这也是下一篇的主题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。