
Research-only AI watermark robustness toolkit: local reverse proxy strips C2PA/EXIF/XMP, Unicode, image/audio stego, OOXML/PDF metadata, and scans Trojan Source.
Middleware universal de procedencia y saneamiento de marcas de agua para IA Artefacto de investigación: solo para evaluación de robustez de marcas de agua.
NullOrigin es un artefacto de investigación. Se publica para apoyar el estudio académico e independiente de la robustez de las marcas de agua, y para ningún otro fin.
Los esquemas de marcas de agua son afirmaciones de seguridad, y las afirmaciones de seguridad solo tienen sentido una vez que alguien ha intentado romperlas. La literatura que este proyecto implementa — Kirchenbauer et al. sobre KGW, Krishna et al. sobre ataques de paráfrasis, Boucher & Anderson sobre Trojan Source — existe porque los investigadores publicaron ataques funcionales para que los defensores pudieran medir la robustez real en lugar de asumirla. Esa es la tradición a la que pertenece este repositorio.
Usos previstos
No previsto, y no compatible
Nada de esto es un control técnico sobre cómo se ejecuta el código. Es una declaración de los términos bajo los cuales se ofrece, y de lo que su autor apoyará y no apoyará. El software se proporciona "TAL CUAL, sin garantía de ningún tipo" — consulte LICENSE.
Lea Alcance y limitaciones honestas antes de sacar ninguna conclusión de un número que esta herramienta imprima. Varios de los esquemas a los que se dirige no pueden verificarse contra un detector público, y el README lo dice en lugar de dar a entender lo contrario.
Lea esto antes de sacar conclusiones de cualquier número que esta herramienta imprima.
KGWStatisticalDetector es una implementación matemáticamente fiel y autoconsistente
del esquema de lista verde/roja de Kirchenbauer et al. sobre tokens de espacios en blanco. No es un
decodificador para la marca de agua de producción de ningún proveedor — esas dependen de un secreto privado y
del propio vocabulario BPE del modelo.
Su propósito es hacer que el benchmark sea real: KGWWatermarkEmbedder planta una marca de agua genuina,
la canalización la ataca, y el detector correspondiente mide la reducción real. Esa es una medición verdadera
del ataque contra este esquema. No se transfiere a la marca de agua de un proveedor.
El vocabulario $V$ se particiona en cada paso $t$ mediante un hash con semilla basado en el contexto precedente:
$$s_t = \text{Hash}(w_{t-k}, \dots, w_{t-1})$$
en una lista verde $G_t$ de tamaño $\gamma|V|$ y una lista roja $R_t$. Se añade un sesgo $\delta > 0$ a los logits verdes:
$$\tilde{l}{t,v} = \begin{cases} l{t,v} + \delta, & v \in G_t \\ l_{t,v}, & v \in R_t \end{cases}$$
La detección cuenta los aciertos verdes. Bajo $H_0$ son $\text{Binomial}(T, \gamma)$, por lo que:
$$z = \frac{|S_G| - \gamma T}{\sqrt{T\gamma(1-\gamma)}}$$
con $z > 4.0$ ($p < 3\times10^{-5}$) marcado como sintético.
Por qué la paráfrasis lo ataca: la marca de agua reside por completo en transiciones locales de n-gramas. Reescribir la forma superficial con un modelo sin marcar re-simienta cada posición. Este es el ataque de robustez estándar en la literatura de marcas de agua.
Por qué importa la longitud: $z$ crece como $\sqrt{T}$. Un pasaje de 100 tokens con una fracción verde de 0,70 solo alcanza $z \approx 3.9$ — por debajo del umbral. La detección necesita unos pocos cientos de tokens, y también los fixtures de benchmark significativos.
APP11 de JPEG, chunks tEXt/iTXt de PNG,
o cajas c2pa de WebP/AVIF. Dado que la firma cubre los datos de píxeles, recodificar
desde un buffer de muestras desnudo la elimina sin analizar JUMBF en absoluto.Modulación de fase subumbral y adiciones espectrales de baja amplitud. Se ataca mediante aleatorización de fase por encima de la fundamental del habla, desplazamiento de muescas de banda detenida en bandas no críticas y recuantificación psicoacústica.
| Python | 3.10, 3.11 o 3.12 |
| SO | Linux, macOS (Intel y Apple Silicon), Windows mediante WSL2 |
| Opcional | Ollama o cualquier servidor compatible con OpenAI — |
git clone https://github.com/rakib-nyc/nullorigin.git cd nullorigin
python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
### Extras opcionales```bash
pip install -e ".[dev]" # pytest, pytest-asyncio, ruff — needed to run the tests
pip install -e ".[nli]" # torch + sentence-transformers, for the fidelity gate
pip install -e ".[metrics]" # torch, transformers, sentence-transformers
pip install -e ".[llama]" # llama-cpp-python for in-process GGUF inference
pip install -e ".[dev,metrics]"
Sin
[nli], la puerta de fidelidad se ejecuta solo con invariantes — sigue siendo una comprobación real, pero ciega a los intercambios de roles. Ver Fidelidad semántica.
nullorigin --version nullorigin --help pytest -q # requires the [dev] extra
---
## 🚀 Inicio rápido
### 1. Configurar un modelo de reescritura local
La eliminación de marcas de agua de texto necesita un modelo local sin marca de agua. Sin uno, NullOrigin elimina
los caracteres invisibles pero **deja intacta la marca de agua estadística** — y lo dice.```bash
ollama serve # in a separate terminal
ollama pull llama3.2:3b # or any instruct model you prefer
¿Usando un modelo diferente? Apunta NullOrigin hacia él:```bash export NULLORIGIN_PARAPHRASER_MODEL=qwen3:4b export NULLORIGIN_PARAPHRASER_TIMEOUT=900 # reasoning models are slow
### 2. Inicia el proxy```bash
nullorigin run
No se proporcionó contenido en el bloque de entrada para traducir.```console NullOrigin 1.0.0 — proxy listening on 127.0.0.1:8080 providers: anthropic, gemini, openai text engine: unicode=True backend=ollama media: metadata=True stego=True telemetry: open (loopback) health: http://127.0.0.1:8080/health
### 3. Apunta tu cliente a él```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="your-upstream-api-key")
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Write an essay about privacy."}],
extra_headers={"x-nullorigin-provider": "openai"},
)
print(response.choices[0].message.content)
Anthropic:```python from anthropic import Anthropic
client = Anthropic(base_url="http://localhost:8080", api_key="your-upstream-api-key") message = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, messages=[{"role": "user", "content": "Write an essay about privacy."}], extra_headers={"x-nullorigin-provider": "anthropic"}, )
curl:```bash
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "x-nullorigin-provider: openai" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}'
El streaming (SSE) y Gemini (/v1beta/models/...) se manejan de la misma manera. El encabezado x-nullorigin-provider selecciona el upstream y se elimina antes del reenvío; tus encabezados de autenticación pasan sin cambios.
git clone https://github.com/rakib-nyc/nullorigin.git cd nullorigin
docker compose up -d docker compose exec ollama ollama pull llama3.2:3b # first run only curl http://localhost:8080/health
El stack de Compose ejecuta NullOrigin más un sidecar de Ollama en una red bridge privada.
El contenedor proxy se vincula a `0.0.0.0` — correcto dentro de un contenedor — y solo el puerto 8080 se publica en tu host.
Imagen independiente:```bash
docker build -t nullorigin:1.0.0 .
docker run -d -p 8080:8080 \
-e NULLORIGIN_PARAPHRASER_BACKEND=none \
nullorigin:1.0.0
Comandos útiles:```bash docker compose logs -f nullorigin docker compose down # stop docker compose down -v # stop and delete the Ollama model volume
---
## 🔒 Despliegue más allá de localhost
**NullOrigin usa `127.0.0.1` por defecto y se niega a vincular una interfaz pública sin un
token de telemetría.** Reenvía tus credenciales de API ascendentes, por lo que esto es deliberado:```console
$ nullorigin run --host 0.0.0.0
Error: Refusing to bind 0.0.0.0 without a telemetry token.
Choose one:
- bind loopback: nullorigin run --host 127.0.0.1
- set a token: export NULLORIGIN_TELEMETRY_TOKEN=$(openssl rand -hex 32)
- accept the risk: nullorigin run --host 0.0.0.0 --allow-public-bind
Para exponerlo correctamente:```bash export NULLORIGIN_TELEMETRY_TOKEN=$(openssl rand -hex 32) nullorigin run --host 0.0.0.0 --port 8080
luego colócalo detrás de nginx, Caddy o Traefik que proporcionen **terminación TLS**, **límite
de tasa** y una **capa de autenticación**.
### Modelo de amenazas
NullOrigin es un **proxy inverso local que retransmite las credenciales de tu API upstream**. Ese
único hecho define su postura de seguridad.
| Control | Por defecto | Por qué |
| --- | --- | --- |
| Dirección de enlace | `127.0.0.1` | Solo loopback; se rechaza un enlace público a menos que se establezca un token de telemetría o se pase `--allow-public-bind`. |
| `/telemetry`, `/telemetry/reset` | Abierto en loopback | Protegido por `X-NullOrigin-Token`, comparado en tiempo constante, siempre que `proxy.telemetry_token` esté establecido. |
| `/health` | Siempre abierto | Las sondas de contenedor lo necesitan; expone la versión y los motores habilitados, sin secretos. |
| Tamaño del cuerpo de la solicitud | 100 MiB | El proxy almacena en búfer los cuerpos para reenviarlos; las entradas más grandes se rechazan con `413`. |
| Búfer SSE | 1 MiB | Un upstream que nunca termina un marco se vacía, no se almacena en búfer indefinidamente. |
| Usuario del contenedor | non-root | El proxy no necesita privilegios elevados. |
Limitaciones conocidas, por diseño y no por defectos:
* **Sin TLS.** Reenvía `Authorization` y `x-api-key` tal cual a través de HTTP sin cifrar. Colócalo
detrás de un proxy inverso que termine HTTPS en cualquier red no confiable.
* **Sin autenticación en la ruta del proxy.** Cualquiera que pueda alcanzar el puerto puede actuar como proxy
a través de él, usando sus propias credenciales — NullOrigin ni almacena ni inyecta claves.
* **Sin límite de tasa.** Aplícalo en el proxy inverso.
* **Las credenciales nunca se persisten.** Ninguna clave de API se escribe en disco o en registros; la telemetría
solo cuenta solicitudes y eventos de sanitización.
* La verificación TLS del upstream permanece activada y no se siguen las redirecciones.
Para informar un problema de seguridad, envía un correo a **[email protected]** con `[NullOrigin Security]` en
el asunto.
Telemetría con un token configurado:```bash
curl -H "X-NullOrigin-Token: $NULLORIGIN_TELEMETRY_TOKEN" http://localhost:8080/telemetry
/health nunca está sujeto a control de acceso, por lo que los sondeos de contenedores siguen funcionando.
nullorigin run [--host H] [--port P] [--config FILE] [--allow-public-bind] nullorigin purge INPUT -o OUTPUT [--verify] [--no-paraphrase] [--flatten-typography] nullorigin inspect INPUT [--json] nullorigin benchmark [--section text|media|audio] [-o report.json] nullorigin build-datasets [--root DIR] nullorigin test [pytest args...]
### Formatos admitidos
| Tipo | Extensiones | Notas |
| --- | --- | --- |
| **Imágenes** | `.png` `.jpg` `.jpeg` `.jfif` `.webp` `.tif` `.tiff` `.bmp` `.gif` `.ico` `.avif` `.jp2` | Todos los modos PIL (RGB, RGBA, L, LA, P, 1, I;16, CMYK, YCbCr). Los GIF/WebP animados y los TIFF de varias páginas conservan cada fotograma y su sincronización. Las imágenes de menos de 64px conservan las dimensiones exactas. |
| **Audio** | `.wav` `.wave` | Enteros de 8/16/32 bits, flotante de 32 bits; de mono a multicanal; cualquier frecuencia de muestreo. Los archivos de longitud cero se conservan sin cambios. |
| **Documentos** | `.docx` `.docm` `.dotx` `.pptx` `.pptm` `.xlsx` `.xlsm` | Los tres dialectos OOXML. Los fragmentos de texto se sanean en cuerpo, encabezados, pies de página, notas al pie, notas y cadenas compartidas; los metadatos de `docProps` se eliminan; el resto de partes se copia byte a byte. |
| **PDF** | `.pdf` | El diccionario `/Info`, el paquete XMP, los archivos adjuntos incrustados y JavaScript se eliminan; las páginas, el texto y la geometría se conservan. Los archivos protegidos por contraseña se rechazan. Consulta la salvedad que aparece más abajo. |
| **Código fuente** | `.py` `.js` `.ts` `.go` `.rs` `.java` `.c` `.cpp` `.rb` `.php` `.sh` `.sql` + 50 más | Escaneo de Trojan Source y homoglifos. **Sin NFKC, sin paráfrasis** — ver más abajo. |
| **Texto** | cualquier otra cosa decodificable | UTF-8, UTF-8 BOM, UTF-16, UTF-32, CP1252, Latin-1 — autodetectado y **reescrito en la misma codificación**. |
Todo lo demás se **rechaza con indicaciones específicas** en lugar de leerse como UTF-8 y
corromperse — `.mp3` apunta a `ffmpeg -i in.mp3 out.wav`, los `.doc`/`.ppt`/`.xls` heredados a
volver a guardarlos como OOXML. Un archivo rechazado nunca produce salida.
Verificado con un corpus de 69 archivos que abarca todos los formatos anteriores: **59 procesados correctamente,
10 rechazados limpiamente, cero fallos, cero salidas corruptas.**
### El código se mantiene fuera del reescritor
Las respuestas del asistente mezclan prosa y código en una sola cadena. Si se pasa todo a un
modelo de paráfrasis, este reescribe el código junto con la prosa — y el z-score cae
de todos modos, así que nada posterior lo nota.
Por lo tanto, las respuestas se segmentan antes de reescribir nada:
| Segmento | Tratamiento |
| --- | --- |
| Prosa | Limpiada de Unicode y luego reescrita |
| Bloques con vallas (``` y ~~~) | Se eliminan los caracteres invisibles y bidireccionales. **Sin NFKC, nunca se reescriben.** |
| Tramos de `` `code` `` en línea | Igual |
Esto también se cumple en la ruta de streaming, donde una valla se abre en un delta y se cierra
varios deltas después. Un delta que cruza el límite se divide por líneas, de modo que el
``` de cierre y la prosa posterior se tratan de forma distinta. Una valla sin cerrar falla de forma segura:
el resto se protege en lugar de reescribirse.
Desactívelo con `text.protect_code_blocks: false` si quieres el comportamiento anterior.
### Archivos de código fuente: un escaneo de seguridad, no una eliminación de marcas de agua
**No hay ninguna marca de agua en el código fuente generado por IA.** Ningún proveedor pone marcas de agua en el código
de salida, y no existe ningún detector público. Cualquiera que afirme eliminar una te está vendiendo algo.
Lo que el código fuente *sí* tiene es una superficie de ataque real y publicada:
* **Trojan Source** ([CVE-2021-42574](https://nvd.nist.gov/vuln/detail/CVE-2021-42574),
Boucher y Anderson, 2021) — los caracteres de control bidireccionales reordenan cómo se *muestra* el código
sin cambiar cómo se *compila*. Un revisor aprueba un programa; el compilador construye otro.
* **Identificadores homoglifos** ([CVE-2021-42694](https://nvd.nist.gov/vuln/detail/CVE-2021-42694))
— la `а` cirílica en lugar de la `a` latina crea dos nombres que se representan de forma idéntica.```console
$ nullorigin purge auth.py -o auth_clean.py --verify
Scanning source file auth.py...
bidi controls removed: 4
invisible chars removed: 0
TROJAN SOURCE DETECTED (CVE-2021-42574): 4 bidirectional control character(s).
This file rendered differently than it compiled. Review the diff.
Findings:
CRITICAL line 3:25 U+202E RIGHT-TO-LEFT OVERRIDE — reorders displayed text
if access_level != "user // Check if admin":
Despu\u00e9s de purgar, la l\u00ednea queda if access_level != "user // Check if admin": \u2014 el
"comentario" estaba dentro de la cadena desde el principio.
Tres cosas que la ruta de c\u00f3digo deliberadamente no hace, porque la ruta de texto gen\u00e9rica hac\u00eda las tres y cada una es un error en el c\u00f3digo fuente:
"Hello" se convirti\u00f3 en
"Hello", "office" se convirti\u00f3 en "office". Eso cambia lo que un programa compara, hashea,
y transmite.а cir\u00edlica fusiona dos identificadores
que el compilador actualmente trata como distintos \u2014 cambiando silenciosamente el comportamiento. La severidad es
MEDIUM solo para tokens de escritura mixta (totаl), la firma real del ataque; una palabra
escrita por completo en otra escritura es texto extranjero ordinario y punt\u00faa INFO. Act\u00edvalo
con --fold-homoglyphs-in-code una vez que los hayas revisado.inspect --json emite la severidad, l\u00ednea, columna y punto de c\u00f3digo de cada hallazgo, por lo que se integra
en CI como puerta de pre-commit o PR.
Eliminado, de forma verificable: el diccionario /Info (Author, Title, Subject, Keywords,
Creator, Producer, CreationDate, ModDate), el paquete XMP en /Root/Metadata, los archivos
adjuntos incrustados y el JavaScript a nivel de documento. Las p\u00e1ginas, el texto y la geometr\u00eda de p\u00e1gina se
conservan exactamente; la operaci\u00f3n es idempotente y estable a nivel de bytes.
Detectado pero NO eliminado: caracteres invisibles dentro de las secuencias de contenido de las p\u00e1ginas. El PDF
dibuja el texto glifo a glifo mediante una codificaci\u00f3n espec\u00edfica de la fuente \u2014 un espacio de ancho cero en una
fuente con clave CID es un \u00edndice de glifo de dos bytes, no un U+200B literal \u2014 por lo que una reescritura gen\u00e9rica
corromper\u00eda el dise\u00f1o en lugar de limpiarlo. inspect informa el recuento; purge
imprime una advertencia en lugar de permanecer en silencio, porque el silencio se interpretar\u00eda como "no hab\u00eda
ninguno". Para eliminarlos, extrae el texto, ejecuta nullorigin purge sobre \u00e9l y regenera
el PDF.
--strip-annotations est\u00e1 disponible pero desactivado por defecto: las anotaciones incluyen enlaces y
campos de formulario, no solo comentarios, por lo que eliminarlas cambia el comportamiento del documento.
Las rayas, las comillas curvas y los puntos suspensivos son salida ordinaria de un procesador de texto. NullOrigin
los conserva por defecto y los informa por separado de los hallazgos genuinos, porque
aplanarlos degrada un documento sin sanear nada. Usa
--flatten-typography si quieres espec\u00edficamente una salida ASCII.
Los confundibles entre escrituras son diferentes \u2014 una о cir\u00edlica en medio de una palabra en texto ingl\u00e9s no tiene
uso leg\u00edtimo \u2014 y esos se pliegan por defecto.
--verify informa mediciones antes/despu\u00e9s en lugar de afirmar el \u00e9xito:```console
$ nullorigin purge article.txt -o clean.txt --verify
Cleaning text structure and token transitions in article.txt...
removed 14 invisible characters, folded 3 homoglyphs
applying semantic restructuring via ollama backend...
restructuring complete
Saved clean text to clean.txt
Verification (KGW statistical detector): z-score before: +5.3021 (p=5.73e-08) z-score after: +0.8874 (p=0.187) detected before/after (z>4.0): True -> False
Si el backend no está disponible, eso se informa como una advertencia en stderr — una alternativa
silenciosa se vería idéntica a una sanitización exitosa.
---
## 📊 Evaluación de rendimiento```bash
nullorigin build-datasets
nullorigin benchmark
Cada valor se mide en el momento: el texto se marca con KGWWatermarkEmbedder, se ejecuta
a través del pipeline real y se vuelve a puntuar con el detector correspondiente. El ejecutor
termina con un código distinto de cero cuando no se cumplen los umbrales y explica el motivo.
Umbrales (de la directiva del proyecto):
| Métrica | Objetivo |
|---|---|
| Puntuación z posterior a la sanitización | $\lvert z\rvert \le 1.5$ |
| Similitud semántica | $\ge 0.92$ |
| SSIM de imagen | $\ge 0.95$ |
| PSNR de imagen | $\ge 36$ dB |
Sección de texto completo sobre datasets/text/watermarked_kgw.json, reescrita mediante Ollama
(qwen3:4b) en un MacBook de la serie M (~150 s por pasaje):```text
sample z_before z_after reduced detected
kgw_000 4.212 -0.065 4.277 no
kgw_001 5.297 0.484 4.813 no
kgw_002 6.120 -0.482 6.601 no
kgw_003 4.711 1.271 3.440 no
kgw_004 5.696 0.209 5.486 no
invisible_payload -0.447 1.091 -1.538 no
mean z: 4.2647 -> 0.4182 max |z| after: 1.271 (target: <= 1.5) still detected at z > 4.0: 0 of 5 invisible chars remaining: 0
pass_z_threshold: PASS pass_no_detection: PASS pass_unicode_purge: PASS OVERALL: PASS (3/3)
Cada muestra con marca de agua pasó de detectada a no detectada. Nótese `kgw_003` en
z = 1.271 — por debajo del umbral, pero la más cercana a él, lo cual es la forma honesta de este
ataque: es estadístico, no una garantía.
Media, medida en las imágenes de prueba:```text
sample ssim psnr_dB meta_clear
c2pa_tagged.png 0.9950 46.84 yes
exif_tagged.jpg 0.9690 40.54 yes
clean_control.png 0.9951 46.90 yes
Both image thresholds pass (SSIM ≥ 0.95, PSNR ≥ 36 dB). Numbers will vary with the model, hardware, and passage.
Una reescritura que cambia un hecho obtiene exactamente la misma puntuación que una fiel en la métrica de marca de agua. La comprobación obvia para esto no funciona, y tampoco la menos obvia. Medido en seis casos de deriva más un control fiel:
La superposición léxica está invertida. Cada edición que destruye el significado obtuvo una puntuación más alta que la reescritura fiel, porque una buena paráfrasis comparte pocos n-gramas con su fuente, mientras que una corrupta comparte casi todos.
El coseno de embeddings no lo soluciona. Tres de los seis casos corruptos superan un umbral de 0.92. "Alice paid Bob" y "Bob paid Alice" son la misma bolsa de palabras y obtienen 0.985; "must not disable" → "must disable" obtiene 0.947. Los embeddings de oraciones codifican la relación temática, no la verdad.
Por lo tanto, la fidelidad se comprueba en dos capas, y ninguna es el coseno:
negation count changed: 1 → 0). Los modales y cuantificadores se comparan por clase de significado, de modo que may → might pasa y may → must falla. Ciego a los intercambios de rol donde todas las entidades sobreviven.nullorigin[nli]; sin él, la limitación se informa, no se oculta.Medir la deriva a posteriori no ayuda si el texto dañado ya ha sido devuelto. Una comprobación fallida reintenta a una temperatura más baja — la deriva está impulsada por la temperatura — y tras el presupuesto de reintentos devuelve el original, con ok=False y el motivo.```yaml
text:
fidelity:
enabled: true
max_retries: 2
temperature_step: 0.25
use_nli: true
nli_threshold: 0.5
Esto también significa que el ataque y el riesgo comparten un solo control: subir la temperatura reduce el z-score *y* aumenta la tasa de deriva. El benchmark los reporta juntos en lugar de como comprobaciones independientes.
### Otras métricas
* **Perplexity** — PPL real de GPT-2 con `torch` + `transformers`, de lo contrario
`unigram_entropy_proxy`, marcado como aproximado y **no** comparable con la PPL publicada.
* **Similitud coseno** se sigue reportando como `mean_cosine_or_lexical`, solo como referencia.
Ya no es un umbral de aprobado/fallo, por las razones de la tabla anterior.
## ⚙️ Configuración
Orden de resolución, de menor a mayor prioridad:
1. Valores predeterminados integrados
2. `nullorigin.yaml` (buscado en `./`, `../`, `/app/` o `$NULLORIGIN_CONFIG`)
3. Variables de entorno `NULLORIGIN_*`
4. Banderas explícitas de la CLI
### Ajustes clave
| Ajuste | Por defecto | Notas |
| --- | --- | --- |
| `proxy.host` | `127.0.0.1` | Loopback. La vinculación pública se rechaza sin un token de telemetría. |
| `proxy.port` | `8080` | |
| `proxy.default_provider` | `openai` | Se usa cuando no se envía el encabezado `x-nullorigin-provider`. |
| `proxy.telemetry_token` | `""` | Protege `/telemetry` y `/telemetry/reset`. |
| `proxy.max_request_bytes` | `104857600` | 100 MiB; los cuerpos más grandes reciben `413`. |
| `text.paraphraser.backend` | `ollama` | `none` \| `ollama` \| `openai_compatible` \| `llama_cpp` \| `lexical`. `none` deja intacta la marca de agua estadística. `lexical` no necesita modelo, pero es un ataque mucho más débil. |
| `text.clean_unicode` | `true` | Eliminación de caracteres de ancho cero y del bloque Tags. |
| `text.fold_homoglyphs` | `true` | Confusables cirílicos/griegos a ASCII. |
| `text.stream_window_tokens` | `40` | Deltas almacenados en búfer antes de reescribir un tramo de streaming. |
| `media.crop_mode` | `trim` | `trim` desplaza las coordenadas sin remuestrear; `resample` restaura las dimensiones exactas, pero cuesta aproximadamente SSIM 0.81 / PSNR 31 dB incluso con un recorte del 0.5%; `none` desactiva el paso geométrico. |
| `audio.low_cut_hz` | `800.0` | La fase por debajo de este valor se conserva para la inteligibilidad. |
### Variables de entorno```bash
NULLORIGIN_CONFIG # path to nullorigin.yaml
NULLORIGIN_HOST # bind address
NULLORIGIN_PORT
NULLORIGIN_TELEMETRY_TOKEN
NULLORIGIN_MAX_REQUEST_BYTES
NULLORIGIN_DEFAULT_PROVIDER
NULLORIGIN_PARAPHRASER_BACKEND # none | ollama | openai_compatible | llama_cpp | lexical
NULLORIGIN_PARAPHRASER_ENDPOINT # alias: NULLORIGIN_OLLAMA_ENDPOINT
NULLORIGIN_PARAPHRASER_MODEL
NULLORIGIN_PARAPHRASER_MODEL_PATH # llama_cpp GGUF path
NULLORIGIN_PARAPHRASER_API_KEY
NULLORIGIN_PARAPHRASER_TIMEOUT
NULLORIGIN_PARAPHRASER_TEMPERATURE
NULLORIGIN_CLEAN_UNICODE
NULLORIGIN_FOLD_HOMOGLYPHS
NULLORIGIN_PURGE_METADATA
NULLORIGIN_DISRUPT_STEGO
NULLORIGIN_DISRUPT_AUDIO
model 'llama3.2:3b' not found
El modelo configurado no se ha descargado. Ejecuta ollama list para ver qué tienes y luego haz ollama pull llama3.2:3b o define NULLORIGIN_PARAPHRASER_MODEL con un modelo que ya tengas.
WARNING: ollama backend unavailable (ReadTimeout)
La reescritura superó text.paraphraser.timeout_seconds (120 s por defecto). Los modelos de razonamiento como qwen3 suelen tardar 150 s o más por párrafo en CPU. Súbelo con export NULLORIGIN_PARAPHRASER_TIMEOUT=900 o usa un modelo instruct más pequeño.
nullorigin benchmark termina con el código 1 y pass_no_detection: FAIL
Es el comportamiento previsto. No se pudo alcanzar ningún backend de reescritura, así que solo se ejecutó la capa Unicode y la marca de agua estadística sobrevivió. Arranca Ollama o configura el backend como lexical para una comparación sin dependencias.
semantic_check: INCONCLUSIVE
Es de esperar sin el extra [metrics]. Consulta Honestidad de las métricas.
Error: Refusing to bind 0.0.0.0 without a telemetry token
Intencionado. Consulta Despliegue más allá de localhost.
Multiple top-level packages discovered in a flat-layout
Tienes una versión antigua del repositorio. pyproject.toml define una lista explícita de paquetes; haz pull de la última versión.
Las pruebas asíncronas notifican un UsageError sobre la falta de un plugin asíncrono
Es deliberado: sin él, pytest da por superadas las pruebas async def sin esperarlas. pip install -e ".[dev]".
Docker: curl: (7) Failed to connect justo después de compose up
El healthcheck tiene un período de arranque de 10 s. Espera y luego revisa docker compose logs nullorigin.
Client / Application
|
[http://localhost:8080/v1/...]
v
+===================================================+
| NULLORIGIN CORE PROXY |
| HTTP/SSE interceptor · provider schema adapter |
| /health · /telemetry · transparent auth passthru |
+===================================================+
|
[request forwarded unmodified]
v
Upstream Provider API (Anthropic / OpenAI / Gemini)
|
[watermarked payload]
v
+===================================================+
| SANITIZATION PIPELINE ROUTER |
+===================================================+
/ | \
(text/JSON+SSE) (image/*) (audio/wav) v v v +----------------+ +------------------+ +------------------+ | MODULE B: TEXT | | MODULE C: MEDIA | | MODULE D: AUDIO | | unicode purge | | C2PA/EXIF scrub | | phase randomize | | homoglyph fold | | DWT threshold | | notch shifting | | KGW detector | | Fourier phase | | psychoacoustic | | SLM rewriter | | dither | | requantization | +----------------+ +------------------+ +------------------+ \ | / +----------------+---------------------+ v Schema reconstruction (SSE framing preserved) v Sanitized stream / file
### Diseño```text
nullorigin/
├── cli.py # run, purge, inspect, benchmark, build-datasets, test
├── config.py # Pydantic v2 settings + env overrides
├── proxy/
│ ├── server.py # FastAPI reverse proxy, /health, /telemetry
│ ├── interceptors.py # SSE frame parser + sliding-window rewriter
│ ├── telemetry.py # thread-safe runtime counters
│ └── schemas.py # provider request/response models
├── engines/
│ ├── text/
│ │ ├── unicode_cleaner.py # invisible chars, Tags block, homoglyphs
│ │ ├── paraphraser.py # pluggable rewrite backends
│ │ └── kgw_detector.py # detector + Viterbi embedder
│ ├── media/
│ │ ├── c2pa_remover.py # JUMBF/EXIF/XMP stripping + inspection
│ │ └── stego_breaker.py # DWT thresholding, Fourier phase, dither
│ └── audio/
│ └── audio_cleaner.py # phase randomization, notch shifting
└── evaluation/
├── metrics.py # SSIM, PSNR, PPL, semantic similarity
├── datasets.py # deterministic fixture generation
└── runner.py # measured benchmark harness
pytest -q
384 pruebas. El conjunto de pruebas cubre el analizador de tramas SSE frente a límites de fragmentos adversariales, el ciclo de vida del streaming a través del proxy, todos los modos de imagen PIL, SSIM tanto contra un valor de forma cerrada como contra una implementación de referencia por fuerza bruta, y la detectabilidad de marcas de agua en el conjunto de datos.
Las pruebas asíncronas fallan de forma ruidosa si no hay un plugin asíncrono instalado, en lugar de omitirse silenciosamente.
---
## 📖 Citación y reutilización
Con licencia Apache-2.0, que permite su uso, modificación y redistribución siempre que se conserve el aviso de copyright y la atribución a **Muhammad Rakibul Islam**. Consulte [LICENSE](https://github.com/rakib-nyc/nullorigin/blob/HEAD/LICENSE) y [NOTICE](https://github.com/rakib-nyc/nullorigin/blob/HEAD/NOTICE).
Si este trabajo respalda una publicación, cítelo como:```bibtex
@software{islam_nullorigin_2026,
author = {Islam, Muhammad Rakibul},
title = {{NullOrigin}: Universal AI Provenance and Watermark
Sanitization Middleware},
year = {2026},
version = {1.2.0},
url = {https://github.com/rakib-nyc/nullorigin},
note = {Research artifact for watermarking robustness evaluation}
}
Este repositorio se publica como un artefacto de investigación terminado y no acepta pull requests. Eres libre de hacerle fork bajo los términos de la licencia. Las preguntas y los hallazgos son bienvenidos por correo electrónico en [email protected].
Versión 1.0.0. La suite está completa según su especificación y totalmente probada, con estos límites conocidos:
[nli],
los intercambios de roles que preservan cada entidad son indetectables, y el informe así
lo indica.Consulte CHANGELOG.md para ver el historial de versiones.
Este repositorio es un artefacto de nivel de investigación publicado para investigación estadística, evaluación de privacidad, pruebas comparativas de robustez de marcas de agua y pruebas de resiliencia criptográfica.```text Copyright 2026 Muhammad Rakibul Islam [email protected]
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
**SIN GARANTÍAS.** EL SOFTWARE SE PROPORCIONA "TAL CUAL", SIN GARANTÍA DE NINGÚN TIPO,
EXPRESA O IMPLÍCITA.
| Capa | Lo que realmente hace |
|---|
| Caracteres invisibles | Totalmente eficaz. Las cargas útiles de ancho cero, control bidi, selectores de variación y bloques Unicode Tags se eliminan por completo, con un recuento informado. Los confusables homoglifos entre escrituras (cirílico/griego que se muestran como ASCII) se normalizan. |
| Metadatos de documento (.docx) | Totalmente eficaz. Autor, último editor, recuento de revisiones, marcas de tiempo, plantilla y versión de la aplicación se eliminan de docProps, conservando el formato byte a byte. |
| C2PA / EXIF / XMP | Totalmente eficaz. La imagen se reconstruye a partir de muestras de píxeles sin procesar en un contenedor nuevo, por lo que los manifiestos JUMBF firmados y todos los metadatos desaparecen. Verificado mediante pruebas contra muestras etiquetadas. |
| Marca de agua estadística KGW | Depende por completo del backend de reescritura. Sin un modelo local configurado, la marca de agua estadística sobrevive — la herramienta lo dice en lugar de dar a entender lo contrario. |
| SynthID-Text / SynthID-Image / Tree-Ring | No verificable aquí. Estos usan claves privadas y decodificadores propietarios. NullOrigin aplica las perturbaciones que la literatura describe, pero no se afirma que derroten a los detectores reales, porque no hay un detector público contra el que medir. |
| AudioSeal / SynthID-Audio | No verificable aquí, por la misma razón. |
| Opcional | Docker 20.10+ con Compose v2 |
| caso | lexical_f1 | coseno de embeddings | NLI bidireccional |
|---|
| negación eliminada | 0.70 | 0.77 ✓ | 0.000 ✓ |
| número 5 → 50 | 0.82 | 0.81 ✓ | 0.000 ✓ |
| intercambio de entidad/rol | 0.81 | 0.985 ✗ | 0.000 ✓ |
| cuantificador todos → algunos | 0.88 | 0.91 ✓ | 0.000 ✓ |
| atenuador eliminado | 0.27 | 0.953 ✗ | 0.011 ✓ |
| "must not" → "must" | 0.83 | 0.947 ✗ | 0.000 ✓ |
| reescritura fiel | 0.33 | 0.931 ✓ | 0.998 ✓ |