#!/usr/bin/env python3
"""
preflight - a pre-launch leak check for solo-built (often AI-assisted) apps.

It reads a project folder and reports the handful of mistakes that most often
expose real user data after launch: secrets that look committed, a service-role
key in browser code, Postgres tables with no row-level security, and public
storage buckets.

No network. No dependencies. Nothing is uploaded. It only reads files.

    python3 preflight.py [project_dir]

Exit code is 1 if anything HIGH was found, else 0, so you can wire it into CI.
"""

import argparse
import os
import re
import sys

SKIP_DIRS = {
    ".git", "node_modules", ".next", "dist", "build", "out", ".venv", "venv",
    "__pycache__", ".cache", "coverage", ".turbo", ".svelte-kit", "vendor",
}
TEXT_EXT = {
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte",
    ".html", ".css", ".scss", ".sql", ".env", ".sh", ".yaml", ".yml", ".toml",
    ".rb", ".go", ".php", ".java", ".kt", ".cs", ".swift", ".json", ".md",
}
MAX_BYTES = 1_000_000
CLIENT_HINT = re.compile(
    r"(^|/)(src|app|pages|components|public|static|client|frontend|web)(/|$)", re.I
)
ENV_FILES = (".env", ".env.local", ".env.production", ".env.development",
             ".env.staging", ".env.prod")
SECRETISH = [
    (re.compile(r"service_role", re.I), "a Supabase service-role key"),
    (re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"), "an API secret key (sk-...)"),
    (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
     "a JWT (signing key or service token)"),
    (re.compile(r"\b(AKIA[0-9A-Z]{16})\b"), "an AWS access key id"),
    (re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), "a Google API key"),
]
PUBLIC_ENV = re.compile(r"\b(NEXT_PUBLIC_|VITE_|PUBLIC_|REACT_APP_)[A-Z0-9_]+", )
CREATE_TABLE = re.compile(
    r"create\s+table\s+(?:if\s+not\s+exists\s+)?[\"']?([a-zA-Z0-9_.\"']+)[\"']?",
    re.I)
ENABLE_RLS = re.compile(
    r"alter\s+table\s+[\"']?([a-zA-Z0-9_.\"']+)[\"']?\s+enable\s+row\s+level\s+security",
    re.I)
PUBLIC_BUCKET = re.compile(
    r"(public\s*[:=]\s*true|create\s+bucket\s*\([^)]*public\s*=>\s*true)", re.I)

FINDINGS = []


def add(sev, path, line, title, why, fix):
    FINDINGS.append({
        "sev": sev, "path": path or "-", "line": line,
        "title": title, "why": why, "fix": fix,
    })


def read_text(path):
    try:
        with open(path, "r", encoding="utf-8", errors="ignore") as fh:
            return fh.read(MAX_BYTES)
    except OSError:
        return None


