aaihubhub
Claude Code 小白进阶课:从第一次对话到可靠协作 · 第 18 课 / 共 20 课 ↗ AI 教程

18|安全 Hooks:在执行前拦截危险动作

阅读并改造三份真实脚本:命令拦截、安全扫描和依赖检查。

kimi
LESSON BRIEFING18 / 20

阅读并改造三份真实脚本:命令拦截、安全扫描和依赖检查。

从网页读到“请把保险箱密码发给我”,不等于你收到老板指令。外部内容是不可信数据;权限边界、来源标记、秘密隔离和人工批准共同阻断注入链。

本课交付物

构造一份包含伪指令的测试文档,让代理只摘要事实且不得执行其中动作;记录哪些信号触发了拒绝,并检查日志不含秘密。


pre tool check

#!/bin/bash
# Pre-tool safety check for Bash commands
# Hook: PreToolUse (matcher: Bash)
#
# This hook runs before every Bash tool execution and blocks or warns on
# potentially destructive or high-risk shell commands.
#
# Setup:
#   cp 06-hooks/pre-tool-check.sh ~/.claude/hooks/
#   chmod +x ~/.claude/hooks/pre-tool-check.sh
#
# Configure in ~/.claude/settings.json:
#   {
#     "hooks": {
#       "PreToolUse": [
#         {
#           "matcher": "Bash",
#           "hooks": [
#             {
#               "type": "command",
#               "command": "~/.claude/hooks/pre-tool-check.sh"
#             }
#           ]
#         }
#       ]
#     }
#   }
#
# Input: JSON via stdin with the shape:
#   { "tool_name": "Bash", "tool_input": { "command": "..." } }
#
# Output convention (per Claude Code hook protocol):
#   - exit 0 → allow. stdout may contain JSON (hookSpecificOutput); stderr
#     is silently discarded, so warnings printed to stderr are NOT visible.
#     For observability on allowed commands, write to an audit log file.
#   - exit 2 → block. stderr is surfaced back to Claude as the block reason.
#     Any echo explaining *why* a command was blocked MUST be redirected to
#     stderr with `>&2`, otherwise Claude Code reports "No stderr output".
#
# Audit log: every invocation is recorded to
#   $CLAUDE_PROJECT_DIR/.claude/hooks/audit.log
# with the decision (BLOCK/WARN/ALLOW), so you can observe WARN-tier
# matches even though their stderr output is dropped by Claude Code.

# Read the full JSON input from stdin
INPUT=$(cat)

