KeiSeiKit-1.0/hooks/secrets-pre-guard.sh
Parfii-bot 8473b4ae80 fix(hooks+install): disk-reclaim Guard 3 + secrets per-line + sha256 fail-closed
Three independent shell hardening fixes from Opus Shell + Sonnet Shell audits.

1. disk-reclaim.sh Guard 3 — protect branches without upstream tracking (HIGH)
   File: hooks/disk-reclaim.sh:88-101
   Bug: when a worktree branch has no upstream tracking ref, `git log @{u}..`
   exited non-zero and `unpushed=""` (empty). The check
   `[ -n "$unpushed" ] && [ "$unpushed" != "0" ]` evaluated FALSE, so the
   worktree fell through Guard 3 and was eligible for mtime-based pruning.
   Local-only branches with committed work were silently deleted.

   Fix: explicit two-branch logic. Run `git rev-parse --abbrev-ref @{u}` first;
   only run the unpushed-count check if upstream exists. If no upstream, log
   SKIP[no-upstream] and `continue` conservatively. New
   `worktrees_skip_unpushed` counter increments in both unpushed paths.

2. secrets-pre-guard.sh — placeholder allowlist scope-narrow (MEDIUM)
   File: hooks/secrets-pre-guard.sh:43-103
   Bug: word "placeholder" anywhere in content disabled all secret-pattern
   scanning for that whole Write. Allowlist was too broad — a doc with the
   word "placeholder" in its prose could mask a real sk-ant- token elsewhere.

   Fix: replaced global early-exit with per-line awk scan. New scan_pattern()
   helper walks content line-by-line; each line matching a secret regex is
   allowed ONLY if the SAME line also matches ALLOWLIST_RE. Doc prose can no
   longer mask cross-line secrets. Added `dummy[_-]?(key|token|secret)` to
   allowlist for legitimate test fixtures.

3. lib-rust-prebuild.sh — sha256 fail-closed (HIGH supply-chain)
   File: install/lib-rust-prebuild.sh:75-88
   Bug: when ${url}.sha256 404'd, installer printed WARNING and proceeded with
   unverified tarball. A compromised github release uploader could ship a
   malicious tarball, omit .sha256, and the installer would extract it into
   ~/.cargo/bin/.

   Fix: missing .sha256 → ERROR + abort. Path A install fails → falls back to
   Path B (cargo build from source). Override via KEI_ALLOW_UNVERIFIED_TARBALL=1
   (visible per-call, intentional friction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:37:57 +08:00

130 lines
3.9 KiB
Bash
Executable file

#!/bin/sh
# secrets-pre-guard.sh — PreToolUse:Edit|Write hard deny (RULE 0.8 SECRETS)
#
# Scans the content being written for hardcoded secret tokens.
# If a live secret pattern is detected, exits 2 (block) and instructs
# the author to move the value to ~/.claude/secrets/.env.
#
# Exit codes:
# 0 = pass
# 2 = block (Claude Code aborts the tool call)
#
# Bypass: set KEI_SECRETS_GUARD_BYPASS=1 in the calling environment.
set -u
if [ "${KEI_SECRETS_GUARD_BYPASS:-0}" = "1" ]; then
exit 0
fi
if ! command -v jq > /dev/null 2>&1; then
exit 0
fi
INPUT=$(cat)
# Extract the file path being written/edited
FILE_PATH=$(printf '%s' "$INPUT" | jq -r \
'.tool_input.path // .tool_input.file_path // empty' 2>/dev/null)
# --- Allowlisted paths (secrets live here intentionally) -------------------
case "$FILE_PATH" in
*/secrets/*.env|*/secrets/.env|*.env.example|*.env.template)
exit 0
;;
esac
# Extract the content being written
CONTENT=$(printf '%s' "$INPUT" | jq -r \
'.tool_input.new_string // .tool_input.content // empty' 2>/dev/null)
[ -z "$CONTENT" ] && exit 0
# --- Per-line allowlist + secret detection ---------------------------------
# Evaluate placeholder allowlist PER LINE (not globally) so a "placeholder"
# marker elsewhere in the file does not disable secret scanning on lines
# that contain real tokens.
#
# A line is allowed iff it contains BOTH a secret-shaped pattern AND a
# placeholder marker on the SAME LINE. Otherwise, the secret pattern on
# that line is treated as a real hit.
ALLOWLIST_RE='YOUR_TOKEN_HERE|<redacted>|\[VERIFY:|placeholder|xxx+|_TOKEN_NAME_HERE|_KEY_HERE|_SECRET_HERE|example[_-]?(key|token|secret)|dummy[_-]?(key|token|secret)'
DETECTED=""
# Helper: scan content line-by-line for a given regex; for each match,
# allow only if the SAME LINE matches ALLOWLIST_RE. Sets DETECTED to label
# on first non-allowlisted hit.
scan_pattern() {
pattern="$1"
label="$2"
[ -n "$DETECTED" ] && return 0
hit=$(printf '%s' "$CONTENT" | awk -v pat="$pattern" -v allow="$ALLOWLIST_RE" '
{
if (match($0, pat)) {
if (match($0, allow)) {
next
}
print "HIT"
exit
}
}
')
if [ "$hit" = "HIT" ]; then
DETECTED="$label"
fi
}
# Anthropic/OpenAI legacy key
scan_pattern 'sk-[A-Za-z0-9]{20,}' "Anthropic/OpenAI legacy key (sk-...)"
# Anthropic current key
scan_pattern 'sk-ant-[A-Za-z0-9_-]{40,}' "Anthropic current key (sk-ant-...)"
# GitHub classic PAT
scan_pattern 'ghp_[A-Za-z0-9]{36}' "GitHub classic PAT (ghp_...)"
# GitHub fine-grained PAT
scan_pattern 'github_pat_[A-Za-z0-9_]{82}' "GitHub fine-grained PAT (github_pat_...)"
# Slack bot token
scan_pattern 'xoxb-[0-9]+-[0-9]+-[A-Za-z0-9]+' "Slack bot token (xoxb-...)"
# Telegram bot token
scan_pattern '[0-9]{8,10}:[A-Za-z0-9_-]{35}' "Telegram bot token (NNNNNNNNN:...)"
# AWS access key
scan_pattern 'AKIA[A-Z0-9]{16}' "AWS access key (AKIA...)"
# PEM private key block
scan_pattern '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----' "PEM private key (-----BEGIN ... PRIVATE KEY-----)"
[ -z "$DETECTED" ] && exit 0
# --- Block ------------------------------------------------------------------
cat >&2 <<EOF
[secrets-pre-guard] BLOCK — RULE 0.8 SECRETS SINGLE SOURCE
Detected hardcoded secret in content being written.
Type: $DETECTED
Hardcoding credentials in source files is forbidden (RULE 0.8).
Even .gitignored files expand the leak surface and resist rotation.
REMEDIATION:
1. Add the value to ~/.claude/secrets/.env (chmod 600):
VARIABLE_NAME=<value>
2. Reference it in code by env var name only:
Shell: source ~/.claude/secrets/.env && use \$VARIABLE_NAME
Python: os.environ["VARIABLE_NAME"]
Rust: std::env::var("VARIABLE_NAME")
3. Never paste the literal value in chat, commits, or docs.
Bypass (per-call, visible):
Set env KEI_SECRETS_GUARD_BYPASS=1 before the tool call.
Log the reason in your session chatlog.
EOF
exit 2