app.py

"""

app.py — Legalito Injection Detector

UI Streamlit standalone para análisis de documentos legales.

"""


import streamlit as st

import tempfile

import os

import time

from legalito_injection_detector import scan_pdf, scan_text, scan_mev, ResultadoScan, Hallazgo


# ──────────────────────────────────────────────

# CONFIG

# ──────────────────────────────────────────────


st.set_page_config(

page_title="Legalito Injection Detector",

page_icon="🔍",

layout="wide",

initial_sidebar_state="collapsed",

)


# ──────────────────────────────────────────────

# CSS CUSTOM

# ──────────────────────────────────────────────


st.markdown("""

<style>

@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600&display=swap');


html, body, [class*="css"] {

font-family: 'IBM Plex Sans', sans-serif;

}


/* Fondo general */

.stApp {

background-color: #0d0d0f;

color: #e0e0e0;

}


/* Header custom */

.lid-header {

font-family: 'IBM Plex Mono', monospace;

font-size: 11px;

letter-spacing: 0.2em;

color: #555;

text-transform: uppercase;

margin-bottom: 4px;

}

.lid-title {

font-family: 'IBM Plex Mono', monospace;

font-size: 28px;

font-weight: 600;

color: #f0f0f0;

letter-spacing: -0.02em;

line-height: 1.1;

}

.lid-subtitle {

font-family: 'IBM Plex Sans', sans-serif;

font-size: 14px;

color: #666;

margin-top: 8px;

}


/* Score badge */

.score-badge {

display: inline-block;

font-family: 'IBM Plex Mono', monospace;

font-size: 48px;

font-weight: 600;

line-height: 1;

}

.score-limpio { color: #4ade80; }

.score-revisar { color: #facc15; }

.score-alerta { color: #f87171; }

.score-error { color: #818cf8; }


/* Nivel badge */

.nivel-badge {

display: inline-block;

font-family: 'IBM Plex Mono', monospace;

font-size: 11px;

font-weight: 600;

letter-spacing: 0.15em;

padding: 4px 12px;

border-radius: 2px;

margin-top: 8px;

}

.nivel-LIMPIO { background: #052e16; color: #4ade80; border: 1px solid #166534; }

.nivel-REVISAR { background: #1c1507; color: #facc15; border: 1px solid #854d0e; }

.nivel-ALERTA { background: #1c0606; color: #f87171; border: 1px solid #991b1b; }

.nivel-ERROR { background: #0f0c1f; color: #818cf8; border: 1px solid #3730a3; }


/* Hallazgo cards */

.hallazgo-card {

background: #141416;

border-left: 3px solid #333;

border-radius: 2px;

padding: 12px 16px;

margin-bottom: 8px;

font-family: 'IBM Plex Sans', sans-serif;

}

.hallazgo-CRITICA { border-left-color: #f87171; }

.hallazgo-ALTA { border-left-color: #fb923c; }

.hallazgo-MEDIA { border-left-color: #facc15; }

.hallazgo-BAJA { border-left-color: #60a5fa; }


.hallazgo-cat {

font-family: 'IBM Plex Mono', monospace;

font-size: 10px;

letter-spacing: 0.15em;

text-transform: uppercase;

color: #666;

}

.hallazgo-desc {

font-size: 14px;

color: #d0d0d0;

margin: 4px 0;

}

.hallazgo-ev {

font-family: 'IBM Plex Mono', monospace;

font-size: 11px;

color: #555;

word-break: break-all;

}

.sev-CRITICA { color: #f87171; font-weight: 600; }

.sev-ALTA { color: #fb923c; font-weight: 600; }

.sev-MEDIA { color: #facc15; }

.sev-BAJA { color: #60a5fa; }


/* Resumen box */

.resumen-box {

background: #141416;

border: 1px solid #222;

border-radius: 4px;

padding: 16px 20px;

font-size: 14px;

color: #aaa;

line-height: 1.6;

margin-bottom: 24px;

}


/* Upload area */

.upload-label {

font-family: 'IBM Plex Mono', monospace;

font-size: 11px;

letter-spacing: 0.15em;

text-transform: uppercase;

color: #555;

margin-bottom: 6px;

}


/* Separator */

.sep {

border: none;

border-top: 1px solid #1e1e20;

margin: 24px 0;

}


/* Stat mini */

.stat-mini {

background: #141416;

border: 1px solid #1e1e20;

border-radius: 4px;

padding: 12px 16px;

text-align: center;

}

.stat-mini-val {

font-family: 'IBM Plex Mono', monospace;

font-size: 24px;

font-weight: 600;

color: #e0e0e0;

}

.stat-mini-label {

font-family: 'IBM Plex Mono', monospace;

font-size: 10px;

letter-spacing: 0.12em;

color: #444;

text-transform: uppercase;

margin-top: 4px;

}


/* Sidebar */

section[data-testid="stSidebar"] {

background: #0a0a0c;

border-right: 1px solid #1a1a1c;

}


/* Botón primario */

.stButton > button {

background: #1e1e22;

color: #e0e0e0;

border: 1px solid #2e2e32;

border-radius: 3px;

font-family: 'IBM Plex Mono', monospace;

font-size: 12px;

letter-spacing: 0.08em;

padding: 8px 24px;

transition: all 0.15s;

}

.stButton > button:hover {

background: #2a2a30;

border-color: #444;

}


/* File uploader */

[data-testid="stFileUploader"] {

background: #141416;

border: 1px dashed #2a2a2e;

border-radius: 4px;

}


/* Tabs */

.stTabs [data-baseweb="tab-list"] {

background: transparent;

gap: 2px;

}

.stTabs [data-baseweb="tab"] {

font-family: 'IBM Plex Mono', monospace;

font-size: 11px;

letter-spacing: 0.12em;

text-transform: uppercase;

color: #555;

background: #141416;

border: 1px solid #1e1e22;

}

.stTabs [aria-selected="true"] {

color: #e0e0e0 !important;

background: #1e1e22 !important;

}


/* Progress bar color */

.stProgress > div > div {

background-color: #f87171;

}

</style>

""", unsafe_allow_html=True)


