""" Layer 1A — Secrets Scanner (TruffleHog) Scans current files AND full git history. --verify flag confirms live secrets. """ from __future__ import annotations import subprocess import json import math import os from pathlib import Path from typing import List, Optional from .models import Finding, Severity, Confidence, ScanDomain SECRET_TYPE_MAP = { "OpenAI": "OPENAI_KEY", "Anthropic": "ANTHROPIC_KEY", "Stripe": "STRIPE_KEY", "AWS": "AWS_KEY", "GitHub": "GITHUB_TOKEN", "Supabase": "SUPABASE_KEY", "Twilio": "TWILIO_KEY", "SendGrid": "SENDGRID_KEY", "Resend": "RESEND_KEY", "Clerk": "CLERK_KEY", "Mapbox": "MAPBOX_KEY", "JWT": "JWT_SECRET", "PrivateKey": "PRIVATE_KEY", "MongoDB": "DB_CONNECTION_STRING", "PostgreSQL": "DB_CONNECTION_STRING", "MySQL": "DB_CONNECTION_STRING", } PLACEHOLDER_PATTERNS = [ "your_", "YOUR_", "example", "EXAMPLE", "replace_", "REPLACE", "xxxxxxx", "XXXXXXXXX", "dummy", "DUMMY", "fake", "FAKE", "test123", "password123", "secret123", "changeme", "CHANGEME", ] # Browser-safe publishable key prefixes — these are designed to be exposed to the client _PUBLISHABLE_KEY_INDICATORS = [ "pk_test_", "pk_live_", # Stripe publishable keys "PUBLISHABLE_KEY", "publishable_key", # Clerk, generic publishable keys "_ANON_KEY", "anon_key", # Supabase anon keys (designed for client) "NEXT_PUBLIC_", "VITE_", "REACT_APP_", # Client-exposed prefix indicators ] def _shannon_entropy(data: str) -> float: """Calculate Shannon entropy of a string. High entropy (>3.5) suggests real secrets.""" if not data: return 0.0 freq: dict[str, int] = {} for c in data: freq[c] = freq.get(c, 0) + 1 length = len(data) return -sum((count / length) * math.log2(count / length) for count in freq.values()) def _has_char_diversity(value: str) -> bool: """Check if a value has mixed character classes (digits + letters). Low diversity = placeholder.""" has_upper = any(c.isupper() for c in value) has_lower = any(c.islower() for c in value) has_digit = any(c.isdigit() for c in value) classes = sum([has_upper, has_lower, has_digit]) return classes >= 2 def is_placeholder(value: str) -> bool: return any(p in value for p in PLACEHOLDER_PATTERNS) or len(value) < 8 def _is_publishable_key(value: str, context_line: str = "") -> bool: """Returns True if the value or its surrounding context indicates a browser-safe publishable key.""" combined = value + " " + context_line return any(indicator in combined for indicator in _PUBLISHABLE_KEY_INDICATORS) def is_in_comment(content: str, start_idx: int) -> bool: """Check if the given character index lies within a single-line or block comment.""" before = content[:start_idx] # Check if inside a block comment /* ... */ last_open_block = before.rfind("/*") last_close_block = before.rfind("*/") if last_open_block != -1 and last_open_block > last_close_block: return True # Check if inside a single-line comment (//, #, or --) line_start = before.rfind("\n") + 1 line_before_match = before[line_start:] for marker in ("//", "#", "--"): marker_idx = line_before_match.find(marker) if marker_idx != -1: # Avoid matching URLs (e.g. https://) as comments if marker == "//": if marker_idx >= 5 and line_before_match[marker_idx-5:marker_idx] in ("http:", "ttps:"): continue return True return False def is_commented_in_file(content: str, val: str, file_path: str) -> bool: """Verify if all occurrences of the secret in the file content are commented out.""" idx = 0 occurrences = [] while True: idx = content.find(val, idx) if idx == -1: break occurrences.append(idx) idx += len(val) if not occurrences: return False return all(is_in_comment(content, o) for o in occurrences) def is_valid_secret(val: str, file_path: Optional[str] = None, repo_path: Optional[str] = None, secret_type: Optional[str] = None) -> bool: """ Validates a potential secret string. Returns True if it passes entropy and formatting checks (likely a real secret). Returns False if it is a false positive. """ val = val.strip().strip("'\"`") # 1. Suspiciously short check if len(val) < 16: return False # 2. Repeated characters check if len(set(val)) == 1: return False # 3. Common placeholder check (case-insensitive) PLACEHOLDERS = [ "your-secret-here", "changeme", "placeholder", "example", "test", "dummy", "fake", "todo" ] val_lower = val.lower() if any(p in val_lower for p in PLACEHOLDERS): return False # 4. Version or semver check import re if re.match(r"^v?\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?$", val): return False # 5. Test files filter if file_path: f_lower = file_path.lower().replace("\\", "/").lstrip("/") parts = f_lower.split("/") if len(parts) >= 2 and parts[0] == "sandbox_unzip": f_lower = "/".join(parts[2:]) from .tier0_pre_filter import classify_file_role role = classify_file_role(f_lower) if role == "test": return False # 6. Comments check if file_path and repo_path: fpath = os.path.join(repo_path, file_path) if os.path.exists(fpath): try: content = Path(fpath).read_text(encoding="utf-8", errors="replace") if is_commented_in_file(content, val, file_path): return False except Exception: pass # 7. Shannon entropy threshold # If it is a known high-confidence secret type (from TruffleHog or regex rules), # use a lower threshold of 2.8 to avoid false negatives (e.g. AWS access keys or simple DB passwords). threshold = 2.8 if secret_type and secret_type != "Unknown" else 3.5 if _shannon_entropy(val) <= threshold: return False return True def run_secrets_scan(repo_path: str, file_roles: dict) -> List[Finding]: findings: List[Finding] = [] # For large repositories or resource-constrained environments, running TruffleHog as a subprocess # will exceed container memory limits (512MB on Render) and crash the server. # We gracefully bypass TruffleHog and use our high-speed, memory-safe custom regex scanner instead. file_count = len(file_roles) max_trufflehog_files = int(os.environ.get("VIBESEC_MAX_TRUFFLEHOG_FILES", "10000")) is_hf = os.environ.get("SPACE_ID", "") != "" low_mem = os.environ.get("VIBESEC_LOW_MEMORY", "false").lower() == "true" and not is_hf # We allow overriding this check via environment variables if desired bypass_trufflehog = file_count > max_trufflehog_files or (low_mem and file_count > 300) force_trufflehog = os.environ.get("VIBESEC_FORCE_TRUFFLEHOG", "false").lower() == "true" if bypass_trufflehog and not force_trufflehog: bypass_reason = f"large repository ({file_count} files)" if file_count > max_trufflehog_files else f"low-memory container with {file_count} files" print(f" -> [L1 Secrets] {bypass_reason.capitalize()} detected. Bypassing TruffleHog to prevent OOM. Running custom regex scanner.") findings.extend(_regex_secrets_scan(repo_path, file_roles)) findings.extend(_scan_env_files(repo_path, file_roles)) return findings production_files = {p for p, r in file_roles.items() if r == "production"} # Write a temporary ignore file for TruffleHog to avoid scanning massive directories and binaries ignore_file_path = os.path.join(repo_path, ".trufflehog-ignore") ignore_patterns = [ r"node_modules", r"\.git", r"dist", r"build", r"\.venv", r"venv", r"public", r"static", r"\.png$", r"\.jpg$", r"\.jpeg$", r"\.gif$", r"\.zip$", r"\.pdf$", r"\.svg$", r"package-lock\.json$", r"yarn\.lock$", r"pnpm-lock\.yaml$", r"bun\.lockb$", r"\.map$" ] try: with open(ignore_file_path, "w", encoding="utf-8") as f: f.write("\n".join(ignore_patterns)) except Exception: pass # Check if this is a Git repository containing history (i.e. has .git folder) git_dir = os.path.join(repo_path, ".git") is_git_repo = os.path.exists(git_dir) # A shallow clone (cloned with --depth=1) has no real git history to scan. # Running TruffleHog in git mode on a shallow clone is extremely memory-intensive # and causes OOM crashes on resource-constrained environments (like 512MB Render instances) # without providing any additional security value over a filesystem scan. is_shallow = os.path.exists(os.path.join(git_dir, "shallow")) # Allow overriding git history scan via environment variables if desired force_fs = os.environ.get("VIBESEC_FORCE_FILESYSTEM_SCAN", "false").lower() == "true" enable_git_scan = os.environ.get("VIBESEC_ENABLE_GIT_SCAN", "false").lower() == "true" if is_git_repo and not is_shallow and (enable_git_scan or not force_fs): # Generate safe URI for the Git repo clone repo_uri = Path(repo_path).as_uri() print(f" -> [L1 Secrets] Git history detected. Running TruffleHog in memory-safe Git mode on: {repo_uri}") cmd = [ "trufflehog", "git", repo_uri, "--json", "--no-update", "--max-depth=50", "--trust-local-git-config", ] else: # Fall back to standard filesystem scan on active files scan_reason = "Shallow clone detected (no history)" if is_shallow else "Local files / FS fallback" print(f" -> [L1 Secrets] {scan_reason}. Running TruffleHog in filesystem mode.") cmd = [ "trufflehog", "filesystem", repo_path, "--json", "--no-update", f"--exclude-paths={ignore_file_path}", ] try: env = { **os.environ, "GOMEMLIMIT": "2GiB", } result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) # TruffleHog exits with non-zero on findings — parse stdout regardless of returncode stdout = result.stdout or "" if stdout.strip(): for line in stdout.strip().split("\n"): if not line.strip(): continue try: item = json.loads(line) except json.JSONDecodeError: continue findings.extend(_parse_trufflehog_finding(item, production_files, repo_path)) else: # TruffleHog produced no output — may have crashed silently. Run regex fallback. print(" !! [L1 Secrets warning] TruffleHog produced no output. Falling back to regex-based secret scanning.") findings.extend(_regex_secrets_scan(repo_path, file_roles)) except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: # TruffleHog not installed or crashed — fall back to regex-based scan print(f" !! [L1 Secrets warning] TruffleHog execution failed: {e}. Falling back to regex-based secret scanning.") findings.extend(_regex_secrets_scan(repo_path, file_roles)) # Clean up temporary ignore file try: if os.path.exists(ignore_file_path): os.remove(ignore_file_path) except Exception: pass # Also scan .env files explicitly (TruffleHog may miss committed env files) findings.extend(_scan_env_files(repo_path, file_roles)) return findings def _parse_trufflehog_finding(item: dict, production_files: set, repo_path: str = "") -> List[Finding]: findings = [] source_data = item.get("SourceMetadata") or {} data_block = source_data.get("Data") or {} source_meta_fs = data_block.get("Filesystem") or {} source_meta_git = data_block.get("Git") or {} file_path = source_meta_fs.get("file") or source_meta_git.get("file", "") line = source_meta_fs.get("line") or source_meta_git.get("line", 0) commit = source_meta_git.get("commit", "") email = source_meta_git.get("email", "") detector = item.get("DetectorName", "Unknown") raw = item.get("Raw", "") verified = item.get("Verified", False) rel_path = file_path if rel_path: # Clean up absolute paths or relative formatting rel_path = os.path.basename(rel_path) if "/" not in rel_path and "\\" not in rel_path else rel_path rel_path = rel_path.replace("\\", "/") if not is_valid_secret(raw, file_path=rel_path, repo_path=repo_path, secret_type=detector): return [] # Filter out browser-safe publishable keys context_line = "" if rel_path and line and repo_path: fpath = os.path.join(repo_path, rel_path) if os.path.exists(fpath): try: lines = Path(fpath).read_text(encoding="utf-8", errors="replace").splitlines() if 1 <= line <= len(lines): context_line = lines[line - 1] except Exception: pass if _is_publishable_key(raw, context_line): return [] rel_path = file_path if rel_path: # Clean up absolute paths or relative formatting rel_path = os.path.basename(rel_path) if "/" not in rel_path and "\\" not in rel_path else rel_path rel_path = rel_path.replace("\\", "/") severity = Severity.CRITICAL if verified else Severity.HIGH confidence = Confidence.HIGH if verified else Confidence.MEDIUM secret_type = "OTHER" for k, v in SECRET_TYPE_MAP.items(): if k.lower() in detector.lower(): secret_type = v break title = f"{'Live ' if verified else ''}Secret Exposed: {detector}" if commit: title += f" (Commit: {commit[:7]})" desc_loc = f"source code file '{rel_path}'" if rel_path in production_files else "repository" if commit: desc_loc += f" in historical commit {commit[:8]}" description = f"{'Verified live ' if verified else ''}API key/secret found in {desc_loc}. Detector: {detector}" if commit: explanation = ( f"A {'verified live' if verified else 'potential'} {detector} secret was found in the repository history " f"in commit {commit} (authored by {email or 'unknown'}). " f"{'This key is currently active and provides unauthorized access.' if verified else 'Verify this is not a real credential.'}" ) else: explanation = ( f"A {'verified live' if verified else 'potential'} {detector} secret was found in active files. " f"{'This key is currently active and provides unauthorized access.' if verified else 'Verify this is not a real credential.'}" ) findings.append(Finding( title=title, description=description, severity=severity, confidence=confidence, domain=ScanDomain.SECRETS, check_id=f"TRUFFLEHOG_{detector.upper().replace(' ', '_')}", check_category="secrets", file_path=rel_path or None, line_number=line or None, explanation=explanation, suggested_fix="Revoke this key immediately, rotate it, and use environment variables instead. Never commit secrets to version control. If found in history, rotate it immediately and rewrite history if possible.", validity="active" if verified else "unknown", secret_type=secret_type, raw_secret_value=raw[:8] + "..." if raw else None, )) return findings def _scan_env_files(repo_path: str, file_roles: dict) -> List[Finding]: """Specifically hunt for committed .env files — extremely common in vibe-coded apps.""" findings = [] env_patterns = [".env", ".env.local", ".env.production", ".env.development", ".env.staging"] for root, dirs, files in os.walk(repo_path): dirs[:] = [d for d in dirs if d not in {"node_modules", ".git", ".next", "dist"}] for fname in files: if fname in env_patterns or fname.endswith(".env"): fpath = os.path.join(root, fname) rel = os.path.relpath(fpath, repo_path) try: content = Path(fpath).read_text(encoding="utf-8", errors="replace") except Exception: continue for i, line in enumerate(content.splitlines(), 1): if "=" in line and not line.startswith("#"): key, _, val = line.partition("=") if val: # Clean the value by removing inline comments, quotes, and spacing val_clean = val.split("#", 1)[0].split("//", 1)[0].strip().strip("'\"`") if is_valid_secret(val_clean, file_path=rel, repo_path=repo_path, secret_type="env_var"): findings.append(Finding( title=f"Secret in Committed .env File: {key.strip()}", description=f"Environment file '{fname}' is committed to the repository and contains what appears to be a real secret value.", severity=Severity.CRITICAL, confidence=Confidence.HIGH, domain=ScanDomain.SECRETS, check_id="COMMITTED_ENV_FILE", check_category="secrets", file_path=rel, line_number=i, explanation=f"The file {rel} is committed to git and contains the variable {key.strip()} with a non-placeholder value. This exposes credentials to anyone with repo access.", suggested_fix=f"Add {fname} to .gitignore immediately. Remove from git history using `git filter-branch` or BFG Repo-Cleaner. Rotate the exposed credential.", )) return findings def _regex_secrets_scan(repo_path: str, file_roles: dict) -> List[Finding]: """Fallback when TruffleHog is not installed.""" import re findings = [] # Prefix-based detection with entropy scoring for robust secret identification. # Each entry: (regex_pattern, label, secret_type, min_entropy) # min_entropy: minimum Shannon entropy threshold to consider the match a real secret. SECRET_REGEXES = [ # OpenAI — legacy sk-XXXX and modern sk-proj-XXXX formats (r"sk-[a-zA-Z0-9]{12,}", "OpenAI API Key", "OPENAI_KEY", 3.5), (r"sk-proj-[a-zA-Z0-9_-]{20,}", "OpenAI Project API Key", "OPENAI_KEY", 3.0), # Anthropic (r"sk-ant-[a-zA-Z0-9_-]{20,}", "Anthropic API Key", "ANTHROPIC_KEY", 3.0), # Stripe (r"sk_live_[a-zA-Z0-9]{24,}", "Stripe Live Secret Key", "STRIPE_KEY", 3.5), (r"rk_live_[a-zA-Z0-9]{24,}", "Stripe Restricted Key", "STRIPE_KEY", 3.5), # AWS (r"AKIA[0-9A-Z]{16}", "AWS Access Key", "AWS_KEY", 3.0), # GitHub — PAT, OAuth, fine-grained, app (r"ghp_[a-zA-Z0-9]{36}", "GitHub Personal Token", "GITHUB_TOKEN", 3.0), (r"gho_[a-zA-Z0-9]{36}", "GitHub OAuth Token", "GITHUB_TOKEN", 3.0), (r"github_pat_[a-zA-Z0-9_]{22,}", "GitHub Fine-Grained PAT", "GITHUB_TOKEN", 3.0), # GitLab (r"glpat-[a-zA-Z0-9_-]{20,}", "GitLab PAT", "GITLAB_TOKEN", 3.0), # JWT (r"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+", "JWT Token", "JWT_SECRET", 3.5), # DB connection strings (r"(?:mongodb|postgresql|mysql|redis)://[^\s\"']+:[^\s\"'@]+@", "DB Connection String", "DB_CONNECTION_STRING", 2.5), # Supabase service role (r"service_role['\"]?\s*[:=]\s*['\"]?eyJ[a-zA-Z0-9_-]{100,}", "Supabase Service Role Key", "SUPABASE_KEY", 3.5), # Client-exposed secrets (NEXT_PUBLIC_, VITE_, REACT_APP_) (r"NEXT_PUBLIC_[A-Z_]*(?:KEY|SECRET|TOKEN)['\"]?\s*=\s*['\"]?[a-zA-Z0-9_-]{20,}", "Client-exposed Secret (NEXT_PUBLIC_)", "CLIENT_EXPOSED_SECRET", 3.5), (r"VITE_[A-Z_]*(?:KEY|SECRET|TOKEN)['\"]?\s*=\s*['\"]?[a-zA-Z0-9_-]{20,}", "Client-exposed Secret (VITE_)", "CLIENT_EXPOSED_SECRET", 3.5), (r"REACT_APP_[A-Z_]*(?:KEY|SECRET|TOKEN)['\"]?\s*=\s*['\"]?[a-zA-Z0-9_-]{20,}", "Client-exposed Secret (REACT_APP_)", "CLIENT_EXPOSED_SECRET", 3.5), ] production_files = {p for p, r in file_roles.items() if r in {"production", "config"}} for rel_path in production_files: fpath = os.path.join(repo_path, rel_path) try: # Skip files larger than 1MB to prevent OOM if os.path.getsize(fpath) > 1024 * 1024: continue content = Path(fpath).read_text(encoding="utf-8", errors="replace") except Exception: continue for pattern, name, stype, min_entropy in SECRET_REGEXES: for m in re.finditer(pattern, content): val = m.group(0) # Extract the secret portion (after the prefix) for entropy scoring # For patterns like sk-proj-XXXX, the entropy-relevant part is XXXX secret_body = val.split("-", 2)[-1] if "-" in val else val if "=" in secret_body: secret_body = secret_body.split("=", 1)[-1].strip().strip("'\"`") else: secret_body = secret_body.strip().strip("'\"`") if not is_valid_secret(secret_body, file_path=rel_path, repo_path=repo_path, secret_type=stype): continue entropy = _shannon_entropy(secret_body) if entropy < min_entropy: continue # Too low entropy — likely a placeholder or structured constant # Filter out browser-safe publishable keys line_num = content[:m.start()].count("\n") + 1 context_line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else "" if _is_publishable_key(val, context_line): continue # Publishable keys are designed to be client-exposed # CRITICAL: Filter commented-out matches to reduce false positives. # Matches in comments (single-line //, #, or block /* */) are almost never real secrets. if is_in_comment(content, m.start()): continue # Determine confidence based on entropy + character diversity if entropy > 4.0 and _has_char_diversity(secret_body): confidence = Confidence.HIGH else: confidence = Confidence.MEDIUM findings.append(Finding( title=f"Potential Secret: {name}", description=f"Pattern matching {name} found in {rel_path}", severity=Severity.HIGH, confidence=confidence, domain=ScanDomain.SECRETS, check_id=f"REGEX_{stype}", check_category="secrets", file_path=rel_path, line_number=line_num, explanation=f"A value matching the pattern for {name} was found (entropy: {entropy:.2f}). If this is a real credential, it is exposed.", suggested_fix="Move this value to environment variables and never commit credentials.", secret_type=stype, )) return findings