#!/usr/bin/env python3 """Block commits that contain AI provider API keys and other common secrets. AI projects leak keys in places normal code review skims past: notebooks, example .env files, prompt fixtures, eval logs and copied curl commands. This script scans the files tracked by git for well-known key formats. It is a fast, dependency-free first line of defence, not a full secret scanner. It only knows the patterns listed below and does not scan git history. Pair it with your platform's secret scanning and rotate any key that was ever pushed. Detected patterns: anthropic-api-key sk-ant-... openai-api-key sk-proj-..., sk-svcacct-..., sk-admin-..., legacy sk-<48 chars> aws-access-key-id AKIA / ASIA + 16 chars github-token ghp_, gho_, ghu_, ghs_, ghr_, github_pat_ google-api-key AIza + 35 chars slack-token xoxb-, xoxp-, xoxa-, xoxr-, xoxs- huggingface-token hf_ + 34 or more letters private-key -----BEGIN ... PRIVATE KEY----- Usage: python3 scripts/scan_ai_keys.py [PATH ...] [--all-files] PATH Files to scan. Defaults to every file tracked by git (git ls-files). --all-files Walk the current directory instead of asking git (skips .git, node_modules and virtualenvs). Suppressing a finding: - add "secretscan:allow" in a comment on the same line, or - list a glob in .secretscan-ignore (one per line, # for comments), e.g. tests/fixtures/fake_keys.txt Only suppress values you have confirmed are fake. Requirements: Python 3.8+, git on PATH (unless --all-files or PATH is given). No environment variables. Matched values are masked in the output. Exit codes: 0 nothing found 1 one or more possible secrets found 2 usage error (not a git repository, unreadable ignore file) """ import argparse import fnmatch import os import re import subprocess import sys PATTERNS = [ ("anthropic-api-key", re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}")), ("openai-api-key", re.compile(r"sk-(?:proj|svcacct|admin)-[A-Za-z0-9_\-]{20,}|\bsk-[A-Za-z0-9]{48}\b")), ("aws-access-key-id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")), ("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b")), ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")), ("slack-token", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}\b")), ("huggingface-token", re.compile(r"\bhf_[A-Za-z]{34,}\b")), ("private-key", re.compile(r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----")), ] SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__"} BINARY_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".mp3", ".mp4", ".mov", ".bin", ".pt", ".onnx", ".safetensors", ".parquet", ".pkl", ".npy", ".jar", ".exe", ".dll", ".so", ".dylib"} MAX_BYTES = 5 * 1024 * 1024 def tracked_files(): try: output = subprocess.run(["git", "ls-files", "-z"], check=True, capture_output=True).stdout except (OSError, subprocess.CalledProcessError): print("error: not a git repository (or git missing); pass paths or --all-files", file=sys.stderr) sys.exit(2) return [p for p in output.decode("utf-8", "replace").split("\0") if p] def walk_files(): files = [] for dirpath, dirnames, filenames in os.walk("."): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] files.extend(os.path.relpath(os.path.join(dirpath, f)) for f in filenames) return files def load_ignores(): path = ".secretscan-ignore" if not os.path.exists(path): return [] try: with open(path, encoding="utf-8") as handle: return [line.strip() for line in handle if line.strip() and not line.startswith("#")] except OSError as exc: print("error: cannot read %s: %s" % (path, exc.strerror), file=sys.stderr) sys.exit(2) def mask(value): return value[:8] + "..." + value[-4:] if len(value) > 16 else value[:4] + "..." def scan_file(path): findings = [] if os.path.splitext(path)[1].lower() in BINARY_EXT: return findings try: if os.path.getsize(path) > MAX_BYTES: return findings with open(path, "rb") as handle: data = handle.read() except OSError: return findings if b"\0" in data[:8192]: return findings for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), start=1): if "secretscan:allow" in line: continue for name, pattern in PATTERNS: for match in pattern.finditer(line): findings.append((number, name, mask(match.group(0)))) return findings def main(): parser = argparse.ArgumentParser(description="Scan files for AI provider keys and common secrets.") parser.add_argument("paths", nargs="*") parser.add_argument("--all-files", action="store_true") args = parser.parse_args() files = args.paths or (walk_files() if args.all_files else tracked_files()) ignores = load_ignores() total = 0 for path in files: rel = path.replace(os.sep, "/") if any(fnmatch.fnmatch(rel, pattern) for pattern in ignores) or not os.path.isfile(path): continue for number, name, masked in scan_file(path): print("%s:%d: %s %s" % (rel, number, name, masked)) if os.environ.get("GITHUB_ACTIONS") == "true": print("::error file=%s,line=%d::possible %s" % (rel, number, name)) total += 1 if total: print("\n%d possible secret(s) found. Remove them, rotate the keys, and rewrite history if they were pushed." % total) return 1 print("no secrets found in %d file(s)" % len(files)) return 0 if __name__ == "__main__": sys.exit(main())