From ac433c033d91cc906dd705d9ce569d62eef267a0 Mon Sep 17 00:00:00 2001 From: Tim WU <8848995+timwukp@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:57:17 +0800 Subject: [PATCH] feat: add fail-closed-security-hooks plugin Adds a second plugin to the marketplace: runtime guardrails on what the agent does, for startups touching regulated data (fintech, health). Five hooks via a fail-closed telemetry shim: - pii-guard PII/secrets + national IDs (US/UK/JP/KR/SG/EU/AU) blocked before reaching the model - git-guard remote allowlist, force-push/protected-branch, destructive-op blocking - audit-logger tamper-evident HMAC-chained JSONL audit log, optional CloudWatch/SIEM dual-write - token-budget-guard per-session runaway-loop circuit breaker - hook-wrapper turns silent hook crashes/timeouts into exit-2 denials (fail-closed) New plugins/fail-closed-security-hooks/ (plugin.json, hooks/, scripts/, README, LICENSE); registered in marketplace.json (bumped 0.3.0 -> 0.4.0) and listed in README. aws-dev-toolkit is untouched. Implements #7. Upstream source + 76-assertion suite: https://github.com/timwukp/claude-code-on-aws-bedrock-best-practices (Apache-2.0; this contributed copy MIT-0 to match the repo). --- .claude-plugin/marketplace.json | 41 +++- README.md | 16 ++ .../.claude-plugin/plugin.json | 12 + plugins/fail-closed-security-hooks/LICENSE | 16 ++ plugins/fail-closed-security-hooks/README.md | 59 +++++ .../hooks/audit-logger.sh | 168 +++++++++++++ .../hooks/git-guard.sh | 230 ++++++++++++++++++ .../hooks/hook-wrapper.sh | 115 +++++++++ .../hooks/hooks.json | 65 +++++ .../hooks/pii-guard.sh | 214 ++++++++++++++++ .../hooks/token-budget-guard.sh | 86 +++++++ .../scripts/chain-verify.sh | 44 ++++ 12 files changed, 1062 insertions(+), 4 deletions(-) create mode 100644 plugins/fail-closed-security-hooks/.claude-plugin/plugin.json create mode 100644 plugins/fail-closed-security-hooks/LICENSE create mode 100644 plugins/fail-closed-security-hooks/README.md create mode 100755 plugins/fail-closed-security-hooks/hooks/audit-logger.sh create mode 100755 plugins/fail-closed-security-hooks/hooks/git-guard.sh create mode 100755 plugins/fail-closed-security-hooks/hooks/hook-wrapper.sh create mode 100644 plugins/fail-closed-security-hooks/hooks/hooks.json create mode 100755 plugins/fail-closed-security-hooks/hooks/pii-guard.sh create mode 100755 plugins/fail-closed-security-hooks/hooks/token-budget-guard.sh create mode 100755 plugins/fail-closed-security-hooks/scripts/chain-verify.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4fff4c3..3b800e1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,20 +4,53 @@ "name": "aws-samples" }, "metadata": { - "description": "A curated marketplace of Claude Code plugins for AWS development — skills, sub-agents, and MCP servers for building on AWS.", - "version": "0.3.0" + "description": "A curated marketplace of Claude Code plugins for AWS development \u2014 skills, sub-agents, and MCP servers for building on AWS.", + "version": "0.4.0" }, "plugins": [ { "name": "aws-dev-toolkit", "source": "./plugins/aws-dev-toolkit", - "description": "AWS development toolkit — 34 skills, 11 agents, and 3 MCP servers for building, migrating, and performing architecture reviews on AWS.", + "description": "AWS development toolkit \u2014 34 skills, 11 agents, and 3 MCP servers for building, migrating, and performing architecture reviews on AWS.", "version": "0.3.0", "author": { "name": "aws-samples" }, - "keywords": ["aws", "iac", "cdk", "cloudformation", "terraform", "serverless", "well-architected", "migration", "gcp", "azure", "bedrock"], + "keywords": [ + "aws", + "iac", + "cdk", + "cloudformation", + "terraform", + "serverless", + "well-architected", + "migration", + "gcp", + "azure", + "bedrock" + ], "category": "cloud-development" + }, + { + "name": "fail-closed-security-hooks", + "source": "./plugins/fail-closed-security-hooks", + "description": "Fail-closed runtime guardrails \u2014 PII/secrets prompt guard (7 jurisdictions), git policy guard, tamper-evident HMAC-chained audit log (CloudWatch/SIEM dual-write), and a token-budget circuit breaker. For startups touching regulated data.", + "version": "1.0.0", + "author": { + "name": "Tim Wu" + }, + "keywords": [ + "security", + "guardrails", + "compliance", + "pii", + "audit", + "fintech", + "healthcare", + "bedrock", + "devsecops" + ], + "category": "security" } ] } diff --git a/README.md b/README.md index 45b68bd..e0505e4 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,22 @@ Add to `~/.kiro/settings/mcp.json` (user-level) or `.kiro/settings/mcp.json` (wo | `awsknowledge` | http | `https://knowledge-mcp.global.api.aws` | AWS documentation search, service recommendations, and regional availability | | `awspricing` | stdio | `awslabs.aws-pricing-mcp-server` | AWS service pricing data, cost reports, and IaC cost analysis | +### fail-closed-security-hooks + +Runtime guardrails on what the agent *does* — the layer a startup needs the moment it touches regulated data (fintech, health). Five hooks via a fail-closed telemetry shim: a PII/secrets prompt guard (credentials + national IDs across 7 jurisdictions), a git policy guard, a tamper-evident HMAC-chained audit log (optional CloudWatch/SIEM dual-write), and a per-session token-budget circuit breaker. A broken control fails *safe*, not open. + +```bash +/plugin install fail-closed-security-hooks@aws-samples +``` + +Or load locally during development: + +```bash +claude --plugin-dir ./plugins/fail-closed-security-hooks +``` + +See [plugins/fail-closed-security-hooks/README.md](plugins/fail-closed-security-hooks/README.md) for configuration and the audit-chain verifier. + ## Prerequisites - [Claude Code](https://code.claude.com) v1.0.33+ diff --git a/plugins/fail-closed-security-hooks/.claude-plugin/plugin.json b/plugins/fail-closed-security-hooks/.claude-plugin/plugin.json new file mode 100644 index 0000000..73d0415 --- /dev/null +++ b/plugins/fail-closed-security-hooks/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "fail-closed-security-hooks", + "version": "1.0.0", + "description": "Fail-closed guardrails for what the agent does: PII/secrets prompt guard (US/UK/JP/KR/SG/EU/AU national IDs + credentials), git policy guard, tamper-evident HMAC-chained audit log with optional CloudWatch/SIEM dual-write, and a per-session token-budget circuit breaker — all wrapped by a telemetry shim that turns silent hook failures into explicit denials. For startups touching regulated data (fintech, health).", + "author": { + "name": "Tim Wu" + }, + "homepage": "https://github.com/timwukp/claude-code-on-aws-bedrock-best-practices", + "repository": "https://github.com/timwukp/claude-code-on-aws-bedrock-best-practices", + "keywords": ["security", "guardrails", "compliance", "pii", "audit", "fintech", "healthcare", "aws-bedrock", "devsecops"], + "license": "MIT-0" +} diff --git a/plugins/fail-closed-security-hooks/LICENSE b/plugins/fail-closed-security-hooks/LICENSE new file mode 100644 index 0000000..56a66b6 --- /dev/null +++ b/plugins/fail-closed-security-hooks/LICENSE @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/plugins/fail-closed-security-hooks/README.md b/plugins/fail-closed-security-hooks/README.md new file mode 100644 index 0000000..3da2f2c --- /dev/null +++ b/plugins/fail-closed-security-hooks/README.md @@ -0,0 +1,59 @@ +# fail-closed-security-hooks + +Guardrails on **what the agent does**, not just what it can build. Five hooks wired through a fail-closed telemetry shim — the layer a startup needs the moment it touches regulated data (fintech, health). + +```bash +/plugin install fail-closed-security-hooks@aws-samples +``` + +Or load locally during development: + +```bash +claude --plugin-dir ./plugins/fail-closed-security-hooks +``` + +## What it does + +| Hook | Events | What it enforces | +|---|---|---| +| **pii-guard** | UserPromptSubmit, PreToolUse | Scans prompts and tool inputs for secrets (AWS keys, private keys, JWTs, DB connection strings, credit cards) and **national identifiers across the US, UK, Japan, South Korea, Singapore, EU (IBAN), and Australia**, then **blocks before the content reaches the model**. Every pattern is individually disable-able. | +| **git-guard** | PreToolUse (Bash) | Remote-URL allowlist, force-push prevention, protected-branch enforcement, and destructive-op blocking (`reset --hard`, `clean -f`, forced checkout). | +| **audit-logger** | UserPromptSubmit, PostToolUse | **Tamper-evident HMAC-SHA256 hash-chained** JSONL audit log. Any post-hoc edit, deletion, reorder, or insertion breaks the chain forward and is caught by `scripts/chain-verify.sh`. Optional dual-write to CloudWatch + SIEM. | +| **token-budget-guard** | PreToolUse, PostToolUse | Per-session circuit breaker. Blocks further tool calls once a token or call budget is exceeded — a backstop against runaway agent loops. | +| **hook-wrapper** | wraps all of the above | Telemetry shim. Emits per-hook timing/exit JSON and **converts silent hook crashes/timeouts into explicit `exit 2` denials** (fail-closed), so a broken control fails *safe* instead of failing *open*. | + +## Quick start + +```bash +# 1. Provide an HMAC key for the tamper-evident audit chain +export AUDIT_HMAC_KEY="$(openssl rand -hex 32)" + +# 2. (optional) git-guard policy +export GIT_GUARD_ALLOWED_DOMAINS="github.com,gitlab.com" +export GIT_GUARD_PROTECTED_BRANCHES="main,master,release/*" + +# 3. Verify the audit chain any time +bash scripts/chain-verify.sh ~/.claude/claude-code-security/audit.jsonl # exit 0 = intact, 1 = tampered +``` + +Default state/log paths are user-writable (`~/.claude/claude-code-security/`) for evaluation. For **un-removable, fleet-wide enforcement**, deploy the same hooks via `managed-settings.json` with root-owned paths — the hook logic is identical; only the trust boundary and defaults change. See the upstream repo for Terraform IaC, managed-settings, a CloudWatch dashboard, and a STRIDE threat model. + +## Configuration + +| Env var | Hook | Default | +|---|---|---| +| `AUDIT_HMAC_KEY` | audit-logger | dev key auto-generated under state dir | +| `CLAUDE_AUDIT_CLOUDWATCH_GROUP` | audit-logger | (off) | +| `CLAUDE_AUDIT_SIEM_REQUIRED` | audit-logger | (off) | +| `GIT_GUARD_ALLOWED_DOMAINS` | git-guard | `github.com,gitlab.com,bitbucket.org` | +| `GIT_GUARD_PROTECTED_BRANCHES` | git-guard | `main,master,release/*,production` | +| `CLAUDE_TOKEN_BUDGET` / `CLAUDE_CALL_BUDGET` | token-budget-guard | `1000000` / `500` | +| `CLAUDE_HOOK_TIMEOUT_MS` | hook-wrapper | `5000` | + +## Testing + +Upstream ships a 76-assertion test suite (`claude plugin validate --strict` clean): https://github.com/timwukp/claude-code-on-aws-bedrock-best-practices + +## License + +MIT-0 (contributed copy). Upstream source is Apache-2.0. diff --git a/plugins/fail-closed-security-hooks/hooks/audit-logger.sh b/plugins/fail-closed-security-hooks/hooks/audit-logger.sh new file mode 100755 index 0000000..4ca1063 --- /dev/null +++ b/plugins/fail-closed-security-hooks/hooks/audit-logger.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# ============================================================================= +# Audit Logger — Tamper-evident, fail-closed audit log for Claude Code +# ============================================================================= +# Hook version: 2.0.0 +# Last updated: 2026-05-29 +# Compatible with: claude-code 2.1.150+ +# Dependencies: bash 4+, jq, openssl (HMAC), aws CLI (optional, for CloudWatch) +# Maintainer: +# Change log: +# 2.0.0 (2026-05-29) — HMAC chain, fail-closed, mandatory SIEM, CloudWatch dual-write, +# prev_hash stored under /var/lib/claude-code/audit-state +# 1.0.0 (2026-05-28) — initial release +# ============================================================================= +# Each event line is a JSON object that includes: +# - prev_hash: HMAC-SHA256 of the previous event line (or "GENESIS") +# - hmac: HMAC-SHA256(key, prev_hash || event_body_canonical) +# Verifying the chain (chain-verify.sh) detects ANY post-hoc edit, deletion, +# reorder, or insertion (the smallest tamper breaks the hash chain forward). +# +# Key sources (in order): +# 1. AUDIT_HMAC_KEY env (must be set in managed-settings, NOT user-readable) +# 2. /etc/claude-code/audit-key (root:audit 0640) +# 3. AWS Secrets Manager (audit-hmac-key) — fetched if neither above present +# +# Fail-closed: if we cannot append to the local log AND cannot ship to +# CloudWatch, exit 2 to block the tool. Audit-evasion is treated as a +# deny-worthy condition by default. Set CLAUDE_AUDIT_FAIL_OPEN=1 to override +# (NOT recommended in production). +# +# Mandatory SIEM check (P0-2): +# Set CLAUDE_AUDIT_SIEM_REQUIRED=1 to refuse startup unless either: +# - CLAUDE_AUDIT_CLOUDWATCH_GROUP is set AND aws CLI works, OR +# - CLAUDE_AUDIT_ALERT_CMD is set +# ============================================================================= + +set -u +input=$(cat) + +# Defaults are user-writable for plugin / opt-in mode. For enterprise +# fail-closed enforcement, override these in managed-settings to root-owned +# paths (/var/log/claude-code, /var/lib/claude-code) — see the full repo. +LOG_FILE="${CLAUDE_AUDIT_LOG:-$HOME/.claude/claude-code-security/audit.jsonl}" +STATE_DIR="${CLAUDE_AUDIT_STATE:-$HOME/.claude/claude-code-security/audit-state}" +CW_GROUP="${CLAUDE_AUDIT_CLOUDWATCH_GROUP:-}" +CW_STREAM="${CLAUDE_AUDIT_CLOUDWATCH_STREAM:-$(hostname 2>/dev/null || echo unknown)}" +ALERT_CMD="${CLAUDE_AUDIT_ALERT_CMD:-}" +SIEM_REQ="${CLAUDE_AUDIT_SIEM_REQUIRED:-0}" +FAIL_OPEN="${CLAUDE_AUDIT_FAIL_OPEN:-0}" + +mkdir -p "$(dirname "$LOG_FILE")" "$STATE_DIR" 2>/dev/null || true + +# --- Mandatory SIEM check ----------------------------------------------------- +if [[ "$SIEM_REQ" == "1" ]]; then + ok=0 + [[ -n "$CW_GROUP" ]] && command -v aws >/dev/null 2>&1 && ok=1 + [[ -n "$ALERT_CMD" ]] && ok=1 + if [[ "$ok" != "1" ]]; then + echo "audit-logger: SIEM forwarding required but no working forwarder configured" >&2 + [[ "$FAIL_OPEN" != "1" ]] && exit 2 + fi +fi + +# --- Resolve HMAC key --------------------------------------------------------- +get_key() { + if [[ -n "${AUDIT_HMAC_KEY:-}" ]]; then printf '%s' "$AUDIT_HMAC_KEY"; return; fi + if [[ -r /etc/claude-code/audit-key ]]; then cat /etc/claude-code/audit-key; return; fi + if command -v aws >/dev/null 2>&1; then + aws secretsmanager get-secret-value --secret-id audit-hmac-key \ + --query SecretString --output text 2>/dev/null && return + fi + # Final fallback: derive from hostname+install marker (NOT cryptographically + # secure but lets unit tests and dev environments run). Marker can be created + # at install time so the same machine produces a stable key. + if [[ -r "$STATE_DIR/key.dev" ]]; then cat "$STATE_DIR/key.dev"; return; fi + return 1 +} + +KEY=$(get_key 2>/dev/null || true) +if [[ -z "$KEY" ]]; then + # Generate a dev key on first run so the chain is stable per machine + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 > "$STATE_DIR/key.dev" 2>/dev/null || true + chmod 0600 "$STATE_DIR/key.dev" 2>/dev/null || true + KEY=$(cat "$STATE_DIR/key.dev" 2>/dev/null || true) + fi +fi +if [[ -z "$KEY" ]]; then + echo "audit-logger: no HMAC key available" >&2 + [[ "$FAIL_OPEN" != "1" ]] && exit 2 +fi + +# --- Parse fields ------------------------------------------------------------- +event=""; session_id=""; cwd=""; tool_name=""; command_str="" +if command -v jq >/dev/null 2>&1; then + event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty') + session_id=$(printf '%s' "$input" | jq -r '.session_id // empty') + cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') + tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty') + case "$event" in + PostToolUse) command_str=$(printf '%s' "$input" | jq -r '.tool_input.command // .tool_input.file_path // empty') ;; + UserPromptSubmit) command_str=$(printf '%s' "$input" | jq -r '.prompt // empty' | head -c 500) ;; + esac +fi + +ts=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ") +user=$(whoami 2>/dev/null || echo unknown) +host=$(hostname 2>/dev/null || echo unknown) + +# --- Read previous hash ------------------------------------------------------- +PREV_FILE="$STATE_DIR/last-hmac" +[[ ! -e "$PREV_FILE" ]] && echo "GENESIS" > "$PREV_FILE" 2>/dev/null +prev_hash=$(cat "$PREV_FILE" 2>/dev/null || echo "GENESIS") + +# --- Build canonical body, compute HMAC --------------------------------------- +body=$(jq -nc \ + --arg ts "$ts" --arg user "$user" --arg host "$host" \ + --arg event "$event" --arg session "$session_id" --arg cwd "$cwd" \ + --arg tool "$tool_name" --arg cmd "$command_str" --arg prev "$prev_hash" \ + '{ts:$ts,user:$user,host:$host,event:$event,session_id:$session,cwd:$cwd,tool:$tool,action:$cmd,prev_hash:$prev}') + +hmac=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$KEY" 2>/dev/null \ + | awk '{print $NF}') +if [[ -z "$hmac" ]]; then + echo "audit-logger: HMAC computation failed" >&2 + [[ "$FAIL_OPEN" != "1" ]] && exit 2 +fi + +entry=$(printf '%s' "$body" | jq -c --arg h "$hmac" '. + {hmac:$h}') + +# --- Write locally (best-effort) --------------------------------------------- +local_ok=0 +if echo "$entry" >> "$LOG_FILE" 2>/dev/null; then + echo "$hmac" > "$PREV_FILE" 2>/dev/null + local_ok=1 +fi +# Try fallback path if root path failed +if [[ "$local_ok" != "1" ]]; then + fb="$HOME/.claude/audit-fallback.jsonl" + mkdir -p "$(dirname "$fb")" 2>/dev/null || true + if echo "$entry" >> "$fb" 2>/dev/null; then + echo "$hmac" > "$PREV_FILE" 2>/dev/null + local_ok=1 + fi +fi + +# --- Ship to CloudWatch (best-effort) ---------------------------------------- +cw_ok=0 +if [[ -n "$CW_GROUP" ]] && command -v aws >/dev/null 2>&1; then + ts_ms=$(python3 -c 'import time;print(int(time.time()*1000))' 2>/dev/null || echo 0) + aws logs put-log-events \ + --log-group-name "$CW_GROUP" --log-stream-name "$CW_STREAM" \ + --log-events "timestamp=$ts_ms,message=$(printf '%s' "$entry" | sed 's/"/\\"/g')" \ + >/dev/null 2>&1 && cw_ok=1 +fi + +# --- Optional alert webhook -------------------------------------------------- +if [[ -n "$ALERT_CMD" ]]; then + printf '%s\n' "$entry" | bash -c "$ALERT_CMD" >/dev/null 2>&1 || true +fi + +# --- Fail-closed if both local AND remote failed ------------------------------ +if [[ "$local_ok" != "1" && "$cw_ok" != "1" ]]; then + echo "audit-logger: cannot persist event locally or remotely; blocking tool" >&2 + [[ "$FAIL_OPEN" != "1" ]] && exit 2 +fi + +exit 0 diff --git a/plugins/fail-closed-security-hooks/hooks/git-guard.sh b/plugins/fail-closed-security-hooks/hooks/git-guard.sh new file mode 100755 index 0000000..694388c --- /dev/null +++ b/plugins/fail-closed-security-hooks/hooks/git-guard.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# ============================================================================= +# Git Guard — Enterprise Git Security Hook for Claude Code +# ============================================================================= +# Hook version: 1.0.0 +# Last updated: 2026-05-28 +# Compatible with: claude-code 2.1.150+ +# Dependencies: bash 4+, jq (preferred), git, sed, grep +# Maintainer: +# Change log: +# 1.0.0 (2026-05-28) — initial release: allowlist, branch protection, +# force-push, destructive op prevention +# ============================================================================= +# Comprehensive git protection that allows enterprise workflows while blocking +# data exfiltration, destructive operations, and policy violations. +# +# Hook event: PreToolUse (matcher: Bash) +# Inspects git commands and enforces: +# 1. Remote URL allowlist (only push to enterprise domains) +# 2. Force-push prevention (--force / --force-with-lease) +# 3. Branch protection (no direct push to main/master/release/*) +# 4. Remote modification control (add/set-url only to allowed domains) +# 5. Destructive operation prevention (reset --hard, clean -fd, checkout --force) +# +# Note: Credential leak detection in file content is handled by pii-guard.sh +# (which scans Write/Edit tool inputs before files are written to disk). +# +# Configuration via environment variables (set in settings.json env block): +# GIT_GUARD_ALLOWED_DOMAINS — comma-separated allowed push domains +# (default: "github.com,gitlab.com,bitbucket.org") +# GIT_GUARD_PROTECTED_BRANCHES — comma-separated branches requiring PR +# (default: "main,master,release/*,production") +# GIT_GUARD_ALLOW_FORCE_PUSH — "true" to allow force push (default: "false") +# GIT_GUARD_MAX_FILE_SIZE_KB — max file size in KB (default: "10240" = 10MB) +# GIT_GUARD_CI_MODE — "true" to relax branch protection for CI/CD +# GIT_GUARD_DISABLED — "true" to bypass all checks (emergency) +# +# Exit codes: +# 0 = allow +# 2 = block (stderr shown to Claude) +# ============================================================================= + +set -u +input=$(cat) + +# --- Parse input --- +if command -v jq >/dev/null 2>&1; then + tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty') + command_str=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') +else + tool_name=$(printf '%s' "$input" | grep -oE '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]+)"$/\1/') + command_str=$(printf '%s' "$input" | grep -oE '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]+)"$/\1/') +fi + +# Only inspect Bash tool +[[ "$tool_name" != "Bash" ]] && exit 0 + +# Normalize whitespace +norm=$(printf '%s' "$command_str" | sed -E 's/[[:space:]]+/ /g' | sed -E 's/^ //') + +# Check if this is a git command at all +if ! echo "$norm" | grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*git[[:space:]]'; then + exit 0 +fi + +# --- Configuration --- +ALLOWED_DOMAINS="${GIT_GUARD_ALLOWED_DOMAINS:-github.com,gitlab.com,bitbucket.org}" +PROTECTED_BRANCHES="${GIT_GUARD_PROTECTED_BRANCHES:-main,master,release/*,production}" +ALLOW_FORCE="${GIT_GUARD_ALLOW_FORCE_PUSH:-false}" +MAX_FILE_KB="${GIT_GUARD_MAX_FILE_SIZE_KB:-10240}" +CI_MODE="${GIT_GUARD_CI_MODE:-false}" +DISABLED="${GIT_GUARD_DISABLED:-false}" + +[[ "$DISABLED" == "true" ]] && exit 0 + +# --- Helper: check if URL domain is in allowlist --- +url_allowed() { + local url="$1" + local domain="" + + # Extract domain from various URL formats + if echo "$url" | grep -qE '^https?://'; then + domain=$(echo "$url" | sed -E 's|https?://([^/:@]+).*|\1|') + elif echo "$url" | grep -qE '^git@'; then + domain=$(echo "$url" | sed -E 's|git@([^:]+):.*|\1|') + elif echo "$url" | grep -qE '^ssh://'; then + domain=$(echo "$url" | sed -E 's|ssh://([^@]+@)?([^/:]+).*|\2|') + else + # Unknown format — block by default + return 1 + fi + + # Check against allowlist + IFS=',' read -ra domains <<< "$ALLOWED_DOMAINS" + for allowed in "${domains[@]}"; do + allowed=$(echo "$allowed" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + # Support wildcard: *.example.com matches sub.example.com + if [[ "$allowed" == \** ]]; then + local suffix="${allowed#\*}" + if [[ "$domain" == *"$suffix" ]]; then + return 0 + fi + elif [[ "$domain" == "$allowed" ]]; then + return 0 + fi + done + return 1 +} + +# --- Helper: check if branch matches protected pattern --- +branch_protected() { + local branch="$1" + IFS=',' read -ra branches <<< "$PROTECTED_BRANCHES" + for pattern in "${branches[@]}"; do + pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + # Support glob: release/* matches release/v1.0 + if [[ "$branch" == $pattern ]]; then + return 0 + fi + done + return 1 +} + +# --- Helper: get remote URL from name --- +get_remote_url() { + local remote_name="$1" + local cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) + if [[ -n "$cwd" ]] && [[ -d "$cwd/.git" || -f "$cwd/.git" ]]; then + git -C "$cwd" remote get-url "$remote_name" 2>/dev/null + else + git remote get-url "$remote_name" 2>/dev/null + fi +} + +# --- Deny helper --- +deny() { + printf 'GIT GUARD: %s\nCommand: %s\n' "$1" "$command_str" >&2 + exit 2 +} + +# ============================================================================= +# CHECK 1: git remote add / set-url — URL must be in allowlist +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?remote[[:space:]]+(add|set-url)[[:space:]]'; then + # Extract URL (last argument that looks like a URL) + url=$(echo "$norm" | grep -oE '(https?://[^ ]+|git@[^ ]+|ssh://[^ ]+)' | tail -1) + if [[ -n "$url" ]]; then + if ! url_allowed "$url"; then + deny "Remote URL not in enterprise allowlist. Allowed domains: $ALLOWED_DOMAINS. Blocked URL: $url" + fi + fi + # If no URL found in command, allow (might be a rename or other subcommand) +fi + +# ============================================================================= +# CHECK 2: git remote rename / rm — block (prevents circumventing allowlist) +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?remote[[:space:]]+(rename|rm|remove)[[:space:]]'; then + deny "Modifying or removing git remotes is restricted. Contact your team lead to change remote configuration." +fi + +# ============================================================================= +# CHECK 3: git push — multiple checks +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?push[[:space:]]'; then + + # 3a. Force push check + if [[ "$ALLOW_FORCE" != "true" ]]; then + if echo "$norm" | grep -qE '\-\-force|\-f[[:space:]]|\-\-force-with-lease'; then + deny "Force push is forbidden by enterprise policy. Use a regular push or create a new branch." + fi + fi + + # 3b. Extract remote name and branch from push command + # Pattern: git push [options] [] + push_args=$(echo "$norm" | sed -E 's/.*git[[:space:]]([^;&|]*[[:space:]])?push[[:space:]]*//' | sed -E 's/[;&|].*//') + # Remove flags + push_args=$(echo "$push_args" | sed -E 's/--[a-z-]+[[:space:]]*//g; s/-[a-z][[:space:]]*//g' | sed 's/^[[:space:]]*//') + + remote_name=$(echo "$push_args" | awk '{print $1}') + branch_name=$(echo "$push_args" | awk '{print $2}') + + # 3c. Check remote URL is in allowlist + if [[ -n "$remote_name" ]]; then + remote_url=$(get_remote_url "$remote_name") + if [[ -n "$remote_url" ]]; then + if ! url_allowed "$remote_url"; then + deny "Push target '$remote_name' ($remote_url) is not in the enterprise allowlist. Allowed domains: $ALLOWED_DOMAINS" + fi + fi + # If we can't resolve the URL (no git repo context), allow — the push will fail anyway + fi + + # 3d. Branch protection (skip in CI mode) + if [[ "$CI_MODE" != "true" ]] && [[ -n "$branch_name" ]]; then + # Handle refspec format (local:remote) + target_branch="${branch_name#*:}" + [[ -z "$target_branch" ]] && target_branch="$branch_name" + + if branch_protected "$target_branch"; then + deny "Direct push to protected branch '$target_branch' is forbidden. Use a pull request instead. Protected branches: $PROTECTED_BRANCHES" + fi + fi +fi + +# ============================================================================= +# CHECK 4: git reset --hard — destructive +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?reset[[:space:]]+--hard'; then + deny "git reset --hard is forbidden. Use 'git reset --soft' or 'git revert' for non-destructive alternatives." +fi + +# ============================================================================= +# CHECK 5: git clean -f/-fd — destructive (removes untracked files) +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?clean[[:space:]]+-[a-z]*f'; then + deny "git clean -f is forbidden. It permanently removes untracked files. Use 'git clean -n' to preview first." +fi + +# ============================================================================= +# CHECK 6: git checkout/switch to detached HEAD with force — can lose work +# ============================================================================= +if echo "$norm" | grep -qE '(^|[;&|])[[:space:]]*git[[:space:]]([^;&|]*[[:space:]])?(checkout|switch)[[:space:]].*--force'; then + deny "Forced checkout/switch can discard uncommitted changes. Remove --force or commit your changes first." +fi + +# ============================================================================= +# All checks passed +# ============================================================================= +exit 0 diff --git a/plugins/fail-closed-security-hooks/hooks/hook-wrapper.sh b/plugins/fail-closed-security-hooks/hooks/hook-wrapper.sh new file mode 100755 index 0000000..da0b132 --- /dev/null +++ b/plugins/fail-closed-security-hooks/hooks/hook-wrapper.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# ============================================================================= +# Hook Telemetry Shim — wraps any hook to emit start/end JSON to a telemetry log. +# ============================================================================= +# Hook version: 1.0.0 +# Last updated: 2026-05-29 +# Purpose: Make hook execution observable so SREs can detect crashes, latency +# regressions, and silent failures (the original audit-logger had +# `exit 0` even on failure which masked real issues). +# +# Usage in managed-settings.json hooks block: +# "command": "/usr/local/etc/claude-code/hooks/hook-wrapper.sh /usr/local/etc/claude-code/hooks/pii-guard.sh" +# +# Output: appends to $CLAUDE_HOOK_TELEMETRY (default /var/log/claude-code/hooks.jsonl) +# each line: {ts, host, user, hook, event, session, duration_ms, exit_code, status} +# +# Status: +# ok — exit 0 +# blocked — exit 2 (policy block, expected) +# crashed — exit other / non-zero with stderr captured (fail-closed) +# timeout — exceeded $CLAUDE_HOOK_TIMEOUT_MS +# +# Fail-closed contract: if the wrapped hook crashes (exit != 0/2), we exit 2 to +# block the tool. This converts silent hook failures into explicit denials, so +# operators get an alert instead of a silent miss. +# ============================================================================= + +set -u +HOOK="${1:-}" +[[ -z "$HOOK" || ! -x "$HOOK" ]] && { + echo "hook-wrapper: missing or non-executable hook: $HOOK" >&2 + exit 2 +} +shift + +# Default is user-writable for plugin / opt-in mode; override in managed-settings +# to a root-owned path (/var/log/claude-code/hooks.jsonl) for enterprise SRE observability. +TELEMETRY="${CLAUDE_HOOK_TELEMETRY:-$HOME/.claude/claude-code-security/hooks.jsonl}" +TIMEOUT_MS="${CLAUDE_HOOK_TIMEOUT_MS:-5000}" +mkdir -p "$(dirname "$TELEMETRY")" 2>/dev/null || true +[[ ! -e "$TELEMETRY" ]] && touch "$TELEMETRY" 2>/dev/null + +# Buffer stdin so we can both read it (extract event/session) and pass to hook +input=$(cat) +event="" +session="" +if command -v jq >/dev/null 2>&1; then + event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty' 2>/dev/null || true) + session=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true) +fi + +now_ms() { python3 -c 'import time;print(int(time.time()*1000))'; } +start_ms=$(now_ms) + +# Invoke hook with timeout. macOS has no `timeout` by default; use Python. +tmpout=$(mktemp); tmperr=$(mktemp) +hook_name=$(basename "$HOOK") + +run_with_timeout() { + python3 - "$TIMEOUT_MS" "$HOOK" "$tmpout" "$tmperr" <<'PY' "$@" +import os, sys, subprocess +timeout_ms, hook = int(sys.argv[1]), sys.argv[2] +out_path, err_path = sys.argv[3], sys.argv[4] +extra = sys.argv[5:] +data = sys.stdin.read() +try: + p = subprocess.run([hook]+extra, input=data, timeout=timeout_ms/1000.0, + capture_output=True, text=True) + open(out_path,'w').write(p.stdout) + open(err_path,'w').write(p.stderr) + sys.exit(p.returncode) +except subprocess.TimeoutExpired: + open(err_path,'w').write(f'hook timeout after {timeout_ms}ms') + sys.exit(124) +PY +} + +printf '%s' "$input" | run_with_timeout "$@" +rc=$? +end_ms=$(now_ms) +duration_ms=$((end_ms - start_ms)) + +case "$rc" in + 0) status="ok" ;; + 2) status="blocked" ;; + 124) status="timeout" ;; + *) status="crashed" ;; +esac + +# Emit telemetry (best-effort; never block on this) +ts=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ") +host=$(hostname 2>/dev/null || echo unknown) +user=$(whoami 2>/dev/null || echo unknown) +err_excerpt=$(head -c 300 "$tmperr" 2>/dev/null || true) + +if command -v jq >/dev/null 2>&1; then + jq -nc \ + --arg ts "$ts" --arg host "$host" --arg user "$user" \ + --arg hook "$hook_name" --arg event "$event" --arg session "$session" \ + --argjson duration "$duration_ms" --argjson exit "$rc" \ + --arg status "$status" --arg err "$err_excerpt" \ + '{ts:$ts,host:$host,user:$user,hook:$hook,event:$event,session_id:$session,duration_ms:$duration,exit_code:$exit,status:$status,stderr:$err}' \ + >> "$TELEMETRY" 2>/dev/null || true +fi + +# Pass stdout/stderr through to caller +cat "$tmpout" +cat "$tmperr" >&2 +rm -f "$tmpout" "$tmperr" + +# Fail-closed: convert crash/timeout into block (exit 2) +case "$rc" in + 0|2) exit "$rc" ;; + *) exit 2 ;; +esac diff --git a/plugins/fail-closed-security-hooks/hooks/hooks.json b/plugins/fail-closed-security-hooks/hooks/hooks.json new file mode 100644 index 0000000..b2facd4 --- /dev/null +++ b/plugins/fail-closed-security-hooks/hooks/hooks.json @@ -0,0 +1,65 @@ +{ + "description": "Enterprise security controls for Claude Code on Amazon Bedrock: PII/secrets guard, git policy guard, tamper-evident HMAC-chained audit log, token-budget circuit breaker — all wrapped by a fail-closed telemetry shim. Opt-in defaults write to ~/.claude/claude-code-security; for un-removable enterprise enforcement deploy via managed-settings + IaC (see homepage).", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/pii-guard.sh\"", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/audit-logger.sh\"", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/git-guard.sh\"", + "timeout": 10 + } + ] + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/pii-guard.sh\"", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/token-budget-guard.sh\"", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/audit-logger.sh\"", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-wrapper.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/token-budget-guard.sh\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugins/fail-closed-security-hooks/hooks/pii-guard.sh b/plugins/fail-closed-security-hooks/hooks/pii-guard.sh new file mode 100755 index 0000000..fb93784 --- /dev/null +++ b/plugins/fail-closed-security-hooks/hooks/pii-guard.sh @@ -0,0 +1,214 @@ +#!/bin/bash +# ============================================================================= +# PII & Secrets Guard Hook for Claude Code +# ============================================================================= +# Hook version: 2.0.1 +# Last updated: 2026-06-01 +# Compatible with: claude-code 2.1.150+ +# Dependencies: bash 4+, jq (preferred), grep, sed +# Maintainer: +# Change log: +# 2.0.1 (2026-06-01) — fix false positive: hyphenated UUID segments no longer +# match CREDIT_CARD_16/AMEX or JP_MYNUMBER (boundary +# classes now exclude '-') +# 2.0.0 (2026-06-01) — multi-country national identifiers: US (SSN/ITIN), +# UK (NINO, NHS), Japan (My Number), South Korea (RRN), +# Singapore (NRIC/FIN), EU (IBAN), Australia (TFN, Medicare) +# 1.0.0 (2026-05-28) — initial release: 15 PII/secret patterns +# ============================================================================= +# Note on false positives: purely-numeric national IDs (e.g. JP My Number, +# AU TFN/Medicare) are matched in their conventional separator-grouped form to +# limit false positives, since ERE (grep -E) has no lookahead. All patterns are +# configurable — comment out any LABEL:::REGEX line below to disable a region. +# ============================================================================= +# Scans user prompts and tool inputs for sensitive data BEFORE they reach the +# model. Blocks the request if PII or secrets are detected. +# +# Hook events supported: +# - UserPromptSubmit: scans user prompt text +# - PreToolUse: scans tool_input (file content being written, commands, etc.) +# +# Exit codes: +# 0 = clean, allow through +# 2 = sensitive data detected, BLOCK (stderr shown to user) +# +# Deploy at: /usr/local/etc/claude-code/hooks/pii-guard.sh +# Ownership: root:root 0755 +# ============================================================================= + +set -u +input=$(cat) + +# Extract the text to scan based on hook event +if command -v jq >/dev/null 2>&1; then + event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty') + case "$event" in + UserPromptSubmit) + text=$(printf '%s' "$input" | jq -r '.prompt // empty') + ;; + PreToolUse) + # Scan the entire tool_input as a string (catches file content, commands, etc.) + text=$(printf '%s' "$input" | jq -r '.tool_input | tostring') + ;; + *) + exit 0 + ;; + esac +else + # Fallback without jq: scan entire input + text="$input" +fi + +# If text is empty or too short, skip +if [[ ${#text} -lt 8 ]]; then + exit 0 +fi + +# ============================================================================= +# DETECTION PATTERNS +# Each pattern: "LABEL:::REGEX" +# ============================================================================= +patterns=( + # Credit card numbers — Visa/MC/Discover/JCB (16 digit, 4-4-4-4) and Amex (15 digit, 4-6-5) + # Standard: ISO/IEC 7812-1 (issuer numbering + Luhn/mod-10 check); industry rule PCI DSS. + # Boundaries exclude '-' so hyphenated UUID segments (8-4-4-4-12) don't false-match. + "CREDIT_CARD_16:::(^|[^0-9-])[3-6][0-9]{3}[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}([^0-9-]|$)" + "CREDIT_CARD_AMEX:::(^|[^0-9-])3[47][0-9]{2}[- ]?[0-9]{6}[- ]?[0-9]{5}([^0-9-]|$)" + + # AWS Access Key ID + "AWS_ACCESS_KEY:::AKIA[0-9A-Z]{16}" + + # AWS Secret Access Key (40 chars base64-ish after common prefixes) + "AWS_SECRET_KEY:::['\"][0-9a-zA-Z/+=]{40}['\"]" + + # Generic API key patterns. NOTE: putting '-' at end of class avoids ERE range parse. + "API_KEY_ASSIGNMENT:::(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key)[ ]*[:=][ ]*['\"]?[A-Za-z0-9_/.+=-]{20,}['\"]?" + + # Private key header + "PRIVATE_KEY:::-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----" + + # JWT token (3 base64url segments separated by dots). '-' placed at end of class. + "JWT_TOKEN:::eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" + + # Database connection strings + "DB_CONNECTION_STRING:::(mongodb(\+srv)?|postgres(ql)?|mysql|mssql|redis|amqp)://[^ '\"]{10,}" + + # Password in common assignment formats + "PASSWORD_ASSIGNMENT:::(password|passwd|pwd|pass)[ ]*[:=][ ]*['\"]?[^ '\"]{8,}['\"]?" + + # --- National identifiers (multi-country) -------------------------------- + # Each line can be disabled by commenting it out. Format authority cited per + # region (the body that issues/defines the number). Full compliance mapping + # (data-protection & financial regulators) is in README.md. These patterns + # validate FORMAT only (digit count / grouping), not government check-digit + # algorithms — several of which (AU TFN/Medicare, SG NRIC, KR RRN) are not + # officially published. Links are official sources; re-verify before relying. + + # United States — SSN (AAA-GG-SSSS), no check digit. + # Authority: Social Security Administration — ssa.gov/employer/structure.html + # (legal basis 20 CFR 422.103). Require grouping to avoid bare 9-digit IDs. + "US_SSN:::(^|[^0-9])[0-9]{3}[- ][0-9]{2}[- ][0-9]{4}([^0-9]|$)" + # United States — ITIN (9XX-7X/8X-XXXX). + # Authority: IRS, Instructions for Form W-7 — irs.gov/instructions/iw7 + "US_ITIN:::(^|[^0-9])9[0-9]{2}[- ](7[0-9]|8[0-8])[- ][0-9]{4}([^0-9]|$)" + + # United Kingdom — National Insurance Number (2 letters, 6 digits, suffix A-D). + # Authority: HMRC/DWP via GOV.UK — gov.uk/national-insurance/your-national-insurance-number + "UK_NINO:::(^|[^A-Za-z0-9])[A-CEGHJ-PR-TW-Z]{2}[0-9]{6}[A-D]([^A-Za-z0-9]|$)" + # United Kingdom — NHS number (10 digits, 3-3-4; Modulus-11 check, published). + # Authority: NHS England Data Dictionary — datadictionary.nhs.uk/attributes/nhs_number.html + "UK_NHS:::(^|[^0-9])[0-9]{3}[- ][0-9]{3}[- ][0-9]{4}([^0-9]|$)" + + # Japan — My Number (個人番号), 12 digits, conventionally grouped 4-4-4. + # Authority: Digital Agency / J-LIS — digital.go.jp/policies/mynumber + # Boundaries exclude '-' so hyphenated UUID segments don't false-match. + "JP_MYNUMBER:::(^|[^0-9-])[0-9]{4}[- ][0-9]{4}[- ][0-9]{4}([^0-9-]|$)" + + # South Korea — Resident Registration Number (RRN), YYMMDD-Sxxxxxx (13 digits). + # Authority: Ministry of the Interior and Safety (MOIS) — mois.go.kr + "KR_RRN:::(^|[^0-9])[0-9]{6}[- ][1-4][0-9]{6}([^0-9]|$)" + + # Singapore — NRIC/FIN (prefix + 7 digits + check letter). + # Authority: Immigration & Checkpoints Authority (ICA) — ica.gov.sg + "SG_NRIC:::[STFGM][0-9]{7}[A-Z]" + + # European Union — IBAN (country code + 2 check digits + BBAN; mod-97 published). + # Standard: ISO 13616, registry by SWIFT — swift.com/standards/data-standards/iban-international-bank-account-number + "EU_IBAN:::(^|[^A-Za-z0-9])(AT|BE|BG|HR|CY|CZ|DK|EE|FI|FR|DE|GR|HU|IE|IT|LV|LT|LU|MT|NL|PL|PT|RO|SK|SI|ES|SE)[0-9]{2}[ ]?([0-9A-Z]{4}[ ]?){2,7}[0-9A-Z]{1,4}" + + # Australia — Tax File Number (8-9 digits, 3-3-3) and Medicare (10 digits, 4-5-1). + # Authority: Australian Taxation Office — ato.gov.au/individuals-and-families/tax-file-number + # Services Australia — servicesaustralia.gov.au/your-medicare-card + "AU_TFN:::(^|[^0-9])[0-9]{3}[- ][0-9]{3}[- ][0-9]{3}([^0-9]|$)" + "AU_MEDICARE:::(^|[^0-9])[0-9]{4}[- ][0-9]{5}[- ][0-9]([^0-9]|$)" + + # Email + "EMAIL_ADDRESS:::[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" + + # Phone numbers (international, allows internal spaces or hyphens between digit groups) + "PHONE_INTL:::\+[0-9]{1,3}([-. ][0-9]{2,5}){2,4}" + + # Passport numbers + "PASSPORT_NUMBER:::[A-Z]{1,2}[0-9]{6,9}" + + # GitHub/GitLab tokens + "GIT_TOKEN:::(ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9-]{20,})" + + # Slack tokens + "SLACK_TOKEN:::xox[bpras]-[0-9]{10,}-[A-Za-z0-9-]+" + + # Generic hex secrets — 32+ hex chars containing at least one a-f letter + # (rejects pure-decimal IDs and 0b binary literals which would otherwise match [0-9a-f]+). + "HEX_SECRET:::(^|[^A-Za-z0-9_])[0-9a-f]*[a-f][0-9a-f]*[a-f][0-9a-f]{28,}([^A-Za-z0-9_]|$)" +) + +# ============================================================================= +# SCAN +# ============================================================================= +detected=() + +for entry in "${patterns[@]}"; do + label="${entry%%:::*}" + regex="${entry#*:::}" + # Identifiers whose letters are defined uppercase — match case-sensitive to + # avoid e.g. lowercase IBAN country prefixes inflating false positives. + case "$label" in + PASSPORT_NUMBER|SG_NRIC|AWS_ACCESS_KEY|UK_NINO|EU_IBAN) + if echo "$text" | grep -qE -- "$regex"; then + detected+=("$label") + fi + ;; + *) + if echo "$text" | grep -qiE -- "$regex"; then + detected+=("$label") + fi + ;; + esac +done + +# ============================================================================= +# RESULT +# ============================================================================= +if [[ ${#detected[@]} -gt 0 ]]; then + matches=$(IFS=', '; echo "${detected[*]}") + cat >&2 <.tokens (cumulative input+output) +# $CLAUDE_TOKEN_STATE/.calls (tool call count) +# +# When either threshold is exceeded the next PreToolUse blocks with exit 2, +# requiring a fresh session. +# +# Configuration (env, set in managed-settings): +# CLAUDE_TOKEN_STATE — state directory (default /var/lib/claude-code/sessions) +# CLAUDE_TOKEN_BUDGET — max cumulative tokens per session (default 1000000) +# CLAUDE_CALL_BUDGET — max tool calls per session (default 500) +# +# This is the circuit breaker for ungoverned agent loops the architect review +# flagged. Agents that legitimately need more should request budget increase. +# ============================================================================= + +set -u +input=$(cat) + +# Default is user-writable for plugin / opt-in mode; override in managed-settings +# to a root-owned path (/var/lib/claude-code/sessions) for enterprise enforcement. +STATE_DIR="${CLAUDE_TOKEN_STATE:-$HOME/.claude/claude-code-security/sessions}" +TOKEN_BUDGET="${CLAUDE_TOKEN_BUDGET:-1000000}" +CALL_BUDGET="${CLAUDE_CALL_BUDGET:-500}" +mkdir -p "$STATE_DIR" 2>/dev/null || true + +if ! command -v jq >/dev/null 2>&1; then + exit 0 # jq missing → cannot enforce; pass through +fi + +session=$(printf '%s' "$input" | jq -r '.session_id // empty') +event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty') +[[ -z "$session" ]] && exit 0 + +token_file="$STATE_DIR/${session}.tokens" +call_file="$STATE_DIR/${session}.calls" +[[ ! -e "$token_file" ]] && echo 0 > "$token_file" 2>/dev/null +[[ ! -e "$call_file" ]] && echo 0 > "$call_file" 2>/dev/null + +cur_tokens=$(cat "$token_file" 2>/dev/null || echo 0) +cur_calls=$(cat "$call_file" 2>/dev/null || echo 0) + +case "$event" in + PreToolUse) + # Block if either budget exhausted + if [[ "$cur_tokens" -ge "$TOKEN_BUDGET" ]]; then + cat >&2 <&2 < "$call_file" 2>/dev/null + ;; + + PostToolUse) + # Token totals — Claude Code reports usage in tool_response.usage if available + in_tokens=$(printf '%s' "$input" | jq -r '.tool_response.usage.input_tokens // 0' 2>/dev/null) + out_tokens=$(printf '%s' "$input" | jq -r '.tool_response.usage.output_tokens // 0' 2>/dev/null) + delta=$((in_tokens + out_tokens)) + if [[ "$delta" -gt 0 ]]; then + echo $((cur_tokens + delta)) > "$token_file" 2>/dev/null + fi + ;; +esac + +exit 0 diff --git a/plugins/fail-closed-security-hooks/scripts/chain-verify.sh b/plugins/fail-closed-security-hooks/scripts/chain-verify.sh new file mode 100755 index 0000000..1f33d88 --- /dev/null +++ b/plugins/fail-closed-security-hooks/scripts/chain-verify.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify the HMAC chain in an audit log. Exits 0 if intact, 1 if broken. +# Usage: chain-verify.sh [--key-file ] +set -u +LOG="${1:-}" +KEY_FILE="${2:-}" +[[ -z "$LOG" || ! -r "$LOG" ]] && { echo "usage: $0 [--key-file path]"; exit 2; } + +if [[ -n "${AUDIT_HMAC_KEY:-}" ]]; then + KEY="$AUDIT_HMAC_KEY" +elif [[ -n "$KEY_FILE" && -r "$KEY_FILE" ]]; then + KEY=$(cat "$KEY_FILE") +elif [[ -r /etc/claude-code/audit-key ]]; then + KEY=$(cat /etc/claude-code/audit-key) +elif [[ -r "${CLAUDE_AUDIT_STATE:-$HOME/.claude/claude-code-security/audit-state}/key.dev" ]]; then + KEY=$(cat "${CLAUDE_AUDIT_STATE:-$HOME/.claude/claude-code-security/audit-state}/key.dev") +else + echo "no key" >&2; exit 2 +fi + +prev="GENESIS" +n=0; bad=0 +while IFS= read -r line; do + n=$((n+1)) + body=$(printf '%s' "$line" | jq -c 'del(.hmac)') + expected=$(printf '%s' "$line" | jq -r '.hmac') + prev_in=$(printf '%s' "$line" | jq -r '.prev_hash') + if [[ "$prev_in" != "$prev" ]]; then + echo "line $n: prev_hash mismatch (expected $prev, got $prev_in)" >&2 + bad=1 + fi + computed=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$KEY" | awk '{print $NF}') + if [[ "$computed" != "$expected" ]]; then + echo "line $n: hmac mismatch" >&2 + bad=1 + fi + prev="$expected" +done < "$LOG" + +if [[ "$bad" == "1" ]]; then + echo "CHAIN BROKEN ($n lines verified, failures detected)"; exit 1 +else + echo "chain intact ($n lines verified)"; exit 0 +fi