diff --git a/engineering/ship-gate/SKILL.md b/engineering/ship-gate/SKILL.md index 665ddb50..5243045b 100644 --- a/engineering/ship-gate/SKILL.md +++ b/engineering/ship-gate/SKILL.md @@ -188,4 +188,3 @@ This skill does not: - **adversarial-reviewer**: deep security review for items ship-gate flags as critical - **security-pen-testing**: penetration testing methodology for SEC-category findings - **code-reviewer**: general code quality review complements ship-gate's automated checks -- **focused-fix**: deep investigation and systematic repair of flagged issues diff --git a/engineering/ship-gate/scripts/ship_gate_scanner.py b/engineering/ship-gate/scripts/ship_gate_scanner.py new file mode 100644 index 00000000..c9f7a99f --- /dev/null +++ b/engineering/ship-gate/scripts/ship_gate_scanner.py @@ -0,0 +1,1231 @@ +#!/usr/bin/env python3 +""" +ship_gate_scanner.py — Pre-production audit CLI +Part of the ship-gate skill: https://github.com/rx4u/ship-gate + +Usage: + python scripts/ship_gate_scanner.py [PATH] [options] + +Options: + --json Output results as JSON + --no-color Disable ANSI color output + --no-interactive Skip manual confirmation prompts + --category CAT Only run a specific category (SEC, DB, CODE, etc.) + --verbose Show PASS results in addition to FAIL + --version Show version and exit + +Exit codes: + 0 = CLEAR TO SHIP (no critical issues) + 1 = DO NOT SHIP (critical issues found) + 2 = SHIP WITH CAUTION (high issues only) +""" + +import argparse +import json +import os +import re +import sys +import time +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import List, Optional + +VERSION = "1.0.0" + +EXCLUDE_DIRS = { + "node_modules", ".next", "dist", "build", ".git", "__pycache__", + "venv", ".venv", "vendor", "coverage", ".turbo", "out", ".cache", + ".pytest_cache", ".mypy_cache", "target", "bin", "obj", +} + +FRONTEND_DIRS = {"src", "app", "pages", "components", "public", "lib", "utils"} + +JS_EXTS = {".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"} +PY_EXTS = {".py"} +ALL_CODE_EXTS = JS_EXTS | PY_EXTS | {".go", ".rb", ".php"} +TEMPLATE_EXTS = {".html", ".jsx", ".tsx", ".vue", ".svelte"} +SQL_EXTS = {".sql", ".prisma"} + + +# --------------------------------------------------------------------------- +# ANSI helpers +# --------------------------------------------------------------------------- + +USE_COLOR = True + + +def _c(code: str, text: str) -> str: + if not USE_COLOR: + return text + return f"\033[{code}m{text}\033[0m" + + +def red(t): return _c("31", t) +def green(t): return _c("32", t) +def yellow(t): return _c("33", t) +def cyan(t): return _c("36", t) +def bold(t): return _c("1", t) +def dim(t): return _c("2", t) + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +class Status(str, Enum): + PASS = "PASS" + FAIL = "FAIL" + SKIP = "SKIP" + MANUAL = "MANUAL" + + +class Severity(str, Enum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + ADVISORY = "ADVISORY" + + +@dataclass +class Finding: + file: str + line: int + snippet: str = "" + + +@dataclass +class CheckDef: + id: str + description: str + severity: Severity + category: str + stack: str = "all" # "all", "js", "ts", "react", "supabase", "ai", "web", "vps" + + +@dataclass +class Result: + check: CheckDef + status: Status + message: str = "" + findings: List[Finding] = field(default_factory=list) + + +@dataclass +class Stack: + has_node: bool = False + framework: str = "" # next, react, vue, svelte, astro, express, fastify, hono + has_python: bool = False + py_framework: str = "" # django, flask, fastapi + has_go: bool = False + has_rust: bool = False + has_supabase: bool = False + has_typescript: bool = False + has_react: bool = False + deploy_target: str = "" # vercel, netlify, docker, fly, railway + has_ai: bool = False + ai_providers: List[str] = field(default_factory=list) + is_web: bool = False + + +# --------------------------------------------------------------------------- +# File walking / grep helpers +# --------------------------------------------------------------------------- + +def walk_files(root: str, exts: Optional[set] = None, dirs: Optional[set] = None): + """Yield (filepath, relpath) for all files under root, skipping EXCLUDE_DIRS.""" + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] + if dirs is not None: + rel = os.path.relpath(dirpath, root) + top = rel.split(os.sep)[0] + if rel != "." and top not in dirs: + dirnames[:] = [] + continue + for fname in filenames: + if exts is None or os.path.splitext(fname)[1].lower() in exts: + fpath = os.path.join(dirpath, fname) + yield fpath, os.path.relpath(fpath, root) + + +def grep_files( + root: str, + pattern: str, + exts: Optional[set] = None, + dirs: Optional[set] = None, + flags: int = 0, + max_findings: int = 20, + exclude_patterns: Optional[List[str]] = None, +) -> List[Finding]: + """Return up to max_findings matches across the codebase.""" + try: + rx = re.compile(pattern, flags) + except re.error: + return [] + + exclude_rxs = [] + if exclude_patterns: + for ep in exclude_patterns: + try: + exclude_rxs.append(re.compile(ep)) + except re.error: + pass + + results: List[Finding] = [] + for fpath, relpath in walk_files(root, exts, dirs): + if any(seg in fpath for seg in (".test.", ".spec.", ".config.")): + if exts and exts <= JS_EXTS: + skip = True + # still yield for config-specific checks + if "tsconfig" in fpath or "package.json" in fpath: + skip = False + if skip: + continue + try: + with open(fpath, "r", encoding="utf-8", errors="ignore") as fh: + for lineno, line in enumerate(fh, 1): + if rx.search(line): + if any(ex.search(line) for ex in exclude_rxs): + continue + results.append(Finding( + file=relpath, + line=lineno, + snippet=line.rstrip()[:120], + )) + if len(results) >= max_findings: + return results + except (OSError, PermissionError): + continue + return results + + +def file_exists_in(root: str, *names: str) -> Optional[str]: + """Return the first found path among names (searched recursively up to depth 5).""" + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] + depth = dirpath.replace(root, "").count(os.sep) + if depth >= 5: + dirnames[:] = [] + continue + for fname in filenames: + if fname in names: + return os.path.join(dirpath, fname) + return None + + +def read_json_file(path: str) -> dict: + try: + with open(path) as f: + return json.load(f) + except Exception: + return {} + + +# --------------------------------------------------------------------------- +# Stack detection +# --------------------------------------------------------------------------- + +def detect_stack(root: str) -> Stack: + s = Stack() + pkg_path = os.path.join(root, "package.json") + if os.path.isfile(pkg_path): + s.has_node = True + pkg = read_json_file(pkg_path) + all_deps = {} + for key in ("dependencies", "devDependencies", "peerDependencies"): + all_deps.update(pkg.get(key, {})) + + if "next" in all_deps: s.framework = "next" + elif "react" in all_deps: s.framework = "react" + elif "vue" in all_deps: s.framework = "vue" + elif "svelte" in all_deps: s.framework = "svelte" + elif "astro" in all_deps: s.framework = "astro" + elif "express" in all_deps: s.framework = "express" + elif "fastify" in all_deps: s.framework = "fastify" + elif "hono" in all_deps: s.framework = "hono" + + s.has_react = s.framework in ("next", "react") + s.is_web = s.framework in ("next", "react", "vue", "svelte", "astro") + + if "@supabase/supabase-js" in all_deps: + s.has_supabase = True + if "typescript" in all_deps or os.path.isfile(os.path.join(root, "tsconfig.json")): + s.has_typescript = True + + for ai_pkg in ("openai", "@anthropic-ai/sdk", "@google/generative-ai", + "ai", "@huggingface/inference"): + if ai_pkg in all_deps: + s.has_ai = True + s.ai_providers.append(ai_pkg) + + if os.path.isdir(os.path.join(root, "supabase")): + s.has_supabase = True + + for pyfile in ("requirements.txt", "pyproject.toml", "Pipfile", "setup.py"): + if os.path.isfile(os.path.join(root, pyfile)): + s.has_python = True + try: + content = open(os.path.join(root, pyfile)).read().lower() + if "django" in content: s.py_framework = "django" + elif "flask" in content: s.py_framework = "flask" + elif "fastapi" in content: s.py_framework = "fastapi" + except Exception: + pass + break + + if os.path.isfile(os.path.join(root, "go.mod")): + s.has_go = True + if os.path.isfile(os.path.join(root, "Cargo.toml")): + s.has_rust = True + + if os.path.isfile(os.path.join(root, "vercel.json")) or \ + os.path.isdir(os.path.join(root, ".vercel")): + s.deploy_target = "vercel" + elif os.path.isfile(os.path.join(root, "netlify.toml")): + s.deploy_target = "netlify" + elif os.path.isfile(os.path.join(root, "fly.toml")): + s.deploy_target = "fly" + elif os.path.isfile(os.path.join(root, "railway.json")): + s.deploy_target = "railway" + elif os.path.isfile(os.path.join(root, "Dockerfile")): + s.deploy_target = "docker" + + return s + + +# --------------------------------------------------------------------------- +# Check definitions +# --------------------------------------------------------------------------- + +CHECKS = { + # SEC + "SEC-01": CheckDef("SEC-01", "No API keys or secrets in frontend code", Severity.CRITICAL, "SEC"), + "SEC-04": CheckDef("SEC-04", "CORS not wildcard", Severity.CRITICAL, "SEC"), + "SEC-05": CheckDef("SEC-05", "CSRF protection on state-changing endpoints", Severity.CRITICAL, "SEC"), + "SEC-06": CheckDef("SEC-06", "Input validated and sanitized server-side", Severity.HIGH, "SEC"), + "SEC-07": CheckDef("SEC-07", "Rate limiting on auth and sensitive endpoints", Severity.HIGH, "SEC"), + "SEC-08": CheckDef("SEC-08", "Passwords hashed with bcrypt or argon2", Severity.CRITICAL, "SEC"), + "SEC-11": CheckDef("SEC-11", "CSP headers configured", Severity.HIGH, "SEC"), + "SEC-13": CheckDef("SEC-13", "No eval() or dangerouslySetInnerHTML without sanitization", Severity.HIGH, "SEC", stack="js"), + "SEC-14": CheckDef("SEC-14", "No sensitive data in URLs or logs", Severity.HIGH, "SEC"), + "SEC-17": CheckDef("SEC-17", "No hardcoded secrets in .env committed to repo", Severity.CRITICAL, "SEC"), + "SEC-18": CheckDef("SEC-18", ".env files listed in .gitignore", Severity.CRITICAL, "SEC"), + # DB + "DB-03": CheckDef("DB-03", "Parameterized queries everywhere (no SQL injection)", Severity.CRITICAL, "DB"), + "DB-05": CheckDef("DB-05", "Connection pooling configured", Severity.HIGH, "DB"), + "DB-06": CheckDef("DB-06", "Migrations in version control", Severity.HIGH, "DB"), + "DB-07": CheckDef("DB-07", "RLS enabled on all Supabase tables", Severity.CRITICAL, "DB", stack="supabase"), + "DB-08": CheckDef("DB-08", "No service_role key in client-side code", Severity.CRITICAL, "DB", stack="supabase"), + "DB-12": CheckDef("DB-12", "No PII stored unencrypted", Severity.HIGH, "DB"), + # DEPLOY + "DEPLOY-09": CheckDef("DEPLOY-09", "Health check endpoint exists", Severity.HIGH, "DEPLOY"), + "DEPLOY-10": CheckDef("DEPLOY-10", "Structured logging (not raw console)", Severity.HIGH, "DEPLOY"), + # CODE + "CODE-01": CheckDef("CODE-01", "No console.log in production build", Severity.HIGH, "CODE", stack="js"), + "CODE-03": CheckDef("CODE-03", "No empty catch blocks", Severity.HIGH, "CODE"), + "CODE-07": CheckDef("CODE-07", "No TODO-auth or TODO-security patterns", Severity.CRITICAL, "CODE"), + "CODE-09": CheckDef("CODE-09", "React error boundaries in place", Severity.HIGH, "CODE", stack="react"), + "CODE-10": CheckDef("CODE-10", "No leaked stack traces in error responses", Severity.HIGH, "CODE"), + "CODE-11": CheckDef("CODE-11", "No eslint-disable on security rules", Severity.HIGH, "CODE", stack="js"), + "CODE-12": CheckDef("CODE-12", "Lockfile committed", Severity.HIGH, "CODE"), + "CODE-13": CheckDef("CODE-13", "No wildcard versions in package.json", Severity.HIGH, "CODE", stack="js"), + "CODE-14": CheckDef("CODE-14", "TypeScript strict mode enabled", Severity.ADVISORY, "CODE", stack="ts"), + # AI + "AI-01": CheckDef("AI-01", "System prompts not leakable via user input", Severity.CRITICAL, "AI", stack="ai"), + "AI-02": CheckDef("AI-02", "No prompt injection vectors in user inputs", Severity.CRITICAL, "AI", stack="ai"), + "AI-03": CheckDef("AI-03", "LLM API keys not in frontend code", Severity.CRITICAL, "AI", stack="ai"), + "AI-05": CheckDef("AI-05", "AI response output sanitized before rendering", Severity.HIGH, "AI", stack="ai"), + # DEP + "DEP-01": CheckDef("DEP-01", "No git:// or URL-based dependencies", Severity.HIGH, "DEP"), + "DEP-05": CheckDef("DEP-05", "No suspicious postinstall scripts", Severity.HIGH, "DEP", stack="js"), + "DEP-06": CheckDef("DEP-06", "Dependencies pinned (no wildcard *)", Severity.HIGH, "DEP"), + # FE + "FE-01": CheckDef("FE-01", "Meta tags present (title, description, OG)", Severity.ADVISORY, "FE", stack="web"), + "FE-02": CheckDef("FE-02", "Favicon configured", Severity.ADVISORY, "FE", stack="web"), + "FE-03": CheckDef("FE-03", "Custom 404 page exists", Severity.ADVISORY, "FE", stack="web"), + "FE-09": CheckDef("FE-09", "robots.txt present", Severity.ADVISORY, "FE", stack="web"), + # OBS + "OBS-01": CheckDef("OBS-01", "Error monitoring configured (Sentry, etc.)", Severity.ADVISORY, "OBS"), + "OBS-03": CheckDef("OBS-03", "Structured logging with request IDs", Severity.ADVISORY, "OBS"), +} + +MANUAL_CHECKS = [ + CheckDef("SEC-02", "Every route checks authentication", Severity.CRITICAL, "SEC"), + CheckDef("SEC-03", "HTTPS enforced, HTTP redirected", Severity.CRITICAL, "SEC"), + CheckDef("SEC-10", "Sessions invalidated on logout (server-side)", Severity.HIGH, "SEC"), + CheckDef("DB-01", "Backups configured and tested", Severity.CRITICAL, "DB"), + CheckDef("DB-02", "Backup restore tested (not just backup)", Severity.CRITICAL, "DB"), + CheckDef("DB-04", "Separate dev and production databases", Severity.HIGH, "DB"), + CheckDef("DB-11", "App uses a non-root DB user", Severity.HIGH, "DB"), + CheckDef("DEPLOY-01", "All env vars set on production server", Severity.CRITICAL, "DEPLOY"), + CheckDef("DEPLOY-02", "SSL certificate installed and valid", Severity.CRITICAL, "DEPLOY"), + CheckDef("DEPLOY-05", "Rollback plan exists", Severity.HIGH, "DEPLOY"), + CheckDef("DEPLOY-06", "Staging test passed before production", Severity.HIGH, "DEPLOY"), + CheckDef("AI-07", "Agent permissions scoped (no unrestricted access)", Severity.HIGH, "AI", stack="ai"), + CheckDef("AI-08", "No sensitive data sent to third-party LLMs without consent", Severity.HIGH, "AI", stack="ai"), + CheckDef("FE-04", "Responsive design tested on mobile", Severity.HIGH, "FE", stack="web"), + CheckDef("OBS-05", "Uptime monitoring configured", Severity.HIGH, "OBS"), +] + + +# --------------------------------------------------------------------------- +# Individual check implementations +# --------------------------------------------------------------------------- + +def check_sec01(root, stack): + c = CHECKS["SEC-01"] + dirs = FRONTEND_DIRS & set(os.listdir(root)) + patterns = [ + r"sk-[a-zA-Z0-9]{20,}", + r"sk-ant-[a-zA-Z0-9-]+", + r"sk-proj-[a-zA-Z0-9-]+", + r"AIza[a-zA-Z0-9_-]{35}", + r"ghp_[a-zA-Z0-9]{36}", + r"glpat-[a-zA-Z0-9_-]{20,}", + r"AKIA[0-9A-Z]{16}", + r"sk_live_[a-zA-Z0-9]{24,}", + r"(api_key|apikey|api_secret|secret_key|auth_token)\s*[:=]\s*['\"][a-zA-Z0-9_\-]{16,}", + ] + findings = [] + for pat in patterns: + findings += grep_files(root, pat, exts=JS_EXTS | {".env", ".json"}, + dirs=dirs if dirs else None, max_findings=5) + if findings: + return Result(c, Status.FAIL, + f"{len(findings)} potential secret(s) found in frontend/client code", + findings[:10]) + return Result(c, Status.PASS) + + +def check_sec04(root, stack): + c = CHECKS["SEC-04"] + findings = grep_files(root, r"(origin\s*:\s*['\"]?\*['\"]?|Access-Control-Allow-Origin.*\*|cors\(\s*\))", + exts=ALL_CODE_EXTS) + if findings: + return Result(c, Status.FAIL, "CORS wildcard (*) detected", findings) + return Result(c, Status.PASS) + + +def check_sec05(root, stack): + c = CHECKS["SEC-05"] + # Check for state-changing routes + route_findings = grep_files(root, r"(app|router)\.(post|put|patch|delete)\s*\(", + exts=JS_EXTS) + if not route_findings: + return Result(c, Status.SKIP, "No Express-style routes found") + # Check for CSRF protection + csrf_findings = grep_files(root, r"(csrf|csrfToken|_csrf|CSRF_COOKIE|csurf)", + exts=ALL_CODE_EXTS) + if not csrf_findings: + return Result(c, Status.FAIL, + f"{len(route_findings)} state-changing route(s) found but no CSRF protection detected", + route_findings[:5]) + return Result(c, Status.PASS) + + +def check_sec06(root, stack): + c = CHECKS["SEC-06"] + # Check for validation library + val_findings = grep_files(root, + r"(from ['\"]zod['\"]|from ['\"]yup['\"]|from ['\"]joi['\"]|from ['\"]class-validator['\"]|from pydantic|import pydantic)", + exts=ALL_CODE_EXTS) + if val_findings: + return Result(c, Status.PASS) + # Check if there are API routes that use req.body without validation + body_findings = grep_files(root, r"(req\.body|request\.json\(\)|request\.form)", + exts=ALL_CODE_EXTS) + if body_findings: + return Result(c, Status.FAIL, + "request body used without a validation library (zod/yup/joi/pydantic)", + body_findings[:5]) + return Result(c, Status.SKIP, "No API route body handling detected") + + +def check_sec07(root, stack): + c = CHECKS["SEC-07"] + findings = grep_files(root, + r"(express-rate-limit|@upstash/ratelimit|rate-limiter-flexible|slowapi|throttle|rateLimit)", + exts=ALL_CODE_EXTS | {".json"}) + if findings: + return Result(c, Status.PASS) + # Only fail if there are auth-related routes + auth_routes = grep_files(root, r"(login|signin|register|signup|forgot.password|reset.password)", + exts=ALL_CODE_EXTS) + if auth_routes: + return Result(c, Status.FAIL, + "Auth routes found but no rate-limiting library detected", auth_routes[:3]) + return Result(c, Status.SKIP, "No auth routes detected") + + +def check_sec08(root, stack): + c = CHECKS["SEC-08"] + # Weak hash for passwords + weak = grep_files(root, r"\b(md5|sha1|sha256)\s*\(", + exts=ALL_CODE_EXTS, + exclude_patterns=[r"//.*\b(md5|sha1|sha256)\b"]) + if weak: + return Result(c, Status.FAIL, "Weak hashing algorithm (md5/sha1/sha256) detected", weak) + strong = grep_files(root, r"(bcrypt|argon2|scrypt|pbkdf2)", exts=ALL_CODE_EXTS) + pw_fields = grep_files(root, r"(password|passwd)", exts=ALL_CODE_EXTS) + if pw_fields and not strong: + return Result(c, Status.FAIL, "Password fields found but no bcrypt/argon2/scrypt usage") + return Result(c, Status.PASS if strong or not pw_fields else Status.SKIP) + + +def check_sec11(root, stack): + c = CHECKS["SEC-11"] + findings = grep_files(root, r"(Content-Security-Policy|contentSecurityPolicy|[^a-z]csp[^a-z])", + exts=ALL_CODE_EXTS | {".json", ".toml", ".yaml", ".yml"}) + if findings: + return Result(c, Status.PASS) + return Result(c, Status.FAIL, "No Content-Security-Policy configuration found") + + +def check_sec13(root, stack): + c = CHECKS["SEC-13"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a JS/TS project") + eval_findings = grep_files(root, r"(\beval\s*\(|new\s+Function\s*\()", exts=JS_EXTS) + dsi_findings = grep_files(root, r"dangerouslySetInnerHTML", exts=JS_EXTS) + # If dangerouslySetInnerHTML is used, check for DOMPurify + unsafe_dsi = [] + for f in dsi_findings: + try: + content = open(os.path.join(root, f.file), errors="ignore").read() + if "DOMPurify" not in content and "sanitize" not in content.lower(): + unsafe_dsi.append(f) + except Exception: + unsafe_dsi.append(f) + all_findings = eval_findings + unsafe_dsi + if all_findings: + return Result(c, Status.FAIL, "Unsafe eval() or unsanitized dangerouslySetInnerHTML", all_findings) + return Result(c, Status.PASS) + + +def check_sec14(root, stack): + c = CHECKS["SEC-14"] + url_findings = grep_files(root, + r"(password|token|secret|key|ssn|credit.card)=", + exts=ALL_CODE_EXTS) + log_findings = grep_files(root, + r"console\.(log|info|debug)\s*\(\s*(req|request)\s*\)", + exts=JS_EXTS) + findings = url_findings + log_findings + if findings: + return Result(c, Status.FAIL, "Sensitive data may appear in URLs or logs", findings[:5]) + return Result(c, Status.PASS) + + +def check_sec17(root, stack): + c = CHECKS["SEC-17"] + # Check for .env files that are not .example/.sample + env_files = [] + for entry in os.scandir(root): + name = entry.name + if name.startswith(".env") and name not in (".env.example", ".env.sample", + ".env.template", ".env.local.example"): + if entry.is_file(): + env_files.append(name) + if not env_files: + return Result(c, Status.PASS) + # Check if git-tracked + gitignore_path = os.path.join(root, ".gitignore") + if os.path.isfile(gitignore_path): + content = open(gitignore_path, errors="ignore").read() + if ".env" in content: + return Result(c, Status.PASS) + return Result(c, Status.FAIL, + f".env file(s) exist ({', '.join(env_files)}) and may not be gitignored", + [Finding(f, 0) for f in env_files]) + + +def check_sec18(root, stack): + c = CHECKS["SEC-18"] + gitignore_path = os.path.join(root, ".gitignore") + if not os.path.isfile(gitignore_path): + return Result(c, Status.FAIL, ".gitignore file not found") + content = open(gitignore_path, errors="ignore").read() + if re.search(r"\.env", content): + return Result(c, Status.PASS) + return Result(c, Status.FAIL, ".env not listed in .gitignore") + + +def check_db03(root, stack): + c = CHECKS["DB-03"] + # Template literal SQL + tl_findings = grep_files(root, + r"(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE).*\$\{", + exts=JS_EXTS) + # Python f-string SQL + py_findings = grep_files(root, + r'f["\'].*\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE)\b.*\{', + exts=PY_EXTS) + # String concat SQL + concat_findings = grep_files(root, + r"(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE).*\+\s*(req\.|params\.|body\.|query\.)", + exts=ALL_CODE_EXTS) + all_findings = tl_findings + py_findings + concat_findings + if all_findings: + return Result(c, Status.FAIL, + f"{len(all_findings)} potential SQL injection vector(s)", all_findings[:10]) + return Result(c, Status.PASS) + + +def check_db05(root, stack): + c = CHECKS["DB-05"] + findings = grep_files(root, + r"(pool|connectionLimit|max_connections|poolSize|pooler|6543)", + exts=ALL_CODE_EXTS | {".env", ".env.local", ".env.production"}) + if findings: + return Result(c, Status.PASS) + db_found = grep_files(root, r"(pg\.|postgres\.|mysql\.|mongoose\.)", exts=ALL_CODE_EXTS) + if db_found: + return Result(c, Status.FAIL, "Database usage detected but no connection pooling configured") + return Result(c, Status.SKIP, "No direct DB connection detected") + + +def check_db06(root, stack): + c = CHECKS["DB-06"] + migration_dirs = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] + depth = dirpath.replace(root, "").count(os.sep) + if depth >= 4: + dirnames[:] = [] + continue + for d in dirnames: + if d in ("migrations", "migrate", "versions", "alembic"): + migration_dirs.append(os.path.join(dirpath, d)) + if migration_dirs: + return Result(c, Status.PASS) + # Check for database usage + db_found = grep_files(root, r"(prisma|supabase|mongoose|pg\.|sqlite)", exts=ALL_CODE_EXTS) + if db_found: + return Result(c, Status.FAIL, "Database usage found but no migrations directory detected") + return Result(c, Status.SKIP, "No database usage detected") + + +def check_db07(root, stack): + c = CHECKS["DB-07"] + if not stack.has_supabase: + return Result(c, Status.SKIP, "Not a Supabase project") + sql_findings = grep_files(root, r"CREATE TABLE", exts=SQL_EXTS) + if not sql_findings: + return Result(c, Status.SKIP, "No CREATE TABLE statements found in migrations") + rls_findings = grep_files(root, r"ENABLE ROW LEVEL SECURITY", exts=SQL_EXTS) + if not rls_findings: + return Result(c, Status.FAIL, + f"{len(sql_findings)} table(s) found but no RLS policies detected", + sql_findings[:5]) + if len(rls_findings) < len(sql_findings): + return Result(c, Status.FAIL, + f"{len(sql_findings)} table(s) but only {len(rls_findings)} RLS statement(s) — some tables may lack RLS", + sql_findings[:5]) + return Result(c, Status.PASS) + + +def check_db08(root, stack): + c = CHECKS["DB-08"] + if not stack.has_supabase: + return Result(c, Status.SKIP, "Not a Supabase project") + dirs = FRONTEND_DIRS & set(os.listdir(root)) + findings = grep_files(root, + r"(service_role|serviceRole|SUPABASE_SERVICE_ROLE)", + exts=JS_EXTS, dirs=dirs if dirs else None) + if findings: + return Result(c, Status.FAIL, "service_role key referenced in client-side code", findings) + return Result(c, Status.PASS) + + +def check_db12(root, stack): + c = CHECKS["DB-12"] + findings = grep_files(root, + r"(ssn|social_security|credit_card|card_number|passport_number)", + exts=SQL_EXTS | {".prisma"}, flags=re.IGNORECASE) + if findings: + return Result(c, Status.FAIL, + "PII column names found in schema — verify encryption at rest", findings) + return Result(c, Status.PASS) + + +def check_deploy09(root, stack): + c = CHECKS["DEPLOY-09"] + findings = grep_files(root, + r"(/health|/healthz|/api/health|/status|/readyz)", + exts=ALL_CODE_EXTS) + if findings: + return Result(c, Status.PASS) + return Result(c, Status.FAIL, "No health check endpoint found") + + +def check_deploy10(root, stack): + c = CHECKS["DEPLOY-10"] + # Check for logging libraries + lib_findings = grep_files(root, + r"(winston|pino|bunyan|morgan|log4js|structlog|loguru)", + exts=ALL_CODE_EXTS | {".json"}) + if lib_findings: + return Result(c, Status.PASS) + # Count console.log in server/api code + server_dirs = {"api", "server", "backend"} + for d in ("pages/api", "app/api"): + if os.path.isdir(os.path.join(root, d)): + server_dirs.add(d.split("/")[0]) + console_findings = grep_files(root, r"console\.(log|debug|info)\(", exts=JS_EXTS) + if console_findings: + return Result(c, Status.FAIL, + f"No structured logger found; {len(console_findings)} console.log(s) in code", + console_findings[:5]) + return Result(c, Status.SKIP, "No server-side code detected") + + +def check_code01(root, stack): + c = CHECKS["CODE-01"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a JS/TS project") + findings = grep_files(root, r"console\.(log|debug|info)\(", + exts=JS_EXTS, + dirs=FRONTEND_DIRS & set(os.listdir(root)) or None, + exclude_patterns=[r"//.*console\.(log|debug|info)\("]) + if findings: + return Result(c, Status.FAIL, f"{len(findings)} console.log statement(s) in production code", findings[:10]) + return Result(c, Status.PASS) + + +def check_code03(root, stack): + c = CHECKS["CODE-03"] + findings = grep_files(root, + r"catch\s*\([^)]*\)\s*\{\s*\}", + exts=ALL_CODE_EXTS) + if findings: + return Result(c, Status.FAIL, f"{len(findings)} empty catch block(s)", findings) + return Result(c, Status.PASS) + + +def check_code07(root, stack): + c = CHECKS["CODE-07"] + findings = grep_files(root, + r"(TODO|FIXME|HACK|XXX).{0,20}(auth|security|permission|validation|sanitiz)", + exts=ALL_CODE_EXTS, flags=re.IGNORECASE) + if findings: + return Result(c, Status.FAIL, f"{len(findings)} deferred security TODO(s)", findings) + return Result(c, Status.PASS) + + +def check_code09(root, stack): + c = CHECKS["CODE-09"] + if not stack.has_react: + return Result(c, Status.SKIP, "Not a React project") + # Next.js App Router: error.tsx + error_page = file_exists_in(root, "error.tsx", "error.jsx", "global-error.tsx") + if error_page: + return Result(c, Status.PASS) + # Class-based error boundary + eb_findings = grep_files(root, + r"(ErrorBoundary|componentDidCatch|getDerivedStateFromError)", + exts=JS_EXTS) + if eb_findings: + return Result(c, Status.PASS) + return Result(c, Status.FAIL, "No React error boundary or error.tsx found") + + +def check_code10(root, stack): + c = CHECKS["CODE-10"] + findings = grep_files(root, + r"(error\.stack|\.stack\s*\)|err\.message.*res\.(json|send)|traceback\.format_exc)", + exts=ALL_CODE_EXTS) + if findings: + return Result(c, Status.FAIL, "Potential stack trace leak in error responses", findings) + return Result(c, Status.PASS) + + +def check_code11(root, stack): + c = CHECKS["CODE-11"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a JS/TS project") + findings = grep_files(root, + r"eslint-disable.*(no-eval|no-implied-eval|no-script-url|security)", + exts=JS_EXTS) + if findings: + return Result(c, Status.FAIL, "Security lint rule(s) disabled", findings) + return Result(c, Status.PASS) + + +def check_code12(root, stack): + c = CHECKS["CODE-12"] + lockfiles = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", + "Pipfile.lock", "poetry.lock", "Gemfile.lock", "go.sum", "Cargo.lock"] + for lf in lockfiles: + if os.path.isfile(os.path.join(root, lf)): + return Result(c, Status.PASS) + return Result(c, Status.FAIL, "No lockfile found — dependencies are not pinned") + + +def check_code13(root, stack): + c = CHECKS["CODE-13"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a JS/TS project") + pkg_path = os.path.join(root, "package.json") + if not os.path.isfile(pkg_path): + return Result(c, Status.SKIP) + findings = grep_files(root, r'"[^"]+"\s*:\s*"\*"', exts={".json"}) + findings += grep_files(root, r'"[^"]+"\s*:\s*"latest"', exts={".json"}) + findings = [f for f in findings if "package.json" in f.file and "node_modules" not in f.file] + if findings: + return Result(c, Status.FAIL, "Wildcard (*) or 'latest' version found in package.json", findings) + return Result(c, Status.PASS) + + +def check_code14(root, stack): + c = CHECKS["CODE-14"] + if not stack.has_typescript: + return Result(c, Status.SKIP, "Not a TypeScript project") + tsconfig_path = os.path.join(root, "tsconfig.json") + if not os.path.isfile(tsconfig_path): + return Result(c, Status.SKIP, "tsconfig.json not found") + content = open(tsconfig_path, errors="ignore").read() + if re.search(r'"strict"\s*:\s*true', content): + return Result(c, Status.PASS) + return Result(c, Status.FAIL, "TypeScript strict mode not enabled in tsconfig.json", + [Finding("tsconfig.json", 0)]) + + +def check_ai01(root, stack): + c = CHECKS["AI-01"] + if not stack.has_ai: + return Result(c, Status.SKIP, "No AI/LLM usage detected") + dirs = FRONTEND_DIRS & set(os.listdir(root)) + findings = grep_files(root, + r"(system.?prompt|system.?message|system_instruction)", + exts=ALL_CODE_EXTS, dirs=dirs if dirs else None, flags=re.IGNORECASE) + if findings: + return Result(c, Status.FAIL, + "System prompt referenced in client-accessible code — may be leakable", + findings) + return Result(c, Status.PASS) + + +def check_ai02(root, stack): + c = CHECKS["AI-02"] + if not stack.has_ai: + return Result(c, Status.SKIP, "No AI/LLM usage detected") + findings = grep_files(root, + r"(messages\.push|content\s*:.*\$\{|content\s*:.*\+\s*user|prompt.*\+)", + exts=ALL_CODE_EXTS) + if findings: + return Result(c, Status.FAIL, + "User input may be concatenated directly into AI prompt", findings[:5]) + return Result(c, Status.PASS) + + +def check_ai03(root, stack): + c = CHECKS["AI-03"] + if not stack.has_ai: + return Result(c, Status.SKIP, "No AI/LLM usage detected") + dirs = FRONTEND_DIRS & set(os.listdir(root)) + findings = grep_files(root, + r"(OPENAI_API_KEY|ANTHROPIC_API_KEY|GOOGLE_AI_API_KEY|sk-ant-|sk-proj-)", + exts=JS_EXTS, dirs=dirs if dirs else None) + if findings: + return Result(c, Status.FAIL, "LLM API key referenced in frontend code", findings) + return Result(c, Status.PASS) + + +def check_ai05(root, stack): + c = CHECKS["AI-05"] + if not stack.has_ai: + return Result(c, Status.SKIP, "No AI/LLM usage detected") + findings = grep_files(root, + r"dangerouslySetInnerHTML.*\b(response|result|completion|message|content)\b", + exts=JS_EXTS) + if findings: + return Result(c, Status.FAIL, "AI output rendered via dangerouslySetInnerHTML", findings) + return Result(c, Status.PASS) + + +def check_dep01(root, stack): + c = CHECKS["DEP-01"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a Node.js project") + findings = grep_files(root, + r'"[^"]+"\s*:\s*"(git://|git\+|github:|https://github\.com|file:)', + exts={".json"}) + findings = [f for f in findings if "package.json" in f.file and "node_modules" not in f.file] + if findings: + return Result(c, Status.FAIL, "Git/URL-based dependency found in package.json", findings) + return Result(c, Status.PASS) + + +def check_dep05(root, stack): + c = CHECKS["DEP-05"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a Node.js project") + pkg_path = os.path.join(root, "package.json") + if not os.path.isfile(pkg_path): + return Result(c, Status.SKIP) + pkg = read_json_file(pkg_path) + scripts = pkg.get("scripts", {}) + suspicious = [] + for key in ("preinstall", "postinstall", "install"): + val = scripts.get(key, "") + if val and any(kw in val for kw in ("curl", "wget", "fetch", "exec", "eval", "sh ", "bash ")): + suspicious.append(Finding("package.json", 0, f'"{key}": "{val}"')) + if suspicious: + return Result(c, Status.FAIL, "Suspicious install script detected in package.json", suspicious) + return Result(c, Status.PASS) + + +def check_dep06(root, stack): + c = CHECKS["DEP-06"] + if not stack.has_node: + return Result(c, Status.SKIP, "Not a Node.js project") + pkg_path = os.path.join(root, "package.json") + if not os.path.isfile(pkg_path): + return Result(c, Status.SKIP) + findings = grep_files(root, r'"\*"', exts={".json"}) + findings = [f for f in findings if "package.json" in f.file and "node_modules" not in f.file] + if findings: + return Result(c, Status.FAIL, "Wildcard (*) version found", findings) + return Result(c, Status.PASS) + + +def check_fe01(root, stack): + c = CHECKS["FE-01"] + if not stack.is_web and not stack.has_node: + return Result(c, Status.SKIP, "Not a web project") + # Next.js metadata export + meta_findings = grep_files(root, + r"(export\s+(const|async\s+function)\s+metadata|generateMetadata|