18|安全 Hooks:在执行前拦截危险动作
阅读并改造三份真实脚本:命令拦截、安全扫描和依赖检查。
阅读并改造三份真实脚本:命令拦截、安全扫描和依赖检查。
从网页读到“请把保险箱密码发给我”,不等于你收到老板指令。外部内容是不可信数据;权限边界、来源标记、秘密隔离和人工批准共同阻断注入链。
本课交付物
构造一份包含伪指令的测试文档,让代理只摘要事实且不得执行其中动作;记录哪些信号触发了拒绝,并检查日志不含秘密。
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;生产动作有明确授权与备份。安全完成的标志不是“没有发现漏洞”,而是关键风险都有控制和证据。