# -*- coding: utf-8 -*-
"""
guardian.py -- el Guardian de seguridad de RGIA.

Barre tu codigo buscando 15 ANTI-PATRONES REALES, sacados de auditar sistemas en
produccion. Determinista, en milisegundos, sin internet, sin IA, sin costo.
Tu codigo NUNCA sale de tu maquina.
La idea: correrlo ANTES de cada deploy. Cierra, a tu escala, el hueco "no tengo pruebas / CI".

IMPORTANTE -- ALCANCE: esta herramienta es la CAPA 1 de 13 del metodo de
seguridad de RGIA. Automatiza lo unico que un script puede ver: anti-patrones
conocidos en el texto del codigo. Las otras 12 capas (aislamiento entre
clientes, disenio de autenticacion, reglas de base de datos, llaves, respaldos
y procesos, entre otras) NO se detectan con patrones -- se auditan con metodo.
Pasar el Guardian limpio significa "no pise estas 15 minas", no "estoy seguro".
Las 12 capas que faltan: https://rgia.tech/guardian/

  Correr:   python guardian.py                 -> revisa la carpeta donde estas parado
            python guardian.py "C:\\ruta\\a\\proyecto"   -> revisa lo que le digas
            python guardian.py --strict         -> ademas de CRITICO, tambien ALTO tumba el deploy

  Mas info: https://rgia.tech/guardian/

  Salida:   agrupa por severidad, con archivo:linea. Codigo de salida 1 si hay CRITICOS
            (o ALTOS con --strict) -> se puede usar como puerta antes de publicar.

  Falso positivo? (la leccion #6: un detector que grita en falso se ignora)
    Pon  # guardian-ok: <razon>   al final de la linea, y el guardian la respeta.
    Ej.: la apiKey de Firebase es PUBLICA por diseno -> se marca con guardian-ok.
"""
import os, re, sys

# La consola de Windows es cp1252: si una linea del codigo revisado trae un emoji,
# print() truena y el guardian se cae a media revision. Se fuerza UTF-8 con
# reemplazo -> nunca se cae por como se ve un caracter.
# (Regla: medir jamas puede romper lo que mide.)
try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
    pass

SKIP_DIRS = {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build",
             ".firebase", ".netlify", "kits-faction", "_backup", "_backups"}
SCAN_EXT = {".py", ".js", ".html", ".ts", ".cmd", ".sh", ".rules"}

# Si no le pasas ninguna ruta, revisa la carpeta donde estas parado.
DEFAULT_TARGETS = [os.getcwd()]

