Skip to content

English · Español

Lab 04 — Supply chain: verify_artifacts.sh y cumplimiento de safetensors

🇪🇸 El lab más corto y de mayor retorno: un script shell que verifica SHA256 de cada artefacto contra MANIFEST.json, más un bandit-rule que prohíbe cargar pickles. Cinco minutos de paranoia, cero RCE por carga de pesos.


Objetivo

Entregar dos defensas baratas que en conjunto cierran los riesgos de supply chain de mayor severidad:

  1. scripts/verify_artifacts.sh — recorre MANIFEST.json, hashea cada artefacto, sale con código distinto de cero en caso de desajuste.
  2. Política safetensors solamente — hecha cumplir por la regla bandit B301 más una regla personalizada que marca cualquier torch.load( sin weights_only=True.

Es el lab más pequeño en líneas de código, el lab más grande en reducción de riesgo residual (T6 + T7 en la matriz de la Teoría 03: \(\Delta R \approx -0.85\) combinado).

Entregables

  • scripts/verify_artifacts.sh — script shell ejecutable; pasa con manifest sano, falla claramente con manipulado.
  • scripts/forge_tampered_manifest.sh — helper de test: copia el manifest a un dir temporal, muta un byte de un artefacto, ejecuta verify, verifica salida distinta de cero. Se usa para demostrar que el verificador realmente dispara.
  • tests/test_verify_artifacts.py — pytest que envuelve los dos scripts shell.
  • Bloque security/banditrc.yaml o pyproject.toml [tool.bandit] — habilita B301 (pickle) y añade regla personalizada para torch.load sin weights_only.
  • Una fila nueva en security/THREATS.md (la añade Borja; commit security: phase-37-threats-supply-chain).
  • security/supply-chain.md extendido con una sección específica del tutor de gramática (el archivo seguramente existe desde la Fase 0; extiéndelo, no lo sobrescribas).

Paso 1 — Escribir scripts/verify_artifacts.sh

Forma (la implementación concreta es de Borja; esto es el contrato):

#!/usr/bin/env bash
set -euo pipefail

MANIFEST="${1:-MANIFEST.json}"

if [[ ! -f "$MANIFEST" ]]; then
    echo "ERROR: manifest not found: $MANIFEST" >&2
    exit 2
fi

mismatches=0

jq -r '.artifacts[] | "\(.sha256)  \(.path)"' "$MANIFEST" | while read -r expected path; do
    if [[ ! -f "$path" ]]; then
        echo "MISSING: $path" >&2
        mismatches=$((mismatches + 1))
        continue
    fi
    actual=$(sha256sum "$path" | awk '{print $1}')
    if [[ "$actual" != "$expected" ]]; then
        echo "MISMATCH: $path" >&2
        echo "  expected: $expected" >&2
        echo "  actual:   $actual" >&2
        mismatches=$((mismatches + 1))
    fi
done

if [[ $mismatches -gt 0 ]]; then
    echo "FAIL: $mismatches mismatch(es)" >&2
    exit 1
fi
echo "OK: all $(jq '.artifacts | length' "$MANIFEST") artifacts verified"

Notas:

  • set -euo pipefail asegura que bash falle realmente en errores intermedios. Sin él, while read tragándose errores es un gotcha clásico.
  • Códigos de salida: 0 = todo bien. 1 = al menos un desajuste. 2 = manifest ausente / ilegible. La distinción importa para CI.
  • El contador mismatches dentro de una subshell while read es el gotcha bash clásico — cuidado con el scoping de subshell. Si la implementación de Borja lee dentro de un pipeline, el contador no se actualiza en el padre. Soluciones: process substitution (< <(jq ...)) o set -o lastpipe.

Paso 2 — Ejecutar el verificador (estado sano)

$ scripts/verify_artifacts.sh
OK: all 7 artifacts verified
$ echo $?
0

Si no devuelve 0 sobre un manifest de Fase 18 recién entrenado, algo ya está mal — investiga antes de continuar.

Paso 3 — Forjar un manifest manipulado y verificar el fallo

scripts/forge_tampered_manifest.sh:

#!/usr/bin/env bash
set -euo pipefail
WORK=$(mktemp -d)
cp -r artifacts "$WORK/"
cp MANIFEST.json "$WORK/"
cd "$WORK"

# Flip one byte in the model weights.
WEIGHTS=$(jq -r '.artifacts[] | select(.role == "model-weights").path' MANIFEST.json)
printf '\x00' | dd of="$WEIGHTS" bs=1 count=1 seek=42 conv=notrunc 2>/dev/null

# Verify must fail.
if scripts/verify_artifacts.sh; then
    echo "FAIL: verifier did not detect tampering" >&2
    exit 1
fi
echo "OK: tampering was detected"

Ejecútalo y confirma "tampering was detected." Si el verificador pasa sobre el archivo manipulado, el verificador está roto — arréglalo antes de continuar.

Paso 4 — Envoltorio pytest

# tests/test_verify_artifacts.py
import subprocess
from pathlib import Path

def test_verifier_passes_on_clean_manifest():
    result = subprocess.run(["scripts/verify_artifacts.sh"], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr

def test_verifier_fails_on_tampered_manifest(tmp_path):
    # Run the forge script which creates a tampered copy in tmp and runs verify.
    result = subprocess.run(
        ["scripts/forge_tampered_manifest.sh"],
        capture_output=True,
        text=True,
        env={**os.environ, "TMPDIR": str(tmp_path)},
    )
    assert result.returncode == 0, "forge script itself failed"
    assert "tampering was detected" in result.stdout

Ambos tests deben pasar en CI.

Paso 5 — Política de Bandit

pyproject.toml añade o extiende:

[tool.bandit]
exclude_dirs = [".venv", "build", "dist"]
skips = []
# Explicitly enabled:
tests = ["B301", "B102", "B307"]    # pickle, exec_used, eval

Regla personalizada para torch.load: bandit no tiene una integrada. O bien:

  • Usa el mecanismo de reglas personalizadas de ruff (pyproject.toml [tool.ruff.lint] select = ["S301"] reutiliza el catálogo de bandit).
  • O escribe una pequeña comprobación basada en grep en scripts/check_torch_load.sh que falle ante cualquier torch.load( sin weights_only=True en src/.
# scripts/check_torch_load.sh
#!/usr/bin/env bash
set -euo pipefail
bad=$(rg -n 'torch\.load\(' src/ | grep -v 'weights_only=True' || true)
if [[ -n "$bad" ]]; then
    echo "ERROR: torch.load without weights_only=True:" >&2
    echo "$bad" >&2
    exit 1
fi
echo "OK: all torch.load calls use weights_only=True (or no calls present)"

Añadir a CI: just security ejecuta tanto bandit src/ como scripts/check_torch_load.sh.

Paso 6 — Refactor: safetensors en todas partes

Para cada save/load de checkpoint en src/:

# Before:
torch.save(model.state_dict(), "checkpoint.pt")
state = torch.load("checkpoint.pt")

# After:
from safetensors.torch import save_file, load_file
save_file(model.state_dict(), "checkpoint.safetensors")
state = load_file("checkpoint.safetensors")

Actualiza el schema de MANIFEST.json en la Fase 18 para registrar artefactos .safetensors en vez de .pt. Los checkpoints .pt viejos pueden convertirse de una sola vez con un helper scripts/convert_pt_to_safetensors.py.

Bandit ahora marcará cualquier torch.load restante como hallazgo. Arregla todos los llamantes, o documenta cada # nosec B301 con justificación en security/THREATS.md.

Paso 7 — Extender security/supply-chain.md

Añade una sección de tutor de gramática:

## Grammar tutor (Phase 32) — artifacts

| Artifact | Path | Format | Integrity check |
|---|---|---|---|
| Model weights | `artifacts/checkpoints/mini-gpt-grammar.safetensors` | safetensors | MANIFEST.json SHA256 |
| Tokenizer vocab | `artifacts/tokenizer/vocab.json` | JSON | MANIFEST.json SHA256 |
| Tokenizer merges | `artifacts/tokenizer/merges.txt` | text | MANIFEST.json SHA256 |
| RAG index | `artifacts/rag/index/embeddings.safetensors` | safetensors | MANIFEST.json SHA256 |
| KB chunks | `data/kb/grammar-rules/chunks.jsonl` | JSONL | MANIFEST.json SHA256 |

## Loading policy

- Model weights: `safetensors.torch.load_file` only. `torch.load` is prohibited in `src/` (CI-enforced by `scripts/check_torch_load.sh` + bandit B301).
- KB chunks: JSON parser with schema validation; no `eval` or `pickle` anywhere in the load path.
- Before any `agent-start`, CI runs `scripts/verify_artifacts.sh`. Non-zero exit blocks the start.

Haz commit como docs(security): grammar-tutor supply-chain extension.

Paso 8 — Fila de THREATS.md

Phase Surface Asset at risk Adversary Mitigation Status
37 Model weight load / KB document load Code execution on host (pickle RCE), integrity of model and KB Malicious checkpoint or tampered KB file safetensors-only (bandit + custom rule); MANIFEST.json SHA256 verification (scripts/verify_artifacts.sh); CI gates agent-start on verification mitigated

Commit: security: phase-37-threats-supply-chain.

Paso 9 — Cómo se ve "hecho"

  • scripts/verify_artifacts.sh sale 0 en sano, 1 en manipulado, 2 con manifest ausente.
  • scripts/forge_tampered_manifest.sh prueba el camino de fallo.
  • tests/test_verify_artifacts.py tiene los casos clean + tampered.
  • bandit src/ corre en just security y pasa.
  • scripts/check_torch_load.sh corre en just security y pasa (sin torch.load sin weights_only=True).
  • Todos los checkpoints en artifacts/ son .safetensors; cualquier .pt sobrante está convertido o eliminado.
  • security/supply-chain.md tiene la sección de tutor de gramática.
  • security/THREATS.md tiene la fila de supply-chain.

Trampas comunes

  1. Scope de variables en subshell de bash. while read; do counter=$((counter+1)); done < file funciona; cmd | while read; ... no actualiza counter en el padre. Usa process substitution.
  2. weights_only=True como única defensa. Es defensa en profundidad, no un reemplazo de safetensors. La deserialización pickle sigue siendo deserialización pickle; ha habido bugs en el unpickler restringido de PyTorch. Usa safetensors.
  3. Olvidar eliminar archivos .pt viejos. Un model.pt rancio junto a model.safetensors invita a un typo que cargue el incorrecto. O elimínalo o muévelo a un _archive/ claramente etiquetado y excluido del manifest.
  4. Manifest sin firma. La verificación SHA256 atrapa corrupción accidental y manipulación ingenua. Un atacante sofisticado reescribe el archivo y el manifest. Objetivo opcional: firma con GPG el propio manifest.
  5. CI sin gatear sobre verificación. Un verificador que existe pero no se ejecuta antes de agent-start es decoración. Cabléalo al just target.

Objetivos opcionales

  • Firma con GPG MANIFEST.json durante el entrenamiento de la Fase 18. verify_artifacts.sh comprueba la firma con una clave pública commiteada en security/keys/.
  • Firmas por-archivo (no sólo a nivel de manifest): cada chunk_id en la KB tiene su propia firma GPG; el verificador comprueba cada una. Más trabajo, pero atrapa manipulación de chunks individuales aunque el manifest se reemplace.
  • Un pre-commit hook que ejecute scripts/verify_artifacts.sh si MANIFEST.json o algún artefacto trackeado cambia. Atrapa commits accidentales de artefactos no verificados.

Fin de la secuencia de labs de la Fase 37. A continuación, escribe experiments/37-redteam-report/findings.md (el writeup honesto) y PHASE_37_REPORT.md.