vibesec-backend / scanner /layer1_secrets.py
dhruvkachhela
feat: upgrade scanner with route-middleware modeling, adaptive context triage, and memory scaling
45406d8
Raw
History Blame Contribute Delete
24.1 kB
"""
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