# ──────────────────────────────────────────────

# HELPERS

# ──────────────────────────────────────────────


def scoreclass(nivel: str) -> str:

return {

"LIMPIO": "score-limpio",

"REVISAR": "score-revisar",

"ALERTA": "score-alerta",

}.get(nivel, "score-error")



def renderhallazgo(h: Hallazgo, idx: int):

pagina_str = f" · pág. {h.pagina}" if h.pagina else ""

st.markdown(f"""

<div class="hallazgo-card hallazgo-{h.severidad}">

<div class="hallazgo-cat">

<span class="sev-{h.severidad}">[{h.severidad}]</span>

&nbsp;·&nbsp;{h.categoria}{pagina_str}

</div>

<div class="hallazgo-desc">{h.descripcion}</div>

<div class="hallazgo-ev">↳ {h.evidencia[:180]}</div>

</div>

""", unsafe_allow_html=True)



def renderresultado(resultado: ResultadoScan):

# Header resultado

col_score, col_info = st.columns([1, 3])


with col_score:

sc = scoreclass(resultado.nivel)

st.markdown(f"""

<div style="text-align:center; padding: 24px 0;">

<div class="score-badge {sc}">{resultado.score}</div>

<div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#444;margin-top:4px;">/ 100</div>

<div><span class="nivel-badge nivel-{resultado.nivel}">{resultado.nivel}</span></div>

</div>

""", unsafe_allow_html=True)


with col_info:

st.markdown(f"""

<div style="padding: 16px 0;">

<div class="upload-label">Archivo analizado</div>

<div style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#aaa;margin-bottom:16px;">{resultado.archivo}</div>

<div class="upload-label">Modo</div>

<div style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#aaa;margin-bottom:16px;">{resultado.modo.upper()}</div>

</div>

""", unsafe_allow_html=True)


# Stats

criticos = len([h for h in resultado.hallazgos if h.severidad == "CRITICA"])

altos = len([h for h in resultado.hallazgos if h.severidad == "ALTA"])

medios = len([h for h in resultado.hallazgos if h.severidad == "MEDIA"])

bajos = len([h for h in resultado.hallazgos if h.severidad == "BAJA"])


c1, c2, c3, c4 = st.columns(4)

for col, label, val, color in [

(c1, "CRÍTICOS", criticos, "#f87171"),

(c2, "ALTOS", altos, "#fb923c"),

(c3, "MEDIOS", medios, "#facc15"),

(c4, "BAJOS", bajos, "#60a5fa"),

]:

with col:

st.markdown(f"""

<div class="stat-mini">

<div class="stat-mini-val" style="color:{color}">{val}</div>

<div class="stat-mini-label">{label}</div>

</div>

""", unsafe_allow_html=True)


st.markdown("<hr class='sep'>", unsafe_allow_html=True)


# Resumen

st.markdown(f'<div class="resumen-box">{resultado.resumen}</div>', unsafe_allow_html=True)


# Hallazgos

if resultado.hallazgos:

st.markdown('<div class="upload-label" style="margin-bottom:12px;">Detalle de hallazgos</div>', unsafe_allow_html=True)


# Filtro por severidad

severidades_presentes = list(dict.fromkeys(h.severidad for h in resultado.hallazgos))

filtro = st.multiselect(

"Filtrar por severidad",

options=severidades_presentes,

default=severidades_presentes,

label_visibility="collapsed",

)


hallazgos_filtrados = [h for h in resultado.hallazgos if h.severidad in filtro]


for i, h in enumerate(hallazgos_filtrados, 1):

renderhallazgo(h, i)

else:

st.markdown("""

<div style="text-align:center;padding:40px;color:#333;font-family:'IBM Plex Mono',monospace;font-size:12px;">

✓ &nbsp; Sin hallazgos detectados

</div>

""", unsafe_allow_html=True)



# ──────────────────────────────────────────────

# LAYOUT PRINCIPAL

# ──────────────────────────────────────────────


st.markdown("""

<div style="padding: 32px 0 24px 0;">

<div class="lid-header">Legalito · Herramientas IA para derecho argentino</div>

<div class="lid-title">Injection Detector</div>

<div class="lid-subtitle">Detecta prompt injection en escritos judiciales antes de procesarlos con IA</div>

</div>

""", unsafe_allow_html=True)


st.markdown("<hr class='sep'>", unsafe_allow_html=True)


# ──────────────────────────────────────────────

# TABS DE MODO

# ──────────────────────────────────────────────


tab_pjn, tab_mev, tab_texto = st.tabs([

"PJN — PDF",

"MEV / SCBA — Texto + PDF",

"Texto plano",

])


# ──────── TAB PJN ────────

with tab_pjn:

