Fase 2: 04_sanitize.py - scrubbing de secretos de skills/agentes/plans (10 secretos unicos, gate en verde)

This commit is contained in:
2026-07-29 00:35:59 +00:00
parent 21bc219f31
commit 837f91060f
79 changed files with 10153 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
"""Fase 2: sanitiza secretos reales en las fuentes de contenido para autoria de seeds
(5 SKILL.md, 7 agentes de ~/.claude/agents/, 66 planes de ~/.claude/plans/) antes de
que ningun subagente developer los lea para escribir ejemplos de entrenamiento.
Corre localmente (no requiere GPU ni el modelo). Usa detect-secrets (entropia alta,
formatos conocidos AWS/JWT/etc.) mas una lista de regex explicitas para los patrones
ya conocidos de este proyecto (SPARK_PASSWORD, GMAIL_APP_PASSWORD, el bearer token
reusado, emails reales, IPs/hostnames internos de spark). La sustitucion es estable:
el mismo secreto real siempre produce el mismo placeholder, via un diccionario hash
persistido localmente en data/raw/.secrets_map.json (gitignoreado, solo para debug).
Salida: data/raw/sanitized/{skills,agents,plans}/... (misma estructura relativa).
Gate: termina con exit code != 0 si detect-secrets o las regex explicitas encuentran
algo en la salida YA sanitizada -- falla cerrado, no adivina reemplazos para
patrones no reconocidos.
"""
import hashlib
import json
import re
import sys
from pathlib import Path
from detect_secrets.core import scan
REPO_ROOT = Path(__file__).resolve().parent.parent
HOME = Path.home()
SOURCES = [
(HOME / ".claude" / "skills", "*/SKILL.md", "skills"),
(HOME / ".claude" / "agents", "*.md", "agents"),
(HOME / ".claude" / "plans", "*.md", "plans"),
]
OUTPUT_ROOT = REPO_ROOT / "data" / "raw" / "sanitized"
SECRETS_MAP_PATH = REPO_ROOT / "data" / "raw" / ".secrets_map.json"
# Patrones explicitos conocidos de este proyecto. Cada tupla es
# (nombre_semantico, regex compilado). El grupo de captura 0 completo es lo que
# se reemplaza; si el patron tiene grupos, se reemplaza el match completo igual
# (el placeholder no intenta preservar texto circundante).
EXPLICIT_PATTERNS = [
("SPARK_PASSWORD", re.compile(r"\b01140102Alb\?")),
("BEARER_TOKEN", re.compile(r"\b7c1f76a62391a47941d7aab8369eb8f20334daf136ba88080815ff3070773a1f\b")),
("DB_PASSWORD", re.compile(r"\bsarh21234\b")),
("SPARK_IP", re.compile(r"\b10\.212\.133\.200\b")),
("INTERNAL_IP", re.compile(r"\b10\.212\.133\.\d{1,3}\b")),
("INTERNAL_SUBNET", re.compile(r"\b10\.212\.133\.0/24\b")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("GMAIL_APP_PASSWORD_LITERAL", re.compile(r"\bGMAIL_APP_PASSWORD=[^\s\"']+")),
]
# Nombres de variables de entorno que NUNCA deben aparecer con un valor real
# asignado literalmente (uso de la variable en si, p.ej. "$SPARK_PASSWORD", esta bien).
ENV_VAR_NAMES = ["SPARK_PASSWORD", "GMAIL_APP_PASSWORD"]
PLACEHOLDER_COUNTS = {}
SECRETS_MAP = {}
def load_secrets_map():
global SECRETS_MAP
if SECRETS_MAP_PATH.exists():
SECRETS_MAP = json.loads(SECRETS_MAP_PATH.read_text())
def save_secrets_map():
SECRETS_MAP_PATH.parent.mkdir(parents=True, exist_ok=True)
SECRETS_MAP_PATH.write_text(json.dumps(SECRETS_MAP, indent=2, ensure_ascii=False))
def stable_placeholder(semantic_name, raw_value):
"""Mismo secreto real -> mismo placeholder, vía un hash estable persistido."""
digest = hashlib.sha256(raw_value.encode("utf-8")).hexdigest()[:8]
if digest not in SECRETS_MAP:
PLACEHOLDER_COUNTS[semantic_name] = PLACEHOLDER_COUNTS.get(semantic_name, 0) + 1
placeholder = f"<<{semantic_name}_{PLACEHOLDER_COUNTS[semantic_name]}>>"
SECRETS_MAP[digest] = {"placeholder": placeholder, "semantic_name": semantic_name}
return SECRETS_MAP[digest]["placeholder"]
def apply_explicit_patterns(text):
for semantic_name, pattern in EXPLICIT_PATTERNS:
def _sub(match, semantic_name=semantic_name):
return stable_placeholder(semantic_name, match.group(0))
text = pattern.sub(_sub, text)
return text
def detect_secrets_scan(text):
"""Corre los plugins default de detect-secrets sobre el texto linea por linea.
Devuelve la lista de (line_number, secret_value) encontrados."""
findings = []
plugins = list(scan.get_plugins())
for lineno, line in enumerate(text.splitlines(), start=1):
for plugin in plugins:
try:
results = plugin.analyze_line(filename="<sanitize>", line=line, line_number=lineno)
except Exception:
continue
if results:
for secret in results:
raw = getattr(secret, "secret_value", None)
if raw:
findings.append((lineno, raw))
return findings
def sanitize_text(text):
text = apply_explicit_patterns(text)
# Segunda pasada: detect-secrets sobre el resultado de las regex explicitas,
# para capturar cualquier secreto de alta entropia no cubierto arriba.
for lineno, raw in detect_secrets_scan(text):
placeholder = stable_placeholder("GENERIC_SECRET", raw)
text = text.replace(raw, placeholder)
return text
def gate_check(text, relpath):
"""Falla cerrado: si sobrevive un patron conocido o detect-secrets encuentra
algo en la salida YA sanitizada, es un error del gate."""
problems = []
for semantic_name, pattern in EXPLICIT_PATTERNS:
if pattern.search(text):
problems.append(f"{relpath}: patron explicito '{semantic_name}' sobrevivio la sanitizacion")
for env_var in ENV_VAR_NAMES:
if re.search(rf"\b{env_var}\s*=\s*[^\s$\"'][^\s]*", text):
problems.append(f"{relpath}: posible asignacion literal de {env_var} sobrevivio")
residual = detect_secrets_scan(text)
if residual:
for lineno, raw in residual:
problems.append(f"{relpath}:{lineno}: detect-secrets encontro un secreto residual")
return problems
def collect_files():
files = []
for base_dir, glob_pattern, bucket in SOURCES:
if not base_dir.exists():
print(f"[WARN] fuente no encontrada: {base_dir}")
continue
for path in sorted(base_dir.glob(glob_pattern)):
if path.is_file():
files.append((path, bucket))
return files
def main():
load_secrets_map()
files = collect_files()
print(f"[INFO] {len(files)} archivos fuente encontrados")
all_problems = []
written = 0
for path, bucket in files:
raw_text = path.read_text(encoding="utf-8")
sanitized_text = sanitize_text(raw_text)
if bucket == "skills":
relpath = Path(bucket) / path.parent.name / path.name
else:
relpath = Path(bucket) / path.name
problems = gate_check(sanitized_text, relpath)
all_problems.extend(problems)
out_path = OUTPUT_ROOT / relpath
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(sanitized_text, encoding="utf-8")
written += 1
save_secrets_map()
print(f"[INFO] {written} archivos sanitizados escritos en {OUTPUT_ROOT}")
total_subs = sum(PLACEHOLDER_COUNTS.values())
print(f"[INFO] {total_subs} secretos unicos sustituidos: {PLACEHOLDER_COUNTS}")
if all_problems:
print(f"[GATE FAIL] {len(all_problems)} problemas encontrados en la salida sanitizada:")
for problem in all_problems:
print(f" - {problem}")
sys.exit(1)
print("[GATE OK] 0 secretos sobrevivientes detectados en la salida sanitizada")
sys.exit(0)
if __name__ == "__main__":
main()