# ---------------------------------------------------------------------------
# LAS REGLAS. Cada una salio de un hallazgo real de las 13 capas.
#   sev: CRITICO | ALTO | MEDIO | INFO
#   rx : patron. si 'anti' esta y tambien matchea -> NO es hallazgo (evita falso positivo).
# ---------------------------------------------------------------------------
REGLAS = [
    dict(id="fail-open-cors", sev="CRITICO", capa="08",
         rx=r'getenv\(\s*["\'][^"\']*(?:CORS|ORIGIN)[^"\']*["\']\s*,\s*["\']\*["\']',
         msg="CORS con default '*': si falta la env, tu API se abre a TODO internet. Default = lista de tus dominios, o no arrancar."),

    dict(id="fail-open-auth", sev="CRITICO", capa="02",
         rx=r'!=\s*os\.getenv\(\s*["\'][^"\']+["\']\s*,\s*["\']["\']\s*\)',
         msg="Comparacion de auth contra getenv(...,''): si falta la env, ''=='' y PASA cualquiera (falla abierto). Valida al arrancar (raise), no en cada request."),

    dict(id="pii-en-log", sev="CRITICO", capa="12",
         rx=r'(print|console\.log)\s*\([^)]*\{[^}]*\b(phone|telefono|codigo|password|secret|refresh|api_key|tarjeta|card)\b',
         anti=r'(phone|telefono)\s*\[:',   # ya enmascarado: {phone[:6]}*** no es hallazgo
         msg="Dato sensible (telefono/codigo/secreto) escrito a los logs en texto plano. Usa logger con redaccion (ej. telefono enmascarado)."),

    dict(id="secreto-en-url", sev="ALTO", capa="02",
         rx=r'[?&](token|secret|password|api_key|hermes_token|key)=\{',   # solo cuando se CONSTRUYE con variable
         anti=r'firebasestorage',   # el token de descarga de Firebase Storage va en la URL por diseno
         msg="Secreto viajando en la URL/query: las URLs se escriben en los logs de acceso en texto plano. Mandalo en un header."),

    dict(id="default-dueno", sev="ALTO", capa="02",
         rx=r'\.get\(\s*["\']client_id["\']\s*,\s*["\'][^"\']+["\']',
         msg="Default de dueno en client_id: 'si no se de quien es, es de X' = la fuga multi-tenant original. Ante lo desconocido, falla CERRADO (ignora + avisa)."),

    dict(id="llave-privada", sev="CRITICO", capa="04",
         rx=r'-----BEGIN [A-Z ]*PRIVATE KEY-----',
         msg="Llave PRIVADA hardcodeada en el codigo. Sacala a una variable de entorno YA y rotala."),

    dict(id="secreto-hardcode", sev="ALTO", capa="04",
         rx=r'(sk_live_|sk_test_|xoxb-|ghp_|AKIA|AIzaSy)[A-Za-z0-9_\-]{10,}',
         msg="Posible secreto/llave hardcodeada. (Nota: la apiKey de Firebase web -AIzaSy...- es PUBLICA por diseno; si es esa, marca la linea con '# guardian-ok: firebase web key publica')."),

    dict(id="stream-sin-limite", sev="MEDIO", capa="06",
         rx=r'\.stream\(\)',
         anti=r'\.limit\(',
         msg="Consulta .stream() sin .limit(): trae la coleccion entera. A tu escala no duele; con 100 clientes cuesta lecturas de Firestore. Ponle tope."),

    dict(id="password-plano", sev="ALTO", capa="04",
         rx=r'(password|contrasena|passwd)\s*[:=]\s*["\'][^"\']{4,}["\']',
         anti=r'(getenv|os\.environ|placeholder|type=|input|label|\.get\()',
         msg="Posible contrasena en texto plano en el codigo. Debe estar hasheada (pbkdf2/argon2) o en env, nunca literal."),

    # ------------------------------------------------------------------
    # CAPA 05 (ampliada) -- INYECCION  (OWASP A05:2025 Injection)
    # Hueco detectado el 2026-07-22 al mapear las 13 capas contra OWASP
    # Top 10:2025. Es el riesgo #5 del mundo y no estaba EXPLICITO en la capa 05.
    # ------------------------------------------------------------------

    dict(id="db-reglas-abiertas", sev="CRITICO", capa="05",
         rx=r'allow\s+(?:read|write|create|update|delete|read\s*,\s*write)\s*:\s*if\s+true',
         msg="Reglas de base de datos ABIERTAS (if true): cualquiera en internet lee y escribe TU base. "
             "Es el error #1 de las apps hechas con IA. Cierra por usuario autenticado y por dueno del dato."),

    dict(id="sql-concatenado", sev="CRITICO", capa="05",
         rx=r'(?:execute|executemany|query|raw)\s*\(\s*(?:f["\']|["\'][^"\']*["\']\s*[+%])',
         anti=r'(?:\?|%s|:\w+)\s*["\']?\s*,',   # ya usa parametros ligados -> no es hallazgo
         msg="Consulta SQL armada pegando texto (f-string o +): si el dato viene del usuario, "
             "puede reescribir tu consulta y sacar/borrar toda la tabla. Usa consultas parametrizadas."),

    dict(id="ejecucion-dinamica", sev="CRITICO", capa="05",
         rx=r'(?:^|[^\w.])(?:eval|exec)\s*\(|new\s+Function\s*\(',
         anti=r'(?:#|//).*(?:eval|exec)|\.evaluate|preview_eval|javascript_exec',
         msg="eval/exec/new Function ejecuta texto como codigo. Si ese texto toca datos del usuario, "
             "le diste control de tu servidor. Casi siempre hay una forma sin eval."),

    dict(id="shell-injection", sev="CRITICO", capa="05",
         rx=r'os\.system\s*\(|subprocess\.[a-z_]+\([^)]*shell\s*=\s*True|child_process\.exec\s*\(',
         msg="Comando de sistema con shell: si el dato viene del usuario puede encadenar otro comando "
             "(; rm -rf). Usa subprocess con lista de argumentos y shell=False."),

    # OJO al calibrar (leccion 6: el detector que grita en falso se ignora):
    #   - solo dispara si hay interpolacion real ${...}: un innerHTML con texto
    #     ESTATICO no es hallazgo.
    #   - el "anti" acepta esc( ademas de _esc( porque cada proyecto nombra su
    #     funcion de escape distinto. Con solo _esc( se marcaba codigo ya seguro.
    dict(id="xss-innerhtml", sev="ALTO", capa="05",  # guardian-ok: es el patron del detector, no un uso
         rx=r'\.innerHTML\s*=[^;]*\$\{|dangerouslySetInnerHTML|document\.write\s*\(',  # guardian-ok: patron
         anti=r'\b_?esc\(|escapeHtml|sanitiz|DOMPurify|textContent',
         msg="Se mete contenido dinamico al HTML sin escapar (XSS): si ese texto trae <script>, "
             "corre en el navegador de tu usuario. Escapa antes, o usa textContent."),

    dict(id="prompt-injection", sev="MEDIO", capa="05",
         rx=r'(?:prompt|system|instruccion)\s*=\s*f?["\'][^"\']*\{\s*(?:mensaje|user_input|texto_usuario|comentario|input_usuario|user_msg|pregunta_usuario)\s*\}',
         msg="Texto del USUARIO pegado directo en el prompt de la IA (prompt injection): puede escribir "
             "'ignora tus instrucciones' y secuestrar tu agente. Delimitalo y digale al modelo que es "
             "DATO, no instruccion. (Riesgo nuevo y real para apps construidas con IA.)"),
]