st.markdown('<div class="upload-label" style="margin:16px 0 8px;">Subir escrito judicial en PDF</div>', unsafe_allow_html=True)


pdf_file = st.file_uploader(

"PDF PJN",

type=["pdf"],

key="uploader_pjn",

label_visibility="collapsed",

)


if pdf_file:

if st.button("Analizar PDF", key="btn_pjn"):

with st.spinner("Analizando documento..."):

# Guardar temporalmente

with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:

tmp.write(pdf_file.read())

tmp_path = tmp.name


resultado = scan_pdf(tmp_path)

os.unlink(tmp_path)


# Sobreescribir nombre con el original

resultado.archivo = pdf_file.name


st.markdown("<hr class='sep'>", unsafe_allow_html=True)

renderresultado(resultado)


# ──────── TAB MEV ────────

with tab_mev:

col_mev1, col_mev2 = st.columns(2)


with col_mev1:

st.markdown('<div class="upload-label" style="margin:16px 0 8px;">Texto del escrito (cuerpo MEV/SCBA)</div>', unsafe_allow_html=True)

texto_mev = st.text_area(

"Texto MEV",

height=250,

placeholder="Pegá aquí el texto extraído del escrito...",

key="texto_mev",

label_visibility="collapsed",

)


with col_mev2:

st.markdown('<div class="upload-label" style="margin:16px 0 8px;">PDF adjunto (opcional)</div>', unsafe_allow_html=True)

pdf_mev = st.file_uploader(

"PDF adjunto MEV",

type=["pdf"],

key="uploader_mev",

label_visibility="collapsed",

)


if st.button("Analizar escrito MEV", key="btn_mev"):

if not texto_mev.strip():

st.warning("El texto del escrito está vacío.")

else:

with st.spinner("Analizando..."):

tmp_path = None

if pdf_mev:

with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:

tmp.write(pdf_mev.read())

tmp_path = tmp.name


resultado = scan_mev(texto_mev, pdf_path=tmp_path)


if tmp_path:

os.unlink(tmp_path)

resultado.archivo = f"{pdf_mev.name} + texto"


st.markdown("<hr class='sep'>", unsafe_allow_html=True)

renderresultado(resultado)


# ──────── TAB TEXTO ────────

with tab_texto:

st.markdown('<div class="upload-label" style="margin:16px 0 8px;">Texto a analizar</div>', unsafe_allow_html=True)

texto_plain = st.text_area(

"Texto plano",

height=300,

placeholder="Pegá aquí cualquier texto que quieras analizar...",

key="texto_plain",

label_visibility="collapsed",

)


if st.button("Analizar texto", key="btn_texto"):

if not texto_plain.strip():

st.warning("El texto está vacío.")

else:

with st.spinner("Analizando..."):

resultado = scan_text(texto_plain, nombre_archivo="texto_ingresado.txt")


st.markdown("<hr class='sep'>", unsafe_allow_html=True)

renderresultado(resultado)


# ──────────────────────────────────────────────

# FOOTER

# ──────────────────────────────────────────────


st.markdown("<hr class='sep'>", unsafe_allow_html=True)

st.markdown("""

<div style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#2a2a2e;text-align:center;padding:8px 0;">

Legalito Injection Detector ·Dr. Darío J. Ramírez · MIT License · legalito.ar

</div>

""", unsafe_allow_html=True)

"""

legalito_injection_detector.py

===============================

Detector de prompt injection en documentos legales argentinos.

Soporta: PDF (PJN), texto plano (MEV/SCBA), PDF adjunto (MEV/SCBA).


Uso standalone:

from legalito_injection_detector import scan_pdf, scan_text, scan_mev


Uso desde CLI:

python legalito_injection_detector.py ruta/al/archivo.pdf


Autor: Dr. Darío J. Ramírez / Legalito

Licencia: MIT

"""


import re

import os

import sys

import json

import unicodedata

from dataclasses import dataclass, field, asdict

from typing import Optional

from pathlib import Path


# ──────────────────────────────────────────────

# DEPENDENCIAS OPCIONALES — se importan con guard

# ──────────────────────────────────────────────

try:

import fitz # PyMuPDF

PYMUPDF_AVAILABLE = True

except ImportError:

PYMUPDF_AVAILABLE = False


# ──────────────────────────────────────────────

# ESTRUCTURAS DE DATOS

# ──────────────────────────────────────────────


@dataclass

class Hallazgo:

categoria: str # Categoría del check

severidad: str # "CRITICA" | "ALTA" | "MEDIA" | "BAJA"

descripcion: str # Qué se encontró

evidencia: str # Fragmento o detalle técnico

pagina: Optional[int] = None # Para PDFs


@dataclass

class ResultadoScan:

archivo: str

modo: str # "pdf" | "texto" | "mev"

score: int # 0-100 (riesgo acumulado)

nivel: str # "LIMPIO" | "REVISAR" | "ALERTA"

hallazgos: list = field(default_factory=list)

resumen: str = ""

error: Optional[str] = None


def to_dict(self):

d = asdict(self)

return d


# ──────────────────────────────────────────────

# PATRONES SEMÁNTICOS DE INYECCIÓN

# ──────────────────────────────────────────────


# Instrucciones directas al modelo — inglés