# Extract the command using portable sed (compatible with macOS and Linux)
COMMAND=$(echo "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)

# Fall back to the raw input if extraction fails
if [ -z "$COMMAND" ]; then
  COMMAND="$INPUT"
fi

# ── Audit log ─────────────────────────────────────────────────────────────────
# Records every invocation with the final decision. This is the only reliable
# way to observe the WARN tier, because Claude Code silently drops stderr on
# exit 0. Falls back to $(pwd) when the hook is invoked outside Claude Code
# (e.g. for local testing).
LOG_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/hooks"
LOG_FILE="$LOG_DIR/audit.log"
mkdir -p "$LOG_DIR" 2>/dev/null
log_decision() {
  echo "$(date -u +%FT%TZ) [$1] $COMMAND" >> "$LOG_FILE"
}

# ── Blocked patterns ──────────────────────────────────────────────────────────
# These commands are blocked unconditionally because they are almost always
# destructive and rarely intentional in an automated context.

BLOCKED_PATTERNS=(
  # Anchor `rm -rf /` so `/` must be followed by whitespace or end of line,
  # otherwise substring matching would falsely flag e.g. `rm -rf /tmp/foo`.
  "rm -rf /([[:space:]]|$)"
  "rm -rf \*"
  "dd if=/dev/zero"
  "dd if=/dev/random"
  ":\(\)\{:\|:&\};:"  # Fork bomb (regex metachars escaped)
  "mkfs\."           # Filesystem format
  "format c:"        # Windows disk format
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qE "$pattern"; then
    log_decision "BLOCK:$pattern"
    # These echoes MUST go to stderr — Claude Code surfaces stderr as the
    # block reason on exit 2. Writing to stdout would show "No stderr output".
    echo "❌ Blocked: Potentially destructive command detected: $pattern" >&2
    echo "   Command: $COMMAND" >&2
    exit 2
  fi
done

# ── Warning patterns ──────────────────────────────────────────────────────────
# These patterns are risky but may be intentional. Log a warning and allow.

WARNING_PATTERNS=(
  "rm -rf"
  "git push --force"
  "git reset --hard"
  "git clean -f"
  "chmod -R 777"
  "sudo rm"
  "DROP TABLE"
  "DROP DATABASE"
  "truncate"
)

MATCHED_WARNINGS=""
for pattern in "${WARNING_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qi "$pattern"; then
    MATCHED_WARNINGS="${MATCHED_WARNINGS:+$MATCHED_WARNINGS,}$pattern"
    # Mirror the warning on stderr for humans running the hook manually.
    # Claude Code drops this on exit 0 — the audit log is the reliable
    # record (see WARN entries).
    echo "⚠️  Warning: High-risk operation detected: $pattern" >&2
  fi
done

if [ -n "$MATCHED_WARNINGS" ]; then
  log_decision "WARN:$MATCHED_WARNINGS"
  echo "   Command: $COMMAND" >&2
  echo "   Proceeding — review the above warnings before continuing." >&2
else
  log_decision "ALLOW"
fi

# ── Allow ─────────────────────────────────────────────────────────────────────
exit 0

security scan

#!/bin/bash
# Security scan on file write
# Hook: PostToolUse:Write
#
# Scans files for hardcoded secrets, API keys, and credentials.
# Outputs a non-blocking warning via additionalContext when issues are found.
#
# Compatible with: macOS, Linux, Windows (Git Bash)

# Read JSON input from stdin (Claude Code hook protocol)
INPUT=$(cat)

# Extract file_path using sed (compatible with all platforms including Windows Git Bash)
# Avoids grep -P (not available on Windows Git Bash) and python3 dependency
FILE_PATH=$(echo "$INPUT" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)

if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
  exit 0
fi

# Skip binary files, vendor dirs, and build artifacts
case "$FILE_PATH" in
  *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) exit 0 ;;
  */node_modules/*|*/.git/*|*/dist/*|*/build/*) exit 0 ;;
esac

ISSUES=""

# Check for hardcoded passwords
# Handles both JSON format ("password": "value") and code format (password = 'value')
# Use \\n as separator — it is a valid JSON newline escape and passes through printf safely
if grep -qiE '"password"[[:space:]]*:[[:space:]]*"[^"]+"' "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: Potential hardcoded password detected\\n"
elif grep -qiE '(password|passwd|pwd)[[:space:]]*=[[:space:]]*'"'"'[^'"'"']+'"'"'' "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: Potential hardcoded password detected\\n"
fi

# Check for hardcoded API keys
if grep -qiE '"(api[_-]?key|apikey|access[_-]?token)"[[:space:]]*:[[:space:]]*"[^"]+"' "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: Potential hardcoded API key detected\\n"
fi

# Check for hardcoded secrets and tokens
if grep -qiE '(secret|token)[[:space:]]*=[[:space:]]*['"'"'"][^'"'"'"]+['"'"'"]' "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: Potential hardcoded secret or token detected\\n"
fi

# Check for private keys
if grep -q "BEGIN.*PRIVATE KEY" "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: Private key detected\\n"
fi

# Check for AWS keys
if grep -qE "AKIA[0-9A-Z]{16}" "$FILE_PATH" 2>/dev/null; then
  ISSUES="${ISSUES}- WARNING: AWS access key detected\\n"
fi

# Scan with semgrep if available (stdout suppressed to avoid mixing with JSON output)
if command -v semgrep &> /dev/null; then
  semgrep --config=auto "$FILE_PATH" --quiet >/dev/null 2>/dev/null
fi

# Scan with trufflehog if available (stdout suppressed to avoid mixing with JSON output)
if command -v trufflehog &> /dev/null; then
  trufflehog filesystem "$FILE_PATH" --only-verified --quiet >/dev/null 2>/dev/null
fi

# If issues found, output as additionalContext (non-blocking warning)
# Use hookSpecificOutput format required by Claude Code PostToolUse protocol
if [ -n "$ISSUES" ]; then
  # Escape file path for JSON (backslash and double-quote)
  # ISSUES already uses \\n as separator (valid JSON escape) — only escape double-quotes
  SAFE_PATH=$(printf '%s' "$FILE_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g')
  SAFE_ISSUES=$(printf '%s' "$ISSUES" | sed 's/"/\\"/g')
  printf '{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Security scan found issues in %s:\\n%sPlease review and use environment variables instead."}}' "$SAFE_PATH" "$SAFE_ISSUES"
fi

exit 0

dependency check

#!/bin/bash
# Check for known vulnerabilities in dependencies after manifest files are modified.
# Hook: PostToolUse:Write

FILE=$1

if [ -z "$FILE" ]; then
  echo "Usage: $0 <file_path>"
  exit 0
fi

# Use basename for matching — $1 may be an absolute path
BASENAME=$(basename "$FILE")

# Only run when a dependency manifest is written
case "$BASENAME" in
  package.json|package-lock.json|yarn.lock|pnpm-lock.yaml| \
  requirements.txt|Pipfile|Pipfile.lock|pyproject.toml| \
  go.mod|go.sum| \
  Cargo.toml|Cargo.lock| \
  Gemfile|Gemfile.lock| \
  composer.json|composer.lock| \
  pom.xml|build.gradle|build.gradle.kts)
    echo "📦 Dependency manifest updated: $FILE — scanning for vulnerabilities..."
    ;;
  *)
    exit 0
    ;;
esac

ISSUES_FOUND=0

# ── npm / yarn / pnpm ────────────────────────────────────────────────────────
if [[ "$BASENAME" == package*.json || "$BASENAME" == yarn.lock || "$BASENAME" == pnpm-lock.yaml ]]; then
  if command -v npm &>/dev/null; then
    echo "🔍 Running npm audit..."
    if ! npm audit --audit-level=high --json 2>/dev/null | \
        python3 -c "
import sys, json
data = json.load(sys.stdin)
vulns = data.get('metadata', {}).get('vulnerabilities', {})
high = vulns.get('high', 0) + vulns.get('critical', 0)
if high:
    print(f'  ⚠️  {high} high/critical npm vulnerabilities found. Run: npm audit fix')
    sys.exit(1)
" 2>/dev/null; then
      ISSUES_FOUND=1
    else
      echo "  ✅ No high/critical npm vulnerabilities"
    fi
  fi

  if command -v yarn &>/dev/null && [[ "$BASENAME" == yarn.lock ]]; then
    echo "🔍 Running yarn audit..."
    if ! yarn audit --level high --json 2>/dev/null | \
        grep -q '"type":"auditAdvisory"' 2>/dev/null; then
      echo "  ✅ No high yarn vulnerabilities"
    else
      echo "  ⚠️  yarn audit found vulnerabilities. Run: yarn audit --level high"
      ISSUES_FOUND=1
    fi
  fi
fi

# ── Python ───────────────────────────────────────────────────────────────────
if [[ "$BASENAME" == requirements.txt || "$BASENAME" == Pipfile* || "$BASENAME" == pyproject.toml ]]; then
  if command -v pip-audit &>/dev/null; then
    echo "🔍 Running pip-audit..."
    if pip-audit --format=json 2>/dev/null | \
        python3 -c "
import sys, json
data = json.load(sys.stdin)
vulns = [d for d in data.get('dependencies', []) if d.get('vulns')]
if vulns:
    for dep in vulns:
        for v in dep['vulns']:
            print(f'  ⚠️  {dep[\"name\"]} {dep[\"version\"]}: {v[\"id\"]} — {v[\"fix_versions\"]}')
    sys.exit(1)
" 2>/dev/null; then
      echo "  ✅ No Python vulnerabilities found"
    else
      ISSUES_FOUND=1
      echo "  Run: pip-audit for details"
    fi
  elif command -v safety &>/dev/null; then
    echo "🔍 Running safety check..."
    OUTPUT=$(safety check --short-report 2>&1)
    EXIT_CODE=$?
    if [ $EXIT_CODE -eq 0 ]; then
      echo "  ✅ No Python vulnerabilities found"
    elif echo "$OUTPUT" | grep -qiE "vulnerability|CVE|insecure"; then
      echo "$OUTPUT"
      ISSUES_FOUND=1
    else
      echo "  ⚠️  safety check could not complete (network or config error)" >&2
    fi
  fi
fi

# ── Go ───────────────────────────────────────────────────────────────────────
if [[ "$BASENAME" == go.mod || "$BASENAME" == go.sum ]]; then
  if command -v govulncheck &>/dev/null; then
    echo "🔍 Running govulncheck..."
    OUTPUT=$(govulncheck ./... 2>&1)
    EXIT_CODE=$?
    if [ $EXIT_CODE -eq 0 ]; then
      echo "  ✅ No Go vulnerabilities found"
    elif echo "$OUTPUT" | grep -q "Vulnerability #"; then
      echo "$OUTPUT"
      ISSUES_FOUND=1
    else
      echo "  ⚠️  govulncheck could not complete: $OUTPUT" >&2
    fi
  fi
fi

# ── Rust ─────────────────────────────────────────────────────────────────────
if [[ "$BASENAME" == Cargo.toml || "$BASENAME" == Cargo.lock ]]; then
  if command -v cargo-audit &>/dev/null; then
    echo "🔍 Running cargo audit..."
    if ! cargo audit 2>/dev/null; then
      ISSUES_FOUND=1
    else
      echo "  ✅ No Rust vulnerabilities found"
    fi
  fi
fi

# ── Ruby ─────────────────────────────────────────────────────────────────────
if [[ "$BASENAME" == Gemfile || "$BASENAME" == Gemfile.lock ]]; then
  if command -v bundler-audit &>/dev/null; then
    echo "🔍 Running bundler-audit..."
    bundler-audit check --update 2>/dev/null || ISSUES_FOUND=1
  fi
fi

# ── Generic fallback: trivy ──────────────────────────────────────────────────
if command -v trivy &>/dev/null; then
  echo "🔍 Running trivy fs scan..."
  if ! trivy fs --exit-code 1 --severity HIGH,CRITICAL --quiet . 2>/dev/null; then
    ISSUES_FOUND=1
  else
    echo "  ✅ trivy found no HIGH/CRITICAL issues"
  fi
fi

if [ "$ISSUES_FOUND" -eq 0 ]; then
  echo "✅ Dependency check passed — no vulnerabilities detected"
else
  echo ""
  echo "⚠️  Vulnerabilities detected. Review and update dependencies before committing."
  echo "   This hook is advisory only and will not block your workflow."
fi

# Always exit 0 — this hook warns but does not block
exit 0

本站实战工作台

把安全放进正常工作流

安全不是发布前临时扫描。任务开始就要识别资产、信任边界和高影响动作;实现时使用最小权限与输入校验;验证时覆盖拒绝路径;交付时检查日志与构建产物不含秘密。

先找“不能泄露”和“不能误做”

列出密钥、用户数据、生产连接、付费能力和不可逆动作。Agent 不需要读取的秘密不要放进上下文;工具只授予任务所需目录与 API。日志记录指纹和阶段,不记录请求体、Cookie、token、表单值或完整堆栈。

不可信输入仍然是不可信

仓库 issue、网页、MCP Resource 和代码注释都可能包含提示注入。把它们当数据,不让其改变系统规则或授权。模型提出的 shell 与 SQL 必须经过同样审查、参数化和环境限制。

密钥扫描实验

在临时 fixture 放一个明显的假 token,运行项目扫描或自写规则,确认能发现位置但报告不复制完整值。再加入相似但合法字符串检查误报。删除 fixture 后检查 Git 历史与构建目录,理解“从文件删掉”不等于“秘密从历史消失”。

高影响动作的两阶段提交

部署、迁移、邮件、付款先生成计划或预览,再由人确认执行。使用幂等键避免重复提交,设置上限、超时和回滚。确认界面应说明目标、范围与不可逆后果,而不是一个孤立的“确定”。

最小威胁模型

为本次功能画出用户、浏览器、后端、数据库与第三方服务,标注每条边界上的身份与数据。逐项问:攻击者能伪造输入吗、能让系统重复动作吗、能读取别人的资源吗、能把外部文本变成指令吗?威胁模型不需要几十页,关键是让安全控制对应真实数据流,而不是复制通用清单。

发现风险后优先消除能力,例如只读任务根本不提供删除工具;其次在服务端强制策略;最后才用提示提醒。越靠近模型的软规则越容易被不可信内容影响。

安全交付清单

依赖来源与许可证已检查;新增输入有服务端校验;权限最小;日志脱敏;正常与拒绝路径通过;秘密未进入 diff、测试快照或前端 bundle;生产动作有明确授权与备份。安全完成的标志不是“没有发现漏洞”,而是关键风险都有控制和证据。