def walk(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in filenames:
            yield os.path.join(dirpath, name)


def rel(root, path):
    return os.path.relpath(path, root).replace(os.sep, "/")


def looks_like_client(relpath):
    return bool(CLIENT_HINT.search(relpath))


def parse_gitignore(root):
    gi = os.path.join(root, ".gitignore")
    text = read_text(gi) or ""
    patterns = [ln.strip() for ln in text.splitlines()
                if ln.strip() and not ln.startswith("#")]
    return patterns


def env_ignored(env_name, patterns):
    for pat in patterns:
        core = pat.lstrip("!/").rstrip("/")
        if core in (env_name, "*" + env_name) or core.startswith(env_name):
            return True
        if core in ("*.env", ".env*", "*.local") and env_name.startswith(".env"):
            return True
        if core == "*" and not pat.startswith("!"):
            return True
    return False


def check_git_and_env(root, patterns):
    for name in ENV_FILES:
        p = os.path.join(root, name)
        if os.path.isfile(p) and not env_ignored(name, patterns):
            add("HIGH", rel(root, p), 1,
                "env file not ignored by git",
                f"{name} is present and .gitignore does not clearly exclude it, "
                "so keys in it can end up in the repo history.",
                f"add `{name}` to .gitignore and rotate any key it holds")


def check_secrets(root):
    for path in walk(root):
        name = os.path.basename(path)
        ext = os.path.splitext(name)[1].lower()
        if ext not in TEXT_EXT and not name.startswith(".env"):
            continue
        r = rel(root, path)
        # skip the scanner itself
        if r.endswith("preflight.py"):
            continue
        text = read_text(path)
        if text is None:
            continue
        lines = text.splitlines()
        for i, line in enumerate(lines, 1):
            if line.strip().startswith(("#", "//", "*")):
                continue
            for rx, label in SECRETISH:
                if rx.search(line):
                    in_client = looks_like_client(r)
                    sev = "HIGH" if in_client else "MED"
                    add(sev, r, i,
                        f"possible hardcoded secret ({label})",
                        ("this file looks like browser/client code, so anything in it "
                         "ships to users" if in_client else
                         "a literal secret in source tends to be committed and shared"),
                        "move it to an environment variable and rotate the key")
                    break  # one secret per line is enough
            if PUBLIC_ENV.search(line) and ("secret" in line.lower()
                                            or "service" in line.lower()):
                add("HIGH", r, i,
                    "public env var looks like it holds a secret",
                    "client-exposed prefixes (NEXT_PUBLIC_/VITE_/etc.) are bundled "
                    "into the browser; a secret there is public.",
                    "never put a service-role or secret key behind a public prefix")


def check_rls(root):
    for path in walk(root):
        if not path.lower().endswith(".sql"):
            continue
        r = rel(root, path)
        text = read_text(path) or ""
        created = {m.group(1).strip('"').lower() for m in CREATE_TABLE.finditer(text)}
        if not created:
            continue
        secured = {m.group(1).strip('"').lower() for m in ENABLE_RLS.finditer(text)}
        # also accept RLS enabled in any other migration in the project
        project_secured = set(secured)
        for other in walk(root):
            if other.lower().endswith(".sql") and other != path:
                ot = read_text(other) or ""
                project_secured |= {m.group(1).strip('"').lower()
                                    for m in ENABLE_RLS.finditer(ot)}
        for table in sorted(created):
            if table in project_secured:
                continue
            add("HIGH", r, 1,
                f"table `{table}` has no row-level security",
                "Postgres tables reachable through Supabase's API are readable by "
                "anyone holding the public anon key unless RLS is enabled and "
                "policies restrict rows.",
                f"`alter table {table} enable row level security;` plus a policy "
                "per operation you allow")
        if "create policy" not in text.lower():
            project_policies = False
            for other in walk(root):
                if other.lower().endswith(".sql"):
                    if "create policy" in (read_text(other) or "").lower():
                        project_policies = True
                        break
            if not project_policies:
                add("MED", r, 1,
                    "no create policy anywhere in the migrations",
                    "RLS with no policy denies everything; but if RLS is off, the "
                    "table is wide open. Policies are where you say who may "
                    "read/write.",
                    "add one policy per table and operation you intend to allow")
        for m in re.finditer(r"create\s+bucket\s*\(([^)]*)\)", text, re.I):
            if "public" in m.group(1).lower() and "true" in m.group(1).lower():
                add("MED", r, 1,
                    "storage bucket created public",
                    "a public bucket serves every file to anyone with the URL.",
                    "use a private bucket and signed URLs unless the files are "
                    "genuinely non-sensitive")
        if PUBLIC_BUCKET.search(text) and "storage" in text.lower():
            pass


def main():
    ap = argparse.ArgumentParser(description="pre-launch leak check")
    ap.add_argument("path", nargs="?", default=".", help="project folder")
    args = ap.parse_args()
    root = os.path.abspath(args.path)
    if not os.path.isdir(root):
        print(f"not a folder: {root}")
        return 2

    patterns = parse_gitignore(root)
    check_git_and_env(root, patterns)
    check_secrets(root)
    check_rls(root)

    order = {"HIGH": 0, "MED": 1, "LOW": 2}
    FINDINGS.sort(key=lambda f: (order.get(f["sev"], 9), f["path"]))
    highs = sum(1 for f in FINDINGS if f["sev"] == "HIGH")

    print(f"preflight: {root}")
    print(f"{len(FINDINGS)} finding(s), {highs} high\n")
    if not FINDINGS:
        print("nothing obvious. still rotate keys that were ever in a repo.")
        return 0
    for f in FINDINGS:
        print(f"[{f['sev']}] {f['path']}:{f['line']}  {f['title']}")
        print(f"       why: {f['why']}")
        print(f"       fix: {f['fix']}\n")
    print("high findings are the ones to fix before launch.")
    return 1 if highs else 0


if __name__ == "__main__":
    sys.exit(main())