PATRONES_EN = [

r'\bignore\s+(previous|prior|above|all)\s+(instructions?|prompts?|context|rules?)\b',

r'\bdisregard\s+(previous|prior|all|the)\s+(instructions?|prompts?|rules?)\b',

r'\bforget\s+(everything|all|previous|prior)\b',

r'\byou\s+are\s+now\b',

r'\bact\s+as\b',

r'\bnew\s+(system\s+)?(instructions?|prompt|role|persona)\b',

r'\boverride\s+(your\s+)?(instructions?|rules?|constraints?|guidelines?)\b',

r'\bpretend\s+(you\s+are|to\s+be)\b',

r'\byou\s+must\s+now\b',

r'\bfrom\s+now\s+on\b',

r'\bas\s+an\s+AI\s+without\s+(restrictions?|limits?|guidelines?)\b',

r'\bDAN\b', # "Do Anything Now" — jailbreak conocido

r'\bGPT-?4?\s*without\s+restrictions?\b',

r'\byour\s+(true|real|actual)\s+(self|purpose|goal|instructions?)\b',

r'\bsystem\s*:\s*you\s+are\b',

r'\[SYSTEM\]',

r'\bASSISTANT\s*:\s*',

r'\bHUMAN\s*:\s*',

]


# Instrucciones directas al modelo — español

PATRONES_ES = [

r'\bignorar?\s+(las?\s+)?(instrucciones?|reglas?|contexto|todo\s+lo\s+anterior)\b',

r'\bolvidar?\s+(todo|las?\s+instrucciones?|el\s+contexto)\b',

r'\bahor[ae]\s+(eres?|soy|somos)\b',

r'\btu\s+(nuevo|nuevo\s+rol|nueva\s+identidad|verdadero\s+ser)\b',

r'\bcomo\s+sistema\b',

r'\bnuevas?\s+instrucciones?\b',

r'\bsin\s+(restricciones?|límites?|limitaciones?)\b',

r'\bdes?de\s+ahor[ae]\b',

r'\bactu[aá]\s+como\b',

r'\bhac[eé]\s+de\s+cuenta\s+(que\s+)?(sos|eres)\b',

r'\bfing[ií]\s+(ser|que\s+sos|que\s+eres)\b',

r'\btu\s+(rol|papel|tarea)\s+(es|será)\s+ahora\b',

r'\bignor[aá]\s+(esto|lo\s+anterior|el\s+sistema)\b',

r'\bpor\s+favor\s+ignorar?\b',

]


# Delimitadores LLM conocidos — alta certeza de inyección

PATRONES_DELIMITADORES_CRITICOS = [

r'\[INST\]',

r'\[\/INST\]',

r'<\|im_start\|>',

r'<\|im_end\|>',

r'<\|system\|>',

r'<<SYS>>',

r'\[END\s*OF\s*(PROMPT|SYSTEM|CONTEXT|INSTRUCTIONS?)\]',

r'\[START\s*OF\s*(NEW\s+)?(PROMPT|SYSTEM|CONTEXT|INSTRUCTIONS?)\]',

r'---\s*SYSTEM\s*---',

r'---\s*PROMPT\s*---',

r'---\s*INSTRUCTIONS?\s*---',

r'<system>',

r'<prompt>',

r'<instructions?>',

]


# Separadores genéricos — aparecen en documentos legales normales, baja severidad

PATRONES_DELIMITADORES_GENERICOS = [

r'#{3,}',

r'={3,}',

r'-{5,}',

]


# Patrones de exfiltración — intentos de extraer info del contexto

PATRONES_EXFILTRACION = [

r'\brepeat\s+(the\s+)?(system\s+)?(prompt|instructions?|context)\b',

r'\bwhat\s+(are\s+)?(your|the)\s+(system\s+)?instructions?\b',

r'\bshow\s+(me\s+)?(your\s+)?(system\s+)?(prompt|instructions?|context)\b',

r'\bprint\s+(the\s+)?(system\s+)?(prompt|instructions?)\b',

r'\brepetí\s+(las?\s+)?(instrucciones?|sistema|prompt)\b',

r'\bmostr[aá]\s+(las?\s+)?(instrucciones?|sistema|prompt)\b',

r'\bcu[aá]les?\s+son\s+(tus?\s+)?(instrucciones?|reglas?)\b',

]


# Patrones de inyección disfrazada en lenguaje jurídico

PATRONES_JURIDICOS_ADULTERADOS = [

r'(juzgado|tribunal|magistrado|juez)\s+debe\s+ignorar',

r'(juzgado|tribunal|magistrado|juez)\s+debe\s+olvidar',

r'inteligencia\s+artificial\s+debe\s+(ignorar|olvidar|actuar)',

r'(ia|llm|gpt|claude|gemini)\s+debe\s+(ignorar|olvidar|actuar)',

r'(sistema|asistente)\s+de\s+ia\s+debe',

r'nota\s+para\s+(el\s+)?(asistente|sistema|ia|llm)',

r'instrucción\s+para\s+(el\s+)?(asistente|sistema|ia|llm)',

]


# Todos los patrones semánticos compilados

TODOS_LOS_PATRONES = [

("Instrucción directa (EN)", "CRITICA", PATRONES_EN),

("Instrucción directa (ES)", "CRITICA", PATRONES_ES),

("Delimitador LLM", "ALTA", PATRONES_DELIMITADORES_CRITICOS),

("Separador sospechoso", "BAJA", PATRONES_DELIMITADORES_GENERICOS),

("Intento de exfiltración", "ALTA", PATRONES_EXFILTRACION),

("Inyección en lenguaje jurídico", "CRITICA", PATRONES_JURIDICOS_ADULTERADOS),

]


