Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions plugins/security-suite/hooks/bash/claudemd-scanner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ scan_file() {
fi

for pattern in "${SUSPICIOUS_PATTERNS[@]}"; do
if grep -qiE "$pattern" "$file" 2>/dev/null; then
if grep -qiE -e "$pattern" "$file" 2>/dev/null; then
WARNINGS+=("Suspicious pattern in $file: matches '$pattern'")
fi
done
Expand All @@ -66,7 +66,10 @@ scan_file() {
fi

# Check for uncommon Unicode characters (potential homoglyph attack)
if grep -P '[^\x00-\x7F]' "$file" 2>/dev/null | grep -qiE "instruction|ignore|run|execute"; then
# Portability note: BSD grep (macOS) has no -P (PCRE). Uses perl
# instead, already present on macOS and Linux, with the same
# \x00-\x7F syntax.
if perl -ne 'print if /[^\x00-\x7F]/' "$file" 2>/dev/null | grep -qiE "instruction|ignore|run|execute"; then
WARNINGS+=("Warning: $file contains non-ASCII characters near sensitive keywords")
fi
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ if [[ "$TOOL_NAME" == "Bash" ]]; then
)

for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
# -e isolates the pattern from option parsing: if a pattern ever
# starts with "-", grep would otherwise treat it as an unknown
# flag and error out instead of matching, silently skipping the
# check. No current pattern here starts with "-", but this keeps
# the loop safe if one is added later (preventive hardening).
if echo "$COMMAND" | grep -qi -e "$pattern"; then
echo "BLOCKED: Potential secret detected in command: '$pattern'" >&2
exit 2
fi
Expand Down
115 changes: 83 additions & 32 deletions plugins/security-suite/hooks/bash/output-secrets-scanner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,55 +41,106 @@ if [[ -z "$TOOL_OUTPUT" ]]; then
fi

# Secret patterns to detect
declare -A SECRET_PATTERNS=(
# Compat: bash 3.2 (macOS default) does not support 'declare -A'
# (associative arrays only exist from bash 4 onward). So names and
# regexes live in two PARALLEL indexed arrays, in the same order,
# instead of a single name->regex map.
SECRET_NAMES=(
# API Keys
["OpenAI API Key"]="sk-[a-zA-Z0-9]{20,}"
["Anthropic API Key"]="sk-ant-[a-zA-Z0-9]{20,}"
["AWS Access Key"]="AKIA[0-9A-Z]{16}"
["AWS Secret Key"]="[0-9a-zA-Z/+]{40}"
["GCP API Key"]="AIza[0-9A-Za-z_-]{35}"
["Azure Key"]="[a-zA-Z0-9]{32,}"
["Stripe Key"]="(sk|pk)_(live|test)_[0-9a-zA-Z]{24,}"
["Twilio Key"]="SK[a-f0-9]{32}"
["SendGrid Key"]="SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}"
["Slack Token"]="xox[baprs]-[0-9a-zA-Z-]{10,}"
["Discord Token"]="[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9-_]{6}\.[A-Za-z0-9-_]{27}"
"OpenAI API Key"
"Anthropic API Key"
"AWS Access Key"
"AWS Secret Key"
"GCP API Key"
"Azure Key"
"Stripe Key"
"Twilio Key"
"SendGrid Key"
"Slack Token"
"Discord Token"

# Tokens
["GitHub Token"]="(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}"
["GitLab Token"]="glpat-[a-zA-Z0-9_-]{20,}"
["NPM Token"]="npm_[a-zA-Z0-9]{36}"
["PyPI Token"]="pypi-[a-zA-Z0-9_-]{50,}"
["JWT Token"]="eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*"
["Heroku API Key"]="[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
"GitHub Token"
"GitLab Token"
"NPM Token"
"PyPI Token"
"JWT Token"
"Heroku API Key"

# Private Keys
["Private Key"]="-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
["PGP Private Key"]="-----BEGIN PGP PRIVATE KEY BLOCK-----"
"Private Key"
"PGP Private Key"

# Database
["Database URL with Password"]="(postgres|mysql|mongodb)://[^:]+:[^@]+@"
["Redis URL with Password"]="redis://:[^@]+@"
"Database URL with Password"
"Redis URL with Password"

# Generic (58% of leaked secrets are "generic" - GitGuardian 2025)
["Generic API Key"]="(api[_-]?key|apikey|api[_-]?secret)['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_-]{20,}"
["Generic Secret"]="(secret|password|passwd|pwd)['\"]?\s*[:=]\s*['\"]?[^\s'\"]{8,}"
["Generic Token"]="(token|auth[_-]?token|access[_-]?token|bearer)['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_-]{20,}"
["Private Key Inline"]="['\"]?-----BEGIN[^-]+PRIVATE KEY-----"
"Generic API Key"
"Generic Secret"
"Generic Token"
"Private Key Inline"

# Environment Variable Leakage
["Env Dump Command"]="^(env|printenv|set)$"
["Proc Environ Access"]="/proc/self/environ|/proc/[0-9]+/environ"
"Env Dump Command"
"Proc Environ Access"
)

SECRET_REGEXES=(
# API Keys
"sk-[a-zA-Z0-9]{20,}"
"sk-ant-[a-zA-Z0-9]{20,}"
"AKIA[0-9A-Z]{16}"
"[0-9a-zA-Z/+]{40}"
"AIza[0-9A-Za-z_-]{35}"
"[a-zA-Z0-9]{32,}"
"(sk|pk)_(live|test)_[0-9a-zA-Z]{24,}"
"SK[a-f0-9]{32}"
"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}"
"xox[baprs]-[0-9a-zA-Z-]{10,}"
"[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9-_]{6}\.[A-Za-z0-9-_]{27}"

# Tokens
"(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}"
"glpat-[a-zA-Z0-9_-]{20,}"
"npm_[a-zA-Z0-9]{36}"
"pypi-[a-zA-Z0-9_-]{50,}"
"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*"
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"

# Private Keys
"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"
"-----BEGIN PGP PRIVATE KEY BLOCK-----"

# Database
"(postgres|mysql|mongodb)://[^:]+:[^@]+@"
"redis://:[^@]+@"

# Generic (58% of leaked secrets are "generic" - GitGuardian 2025)
"(api[_-]?key|apikey|api[_-]?secret)['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_-]{20,}"
"(secret|password|passwd|pwd)['\"]?\s*[:=]\s*['\"]?[^\s'\"]{8,}"
"(token|auth[_-]?token|access[_-]?token|bearer)['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_-]{20,}"
"['\"]?-----BEGIN[^-]+PRIVATE KEY-----"

# Environment Variable Leakage
"^(env|printenv|set)$"
"/proc/self/environ|/proc/[0-9]+/environ"
)

DETECTED_SECRETS=()

# Check each pattern
for secret_type in "${!SECRET_PATTERNS[@]}"; do
pattern="${SECRET_PATTERNS[$secret_type]}"
if echo "$TOOL_OUTPUT" | grep -qiE "$pattern" 2>/dev/null; then
# Check each pattern (walks both arrays by the same index)
i=0
while [ "$i" -lt "${#SECRET_NAMES[@]}" ]; do
secret_type="${SECRET_NAMES[$i]}"
pattern="${SECRET_REGEXES[$i]}"
# -e isolates the pattern from option parsing: patterns like
# "Private Key" start with "-----", and without -e grep treats them
# as an unknown flag instead of a regex, never matching anything.
if echo "$TOOL_OUTPUT" | grep -qiE -e "$pattern" 2>/dev/null; then
DETECTED_SECRETS+=("$secret_type")
fi
i=$((i + 1))
done

# If secrets detected, warn via systemMessage
Expand Down
76 changes: 56 additions & 20 deletions plugins/security-suite/hooks/bash/pre-commit-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,57 @@

set -euo pipefail

# Context: this is a pre-commit hook (uses git diff --cached); outside a git
# repo (e.g. running as PostToolUse:Edit under ~/.claude) there are no staged
# files to check.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi

# Colors
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Secret patterns (extended regex)
declare -A PATTERNS=(
["OpenAI API Key"]="sk-[A-Za-z0-9]{48}"
["GitHub Token (ghp)"]="ghp_[A-Za-z0-9]{36}"
["GitHub Token (gho)"]="gho_[A-Za-z0-9]{36}"
["GitHub Token (ghu)"]="ghu_[A-Za-z0-9]{36}"
["GitHub Token (ghs)"]="ghs_[A-Za-z0-9]{36}"
["GitHub Token (ghr)"]="ghr_[A-Za-z0-9]{36}"
["AWS Access Key"]="AKIA[A-Z0-9]{16}"
["AWS Secret Key"]="[A-Za-z0-9/+=]{40}"
["Anthropic API Key"]="sk-ant-[A-Za-z0-9-]{95,}"
["Generic API Key"]="api[_-]?key[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
["Generic Secret"]="secret[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
["Generic Token"]="token[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
["Database URL with Password"]="(postgres|mysql|mongodb)://[^:]+:[^@]+@"
["Private Key"]="-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"
["JWT Token"]="eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"
# Compat: bash 3.2 (macOS default) does not support 'declare -A'
# (associative arrays only exist from bash 4 onward). So names and
# regexes live in two PARALLEL indexed arrays, in the same order,
# instead of a single name->regex map.
PATTERN_NAMES=(
"OpenAI API Key"
"GitHub Token (ghp)"
"GitHub Token (gho)"
"GitHub Token (ghu)"
"GitHub Token (ghs)"
"GitHub Token (ghr)"
"AWS Access Key"
"AWS Secret Key"
"Anthropic API Key"
"Generic API Key"
"Generic Secret"
"Generic Token"
"Database URL with Password"
"Private Key"
"JWT Token"
)

PATTERN_REGEXES=(
"sk-[A-Za-z0-9]{48}"
"ghp_[A-Za-z0-9]{36}"
"gho_[A-Za-z0-9]{36}"
"ghu_[A-Za-z0-9]{36}"
"ghs_[A-Za-z0-9]{36}"
"ghr_[A-Za-z0-9]{36}"
"AKIA[A-Z0-9]{16}"
"[A-Za-z0-9/+=]{40}"
"sk-ant-[A-Za-z0-9-]{95,}"
"api[_-]?key[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
"secret[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
"token[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9]{20,}"
"(postgres|mysql|mongodb)://[^:]+:[^@]+@"
"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"
"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"
)

# Whitelisted patterns (safe to ignore)
Expand Down Expand Up @@ -112,11 +141,18 @@ detect_secrets() {
local content
content=$(git show ":$file" 2>/dev/null || continue)

# Check each pattern
for pattern_name in "${!PATTERNS[@]}"; do
local pattern="${PATTERNS[$pattern_name]}"
# Check each pattern (walks both arrays by the same index)
local pidx=0
while [ "$pidx" -lt "${#PATTERN_NAMES[@]}" ]; do
local pattern_name="${PATTERN_NAMES[$pidx]}"
local pattern="${PATTERN_REGEXES[$pidx]}"
pidx=$((pidx + 1))
local matches
matches=$(echo "$content" | grep -noE "$pattern" || true)
# -e isolates the pattern from option parsing: some patterns
# (e.g. "Private Key") start with "-----", and without -e
# grep treats them as an unknown flag instead of a regex,
# failing with "unrecognized option" and never matching.
matches=$(echo "$content" | grep -noE -e "$pattern" || true)

if [ -n "$matches" ]; then
# Check each match against whitelist
Expand Down
24 changes: 19 additions & 5 deletions plugins/security-suite/hooks/bash/prompt-injection-detector.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,23 @@ case "$TOOL_NAME" in
esac

# Extract content to analyze based on tool type
# CONTENT_JQ_FILTER is kept separately (plain string, no risk) so the
# null-byte check further below can re-apply the same filter through a
# pure pipe, without ever passing the result through a bash variable.
CONTENT=""
CONTENT_JQ_FILTER=""
case "$TOOL_NAME" in
Bash)
CONTENT=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
CONTENT_JQ_FILTER='.command // empty'
;;
Write|Edit)
CONTENT=$(echo "$TOOL_INPUT" | jq -r '.content // .new_string // empty')
CONTENT_JQ_FILTER='.content // .new_string // empty'
;;
WebFetch)
CONTENT=$(echo "$TOOL_INPUT" | jq -r '.url // empty')
CONTENT_JQ_FILTER='.url // empty'
;;
esac
CONTENT=$(echo "$TOOL_INPUT" | jq -r "$CONTENT_JQ_FILTER")

# Skip if no content to analyze
[[ -z "$CONTENT" ]] && exit 0
Expand Down Expand Up @@ -169,7 +174,16 @@ fi

# === NULL BYTE INJECTION ===
# Null bytes can truncate strings and bypass security checks
if echo "$CONTENT" | grep -qP '\x00'; then
# Portability note: BSD grep (macOS) has no -P (PCRE), so 'grep -qP' always
# fails with "invalid option -- P". On top of that, bash can NEVER retain a
# real \0 inside a variable (it is silently dropped on assignment via $()),
# so checking "$CONTENT" for \0 would never work on ANY system, even with
# GNU grep. The correct check compares stream size (via pipe, never
# materialized into a variable) before and after stripping null bytes,
# using LC_ALL=C to operate byte by byte.
CONTENT_RAW_SIZE=$(echo "$TOOL_INPUT" | jq -r "$CONTENT_JQ_FILTER" | wc -c | tr -d ' ')
CONTENT_STRIPPED_SIZE=$(echo "$TOOL_INPUT" | jq -r "$CONTENT_JQ_FILTER" | LC_ALL=C tr -d '\000' | wc -c | tr -d ' ')
if [ "$CONTENT_RAW_SIZE" != "$CONTENT_STRIPPED_SIZE" ]; then
echo "BLOCKED: Null byte detected - potential truncation attack" >&2
exit 2
fi
Expand All @@ -185,7 +199,7 @@ NESTED_CMD_PATTERNS=(
)

for pattern in "${NESTED_CMD_PATTERNS[@]}"; do
if echo "$CONTENT" | grep -qE "$pattern"; then
if echo "$CONTENT" | grep -qE -e "$pattern"; then
echo "BLOCKED: Nested command execution detected - potential bypass attempt" >&2
exit 2
fi
Expand Down
7 changes: 6 additions & 1 deletion plugins/security-suite/hooks/bash/repo-integrity-scanner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ check_package_json() {
)

for pattern in "${SUSPICIOUS_PATTERNS[@]}"; do
if echo "$scripts" | grep -qiE "$pattern"; then
# -e isolates the pattern from option parsing: if a pattern ever
# starts with "-", grep would otherwise treat it as an unknown
# flag and error out instead of matching, silently skipping the
# check. No current pattern here starts with "-", but this keeps
# the loop safe if one is added later (preventive hardening).
if echo "$scripts" | grep -qiE -e "$pattern"; then
echo "BLOCKED: Suspicious npm script detected in $file: pattern '$pattern'" >&2
return 1
fi
Expand Down
Loading