Datos sensibles fuera del prompt: una librería de TypeScript escrita en 39 minutos desde su especificación
sanitype redacta, enmascara o elimina datos sensibles dentro del proceso antes de que un payload salga hacia un LLM, un log o un tercero. Así está implementada en TypeScript.
Cada vez que un backend concatena un mensaje de usuario dentro de un prompt, escribe el body de una petición en un log o reenvía un payload a un helpdesk, está sacando datos personales del proceso hacia un sistema que nunca fue diseñado para guardarlos. sanitype es una librería de TypeScript que interpone una llamada entre ese payload y su destino: recibe el objeto, devuelve una copia con la misma forma y los campos sensibles redactados, enmascarados, hasheados o eliminados, más un reporte de todo lo que tocó. Corre entera dentro del proceso, sin llamadas de red y sin dependencias en tiempo de ejecución. También se construyó de una forma poco habitual: la idea se dictó por voz a un agente, el agente escribió 563 líneas de especificación y Claude Code implementó la versión 0.1.0 contra ellas en un solo objetivo, 39 minutos después.
Resumen para perezosos
- Combina dos estrategias: reglas por ruta de campo para lo que ya sabes que es sensible, y detectores sobre texto libre para el correo que alguien pegó en un campo de notas.
- Por defecto, entra un objeto y sale un objeto con la misma estructura. Cada llamada devuelve un reporte de qué se tocó, dónde y con qué detector, y ese reporte nunca contiene el valor original.
- El repositorio se escribió al revés de lo habitual: primero SPEC, ARCHITECTURE, ROADMAP y COMPARISON; después el código. Entre el commit de las especificaciones y el de la implementación pasaron 39 minutos.
En este artículo:
- Fundamentos — Qué se escapa de un backend · Por qué no me servía lo que ya existe · Cómo se construyó
- Implementación — Decir qué es sensible · Detectores · Acciones · El reporte
- Operación — Wrappers · Superficie de confianza · Rendimiento y límites
sanitype en veinte segundos:
tu backend
│
▼
sanitize(payload)
├─ reglas por campo lo que ya sabes que es sensible
├─ detectores lo que aparece dentro del texto libre
└─ acción redact · mask · hash · drop · tokenize
│
▼
LLM · logs · analítica · APIs de terceros
+ report: qué se tocó, dónde y con qué detector
Qué datos sensibles se escapan de un backend
El problema no es que alguien quiera filtrar datos. Es que hay cuatro salidas habituales por las que un payload sale completo del proceso, y ninguna de las cuatro se siente como una decisión sobre privacidad cuando la escribes:
payload del usuario
├──► prompt de un LLM OpenAI, Anthropic, un modelo local
├──► logs y observabilidad Sentry, Datadog, el logger de peticiones
├──► analítica Segment, PostHog, eventos internos
└──► APIs de terceros helpdesk, webhooks, integraciones de socios
La primera es la más nueva y la más silenciosa. Un ticket de soporte con el teléfono y la cédula del cliente se concatena tal cual dentro del prompt, y el prompt viaja a un proveedor externo que puede retenerlo. La segunda es la más vieja: alguien puso logger.info({ body: req.body }) para depurar un caso y ese log lleva dos años recogiendo correos y números de tarjeta. La tercera y la cuarta son variantes del mismo descuido: se manda el objeto entero porque separar los campos que el destino sí necesita da trabajo.
Lo que estas cuatro tienen en común es que ocurren en el borde de salida del proceso. Ahí es donde hay que interponer algo, y ese algo tiene que ser barato de llamar, porque si cuesta una llamada de red nadie lo va a poner en el logger.
Por qué no me servía lo que ya existe
Antes de escribir una línea revisé el panorama, y lo dejé escrito en un COMPARISON.md dentro del repositorio, porque “por qué no usar X” es una pregunta que aparece en el primer issue.
| Opción | Qué hace bien | Por qué no encajaba |
|---|---|---|
| DLP como servicio (Google Cloud DLP, Purview, Macie) | Cobertura amplia de entidades, detección con modelos, herramientas de cumplimiento alrededor | Una llamada de red por escaneo: latencia, costo por petición y una dependencia de proveedor más |
| Microsoft Presidio autoalojado | Maduro, buen reconocimiento de entidades, sin costo por llamada | Es un proyecto de Python: desde un backend de Node significa desplegar y monitorear otro servicio |
| Paquetes npm de regex | Livianos, sin dependencias, fáciles de leer | Operan sobre texto suelto: no entienden que user.ssn de tu tipo es un número de identidad, y no devuelven un reporte |
| Extensiones de navegador | Atajan lo que el usuario pega en una interfaz | No ven nada del tráfico de servidor a servidor, que es justamente el caso |
El hueco es concreto: una librería nativa de TypeScript, que corra dentro del proceso, que entienda la forma de tus objetos y no solo el texto, y que devuelva un registro auditable de lo que hizo. Esa es la posición que ocupa sanitype, y el precio que paga por ella está en la comparación: no hay modelo de reconocimiento de entidades detrás, la detección sobre texto libre está acotada por los patrones que trae.
Cómo se construyó: de una idea dictada a una librería publicada
Dicté la idea por voz a mi agente de Hermes y le pedí que la convirtiera en documentos de diseño, no en código. Después le pasé el repositorio a Claude Code con un solo objetivo: implementar la versión 0.1.0 contra esos documentos.
idea dictada por voz
│
▼
Hermes agent ──► SPEC.md · ARCHITECTURE.md · ROADMAP.md · COMPARISON.md
│ 563 líneas · cero TypeScript
▼ un solo objetivo
Claude Code ──► src/ · test/ · docs/ · examples/
84 archivos · 12 245 líneas · 108 tests
39 minutos entre el commit de las especificaciones y el commit de la implementación. Los cuatro documentos cubren el problema y los objetivos, el diseño interno, el orden de las versiones y la comparación con lo que ya existe. El commit de código que vino después trae el núcleo, los adaptadores, la documentación y ocho ejemplos ejecutables.
Lo que hizo posible ese tiempo no fue la velocidad del agente, sino que la especificación ya había cerrado las decisiones que normalmente se resuelven a mitad de la implementación, con la prisa del momento y sin dejar rastro.
El Spec-Driven Development ya lo conté en detalle con otro proyecto, así que no lo repito. Lo que sí quiero destacar es una sección del SPEC.md. La sección 7 se llama “preguntas abiertas” y lista lo que el documento no supo decidir: qué identidades nacionales entran en la v1, si tokenize trae almacenamiento o solo la interfaz, si la detección es síncrona o asíncrona. La sección 8, escrita después de implementar, contesta cada una y dice qué se eligió. La especificación no quedó como un documento que envejece contradiciendo al código: quedó como el registro de por qué el código es como es.
Las dos formas de decir qué es sensible
El núcleo combina dos estrategias, y el diseño se sostiene sobre que las dos convivan.
La primera son las reglas por campo: tú declaras qué rutas de tu payload son sensibles y qué hacer con cada una. Es explícita y no depende de que un patrón acierte. La segunda son los detectores: patrones que se ejecutan sobre cada string que quedó sin regla, para atrapar lo que la estructura no anticipó, como un correo escrito dentro de un campo de notas.
import { createSanitizer } from '@devrchancay/sanitype';
const sanitizer = createSanitizer({
// Lo que ya sé que es sensible, por ruta.
fields: {
'user.email': 'mask', // john.doe@example.com -> j***.d**@e******.com
'user.ssn': 'redact', // 123-45-6789 -> [REDACTED_SSN_US]
'user.phone': 'hash', // -> sha256 determinista
password: 'drop', // la clave desaparece del payload
'items[*].internalNote': 'allow' // nunca se toca, silencia un falso positivo
},
// La red de seguridad para todo lo demás.
detectors: { email: 'mask', phone: 'mask' },
hash: { salt: process.env.SANITYPE_SALT },
});
const { data, report } = sanitizer.sanitize(payload);
Las rutas usan notación de puntos con comodines: users[*].email para cualquier índice, *.password para cualquier clave de primer nivel, **.password para cualquier profundidad, $ para la raíz. Cuando varios patrones aplican a la misma ruta gana el más específico, y en empate gana el último declarado. Las reglas por campo siempre tienen prioridad sobre los detectores: lo explícito le gana a la heurística.
Si ya tienes esquemas de Zod, la sensibilidad se puede declarar al lado de la validación, sin cambiar el comportamiento del esquema:
import { z } from 'zod';
import { createSanitizer } from '@devrchancay/sanitype';
import { sensitive, fieldsFromSchema } from '@devrchancay/sanitype/zod';
const User = z.object({
id: z.string().uuid(),
email: sensitive(z.string().email(), 'mask'), // la categoría "email" se infiere
ssn: sensitive(z.string(), { action: 'redact', category: 'ssn_us' }),
password: sensitive(z.string(), 'drop'),
notes: z.string(), // texto libre: aquí corren los detectores
});
const sanitizer = createSanitizer({ fields: fieldsFromSchema(User) });
sensitive() no envuelve ni modifica el esquema: registra la instancia en un WeakMap y fieldsFromSchema() recorre la estructura para producir las rutas. Por eso la librería funciona con Zod 3 y Zod 4 sin importar zod en ningún momento, y por eso tu validación sigue comportándose exactamente igual que antes.
El recorrido completo de una llamada es este:
payload
│
├─ [1] resolución de rutas ¿esta ruta tiene una regla explícita?
├─ [2] recorrido estructural objetos, arrays, primitivos
├─ [3] pase de detectores sobre cada string que quedó sin regla
├─ [4] aplicación de acción redact | mask | hash | drop | tokenize | allow
└─ [5] armado del reporte qué, dónde, con qué detector, qué acción
│
▼
{ data, report }
Cada etapa es una función pura de su entrada y de la configuración, sin estado mutable compartido entre llamadas. Una instancia de Sanitizer compila su configuración una sola vez y se puede compartir entre peticiones concurrentes.
Los detectores son la costura de extensión
Un detector encuentra subcadenas sensibles dentro de texto libre. La decisión de diseño que más consecuencias tuvo fue no escribir un solo regex grande, sino unidades independientes con nombre:
export interface Detector {
name: string;
confidence: 'high' | 'heuristic';
defaultAction: Action;
priority?: number;
test(value: string): DetectorMatch[] | null;
mask?(value: string, options: Required<MaskOptions>): string;
}
Con un solo patrón se caen tres cosas a la vez: no podrías desactivar el detector que te da ruido sin perder los demás, el reporte no podría atribuir cada corrección a un detector concreto —que es el requisito de auditoría de la especificación— y cada patrón nuevo pondría en riesgo a los que ya funcionaban.
Los que vienen incluidos se dividen por confianza, y esa división decide cuáles están encendidos por defecto:
| Detector | Confianza | Por defecto | Qué reconoce |
|---|---|---|---|
email | alta | encendido | Direcciones de correo, con soporte Unicode |
phone | alta | encendido | Números de 7 a 15 dígitos, con +, paréntesis y separadores; excluye fechas |
credit_card | alta | encendido | 13 a 19 dígitos validados con Luhn, no solo el patrón |
ip_address | alta | encendido | IPv4 e IPv6, validado con net.isIPv6 de Node |
ssn_us | alta | encendido | Seguro social de EE. UU. con separadores, descartando rangos inválidos |
cedula_ec | alta | encendido | Cédula ecuatoriana y RUC de persona natural, validados por dígito verificador |
api_key_secret | alta | encendido | Llaves de AWS, GitHub, Stripe, OpenAI, Anthropic, Slack, Twilio; JWT, tokens bearer, bloques PEM y asignaciones api_key=... |
person_name | heurística | apagado | Secuencias de palabras capitalizadas con títulos y partículas |
physical_address | heurística | apagado | Direcciones en inglés y español |
Un detector de tarjetas que solo cuente dígitos marca cualquier identificador numérico largo; con Luhn, la mayoría de esos falsos positivos desaparece antes de llegar al reporte. Lo mismo con el dígito verificador de la cédula. Los dos heurísticos, en cambio, están apagados a propósito: person_name marca nombres de producto, de empresa y de ciudad, y eso no se arregla con patrones. Sirven como red de seguridad en logs y prompts, no como control de cumplimiento.
Agregar el formato interno de tu empresa usa exactamente la misma interfaz que los detectores incluidos:
import { defineDetector, createSanitizer } from '@devrchancay/sanitype';
const customerId = defineDetector({
name: 'customer_id',
pattern: /\bCUST-\d{6}\b/,
action: 'hash',
});
const sanitizer = createSanitizer({ customDetectors: [customerId] });
defineDetector acepta además un validate para checksums, un test cuando el regex no alcanza, un mask propio de la categoría y un prefilter: una comprobación barata que descarta el string antes de tocar los patrones. El detector de correos, por ejemplo, ignora cualquier cadena sin @, así que la mayoría de los strings de un payload nunca llega a la lista de patrones.
Seis acciones y una garantía de forma
Encontrar el dato es la mitad. La otra mitad es qué se hace con él, y ahí no hay una respuesta única: enmascarar sirve para depurar, hashear sirve para correlacionar, eliminar sirve para lo que el destino no debe recibir nunca.
| Acción | Resultado |
|---|---|
redact | Marcador fijo: [REDACTED_EMAIL], [REDACTED_CREDIT_CARD] |
mask | Máscara parcial que conserva el formato: j***.d**@e******.com, **** **** **** 1111, 192.***.**.** |
hash | Hash determinista de una vía, sha256 en hexadecimal por defecto |
drop | Quita la clave del objeto o el elemento del array |
tokenize | Reemplaza el valor por un token reversible contra un TokenStore que provee quien llama |
allow | Deja el campo intacto, para silenciar un falso positivo en una ruta sin apagar el detector |
Sobre esto hay una garantía que atraviesa toda la librería: por defecto, entra un objeto y sale con exactamente la misma estructura. Mismas claves, mismos largos de array, mismo anidamiento, y la entrada nunca se muta. drop es la única salida de esa garantía, y solo se aplica donde la pides por nombre: es un opt-out explícito, no una excepción escondida detrás de una promesa absoluta.
Esa propiedad tampoco se sostiene con buenas intenciones: hay un test basado en propiedades que genera payloads y comprueba que la estructura de salida coincide con la de entrada, sin importar qué valores se hayan corregido.
El reporte: auditable sin filtrar el valor
Un control de privacidad que no deja registro no se puede revisar. Cada llamada devuelve un reporte junto con los datos:
const { data, report } = sanitize(payload, { audit: true });
report.entries[0];
// {
// path: 'user.email',
// category: 'email', // detector o categoría del campo
// source: 'detector', // o 'field'
// action: 'redact',
// matches: 1,
// preview: 'j***.d**@e******.com', // solo en modo auditoría, siempre enmascarado
// }
report.summary; // { email: 1, phone: 1, credit_card: 1 }
report.skipped; // strings demasiado largos, circulares, objetos no soportados
report.modified; // true si algo cambió
report.durationMs;
La restricción que hace útil a este objeto es que nunca contiene el valor original, ni siquiera en modo auditoría: el preview ya viene enmascarado. Sin eso, el reporte sería otra copia del dato sensible viajando hacia el mismo log del que lo estabas sacando. Con eso, el reporte se puede escribir en el logger sin pensarlo, y report.summary sirve como contador por categoría para un panel.
Los wrappers: sanear antes de que la petición salga
El núcleo no sabe nada de HTTP ni de ningún SDK, así que se puede llamar desde un worker de cola o un cron. Los adaptadores son puntos de entrada separados que dependen del núcleo, nunca al revés, así que quien solo usa sanitize() no arrastra código de Express en su bundle.
En Express son dos middlewares:
import express from 'express';
import { createSanitizer } from '@devrchancay/sanitype';
import { sanitizeRequest, sanitizeResponse } from '@devrchancay/sanitype/express';
const sanitizer = createSanitizer({ fields: { password: 'drop' } });
const app = express();
app.use(express.json());
app.use(sanitizeRequest(sanitizer)); // reemplaza req.body, deja el reporte en req.sanitizeReport
app.use(sanitizeResponse(sanitizer)); // limpia los payloads de res.json()
app.post('/tickets', (req, res) => {
logger.info({ body: req.body, scrubbed: req.sanitizeReport.summary }); // seguro de loguear
res.json({ ok: true });
});
Activando la opción headers también se limpian authorization y las cookies antes de que el log de peticiones las escriba, que es de las filtraciones más comunes y menos vistosas.
Para los LLM hay wrappers por SDK. Parchean el cliente en su lugar y solo tocan la petición de salida: la respuesta del modelo vuelve exactamente como el SDK la produjo, y por eso el streaming sigue funcionando sin cambios.
import OpenAI from 'openai';
import { createSanitizer } from '@devrchancay/sanitype';
import { sanitizeOpenAI } from '@devrchancay/sanitype/openai';
const openai = sanitizeOpenAI(new OpenAI(), createSanitizer(), {
roles: ['user', 'tool'], // deja en paz los system prompts que escribiste tú
onReport: (report) => audit.log(report),
});
await openai.chat.completions.create({ model: 'gpt-4o-mini', messages });
Hay uno equivalente para Anthropic y un wrapLLMCall(fn, sanitizer, { keys }) genérico para cualquier otro SDK. Están escritos por forma de SDK y no como un wrapper universal por reflexión: es más código, pero cada uno se lee y se testea contra la petición real que envuelve.
Cuando el modelo necesita responder sobre las personas cuyos datos acabas de quitar, la acción tokenize cierra el viaje de ida y vuelta:
import { createSanitizer, createInMemoryTokenStore } from '@devrchancay/sanitype';
const store = createInMemoryTokenStore();
const sanitizer = createSanitizer({
detectors: { email: 'tokenize', phone: 'tokenize' },
tokenStore: store,
});
const { data: prompt } = sanitizer.sanitize(userMessage); // "escríbele a tok_3f9a... sobre ..."
const answer = await llm(prompt);
const restored = store.restore(answer); // los tokens vuelven a ser los valores
El almacén en memoria es para desarrollo; para seudonimización duradera implementas la interfaz TokenStore sobre tu propio almacenamiento, y si es asíncrono usas sanitizeAsync(). Esa separación entre el camino síncrono y el asíncrono fue una de las preguntas abiertas de la especificación, y se resolvió así para que un almacén lento no pueda degradar el camino rápido.
Cero dependencias y un test que lo verifica
En una herramienta de este tipo el árbol de dependencias es parte del producto. La promesa es que ningún dato sale del proceso, y una promesa así no se sostiene con una línea en el README. test/trust.test.ts la convierte en algo que rompe la compilación:
it('imports no networking modules and never calls fetch', () => {
const forbidden = [
/from\s+['"](node:)?(http|https|http2|dgram|dns|tls|child_process|worker_threads)['"]/,
/from\s+['"](undici|axios|node-fetch|got|ky)['"]/,
/\bfetch\s*\(/,
/\bXMLHttpRequest\b/,
/\bWebSocket\b/,
/\bnet\.(connect|createConnection|createServer)\b/,
];
for (const file of sources) {
const content = readFileSync(file, 'utf8');
for (const pattern of forbidden) {
expect(content, `${file} matches ${pattern}`).not.toMatch(pattern);
}
}
});
El mismo archivo comprueba dos cosas más: que los únicos módulos de Node importados sean node:crypto y node:net, y que package.json no declare ninguna dependencia en tiempo de ejecución, con zod como única peer dependency y marcada como opcional. Cualquier contribución que agregue un cliente HTTP falla en CI antes de llegar a revisión.
Rendimiento y límites
Las mediciones vienen de npm run bench sobre un payload de pedido de unos 1,6 KB con 45 campos, en Node 22. Son cifras indicativas de una máquina concreta, no un benchmark formal:
| Escenario | Media por llamada |
|---|---|
| Solo reglas por campo, detectores apagados | ~8 µs |
| Detectores por defecto, payload con datos sensibles | ~50 µs |
| Detectores por defecto, payload sin datos sensibles | ~25 µs |
| Todos los detectores, incluidos los heurísticos | ~200 µs |
| Un string de 1 KB de texto libre, detectores por defecto | ~60 µs |
El orden de magnitud es el que importa: microsegundos, no milisegundos. A ese costo, poner la llamada en el middleware de logs no es una decisión de arquitectura. Los patrones se compilan una vez por instancia, cada detector tiene su prefilter, y los strings más largos que maxStringLength (100 000 por defecto) se saltan y quedan anotados en report.skipped en lugar de escanearse entero.
Los límites hay que decirlos con la misma claridad:
- No es una certificación. Es un control técnico, uno entre varios. No hace que una aplicación cumpla el GDPR por sí solo.
- La detección es por patrones. No hay reconocimiento de entidades con modelos, así que la cobertura sobre texto libre está acotada por lo que trae. Para datos conocidos, las reglas por campo son la garantía; los detectores son la red.
dropes el opt-out de la garantía de estructura, y los números bajo una regla por campo se convierten en strings.- La cobertura de identidades nacionales es estrecha a propósito: seguro social de EE. UU. y cédula de Ecuador. Intentar “todos los países” en una v1 habría sido una lista larga de patrones sin validar. El resto se agrega con
defineDetector.
Ese último punto también salió de la especificación: la sección de no-objetivos dice explícitamente qué queda fuera de la v1, y por eso el alcance no se movió durante la implementación.
Preguntas frecuentes
¿sanitype hace que mi aplicación cumpla el GDPR?
No. Es un control técnico sobre datos en tránsito en la capa de aplicación, y hace falta combinarlo con políticas, revisión legal y gobierno de datos. Lo que sí aporta a una auditoría es el reporte: cada corrección queda registrada con su ruta, su categoría, su origen y la acción aplicada.
¿En qué se diferencia de Presidio o de un DLP en la nube?
En dónde corre y qué cuesta llamarlo. Un DLP en la nube agrega una llamada de red y un costo por petición; Presidio es Python, así que desde Node implica desplegar otro servicio. sanitype corre dentro del mismo proceso en microsegundos. A cambio no tiene reconocimiento de entidades con modelos: su detección sobre texto libre es más débil que la de Presidio, y las reglas por campo son las que compensan esa diferencia.
¿Se puede recuperar el valor original después de la llamada al LLM?
Sí, con la acción tokenize. Los valores se reemplazan por tokens antes de enviar el prompt y store.restore(answer) los devuelve en la respuesta del modelo. El almacén en memoria que trae la librería es para desarrollo; para producción se implementa la interfaz TokenStore sobre almacenamiento propio.
¿Detecta nombres de personas y direcciones?
Los tiene, apagados por defecto y marcados como heurísticos. Reconocen secuencias de palabras capitalizadas y direcciones en inglés y español, y producen falsos positivos con nombres de producto, de empresa y de lugar. Sirven como red de seguridad en logs y prompts; no como control de cumplimiento.
¿Necesito Zod para usarla?
No. zod es una peer dependency opcional y solo hace falta para el punto de entrada @devrchancay/sanitype/zod. Sin Zod se declaran las rutas a mano en fields, o se usan únicamente los detectores.
¿Qué pasa con lo que la librería no sabe recorrer?
Map, Set, buffers e instancias de clases pasan intactos y quedan listados en report.skipped, no se ignoran en silencio. Las referencias circulares se reemplazan por '[Circular]'. La idea es que después de leer el reporte sepas exactamente qué no se revisó.
Conclusión
El problema que resuelve sanitype no es detectar datos personales: es tener un lugar barato donde interponerse en el borde de salida del proceso. Por eso las decisiones que más pesan no son los patrones, sino que la llamada cueste microsegundos, que la salida conserve la estructura de la entrada, que cada corrección quede registrada sin copiar el valor y que el árbol de dependencias esté vacío y haya un test que lo defienda.
Si quieres aplicarlo, el orden que funciona es este: empieza llamando a sanitize() sin configuración sobre un payload real y lee el reporte, que te dice qué está encontrando y dónde. Después convierte en reglas por campo todo lo que ya sabías que era sensible, porque una ruta declarada no depende de que un patrón acierte. Deja los detectores encendidos como red para el texto libre, ajusta con allow las rutas donde te den falsos positivos y agrega con defineDetector los formatos internos de tu empresa. Y recién al final enciende los heurísticos, si es que los necesitas, sabiendo lo que traen.
Sobre la otra mitad de la historia: escribir primero la especificación no aceleró el proyecto porque el agente escriba rápido. Lo aceleró porque las preguntas que normalmente se contestan a mitad de la implementación ya estaban contestadas por escrito, y las que no se supieron contestar quedaron marcadas como preguntas abiertas en lugar de convertirse en decisiones accidentales.
El repositorio está en github.com/devrchancay/sanitype, publicado como @devrchancay/sanitype bajo licencia MIT, con el SPEC.md y el ARCHITECTURE.md dentro para que se pueda leer el razonamiento y no solo el resultado.