# Caracteres unicode sospechosos

ZERO_WIDTH_CHARS = {

'\u200b': 'ZERO WIDTH SPACE',

'\u200c': 'ZERO WIDTH NON-JOINER',

'\u200d': 'ZERO WIDTH JOINER',

'\u200e': 'LEFT-TO-RIGHT MARK',

'\u200f': 'RIGHT-TO-LEFT MARK',

'\u202a': 'LEFT-TO-RIGHT EMBEDDING',

'\u202b': 'RIGHT-TO-LEFT EMBEDDING',

'\u202c': 'POP DIRECTIONAL FORMATTING',

'\u202d': 'LEFT-TO-RIGHT OVERRIDE',

'\u202e': 'RIGHT-TO-LEFT OVERRIDE',

'\u2060': 'WORD JOINER',

'\u2061': 'FUNCTION APPLICATION',

'\u2062': 'INVISIBLE TIMES',

'\u2063': 'INVISIBLE SEPARATOR',

'\u2064': 'INVISIBLE PLUS',

'\ufeff': 'BOM / ZERO WIDTH NO-BREAK SPACE',

'\u00ad': 'SOFT HYPHEN',

'\u034f': 'COMBINING GRAPHEME JOINER',

'\u115f': 'HANGUL CHOSEONG FILLER',

'\u1160': 'HANGUL JUNGSEONG FILLER',

'\u17b4': 'KHMER VOWEL INHERENT AQ',

'\u17b5': 'KHMER VOWEL INHERENT AA',

'\u3164': 'HANGUL FILLER',

'\uffa0': 'HALFWIDTH HANGUL FILLER',

}


# Scripts que no deberían aparecer en documentos legales argentinos

SCRIPTS_SOSPECHOSOS = [

'CYRILLIC', # letras cirílicas que se confunden con latinas

'GREEK', # idem

'ARABIC',

'HEBREW',

'THAI',

'GEORGIAN',

]


# ──────────────────────────────────────────────

# FUNCIONES DE DETECCIÓN — TEXTO

# ──────────────────────────────────────────────


def detectarpatrones_semanticos(texto: str) -> list[Hallazgo]:

"""Busca patrones de instrucciones de inyección en el texto."""

hallazgos = []

texto_lower = texto.lower()


for categoria, severidad, patrones in TODOS_LOS_PATRONES:

for patron in patrones:

matches = list(re.finditer(patron, texto_lower, re.IGNORECASE | re.MULTILINE))

for m in matches:

# Extraer contexto alrededor del match

inicio = max(0, m.start() - 40)

fin = min(len(texto), m.end() + 40)

contexto = texto[inicio:fin].replace('\n', ' ').strip()

hallazgos.append(Hallazgo(

categoria=categoria,

severidad=severidad,

descripcion=f"Patrón detectado: '{m.group()}'",

evidencia=f"...{contexto}...",

))


return hallazgos



def detectarunicode_sospechoso(texto: str) -> list[Hallazgo]:

"""Detecta caracteres de ancho cero, BOM, y scripts no latinos."""

hallazgos = []


# Caracteres de ancho cero

for char, nombre in ZERO_WIDTH_CHARS.items():

count = texto.count(char)

if count > 0:

hallazgos.append(Hallazgo(

categoria="Unicode invisible",

severidad="ALTA" if count > 5 else "MEDIA",

descripcion=f"Carácter invisible encontrado {count} veces: U+{ord(char):04X} ({nombre})",

evidencia=f"Cantidad: {count} ocurrencias en el documento",

))


# Scripts no latinos

scripts_encontrados = {}

for i, char in enumerate(texto):

if char.isalpha():

try:

name = unicodedata.name(char, '')

for script in SCRIPTS_SOSPECHOSOS:

if script in name:

if script not in scripts_encontrados:

scripts_encontrados[script] = []

if len(scripts_encontrados[script]) < 3: # máximo 3 ejemplos

contexto_ini = max(0, i - 10)

contexto_fin = min(len(texto), i + 10)

scripts_encontrados[script].append(

f"'{char}' en: {repr(texto[contexto_ini:contexto_fin])}"

)

except (ValueError, TypeError):

pass


for script, ejemplos in scripts_encontrados.items():

hallazgos.append(Hallazgo(

categoria="Unicode confusable",

severidad="MEDIA",

descripcion=f"Caracteres de script {script} detectados. Revisar posible homoglifo visual.",

evidencia="; ".join(ejemplos),

))


return hallazgos



def detectarrepeticion_instrucciones(texto: str) -> list[Hallazgo]:

"""

Detecta bloques de texto idéntico o casi idéntico repetidos muchas veces

— técnica de amplificación de inyección.

"""

hallazgos = []

lineas = [l.strip() for l in texto.split('\n') if len(l.strip()) > 20]

from collections import Counter

conteo = Counter(lineas)

for linea, count in conteo.most_common(5):

if count >= 5:

hallazgos.append(Hallazgo(

categoria="Repetición sospechosa",

severidad="MEDIA",

descripcion=f"Línea idéntica repetida {count} veces",

evidencia=f"'{linea[:80]}...' " if len(linea) > 80 else f"'{linea}'",

))

return hallazgos



# ──────────────────────────────────────────────

# FUNCIONES DE DETECCIÓN — PDF

# ──────────────────────────────────────────────


