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:
scripts/verify_artifacts.sh— recorreMANIFEST.json, hashea cada artefacto, sale con código distinto de cero en caso de desajuste.- Política
safetensorssolamente — hecha cumplir por la reglabanditB301 más una regla personalizada que marca cualquiertorch.load(sinweights_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.yamlopyproject.toml[tool.bandit]— habilita B301 (pickle) y añade regla personalizada paratorch.loadsinweights_only. - Una fila nueva en
security/THREATS.md(la añade Borja; commitsecurity: phase-37-threats-supply-chain). security/supply-chain.mdextendido 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 pipefailasegura que bash falle realmente en errores intermedios. Sin él,while readtragá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
mismatchesdentro de una subshellwhile reades 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 ...)) oset -o lastpipe.
Paso 2 — Ejecutar el verificador (estado sano)¶
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.shque falle ante cualquiertorch.load(sinweights_only=Trueensrc/.
# 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.shsale 0 en sano, 1 en manipulado, 2 con manifest ausente. -
scripts/forge_tampered_manifest.shprueba el camino de fallo. -
tests/test_verify_artifacts.pytiene los casos clean + tampered. -
bandit src/corre enjust securityy pasa. -
scripts/check_torch_load.shcorre enjust securityy pasa (sintorch.loadsinweights_only=True). - Todos los checkpoints en
artifacts/son.safetensors; cualquier.ptsobrante está convertido o eliminado. -
security/supply-chain.mdtiene la sección de tutor de gramática. -
security/THREATS.mdtiene la fila de supply-chain.
Trampas comunes¶
- Scope de variables en subshell de bash.
while read; do counter=$((counter+1)); done < filefunciona;cmd | while read; ...no actualizacounteren el padre. Usa process substitution. weights_only=Truecomo ú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.- Olvidar eliminar archivos
.ptviejos. Unmodel.ptrancio junto amodel.safetensorsinvita a un typo que cargue el incorrecto. O elimínalo o muévelo a un_archive/claramente etiquetado y excluido del manifest. - 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.
- CI sin gatear sobre verificación. Un verificador que existe pero no se ejecuta antes de
agent-startes decoración. Cabléalo al just target.
Objetivos opcionales¶
- Firma con GPG
MANIFEST.jsondurante el entrenamiento de la Fase 18.verify_artifacts.shcomprueba la firma con una clave pública commiteada ensecurity/keys/. - Firmas por-archivo (no sólo a nivel de manifest): cada
chunk_iden 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.shsiMANIFEST.jsono 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.