From 34fa7a66b78f0eb563d7afcca60e883333fc2d5f Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:40:36 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + hooks/__init__.py | 0 hooks/hooks.json | 46 +++ hooks/secrets_scanner_hook.py | 516 ++++++++++++++++++++++++++++++++++ plugin.lock.json | 53 ++++ 6 files changed, 630 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 hooks/__init__.py create mode 100644 hooks/hooks.json create mode 100644 hooks/secrets_scanner_hook.py create mode 100644 plugin.lock.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..2dd204d --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "secrets-scanner", + "description": "Scans for common credential formats across cloud, source control, payment, and collaboration providers", + "version": "0.1.14", + "author": { + "name": "MintMCP", + "email": "support@mintmcp.com" + }, + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..caa7bd0 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# secrets-scanner + +Scans for common credential formats across cloud, source control, payment, and collaboration providers diff --git a/hooks/__init__.py b/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..c38372d --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,46 @@ +{ + "description": "Secret scanner that blocks sensitive credentials before they are sent to Claude Code or Cursor", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/secrets_scanner_hook.py --mode=pre --client=claude_code" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Read|read", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/secrets_scanner_hook.py --mode=pre --client=claude_code" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Read|read", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/secrets_scanner_hook.py --mode=post --client=claude_code" + } + ] + }, + { + "matcher": "Bash|bash", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/secrets_scanner_hook.py --mode=post --client=claude_code" + } + ] + } + ] + } +} diff --git a/hooks/secrets_scanner_hook.py b/hooks/secrets_scanner_hook.py new file mode 100644 index 0000000..44ec7cb --- /dev/null +++ b/hooks/secrets_scanner_hook.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python3 +"""Secret scanner for Claude Code and Cursor hooks (single-file). + +Provides pre/post hook scanning with minimal dependencies. Designed to be +portable (copy one file) while readable and maintainable. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from bisect import bisect_right + +__all__ = [ + "__version__", + "main", + "console_main", + "console_main_claude", + "console_main_cursor", +] + +__version__ = "0.1.14" + +# ----------------------------------------------------------------------------- +# Configuration and Patterns +# ----------------------------------------------------------------------------- + +MAX_SCAN_BYTES = 5 * 1024 * 1024 # 5MB cap per file +SAMPLE_BYTES = 4096 # used for binary sniffing + + +# (strict schema access; direct .get only) + + +PATTERNS = { + # AWS + "AWS Access Key ID": re.compile(r"\b(?:A3T[A-Z0-9]|ABIA|ACCA|AKIA|ASIA)[A-Z0-9]{16}\b"), + "AWS Secret Access Key": re.compile(r"(?i)(?:aws_?secret_?access_?key|secret_?access_?key)\s*[:=]\s*['\"]?([A-Za-z0-9/+=]{40})['\"]?"), + + # GitHub / GitLab + "GitHub Token": re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36}\b"), + "GitHub Fine-Grained PAT": re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,255}\b"), + "GitLab Tokens": re.compile(r"\b(?:glpat|gldt|glft|glsoat|glrt)-[A-Za-z0-9_\-]{20,50}(?!\w)\b|\bGR1348941[A-Za-z0-9_\-]{20,50}(?!\w)\b|\bglcbt-(?:[0-9a-fA-F]{2}_)?[A-Za-z0-9_\-]{20,50}(?!\w)\b|\bglimt-[A-Za-z0-9_\-]{25}(?!\w)\b|\bglptt-[A-Za-z0-9_\-]{40}(?!\w)\b|\bglagent-[A-Za-z0-9_\-]{50,1024}(?!\w)\b|\bgloas-[A-Za-z0-9_\-]{64}(?!\w)\b"), + + # Slack / Discord / Telegram + "Slack Token": re.compile(r"xox(?:a|b|p|o|s|r)-(?:\d+-)+[a-z0-9]+", re.IGNORECASE), + "Slack Webhook": re.compile(r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+", re.IGNORECASE), + "Discord Bot Token": re.compile(r"\b[MNO][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}\b"), + "Discord Webhook": re.compile(r"https://(?:canary\.|ptb\.)?discord(?:app)?\.com/api/webhooks/\d{5,30}/[A-Za-z0-9_-]{30,}"), + "Telegram Bot Token": re.compile(r"\b\d{8,10}:[0-9A-Za-z_-]{35}\b"), + + # Stripe / Twilio / SendGrid + "Stripe Secret Key": re.compile(r"\b(?:r|s)k_(?:live|test)_[0-9A-Za-z]{24,}\b"), + "Stripe Publishable Key": re.compile(r"\bpk_(?:live|test)_[A-Za-z0-9]{20,}\b"), + "Twilio Account SID": re.compile(r"\bAC[0-9a-fA-F]{32}\b"), + "Twilio API Key SID": re.compile(r"\bSK[0-9a-fA-F]{32}\b"), + "Twilio Auth Token": re.compile(r"(?i)\b(?:twilio_)?auth_?token['\"]?\s*[:=]\s*['\"]?([0-9a-f]{32})['\"]?"), + "SendGrid API Key": re.compile(r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"), + + # Package registries + "NPM Token": re.compile(r"\bnpm_[A-Za-z0-9]{30,}\b"), + "NPM .npmrc Auth Token": re.compile(r"\/\/[^\n]+\/:_authToken=\s*((npm_.+)|([A-Fa-f0-9-]{36}))"), + "PyPI Token": re.compile(r"\bpypi-(?:AgEIcHlwaS5vcmc|AgENdGVzdC5weXBpLm9yZw)[A-Za-z0-9-_]{70,}\b"), + + # Cloud providers & services + "Azure Storage Connection String": re.compile(r"DefaultEndpointsProtocol=(?:http|https);AccountName=[A-Za-z0-9\-]+;AccountKey=([A-Za-z0-9+/=]{40,});EndpointSuffix=core\.windows\.net"), + "Azure Storage Account Key": re.compile(r"AccountKey=[A-Za-z0-9+/=]{88}"), + "Azure SAS Token": re.compile(r"[\?&]sv=\d{4}-\d{2}-\d{2}[^ \n]*?&sig=[A-Za-z0-9%+/=]{16,}"), + "Artifactory Credentials": re.compile(r"(?:\s|=|:|\"|^)AKC[a-zA-Z0-9]{10,}(?:\s|\"|$)"), + "Artifactory Encrypted Password": re.compile(r"(?:\s|=|:|\"|^)AP[\dABCDEF][a-zA-Z0-9]{8,}(?:\s|\"|$)"), + "Cloudant URL Credential": re.compile(r"https?://[\w\-]+:([0-9a-f]{64}|[a-z]{24})@[\w\-]+\.cloudant\.com", re.IGNORECASE), + "SoftLayer API Token": re.compile(r"https?://api\.softlayer\.com/soap/(?:v3|v3\.1)/([a-z0-9]{64})", re.IGNORECASE), + + # JWT and keys + "JWT Token": re.compile(r"\beyJ[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+\.?[A-Za-z0-9\-_.+/=]*?\b"), + "Private Key (PEM)": re.compile(r"-----BEGIN (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----\s*\n(?:(?:[A-Za-z0-9\-]+:[^\n]*\n)*\s*)?(?:[A-Za-z0-9+/=]{40,}\s*\n)+-----END (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----"), + "OpenSSH Private Key": re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----\s*\n(?:[A-Za-z0-9+/=]{40,}\s*\n)+-----END OPENSSH PRIVATE KEY-----"), + "PGP Private Key": re.compile(r"-----BEGIN PGP PRIVATE KEY BLOCK-----\s*\n(?:(?:[A-Za-z0-9\-]+:[^\n]*\n)*\s*)?(?:[A-Za-z0-9+/=]{40,}\s*\n)+-----END PGP PRIVATE KEY BLOCK-----"), + "SSH2 Encrypted Private Key": re.compile(r"-----BEGIN SSH2 ENCRYPTED PRIVATE KEY-----\s*\n(?:[A-Za-z0-9+/=]{40,}\s*\n)+-----END SSH2 ENCRYPTED PRIVATE KEY-----"), + "PuTTY Private Key": re.compile(r"(?:^|\n)PuTTY-User-Key-File-\d+:\s*\S+"), + + # Other common tokens + "Google API Key": re.compile(r"\bAIza[0-9A-Za-z\-_\\]{32,40}\b"), + "Google OAuth Token": re.compile(r"\bya29\.[0-9A-Za-z\-_]{20,}\b"), + "Anthropic API Key": re.compile(r"\bsk-ant-api\d+-[A-Za-z0-9_-]{90,}\b"), + "OpenAI API Key": re.compile(r"\bsk-[A-Za-z0-9-_]*[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b"), + "Password Assignment": re.compile(r"(?i)\b(pass(word)?|pwd)\s*[:=]\s*['\"][^'\"\n]{8,}['\"]"), + "Mailchimp API Key": re.compile(r"\b[0-9a-z]{32}-us[0-9]{1,2}\b"), + # From detect-secrets basic_auth plugin, limits username/password chars to avoid false positives + "Basic Auth Credentials": re.compile(r"://[^:/?#\[\]@!$&'()*+,;=\s]+:([^:/?#\[\]@!$&'()*+,;=\s]+)@"), + "Databricks PAT": re.compile(r"\bdapi[A-Za-z0-9]{32}\b"), + "Firebase FCM Server Key": re.compile(r"\bAAAA[A-Za-z0-9_-]{7,}:[A-Za-z0-9_-]{140,}\b"), + "Shopify Token": re.compile(r"\bshp(?:at|pa|ss)_[0-9a-f]{32}\b"), + "Notion Integration Token": re.compile(r"\bsecret_[A-Za-z0-9]{32,}\b"), + "Linear API Key": re.compile(r"\blin_api_[A-Za-z0-9]{40,}\b"), + "Mapbox Access Token": re.compile(r"\b[ps]k\.[A-Za-z0-9\-_.]{30,}\b"), + "Dropbox Access Token": re.compile(r"\bsl\.[A-Za-z0-9_-]{120,}\b"), + "DigitalOcean Personal Access Token": re.compile(r"\bdop_v1_[a-f0-9]{64}\b"), + "Square Access Token": re.compile(r"\bEAAA[A-Za-z0-9]{60}\b"), + "Square OAuth Secret": re.compile(r"\bsq0csp-[0-9A-Za-z_\-]{43}\b"), + "Airtable Personal Access Token": re.compile(r"\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b"), + "Facebook Access Token": re.compile(r"\bEAA[A-Za-z0-9]{30,}\b"), +} + +# ----------------------------------------------------------------------------- +# Scanning utilities +# ----------------------------------------------------------------------------- + +def is_probably_binary(block: bytes) -> bool: + if b"\x00" in block: + return True + textchars = bytes(range(32, 127)) + b"\n\r\t\b" + nontext = block.translate(None, textchars) + return len(nontext) / max(1, len(block)) > 0.30 + + +def should_scan_file(path: str) -> bool: + try: + with open(path, "rb") as sample: + head = sample.read(SAMPLE_BYTES) + except OSError: + return False + if not head: + return True + return not is_probably_binary(head) + + + +def scan_text(text: str, path: str): + findings = [] + line_starts = [0] + for idx, ch in enumerate(text): + if ch == "\n": + line_starts.append(idx + 1) + for pname, rx in PATTERNS.items(): + for m in rx.finditer(text): + line_no = bisect_right(line_starts, m.start()) + findings.append({"file": path, "line": line_no, "type": pname, "match": m.group(0)}) + return findings + + +def scan_file(path: str): + if not os.path.exists(path): + raise FileNotFoundError(f"File does not exist: {path}") + if not should_scan_file(path): + return [] + size = os.path.getsize(path) + if size > MAX_SCAN_BYTES: + raise RuntimeError(f"File size {size} bytes exceeds scan limit of {MAX_SCAN_BYTES} bytes") + with open(path, "rb") as f: + blob = f.read() + if is_probably_binary(blob): + return [] + return scan_text(blob.decode("utf-8", "ignore"), path) + + +def build_findings_message(findings, heading: str, limit: int = 5) -> str: + if not findings: + return heading + grouped = {} + for it in findings: + grouped.setdefault(it.get("file") or "[unknown]", []).append(it) + lines = [] + for label, entries in grouped.items(): + types = sorted({e["type"] for e in entries}) + nums = ", ".join(str(e["line"]) for e in entries[:limit]) + s = f"{label}: {', '.join(types[:3])}" + if nums: + s += f" (lines {nums})" + if len(entries) > limit: + s += f" (+{len(entries) - limit} more)" + lines.append(s) + msg = "\n".join(f" - {ln}" for ln in lines[:limit]) + out = f"{heading}\n{msg}" + total = len(findings) + if total > limit: + out += f"\nShowing first {limit} of {total} findings." + return out + + +# ----------------------------------------------------------------------------- +# Client adapters (Cursor / Claude) +# ----------------------------------------------------------------------------- + +def detect_hook_type(hook_input): + if not isinstance(hook_input, dict): + return "claude_code" + ev = hook_input.get("hook_event_name") + if isinstance(ev, str): + ev = ev.strip() + claude_events = { + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Notification", + "Stop", + "SubagentStop", + "PreCompact", + "SessionStart", + "SessionEnd", + } + cursor_events = { + "beforeReadFile", + "afterFileEdit", + "beforeSubmitPrompt", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "stop", + } + if ev in claude_events: + return "claude_code" + if ev in cursor_events: + return "cursor" + return "claude_code" + + +# Removed legacy helpers; using direct documented fields only + + +def _detect_tool_name(tool_input) -> str: + if isinstance(tool_input, str) and tool_input.strip(): + return tool_input + if isinstance(tool_input, dict): + value = tool_input.get("tool_name") + if isinstance(value, str) and value.strip(): + return value + if isinstance(tool_input.get("command"), str): + return "command" + return "tool" + + +def collect_cursor_post_payloads(hook_input, event_name: str | None): + """Extract Cursor post-event outputs from documented fields.""" + evt = (event_name or "").strip() + payloads = [] + + if evt == "afterShellExecution": + stdout = hook_input.get("stdout") + stderr = hook_input.get("stderr") + if isinstance(stdout, str) and stdout.strip(): + payloads.append(("[shell stdout]", stdout)) + if isinstance(stderr, str) and stderr.strip(): + payloads.append(("[shell stderr]", stderr)) + return payloads + + if evt == "afterMCPExecution": + for key, label in ( + ("stdout", "[mcp stdout]"), + ("stderr", "[mcp stderr]"), + ("text", "[mcp output]"), + ("message", "[mcp output]"), + ): + val = hook_input.get(key) + if isinstance(val, str) and val.strip(): + payloads.append((label, val)) + return payloads + + return payloads + + +def collect_claude_post_payloads(hook_input): + """Extract Claude post-event outputs from documented fields.""" + tool_input = hook_input.get("tool_input") or {} + tool_result = hook_input.get("tool_response") or {} + tool_name = (hook_input.get("tool_name") or _detect_tool_name(tool_input) or "").strip() + + payloads = [] + + if tool_name.lower() == "bash": + stdout = tool_result.get("stdout") if isinstance(tool_result, dict) else None + stderr = tool_result.get("stderr") if isinstance(tool_result, dict) else None + if isinstance(stdout, str) and stdout.strip(): + payloads.append(("[bash stdout]", stdout)) + if isinstance(stderr, str) and stderr.strip(): + payloads.append(("[bash stderr]", stderr)) + return payloads + + # Read tool or similar may return content directly + if isinstance(tool_result, dict): + content = tool_result.get("content") + if isinstance(content, str) and content.strip(): + label = tool_input.get("file_path") if isinstance(tool_input, dict) else None + payloads.append((label or "[tool output]", content)) + elif isinstance(tool_result, str) and tool_result.strip(): + label = tool_input.get("file_path") if isinstance(tool_input, dict) else None + payloads.append((label or "[tool output]", tool_result)) + + return payloads + + +def format_cursor_response(action: str, message: str | None, event_name: str | None): + permission_map = {"allow": "allow", "block": "deny", "ask": "ask"} + event = (event_name or "").strip() + if event == "beforeSubmitPrompt": + payload = {"continue": action != "block"} + if message: + payload["userMessage"] = message + return payload + if event in {"beforeReadFile", "beforeShellExecution", "beforeMCPExecution"}: + payload = {"permission": permission_map.get(action, "allow")} + if message: + payload["userMessage"] = message + return payload + if event in {"afterFileEdit", "afterShellExecution", "afterMCPExecution", "stop"}: + payload = {} + if message: + payload["message"] = message + return payload + payload = {} + if action in permission_map: + payload["permission"] = permission_map[action] + elif action == "block": + payload["permission"] = "deny" + if message: + payload["userMessage"] = message + if not payload: + payload["continue"] = action != "block" + return payload + + +def format_claude_response(action: str, message: str | None, hook_event: str): + msg = message.rstrip() if isinstance(message, str) else None + if hook_event == "PreToolUse": + decision = "deny" if action == "block" else "allow" + out = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision}} + if msg: + out["hookSpecificOutput"]["permissionDecisionReason"] = msg + return out + if hook_event == "PostToolUse": + out = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}} + if action == "block" and msg: + out["decision"] = "block" + out["reason"] = msg + elif msg: + out["hookSpecificOutput"]["additionalContext"] = msg + return out + if hook_event == "UserPromptSubmit": + out = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit"}} + if action == "block": + out["decision"] = "block" + if msg: + out["reason"] = msg + elif msg: + out["hookSpecificOutput"]["additionalContext"] = msg + return out + out = {"action": action} + if msg: + out["message"] = msg + return out + + +# ----------------------------------------------------------------------------- +# CLI +# ----------------------------------------------------------------------------- + +def _emit(hook_type: str, hook_event: str, action: str, message: str | None, event_name: str | None = None, *, allow_code=0, block_code=2, warn_code=1): + if hook_type == "cursor": + print(json.dumps(format_cursor_response(action, message, event_name))) + return + payload = format_claude_response(action, message, hook_event) + text = json.dumps(payload) + if action == "block": + sys.stderr.write(text + "\n") + sys.stderr.flush() + sys.exit(block_code) + else: + sys.stdout.write(text + "\n") + sys.stdout.flush() + sys.exit(allow_code) + + +def run_pre_hook(client_override: str | None = None): + hook_type = "claude_code" + event_name = None + try: + hook_input = json.load(sys.stdin) + hook_type = client_override or detect_hook_type(hook_input) + event_name = hook_input.get("hook_event_name") + hook_event = event_name or ("PreToolUse" if hook_type == "claude_code" else "beforeReadFile") + + findings = [] + + if hook_type == "cursor": + # Cursor: extract directly per docs, avoid walking arbitrary JSON + evt = (event_name or "").strip() + if evt == "beforeReadFile": + file_path = hook_input.get("file_path") or "" + content = hook_input.get("content") + if isinstance(content, str) and content.strip(): + findings.extend(scan_text(content, file_path or "[file content]")) + elif file_path: + try: + findings.extend(scan_file(file_path)) + except Exception as exc: + _emit(hook_type, hook_event, "block", f"Secret scan error: {exc}", event_name) + return + elif evt == "beforeSubmitPrompt": + prompt = hook_input.get("prompt") + if isinstance(prompt, str) and prompt.strip(): + findings.extend(scan_text(prompt, "[prompt]")) + elif evt == "beforeShellExecution": + cmd = hook_input.get("command") + if isinstance(cmd, str) and cmd.strip(): + findings.extend(scan_text(cmd, "[shell command]")) + elif evt == "beforeMCPExecution": + cmd = hook_input.get("command") + if isinstance(cmd, str) and cmd.strip(): + findings.extend(scan_text(cmd, "[mcp command]")) + # No other Cursor pre-events are scanned + else: + ev = hook_input.get("hook_event_name") or "" + ev = ev.strip() + + if ev == "PreToolUse": + tool_input = hook_input.get("tool_input") or {} + tool_name = (hook_input.get("tool_name") or _detect_tool_name(tool_input) or "").strip() + if isinstance(tool_input, dict): + if tool_name in {"Write", "Edit", "MultiEdit", "Read"}: + content = tool_input.get("content") + if isinstance(content, str) and content.strip(): + findings.extend(scan_text(content, tool_input.get("file_path") or "[content]")) + else: + fp = tool_input.get("file_path") + if isinstance(fp, str) and fp.strip(): + try: + findings.extend(scan_file(fp)) + except Exception as exc: + _emit(hook_type, hook_event, "block", f"Secret scan error: {exc}", event_name) + return + elif tool_name == "Bash": + cmd = tool_input.get("command") + if isinstance(cmd, str) and cmd.strip(): + findings.extend(scan_text(cmd, "[bash command]")) + else: + content = tool_input.get("content") + if isinstance(content, str) and content.strip(): + findings.extend(scan_text(content, "[tool content]")) + + elif ev == "UserPromptSubmit": + prompt = hook_input.get("prompt") + if isinstance(prompt, str) and prompt.strip(): + findings.extend(scan_text(prompt, "[prompt]")) + # No other Claude pre-events are scanned + + if findings: + _emit(hook_type, hook_event, "block", build_findings_message(findings, "SECRET DETECTED (submission blocked)"), event_name) + else: + _emit(hook_type, hook_event, "allow", None, event_name) + except Exception as exc: + _emit(hook_type, "UserPromptSubmit", "block", f"Secret scan error: {exc}", event_name) + + +def run_post_hook(client_override: str | None = None): + hook_type = "claude_code" + event_name = None + try: + hook_input = json.load(sys.stdin) + hook_type = client_override or detect_hook_type(hook_input) + event_name = hook_input.get("hook_event_name") if hook_type == "cursor" else None + payloads = collect_cursor_post_payloads(hook_input, event_name) if hook_type == "cursor" else collect_claude_post_payloads(hook_input) + if not payloads: + _emit(hook_type, "PostToolUse", "allow", None, event_name) + return + findings = [] + for label, text in payloads: + findings.extend(scan_text(text, label)) + if findings: + msg = build_findings_message(findings, "SECRET DETECTED in recent output") + "\nBe careful with this sensitive data!" + _emit(hook_type, "PostToolUse", "block", msg, event_name) + else: + _emit(hook_type, "PostToolUse", "allow", None, event_name) + except Exception as exc: + if hook_type == "claude_code": + sys.stderr.write(json.dumps(format_claude_response("allow", f"Post-read secret scan error: {exc}", "PostToolUse")) + "\n") + sys.stderr.flush() + sys.exit(1) + else: + print(json.dumps(format_cursor_response("allow", f"Post-read secret scan error: {exc}", event_name))) + + +def _build_cli_parser(): + p = argparse.ArgumentParser(description=f"Secret scanner hooks v{__version__}") + p.add_argument("--mode", choices=["pre", "post"], required=True) + p.add_argument("--client", choices=["claude_code", "cursor"], default=None) + return p + + +def main(argv=None, *, default_client=None): + args = _build_cli_parser().parse_args(argv) if argv is not None else _build_cli_parser().parse_args() + if default_client and args.client is None: + args.client = default_client + if args.mode == "pre": + run_pre_hook(args.client) + else: + run_post_hook(args.client) + + +def console_main(): + main() + + +def console_main_claude(): + main(default_client="claude_code") + + +def console_main_cursor(): + main(default_client="cursor") + + +if __name__ == "__main__": + main() diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..d9fb78a --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,53 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:mintmcp/agent-security:plugins/secrets_scanner", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "7b28c6f317e1e8ff9df856c76963cc52a89e0fca", + "treeHash": "ae6b1b2346b79a37b50282e3df5a1b0652ff3237158769de226dd7f0ae480bc5", + "generatedAt": "2025-11-28T10:27:06.849157Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "secrets-scanner", + "description": "Scans for common credential formats across cloud, source control, payment, and collaboration providers", + "version": "0.1.14" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "09b014365bae514eb482c09c37aecfee731cf228c36ef4ec3201b146ce2ae147" + }, + { + "path": "hooks/secrets_scanner_hook.py", + "sha256": "f491cd8f22d2ccd7c57c94689aef5250bff83f7bddd4d265ca511ef1dedfa443" + }, + { + "path": "hooks/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "hooks/hooks.json", + "sha256": "55b04a5228ace09d000db5272c748c6370049a2a2f068bbd3adc173f392ed690" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "59e1bcaa1f3765b54ba11f25be54e4b4fe46408d8548e073a2beca13d2e744dc" + } + ], + "dirSha256": "ae6b1b2346b79a37b50282e3df5a1b0652ff3237158769de226dd7f0ae480bc5" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file