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>
· {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;">
✓ 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)
