From 5c91e0aa7a86a56a2e9b935307421318db0028da Mon Sep 17 00:00:00 2001 From: Jeff Roche Date: Wed, 22 Apr 2026 13:05:36 -0400 Subject: [PATCH 1/5] feat(plugins): add security-review plugin with credential and safety hooks Adds PreToolUse hooks that block credential commits (AWS keys, private keys, passwords, tokens), destructive commands (force push, rm -rf /, hard reset), and credential patterns in file writes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../.claude-plugin/plugin.json | 8 ++ plugins/security-review/README.md | 35 +++++++++ plugins/security-review/hooks/hooks.json | 34 +++++++++ .../scripts/block-destructive.sh | 63 ++++++++++++++++ .../scripts/check-file-secrets.sh | 74 +++++++++++++++++++ .../security-review/scripts/check-secrets.sh | 69 +++++++++++++++++ 6 files changed, 283 insertions(+) create mode 100644 plugins/security-review/.claude-plugin/plugin.json create mode 100644 plugins/security-review/README.md create mode 100644 plugins/security-review/hooks/hooks.json create mode 100755 plugins/security-review/scripts/block-destructive.sh create mode 100755 plugins/security-review/scripts/check-file-secrets.sh create mode 100755 plugins/security-review/scripts/check-secrets.sh diff --git a/plugins/security-review/.claude-plugin/plugin.json b/plugins/security-review/.claude-plugin/plugin.json new file mode 100644 index 00000000..8875d39b --- /dev/null +++ b/plugins/security-review/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "security-review", + "description": "Security guards for credential protection and destructive command prevention", + "version": "1.0.0", + "author": { "name": "jeff-roche" }, + "homepage": "https://github.com/openshift-eng/edge-tooling", + "license": "Apache-2.0" +} diff --git a/plugins/security-review/README.md b/plugins/security-review/README.md new file mode 100644 index 00000000..1d2a67d6 --- /dev/null +++ b/plugins/security-review/README.md @@ -0,0 +1,35 @@ +# security-review + +Security guards for credential protection and destructive command prevention. Hooks only -- no skills or commands. + +## What It Does + +Automatic PreToolUse hooks fire on every relevant tool invocation: + +### Bash hooks +- **check-secrets.sh** -- Scans `git diff --cached` for credentials before `git commit` or `git add` runs. Detects AWS keys, private keys, hardcoded passwords/tokens/secrets/API keys, and `.env` files. +- **block-destructive.sh** -- Blocks dangerous commands: `rm -rf /`, `git push --force`, `git reset --hard`, `git clean -fd`, `git checkout -- .`, `git restore .`, `chmod -R 777`. Suggests safer alternatives. + +### Write hooks +- **check-file-secrets.sh** -- Scans file content for credential patterns before writing. Detects the same credential types as check-secrets.sh plus database connection strings with embedded passwords. + +## Detected Patterns + +| Category | Examples | +|----------|----------| +| AWS keys | `AKIA` followed by 16 alphanumeric characters | +| Private keys | PEM-encoded RSA, generic, or OpenSSH private keys | +| Hardcoded credentials | `password`, `token`, `secret`, `api_key` assignments | +| Connection strings | Database URIs with embedded passwords | +| Sensitive files | `.env` files in staged git content | +| Destructive commands | Force push, hard reset, root deletion, insecure chmod | + +## False Positives + +If a hook blocks a legitimate action, Claude Code will present the block reason and let you approve the action through the permission prompt. No configuration changes needed. + +## Installation + +``` +/plugin marketplace add openshift-eng/edge-tooling security-review +``` diff --git a/plugins/security-review/hooks/hooks.json b/plugins/security-review/hooks/hooks.json new file mode 100644 index 00000000..0a34532a --- /dev/null +++ b/plugins/security-review/hooks/hooks.json @@ -0,0 +1,34 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-secrets.sh", + "timeout": 5, + "statusMessage": "Scanning for secrets..." + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/block-destructive.sh", + "timeout": 2, + "statusMessage": "Safety check..." + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/check-file-secrets.sh", + "timeout": 3, + "statusMessage": "Scanning file content..." + } + ] + } + ] + } +} diff --git a/plugins/security-review/scripts/block-destructive.sh b/plugins/security-review/scripts/block-destructive.sh new file mode 100755 index 00000000..d9fe8189 --- /dev/null +++ b/plugins/security-review/scripts/block-destructive.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# PreToolUse(Bash) hook — block dangerous commands +set -euo pipefail + +if ! command -v jq &>/dev/null; then + exit 0 # Fail open if jq is missing +fi + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.toolInput.command // empty') + +if [[ -z "$COMMAND" ]]; then + exit 0 +fi + +# rm -rf targeting root, home, or bare / +if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+(/|~|\$HOME)\s*$' || \ + echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\s+(/|~|\$HOME)\s*$'; then + echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 + exit 1 +fi + +# git push --force / -f (but not --force-with-lease) +if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force($|\s)' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then + echo "Blocked: \`git push --force\` can overwrite remote history. Use \`git push --force-with-lease\` instead." >&2 + exit 1 +fi +if echo "$COMMAND" | grep -qE 'git\s+push\s+.*\s-[a-zA-Z]*f' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then + echo "Blocked: \`git push -f\` can overwrite remote history. Use \`git push --force-with-lease\` instead." >&2 + exit 1 +fi + +# git reset --hard +if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then + echo "Blocked: \`git reset --hard\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 + exit 1 +fi + +# git clean -fd / -fdx +if echo "$COMMAND" | grep -qE 'git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d'; then + echo "Blocked: \`git clean -fd\` permanently deletes untracked files. Use \`git stash --include-untracked\` instead." >&2 + exit 1 +fi + +# git checkout -- . (discard all changes) +if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--\s+\.'; then + echo "Blocked: \`git checkout -- .\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 + exit 1 +fi + +# git restore . (discard all changes) +if echo "$COMMAND" | grep -qE 'git\s+restore\s+\.'; then + echo "Blocked: \`git restore .\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 + exit 1 +fi + +# chmod -R 777 +if echo "$COMMAND" | grep -qE 'chmod\s+(-R\s+777|777\s+-R)'; then + echo "Blocked: \`chmod -R 777\` sets insecure permissions. Use more restrictive permissions (e.g., 755 for directories, 644 for files)." >&2 + exit 1 +fi + +exit 0 diff --git a/plugins/security-review/scripts/check-file-secrets.sh b/plugins/security-review/scripts/check-file-secrets.sh new file mode 100755 index 00000000..0f57ecd4 --- /dev/null +++ b/plugins/security-review/scripts/check-file-secrets.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# PreToolUse(Write) hook — scan file content for credentials before writing +set -euo pipefail + +if ! command -v jq &>/dev/null; then + exit 0 # Fail open if jq is missing +fi + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.toolInput.file_path // empty') +CONTENT=$(echo "$INPUT" | jq -r '.toolInput.content // empty') + +if [[ -z "$CONTENT" ]]; then + exit 0 +fi + +FOUND="" + +# Scan content for credential patterns + +# AWS access key +if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then + FOUND="${FOUND}\n- AWS access key (AKIA...) detected" +fi + +# Private keys +if echo "$CONTENT" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY'; then + FOUND="${FOUND}\n- Private key detected" +fi + +# Password assignments +if echo "$CONTENT" | grep -qiE 'password\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded password detected" +fi + +# Token assignments +if echo "$CONTENT" | grep -qiE 'token\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded token detected" +fi + +# Secret assignments +if echo "$CONTENT" | grep -qiE 'secret\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded secret detected" +fi + +# API key assignments +if echo "$CONTENT" | grep -qiE 'api_key\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded API key detected" +fi + +# Database connection strings with embedded passwords +if echo "$CONTENT" | grep -qiE '(mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^@]+@'; then + FOUND="${FOUND}\n- Database connection string with embedded password detected" +fi + +# Sensitive file path check (warn only if content also has secrets) +if [[ -n "$FOUND" ]]; then + if echo "$FILE_PATH" | grep -qiE '\.(pem|key|p12|pfx)$'; then + FOUND="${FOUND}\n- Writing to sensitive file type: ${FILE_PATH}" + fi + if echo "$FILE_PATH" | grep -qiE '(credential|secret)'; then + FOUND="${FOUND}\n- Writing to sensitive path: ${FILE_PATH}" + fi + if echo "$FILE_PATH" | grep -qE '\.env$'; then + FOUND="${FOUND}\n- Writing to .env file: ${FILE_PATH}" + fi +fi + +if [[ -n "$FOUND" ]]; then + echo -e "BLOCKED: Potential credentials detected in file content (${FILE_PATH}):${FOUND}\n\nUse environment variables or external secret management instead of hardcoded credentials." >&2 + exit 1 +fi + +exit 0 diff --git a/plugins/security-review/scripts/check-secrets.sh b/plugins/security-review/scripts/check-secrets.sh new file mode 100755 index 00000000..e7215e96 --- /dev/null +++ b/plugins/security-review/scripts/check-secrets.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# PreToolUse(Bash) hook — detect credentials in git staged content +set -euo pipefail + +if ! command -v jq &>/dev/null; then + exit 0 # Fail open if jq is missing +fi + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.toolInput.command // empty') + +# Fast path: only inspect git commit/add commands +if [[ -z "$COMMAND" ]]; then + exit 0 +fi + +if ! echo "$COMMAND" | grep -qE 'git (commit|add)'; then + exit 0 +fi + +# Get staged diff content +STAGED=$(git diff --cached --diff-filter=ACM 2>/dev/null || true) +if [[ -z "$STAGED" ]]; then + exit 0 +fi + +FOUND="" + +# AWS access key +if echo "$STAGED" | grep -qE 'AKIA[0-9A-Z]{16}'; then + FOUND="${FOUND}\n- AWS access key (AKIA...) detected in staged content" +fi + +# Private keys +if echo "$STAGED" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY'; then + FOUND="${FOUND}\n- Private key detected in staged content" +fi + +# Password assignments +if echo "$STAGED" | grep -qiE 'password\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded password detected in staged content" +fi + +# Token assignments +if echo "$STAGED" | grep -qiE 'token\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded token detected in staged content" +fi + +# Secret assignments +if echo "$STAGED" | grep -qiE 'secret\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded secret detected in staged content" +fi + +# API key assignments +if echo "$STAGED" | grep -qiE 'api_key\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then + FOUND="${FOUND}\n- Hardcoded API key detected in staged content" +fi + +# .env files being staged +if echo "$STAGED" | grep -qE '^\+\+\+ b/.*\.env$'; then + FOUND="${FOUND}\n- .env file detected in staged content" +fi + +if [[ -n "$FOUND" ]]; then + echo -e "BLOCKED: Potential credentials found in staged changes:${FOUND}\n\nUse environment variables instead of hardcoded credentials. Remove sensitive data and use .gitignore to exclude secret files." >&2 + exit 1 +fi + +exit 0 From d85701bdf0938d357388569089441e7d182b3f4a Mon Sep 17 00:00:00 2001 From: Jeff Roche Date: Wed, 22 Apr 2026 13:33:27 -0400 Subject: [PATCH 2/5] fix(plugins): address PR review feedback for security-review plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use #!/usr/bin/bash shebang across all scripts - Add jq→python3 fallback, fail closed if neither available - Broaden credential regexes to catch unquoted values and export prefix - Improve rm -rf detection to handle sudo and -- separator - Restrict check-secrets to git commit only (git add covered by Write hook) - Fix README markdown: blank lines after headings, bash language tag Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/security-review/README.md | 4 +- .../scripts/block-destructive.sh | 30 +++++++++----- .../scripts/check-file-secrets.sh | 39 ++++++++++++------- .../security-review/scripts/check-secrets.sh | 37 +++++++++--------- 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/plugins/security-review/README.md b/plugins/security-review/README.md index 1d2a67d6..59c91d31 100644 --- a/plugins/security-review/README.md +++ b/plugins/security-review/README.md @@ -7,10 +7,12 @@ Security guards for credential protection and destructive command prevention. Ho Automatic PreToolUse hooks fire on every relevant tool invocation: ### Bash hooks + - **check-secrets.sh** -- Scans `git diff --cached` for credentials before `git commit` or `git add` runs. Detects AWS keys, private keys, hardcoded passwords/tokens/secrets/API keys, and `.env` files. - **block-destructive.sh** -- Blocks dangerous commands: `rm -rf /`, `git push --force`, `git reset --hard`, `git clean -fd`, `git checkout -- .`, `git restore .`, `chmod -R 777`. Suggests safer alternatives. ### Write hooks + - **check-file-secrets.sh** -- Scans file content for credential patterns before writing. Detects the same credential types as check-secrets.sh plus database connection strings with embedded passwords. ## Detected Patterns @@ -30,6 +32,6 @@ If a hook blocks a legitimate action, Claude Code will present the block reason ## Installation -``` +```bash /plugin marketplace add openshift-eng/edge-tooling security-review ``` diff --git a/plugins/security-review/scripts/block-destructive.sh b/plugins/security-review/scripts/block-destructive.sh index d9fe8189..19baa1d3 100755 --- a/plugins/security-review/scripts/block-destructive.sh +++ b/plugins/security-review/scripts/block-destructive.sh @@ -1,23 +1,33 @@ -#!/bin/bash +#!/usr/bin/bash # PreToolUse(Bash) hook — block dangerous commands set -euo pipefail -if ! command -v jq &>/dev/null; then - exit 0 # Fail open if jq is missing -fi +parse_command() { + if command -v jq &>/dev/null; then + echo "$1" | jq -r '.toolInput.command // empty' + elif command -v python3 &>/dev/null; then + echo "$1" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("toolInput",{}).get("command",""))' + else + echo "security-review: neither jq nor python3 available — cannot parse hook input" >&2 + exit 1 + fi +} INPUT=$(cat) -COMMAND=$(echo "$INPUT" | jq -r '.toolInput.command // empty') +COMMAND=$(parse_command "$INPUT") if [[ -z "$COMMAND" ]]; then exit 0 fi -# rm -rf targeting root, home, or bare / -if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+(/|~|\$HOME)\s*$' || \ - echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\s+(/|~|\$HOME)\s*$'; then - echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 - exit 1 +# rm -rf targeting root, home — handles sudo, --, and path as non-final token +if echo "$COMMAND" | grep -qE '(^|\s)(sudo\s+)?rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*(\s+--)?(\s+|$)' && \ + echo "$COMMAND" | grep -qE '(\s|/)(/|~|\$HOME)(\s|$)'; then + # Exclude safe relative paths like ./build, ../tmp + if ! echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+\./'; then + echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 + exit 1 + fi fi # git push --force / -f (but not --force-with-lease) diff --git a/plugins/security-review/scripts/check-file-secrets.sh b/plugins/security-review/scripts/check-file-secrets.sh index 0f57ecd4..d7e14d19 100755 --- a/plugins/security-review/scripts/check-file-secrets.sh +++ b/plugins/security-review/scripts/check-file-secrets.sh @@ -1,14 +1,29 @@ -#!/bin/bash +#!/usr/bin/bash # PreToolUse(Write) hook — scan file content for credentials before writing set -euo pipefail -if ! command -v jq &>/dev/null; then - exit 0 # Fail open if jq is missing -fi +parse_json_field() { + local input="$1" + local field="$2" + if command -v jq &>/dev/null; then + echo "$input" | jq -r "$field" + elif command -v python3 &>/dev/null; then + echo "$input" | python3 -c " +import sys, json, functools +data = json.load(sys.stdin) +keys = '$field'.strip('.').split('.') +val = functools.reduce(lambda d, k: d.get(k, '') if isinstance(d, dict) else '', keys, data) +print(val if val else '') +" + else + echo "security-review: neither jq nor python3 available — cannot parse hook input" >&2 + exit 1 + fi +} INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.toolInput.file_path // empty') -CONTENT=$(echo "$INPUT" | jq -r '.toolInput.content // empty') +FILE_PATH=$(parse_json_field "$INPUT" '.toolInput.file_path') +CONTENT=$(parse_json_field "$INPUT" '.toolInput.content') if [[ -z "$CONTENT" ]]; then exit 0 @@ -16,8 +31,6 @@ fi FOUND="" -# Scan content for credential patterns - # AWS access key if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then FOUND="${FOUND}\n- AWS access key (AKIA...) detected" @@ -28,23 +41,23 @@ if echo "$CONTENT" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KE FOUND="${FOUND}\n- Private key detected" fi -# Password assignments -if echo "$CONTENT" | grep -qiE 'password\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +# Password assignments (quoted and unquoted, with optional export) +if echo "$CONTENT" | grep -qiE '(export\s+)?password\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded password detected" fi # Token assignments -if echo "$CONTENT" | grep -qiE 'token\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$CONTENT" | grep -qiE '(export\s+)?token\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded token detected" fi # Secret assignments -if echo "$CONTENT" | grep -qiE 'secret\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$CONTENT" | grep -qiE '(export\s+)?secret\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded secret detected" fi # API key assignments -if echo "$CONTENT" | grep -qiE 'api_key\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$CONTENT" | grep -qiE '(export\s+)?api_key\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded API key detected" fi diff --git a/plugins/security-review/scripts/check-secrets.sh b/plugins/security-review/scripts/check-secrets.sh index e7215e96..8545059f 100755 --- a/plugins/security-review/scripts/check-secrets.sh +++ b/plugins/security-review/scripts/check-secrets.sh @@ -1,24 +1,30 @@ -#!/bin/bash +#!/usr/bin/bash # PreToolUse(Bash) hook — detect credentials in git staged content set -euo pipefail -if ! command -v jq &>/dev/null; then - exit 0 # Fail open if jq is missing -fi +parse_command() { + if command -v jq &>/dev/null; then + echo "$1" | jq -r '.toolInput.command // empty' + elif command -v python3 &>/dev/null; then + echo "$1" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("toolInput",{}).get("command",""))' + else + echo "security-review: neither jq nor python3 available — cannot parse hook input" >&2 + exit 1 + fi +} INPUT=$(cat) -COMMAND=$(echo "$INPUT" | jq -r '.toolInput.command // empty') +COMMAND=$(parse_command "$INPUT") -# Fast path: only inspect git commit/add commands if [[ -z "$COMMAND" ]]; then exit 0 fi -if ! echo "$COMMAND" | grep -qE 'git (commit|add)'; then +# Only inspect git commit — git add is covered by the Write hook (check-file-secrets.sh) +if ! echo "$COMMAND" | grep -qE 'git\s+commit'; then exit 0 fi -# Get staged diff content STAGED=$(git diff --cached --diff-filter=ACM 2>/dev/null || true) if [[ -z "$STAGED" ]]; then exit 0 @@ -26,37 +32,30 @@ fi FOUND="" -# AWS access key if echo "$STAGED" | grep -qE 'AKIA[0-9A-Z]{16}'; then FOUND="${FOUND}\n- AWS access key (AKIA...) detected in staged content" fi -# Private keys if echo "$STAGED" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY'; then FOUND="${FOUND}\n- Private key detected in staged content" fi -# Password assignments -if echo "$STAGED" | grep -qiE 'password\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$STAGED" | grep -qiE '(export\s+)?password\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded password detected in staged content" fi -# Token assignments -if echo "$STAGED" | grep -qiE 'token\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$STAGED" | grep -qiE '(export\s+)?token\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded token detected in staged content" fi -# Secret assignments -if echo "$STAGED" | grep -qiE 'secret\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$STAGED" | grep -qiE '(export\s+)?secret\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded secret detected in staged content" fi -# API key assignments -if echo "$STAGED" | grep -qiE 'api_key\s*[:=]\s*['\''"][^'\''"]+['\''"]'; then +if echo "$STAGED" | grep -qiE '(export\s+)?api_key\s*[:=]\s*\S'; then FOUND="${FOUND}\n- Hardcoded API key detected in staged content" fi -# .env files being staged if echo "$STAGED" | grep -qE '^\+\+\+ b/.*\.env$'; then FOUND="${FOUND}\n- .env file detected in staged content" fi From c5a084952d81e51d96a8a0d850afc0a84c304f01 Mon Sep 17 00:00:00 2001 From: Jeff Roche Date: Wed, 22 Apr 2026 13:42:04 -0400 Subject: [PATCH 3/5] chore(plugins): register security-review in marketplace catalog Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ebae08ed..5cd4533c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -60,6 +60,12 @@ "description": "Post-review utilities for PR workflows — vet findings, triage CodeRabbit reviews, filter noise", "version": "1.1.0" }, + { + "name": "security-review", + "source": "./plugins/security-review", + "description": "Security guards for credential protection and destructive command prevention", + "version": "1.0.0" + }, { "name": "two-node", "source": "./plugins/two-node", From 051be4b5b6dc271bbeb251cb3e2db74d774e2a05 Mon Sep 17 00:00:00 2001 From: Jeff Roche Date: Wed, 22 Apr 2026 14:56:36 -0400 Subject: [PATCH 4/5] fix(plugins): address review feedback for security-review hooks block-destructive.sh: - Remove ./ exclusion that allowed bypassing rm -rf / detection - Replace \s with POSIX [[:space:]] for portability check-file-secrets.sh: - Skip env var refs ($VAR, ${VAR}) and templates ({{ }}) in credential checks - Tighten DB connection string regex to skip variable passwords Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/block-destructive.sh | 27 +++++++++---------- .../scripts/check-file-secrets.sh | 14 +++++----- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/plugins/security-review/scripts/block-destructive.sh b/plugins/security-review/scripts/block-destructive.sh index 19baa1d3..3e58069d 100755 --- a/plugins/security-review/scripts/block-destructive.sh +++ b/plugins/security-review/scripts/block-destructive.sh @@ -20,52 +20,49 @@ if [[ -z "$COMMAND" ]]; then exit 0 fi -# rm -rf targeting root, home — handles sudo, --, and path as non-final token -if echo "$COMMAND" | grep -qE '(^|\s)(sudo\s+)?rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*(\s+--)?(\s+|$)' && \ - echo "$COMMAND" | grep -qE '(\s|/)(/|~|\$HOME)(\s|$)'; then - # Exclude safe relative paths like ./build, ../tmp - if ! echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+\./'; then - echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 - exit 1 - fi +# rm -rf targeting root, home — handles sudo, --, and dangerous path anywhere in args +if echo "$COMMAND" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+)?rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*([[:space:]]+--)?([[:space:]]+|$)' && \ + echo "$COMMAND" | grep -qE '([[:space:]]|/)(/|~|\$HOME)([[:space:]]|$)'; then + echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 + exit 1 fi # git push --force / -f (but not --force-with-lease) -if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force($|\s)' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]]+.*--force($|[[:space:]])' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then echo "Blocked: \`git push --force\` can overwrite remote history. Use \`git push --force-with-lease\` instead." >&2 exit 1 fi -if echo "$COMMAND" | grep -qE 'git\s+push\s+.*\s-[a-zA-Z]*f' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]]+.*[[:space:]]-[a-zA-Z]*f' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then echo "Blocked: \`git push -f\` can overwrite remote history. Use \`git push --force-with-lease\` instead." >&2 exit 1 fi # git reset --hard -if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+--hard'; then echo "Blocked: \`git reset --hard\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 exit 1 fi # git clean -fd / -fdx -if echo "$COMMAND" | grep -qE 'git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*d'; then echo "Blocked: \`git clean -fd\` permanently deletes untracked files. Use \`git stash --include-untracked\` instead." >&2 exit 1 fi # git checkout -- . (discard all changes) -if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--\s+\.'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then echo "Blocked: \`git checkout -- .\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 exit 1 fi # git restore . (discard all changes) -if echo "$COMMAND" | grep -qE 'git\s+restore\s+\.'; then +if echo "$COMMAND" | grep -qE 'git[[:space:]]+restore[[:space:]]+\.'; then echo "Blocked: \`git restore .\` discards all uncommitted changes. Use \`git stash\` to preserve work." >&2 exit 1 fi # chmod -R 777 -if echo "$COMMAND" | grep -qE 'chmod\s+(-R\s+777|777\s+-R)'; then +if echo "$COMMAND" | grep -qE 'chmod[[:space:]]+(-R[[:space:]]+777|777[[:space:]]+-R)'; then echo "Blocked: \`chmod -R 777\` sets insecure permissions. Use more restrictive permissions (e.g., 755 for directories, 644 for files)." >&2 exit 1 fi diff --git a/plugins/security-review/scripts/check-file-secrets.sh b/plugins/security-review/scripts/check-file-secrets.sh index d7e14d19..3b3de1d5 100755 --- a/plugins/security-review/scripts/check-file-secrets.sh +++ b/plugins/security-review/scripts/check-file-secrets.sh @@ -41,28 +41,28 @@ if echo "$CONTENT" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KE FOUND="${FOUND}\n- Private key detected" fi -# Password assignments (quoted and unquoted, with optional export) -if echo "$CONTENT" | grep -qiE '(export\s+)?password\s*[:=]\s*\S'; then +# Password assignments — skip env var refs ($VAR, ${VAR}) and templates ({{ }}) +if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?password[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then FOUND="${FOUND}\n- Hardcoded password detected" fi # Token assignments -if echo "$CONTENT" | grep -qiE '(export\s+)?token\s*[:=]\s*\S'; then +if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?token[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then FOUND="${FOUND}\n- Hardcoded token detected" fi # Secret assignments -if echo "$CONTENT" | grep -qiE '(export\s+)?secret\s*[:=]\s*\S'; then +if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?secret[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then FOUND="${FOUND}\n- Hardcoded secret detected" fi # API key assignments -if echo "$CONTENT" | grep -qiE '(export\s+)?api_key\s*[:=]\s*\S'; then +if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?api_key[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then FOUND="${FOUND}\n- Hardcoded API key detected" fi -# Database connection strings with embedded passwords -if echo "$CONTENT" | grep -qiE '(mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^@]+@'; then +# Database connection strings with embedded passwords (skip variable refs in password position) +if echo "$CONTENT" | grep -qiE '(mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^${[:space:]@][^@]*@'; then FOUND="${FOUND}\n- Database connection string with embedded password detected" fi From 56af75a9f68ddd8aa4a57fce3440517f7929e510 Mon Sep 17 00:00:00 2001 From: Jeff Roche Date: Wed, 22 Apr 2026 15:06:03 -0400 Subject: [PATCH 5/5] fix(plugins): improve destructive command detection and credential regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block-destructive.sh: - Detect -r and -f flags independently (catches rm -r -f /, rm -fr, etc.) - Catch ~/ and $HOME/ as dangerous targets (bare home deletion) - Detect git clean -df, -f -d, -xdf (any flag order/combination) check-file-secrets.sh: - Add jq // empty to prevent null → "null" string on missing fields - Exclude quotes from credential char class (skips password="${VAR}") - Extract shared CRED_REJECT variable for consistent exclusion pattern - Also tighten DB connection string regex Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/block-destructive.sh | 38 ++++++++++++++----- .../scripts/check-file-secrets.sh | 18 +++++---- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/plugins/security-review/scripts/block-destructive.sh b/plugins/security-review/scripts/block-destructive.sh index 3e58069d..2fa4b379 100755 --- a/plugins/security-review/scripts/block-destructive.sh +++ b/plugins/security-review/scripts/block-destructive.sh @@ -20,11 +20,25 @@ if [[ -z "$COMMAND" ]]; then exit 0 fi -# rm -rf targeting root, home — handles sudo, --, and dangerous path anywhere in args -if echo "$COMMAND" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+)?rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*([[:space:]]+--)?([[:space:]]+|$)' && \ - echo "$COMMAND" | grep -qE '([[:space:]]|/)(/|~|\$HOME)([[:space:]]|$)'; then - echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 - exit 1 +# rm with recursive+force targeting root or home +# Detect rm, then check for both -r and -f (in any order, combined or separate), +# then check for dangerous target paths +if echo "$COMMAND" | grep -qE '(^|[[:space:]])(sudo[[:space:]]+)?rm[[:space:]]'; then + HAS_R=false + HAS_F=false + echo "$COMMAND" | grep -qE '[[:space:]]+-[a-zA-Z]*r' && HAS_R=true + echo "$COMMAND" | grep -qE '[[:space:]]+-[a-zA-Z]*f' && HAS_F=true + echo "$COMMAND" | grep -qE '[[:space:]]+--recursive' && HAS_R=true + echo "$COMMAND" | grep -qE '[[:space:]]+--force' && HAS_F=true + + if [[ "$HAS_R" = true && "$HAS_F" = true ]]; then + # Check for dangerous targets: standalone /, ~, ~/, $HOME, $HOME/ + if echo "$COMMAND" | grep -qE '([[:space:]]|/)(/|~|\$HOME)([[:space:]]|$)' || \ + echo "$COMMAND" | grep -qE '[[:space:]](~/|\$HOME/)([[:space:]]|$)'; then + echo "Blocked: \`rm -rf /\` (or ~ / \$HOME) would delete critical filesystem content." >&2 + exit 1 + fi + fi fi # git push --force / -f (but not --force-with-lease) @@ -43,10 +57,16 @@ if echo "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+--hard'; then exit 1 fi -# git clean -fd / -fdx -if echo "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*d'; then - echo "Blocked: \`git clean -fd\` permanently deletes untracked files. Use \`git stash --include-untracked\` instead." >&2 - exit 1 +# git clean with both -f and -d flags (any order, combined or separate) +if echo "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]]'; then + CLEAN_HAS_F=false + CLEAN_HAS_D=false + echo "$COMMAND" | grep -qE '[[:space:]]+-[a-zA-Z]*f' && CLEAN_HAS_F=true + echo "$COMMAND" | grep -qE '[[:space:]]+-[a-zA-Z]*d' && CLEAN_HAS_D=true + if [[ "$CLEAN_HAS_F" = true && "$CLEAN_HAS_D" = true ]]; then + echo "Blocked: \`git clean -fd\` permanently deletes untracked files. Use \`git stash --include-untracked\` instead." >&2 + exit 1 + fi fi # git checkout -- . (discard all changes) diff --git a/plugins/security-review/scripts/check-file-secrets.sh b/plugins/security-review/scripts/check-file-secrets.sh index 3b3de1d5..0401155e 100755 --- a/plugins/security-review/scripts/check-file-secrets.sh +++ b/plugins/security-review/scripts/check-file-secrets.sh @@ -6,7 +6,7 @@ parse_json_field() { local input="$1" local field="$2" if command -v jq &>/dev/null; then - echo "$input" | jq -r "$field" + echo "$input" | jq -r "($field) // empty" elif command -v python3 &>/dev/null; then echo "$input" | python3 -c " import sys, json, functools @@ -31,6 +31,10 @@ fi FOUND="" +# Credential value character class: reject $, {, quotes, and whitespace as first char +# This skips env var refs ($VAR, ${VAR}), templates ({{ }}), and quoted vars ("$VAR") +CRED_REJECT='[^${"'"'"'{}[:space:]]' + # AWS access key if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then FOUND="${FOUND}\n- AWS access key (AKIA...) detected" @@ -41,28 +45,28 @@ if echo "$CONTENT" | grep -qE 'BEGIN (RSA )?PRIVATE KEY|BEGIN OPENSSH PRIVATE KE FOUND="${FOUND}\n- Private key detected" fi -# Password assignments — skip env var refs ($VAR, ${VAR}) and templates ({{ }}) -if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?password[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then +# Password assignments +if echo "$CONTENT" | grep -qiE "(export[[:space:]]+)?password[[:space:]]*[:=][[:space:]]*${CRED_REJECT}"; then FOUND="${FOUND}\n- Hardcoded password detected" fi # Token assignments -if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?token[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then +if echo "$CONTENT" | grep -qiE "(export[[:space:]]+)?token[[:space:]]*[:=][[:space:]]*${CRED_REJECT}"; then FOUND="${FOUND}\n- Hardcoded token detected" fi # Secret assignments -if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?secret[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then +if echo "$CONTENT" | grep -qiE "(export[[:space:]]+)?secret[[:space:]]*[:=][[:space:]]*${CRED_REJECT}"; then FOUND="${FOUND}\n- Hardcoded secret detected" fi # API key assignments -if echo "$CONTENT" | grep -qiE '(export[[:space:]]+)?api_key[[:space:]]*[:=][[:space:]]*[^${[:space:]]'; then +if echo "$CONTENT" | grep -qiE "(export[[:space:]]+)?api_key[[:space:]]*[:=][[:space:]]*${CRED_REJECT}"; then FOUND="${FOUND}\n- Hardcoded API key detected" fi # Database connection strings with embedded passwords (skip variable refs in password position) -if echo "$CONTENT" | grep -qiE '(mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^${[:space:]@][^@]*@'; then +if echo "$CONTENT" | grep -qiE '(mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^${"'"'"'{}[:space:]@][^@]*@'; then FOUND="${FOUND}\n- Database connection string with embedded password detected" fi