密钥与凭据处理的安全实践

系统讲解 Shell 脚本中凭据的安全流转:避免明文硬编码、文件权限与内存保护、keyring 与 Vault 动态取用、日志脱敏与进程参数泄露防护。

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 不可达时宁可失败,也绝不回退到硬编码。凭据管好之后,下一个高频场景就是拿着它去连数据库,这也是下一篇的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战