for r in REGLAS:
    r["_rx"] = re.compile(r["rx"])
    r["_anti"] = re.compile(r["anti"]) if r.get("anti") else None

SEV_ORDER = {"CRITICO": 0, "ALTO": 1, "MEDIO": 2, "INFO": 3}
OKMARK = re.compile(r'guardian-ok', re.I)


def archivos(target):
    if os.path.isfile(target):
        yield target
        return
    for root, dirs, files in os.walk(target):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            if os.path.splitext(f)[1].lower() in SCAN_EXT:
                yield os.path.join(root, f)


def revisar(path):
    hallazgos, exentos = [], 0
    try:
        with open(path, encoding="utf-8", errors="ignore") as fh:
            lineas = fh.readlines()
    except Exception:
        return hallazgos, exentos
    for i, linea in enumerate(lineas, 1):
        stripped = linea.lstrip()
        # Saltar comentarios puros: describen el patron, no lo ejecutan (evita falsos positivos).
        if stripped.startswith(("#", "//", "REM ", "<!--")):
            continue
        for r in REGLAS:
            if r["_rx"].search(linea):
                if r["_anti"] and r["_anti"].search(linea):
                    continue
                if OKMARK.search(linea):
                    exentos += 1
                    continue
                hallazgos.append((r["sev"], r["capa"], r["id"], path, i, linea.strip()[:140], r["msg"]))
    return hallazgos, exentos


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    strict = "--strict" in sys.argv
    targets = args if args else DEFAULT_TARGETS

    todos, total_exentos, n_arch = [], 0, 0
    raiz = {}   # archivo -> carpeta desde la que se pidio (para mostrar rutas cortas)
    for t in targets:
        if not os.path.exists(t):
            print(f"  (aviso) no existe: {t}")
            continue
        base = t if os.path.isdir(t) else os.path.dirname(t)
        for path in archivos(t):
            n_arch += 1
            raiz[path] = base
            h, ex = revisar(path)
            todos.extend(h)
            total_exentos += ex

    todos.sort(key=lambda x: (SEV_ORDER.get(x[0], 9), x[3], x[4]))

    print("=" * 66)
    print("  GUARDIAN DE SEGURIDAD RGIA  --  el Estandar de Produccion en codigo")
    print("=" * 66)
    print(f"  Revisados: {n_arch} archivos  |  Exenciones (guardian-ok): {total_exentos}")
    print()

    if not todos:
        print("  LIMPIO. Ningun anti-patron conocido. (No prueba que sea seguro;")
        print("  prueba que no cayo en los errores que ya conocemos de las 13 capas.)")
        return 0

    por_sev = {}
    for h in todos:
        por_sev.setdefault(h[0], []).append(h)

    for sev in ("CRITICO", "ALTO", "MEDIO", "INFO"):
        grupo = por_sev.get(sev, [])
        if not grupo:
            continue
        print(f"  [{sev}]  {len(grupo)} hallazgo(s)")
        print("  " + "-" * 62)
        msgs_vistos = set()
        for _, capa, rid, path, ln, frag, msg in grupo:
            # Ruta relativa a LO QUE SE REVISO, no a donde vive el Guardian:
            # si no, revisar una carpeta de otro disco imprime un ..\..\..\ eterno.
            corto = path
            try:
                rel = os.path.relpath(path, raiz.get(path, os.getcwd()))
                if not rel.startswith(".."):
                    corto = rel
            except Exception:
                pass
            print(f"    {corto}:{ln}  (capa {capa}) [{rid}]")
            print(f"      > {frag}")
            if msg not in msgs_vistos:
                print(f"      = {msg}")
                msgs_vistos.add(msg)
        print()

    n_crit = len(por_sev.get("CRITICO", []))
    n_alto = len(por_sev.get("ALTO", []))
    print("=" * 66)
    print(f"  TOTAL: {len(todos)}  ({n_crit} criticos, {n_alto} altos)")
    falla = n_crit > 0 or (strict and n_alto > 0)
    if falla:
        print("  >> PUERTA CERRADA: hay que revisar esto antes de publicar.")
        print("     (Falso positivo? agrega  # guardian-ok: <razon>  a la linea.)")
    else:
        print("  >> Puerta abierta. Solo quedan hallazgos menores (revisa cuando puedas).")
    print("=" * 66)
    print()
    print("  Esto fue la CAPA 1 de 13.")
    print("  Lo de arriba es lo que un script puede ver en el texto de tu codigo.")
    print("  Las otras 12 capas del metodo RGIA (aislamiento entre clientes,")
    print("  autenticacion, reglas de base de datos, llaves y procesos...) no se")
    print("  detectan con patrones: se auditan con metodo, capa por capa.")
    print("  Las 12 que faltan -> https://rgia.tech/guardian/  (boton de WhatsApp)")
    print("=" * 66)
    return 1 if falla else 0


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