def detectartexto_invisible_pdf(doc) -> list[Hallazgo]:

"""

Detecta texto con color igual o similar al fondo (blanco sobre blanco, etc).

Usa PyMuPDF para acceder a nivel de span.

"""

hallazgos = []


for page_num, page in enumerate(doc, 1):

dict_page = page.get_text("rawdict", flags=fitz.TEXT_PRESERVE_WHITESPACE)


for block in dict_page.get("blocks", []):

if block.get("type") != 0: # solo bloques de texto

continue

for line in block.get("lines", []):

for span in line.get("spans", []):

text = span.get("text", "").strip()

if not text:

continue


color = span.get("color", None)

if color is None:

continue


# Color está en formato entero RGB de PyMuPDF

# Blanco = 16777215 (0xFFFFFF), Negro = 0 (0x000000)

r = (color >> 16) & 0xFF

g = (color >> 8) & 0xFF

b = color & 0xFF


# Texto muy claro (casi blanco)

if (

r > COLOR_THRESHOLD

and g > COLOR_THRESHOLD

and b > COLOR_THRESHOLD

and len(text) > 3

):

hallazgos.append(Hallazgo(

categoria="Texto invisible por color",

severidad="CRITICA",

descripcion=f"Texto con color casi blanco (R:{r} G:{g} B:{b})",

evidencia=f"Contenido: '{text[:100]}'",

pagina=page_num,

))


# Texto con opacidad muy baja — se detecta por flags de render

# PyMuPDF no expone alpha directamente en rawdict, pero sí size=0

size = span.get("size", 12)

if size < 0.5 and len(text) > 3:

hallazgos.append(Hallazgo(

categoria="Texto invisible por tamaño",

severidad="CRITICA",

descripcion=f"Texto con tamaño de fuente {size:.2f}pt (prácticamente invisible)",

evidencia=f"Contenido: '{text[:100]}'",

pagina=page_num,

))


return hallazgos



def detectarcapas_ocultas_pdf(doc) -> list[Hallazgo]:

"""Detecta capas (OCGs) marcadas como ocultas en el PDF."""

hallazgos = []


try:

layers = doc.layer_ui_configs()

for layer in layers:

if not layer.get("on", True): # capa apagada

hallazgos.append(Hallazgo(

categoria="Capa oculta en PDF",

severidad="ALTA",

descripcion=f"Capa '{layer.get('name', 'sin nombre')}' está desactivada/oculta",

evidencia=f"Config de capa: {layer}",

))

except Exception:

pass # no todos los PDFs tienen capas


return hallazgos



def detectarmetadatos_pdf(doc) -> list[Hallazgo]:

"""Analiza los metadatos del PDF en busca de instrucciones incrustadas."""

hallazgos = []


try:

meta = doc.metadata

campos_relevantes = {

'author': 'Autor',

'title': 'Título',

'subject': 'Asunto',

'keywords': 'Palabras clave',

'creator': 'Creador',

'producer': 'Productor',

'trapped': 'Trapped',

}


for campo, nombre in campos_relevantes.items():

valor = meta.get(campo, '') or ''

if not valor:

continue


# Verificar longitud sospechosa

if len(valor) > 500:

hallazgos.append(Hallazgo(

categoria="Metadatos sospechosos",

severidad="ALTA",

descripcion=f"Campo '{nombre}' inusualmente largo ({len(valor)} caracteres)",

evidencia=f"{valor[:150]}...",

))


# Verificar patrones de inyección en metadatos

for categoria, severidad, patrones in TODOS_LOS_PATRONES:

for patron in patrones:

if re.search(patron, valor, re.IGNORECASE):

hallazgos.append(Hallazgo(

categoria=f"Inyección en metadato ({nombre})",

severidad="CRITICA",

descripcion=f"Patrón de inyección en campo '{nombre}'",

evidencia=valor[:200],

))

break # un hallazgo por campo es suficiente


except Exception as e:

pass


return hallazgos



def detectardiscrepancia_texto_pdf(doc) -> list[Hallazgo]:

"""

Compara texto extraído por PyMuPDF vs renderizado visual.

Una discrepancia grande puede indicar texto incrustado no visible.

"""

hallazgos = []


for page_num, page in enumerate(doc, 1):

# Texto embebido en el PDF

texto_embebido = page.get_text("text")

# Texto visible en el render (aproximado por el area del clip)

palabras_visibles = page.get_text("words") # lista de (x0,y0,x1,y1,word,...)


chars_embebidos = len(texto_embebido.replace('\n', '').replace(' ', ''))

chars_visibles = sum(len(w[4]) for w in palabras_visibles)


if chars_embebidos > 0 and chars_visibles > 0:

ratio = chars_visibles / chars_embebidos

if ratio < 0.4 and chars_embebidos > 300:

hallazgos.append(Hallazgo(

categoria="Discrepancia texto embebido/visible",

severidad="ALTA",

descripcion=f"Solo el {ratio*100:.0f}% del texto embebido es visualmente accesible",

evidencia=f"Caracteres embebidos: {chars_embebidos} | Caracteres visibles: {chars_visibles}",

pagina=page_num,

))


return hallazgos



def detectarjavascript_pdf(doc) -> list[Hallazgo]:

"""Detecta JavaScript embebido en el PDF — vector de ataque conocido."""

hallazgos = []


try:

# Buscar JavaScript en el PDF a nivel de objeto

for xref in range(doc.xref_length()):

try:

obj_str = doc.xref_object(xref, compressed=False)

if '/JavaScript' in obj_str or '/JS' in obj_str:

hallazgos.append(Hallazgo(

categoria="JavaScript en PDF",

severidad="ALTA",

descripcion="JavaScript detectado embebido en el PDF",

evidencia=f"Objeto xref #{xref}: {obj_str[:200]}",

))

break # un hallazgo es suficiente para alertar

except Exception:

continue

except Exception:

pass


return hallazgos



# ──────────────────────────────────────────────

# CÁLCULO DE SCORE

# ──────────────────────────────────────────────


PESOS_SEVERIDAD = {

"CRITICA": 35,

"ALTA": 20,

"MEDIA": 10,

"BAJA": 3,

}


# ──────────────────────────────────────────────

# CONFIGURACIÓN

# ──────────────────────────────────────────────


COLOR_THRESHOLD = 240 # umbral para detección de texto "casi blanco" (0-255)


MAX_SCORE_POR_CATEGORIA = {

"Instrucción directa (EN)": 35,

"Instrucción directa (ES)": 35,

"Inyección en lenguaje jurídico": 35,

"Intento de exfiltración": 20,

"Delimitador LLM": 20,

"Separador sospechoso": 6,

"Unicode invisible": 20,

"Unicode confusable": 20,

"Texto invisible por color": 35,

"Texto invisible por tamaño": 35,

"Metadatos sospechosos": 20,

"Capa oculta en PDF": 20,

"JavaScript en PDF": 20,

"Discrepancia texto embebido/visible": 20,

}


def calcularscore(hallazgos: list[Hallazgo]) -> tuple[int, str]:

"""

Calcula score 0-100 con cap por categoría para evitar inflación

por matches repetidos del mismo tipo de patrón.

"""

score = 0

acumulado: dict[str, int] = {}


for h in hallazgos:

puntos = PESOS_SEVERIDAD.get(h.severidad, 0)

categoria = h.categoria


if categoria not in acumulado:

acumulado[categoria] = 0


limite = MAX_SCORE_POR_CATEGORIA.get(categoria, 100)

disponible = max(0, limite - acumulado[categoria])

sumar = min(disponible, puntos)


acumulado[categoria] += sumar

score += sumar


score = min(100, score)


if score == 0:

nivel = "LIMPIO"

elif score < 30:

nivel = "REVISAR"

else:

nivel = "ALERTA"


return score, nivel



def generarresumen(hallazgos: list[Hallazgo], nivel: str) -> str:

"""Genera resumen textual del análisis."""

if not hallazgos:

return "No se detectaron patrones de prompt injection. El documento parece seguro para procesar."


criticos = [h for h in hallazgos if h.severidad == "CRITICA"]

altos = [h for h in hallazgos if h.severidad == "ALTA"]

medios = [h for h in hallazgos if h.severidad == "MEDIA"]


partes = []

if criticos:

partes.append(f"{len(criticos)} hallazgo(s) CRÍTICO(S)")

if altos:

partes.append(f"{len(altos)} hallazgo(s) de severidad ALTA")

if medios:

partes.append(f"{len(medios)} hallazgo(s) de severidad MEDIA")


resumen = f"Se detectaron {', '.join(partes)}. "


if nivel == "ALERTA":

resumen += "NO procesar este documento con un LLM sin revisión manual previa. Alto riesgo de manipulación del sistema de IA."

else:

resumen += "Se recomienda revisión manual antes de procesar con IA."


return resumen



# ──────────────────────────────────────────────

# ENTRY POINTS PÚBLICOS

# ──────────────────────────────────────────────


def scan_pdf(path: str) -> ResultadoScan:

"""

Analiza un PDF completo (caso PJN).

Incluye: texto invisible, capas ocultas, metadatos, JS, patrones semánticos, unicode.

"""

if not PYMUPDF_AVAILABLE:

return ResultadoScan(

archivo=path, modo="pdf", score=0, nivel="ERROR",

error="PyMuPDF no está instalado. Ejecutar: pip install pymupdf"

)


if not os.path.exists(path):

return ResultadoScan(

archivo=path, modo="pdf", score=0, nivel="ERROR",

error=f"Archivo no encontrado: {path}"

)


hallazgos = []


try:

doc = fitz.open(path)


# Checks específicos de PDF

hallazgos += detectartexto_invisible_pdf(doc)

hallazgos += detectarcapas_ocultas_pdf(doc)

hallazgos += detectarmetadatos_pdf(doc)

hallazgos += detectardiscrepancia_texto_pdf(doc)

hallazgos += detectarjavascript_pdf(doc)


# Extraer texto para análisis semántico y unicode

texto_completo = ""

for page in doc:

texto_completo += page.get_text("text") + "\n"


doc.close()


except Exception as e:

return ResultadoScan(

archivo=path, modo="pdf", score=0, nivel="ERROR",

error=f"Error al procesar PDF: {str(e)}"

)


# Checks sobre el texto extraído

hallazgos += detectarpatrones_semanticos(texto_completo)

hallazgos += detectarunicode_sospechoso(texto_completo)

# detectarrepeticion_instrucciones desactivado hasta tener datos reales de calibración

# hallazgos += detectarrepeticion_instrucciones(texto_completo)


score, nivel = calcularscore(hallazgos)

resumen = generarresumen(hallazgos, nivel)


return ResultadoScan(

archivo=os.path.basename(path),

modo="pdf",

score=score,

nivel=nivel,

hallazgos=hallazgos,

resumen=resumen,

)



def scan_text(texto: str, nombre_archivo: str = "texto_plano.txt") -> ResultadoScan:

"""

Analiza texto plano (caso MEV/SCBA — cuerpo del escrito).

"""

hallazgos = []


hallazgos += detectarpatrones_semanticos(texto)

hallazgos += detectarunicode_sospechoso(texto)

# detectarrepeticion_instrucciones desactivado hasta tener datos reales de calibración

# hallazgos += detectarrepeticion_instrucciones(texto)


score, nivel = calcularscore(hallazgos)

resumen = generarresumen(hallazgos, nivel)


return ResultadoScan(

archivo=nombre_archivo,

modo="texto",

score=score,

nivel=nivel,

hallazgos=hallazgos,

resumen=resumen,

)



def scan_mev(texto: str, pdf_path: Optional[str] = None) -> ResultadoScan:

"""

Analiza el caso MEV/SCBA: texto plano + PDF adjunto opcional.

Combina ambos análisis y devuelve el resultado consolidado.

"""

resultado_texto = scan_text(texto, nombre_archivo="cuerpo_escrito.txt")


if pdf_path and os.path.exists(pdf_path):

resultado_pdf = scan_pdf(pdf_path)

# Consolidar hallazgos

todos_hallazgos = resultado_texto.hallazgos + resultado_pdf.hallazgos

score, nivel = calcularscore(todos_hallazgos)

resumen = generarresumen(todos_hallazgos, nivel)


return ResultadoScan(

archivo=f"{os.path.basename(pdf_path)} + texto",

modo="mev",

score=score,

nivel=nivel,

hallazgos=todos_hallazgos,

resumen=resumen,

)


return ResultadoScan(

archivo="cuerpo_escrito.txt",

modo="mev",

score=resultado_texto.score,

nivel=resultado_texto.nivel,

hallazgos=resultado_texto.hallazgos,

resumen=resultado_texto.resumen,

)



# ──────────────────────────────────────────────

# CLI STANDALONE

# ──────────────────────────────────────────────


def printresultado(resultado: ResultadoScan):

"""Imprime el resultado en consola de manera legible."""

COLOR = {

"LIMPIO": "\033[92m", # verde

"REVISAR": "\033[93m", # amarillo

"ALERTA": "\033[91m", # rojo

"ERROR": "\033[91m",

}

RESET = "\033[0m"

color = COLOR.get(resultado.nivel, "")


print(f"\n{'='*60}")

print(f" LEGALITO INJECTION DETECTOR")

print(f"{'='*60}")

print(f" Archivo : {resultado.archivo}")

print(f" Modo : {resultado.modo.upper()}")

print(f" Score : {resultado.score}/100")

print(f" Nivel : {color}{resultado.nivel}{RESET}")

print(f"{'='*60}")

print(f"\n {resultado.resumen}\n")


if resultado.error:

print(f" ERROR: {resultado.error}\n")

return


if resultado.hallazgos:

print(f" HALLAZGOS ({len(resultado.hallazgos)}):")

print(f" {'-'*56}")

for i, h in enumerate(resultado.hallazgos, 1):

sev_color = {

"CRITICA": "\033[91m",

"ALTA": "\033[93m",

"MEDIA": "\033[96m",

"BAJA": "\033[97m",

}.get(h.severidad, "")

pagina_str = f" [pág. {h.pagina}]" if h.pagina else ""

print(f"\n [{i}] {sev_color}[{h.severidad}]{RESET} {h.categoria}{pagina_str}")

print(f" {h.descripcion}")

print(f" Evidencia: {h.evidencia[:120]}")

else:

print(" Sin hallazgos.\n")


print(f"\n{'='*60}\n")



if name == "__main__":

if len(sys.argv) < 2:

print("Uso: python legalito_injection_detector.py <archivo.pdf|archivo.txt>")

print(" python legalito_injection_detector.py escrito.txt adjunto.pdf (modo MEV)")

sys.exit(1)


arg1 = sys.argv[1]


if arg1.lower().endswith('.pdf'):

resultado = scan_pdf(arg1)

elif arg1.lower().endswith('.txt') and len(sys.argv) >= 3:

# Modo MEV: texto + pdf

with open(arg1, 'r', encoding='utf-8', errors='ignore') as f:

texto = f.read()

resultado = scan_mev(texto, pdf_path=sys.argv[2])

elif arg1.lower().endswith('.txt'):

with open(arg1, 'r', encoding='utf-8', errors='ignore') as f:

texto = f.read()

resultado = scan_text(texto, nombre_archivo=arg1)

else:

# Intentar como PDF de todos modos

resultado = scan_pdf(arg1)


printresultado(resultado)


# Exit code según nivel

if resultado.nivel == "ALERTA":

sys.exit(2)

elif resultado.nivel == "REVISAR":

sys.exit(1)

else:

sys.exit(0)

Les dejo el código del detector de prompt injection, para que lo copien y peguen en su editor favorito y que le hagan las mejoras que necesiten. El código funciona bajo python 3.11+

Contacto

Estamos aquí para ayudarte en tus necesidades legales.

Email: darioramirez@legalito.ar

WhatsApp +54 11 6450 1571

© 2026. All rights reserved.

Nos encontramos en Cavassa 2613, Caseros - Pcia de Buenos Aires. Argentina