Módulo 5: Secrets Management
2. Más Allá de .env: Por Qué No Es Suficiente
Descripción
El archivo .env es probablemente la primera herramienta de gestión de secrets que aprendiste. Creas un archivo, pones tus keys, lo agregas a .gitignore, y usas python-dotenv para cargarlo. Funciona. Es simple. Y es exactamente lo que necesitas para desarrollo local. Pero cuando tu sistema AI llega a producción — con múltiples servicios, equipos, y credenciales de alto valor como API keys de LLM — .env se convierte en una vulnerabilidad.
Esta cápsula no es un argumento abstracto contra .env. Es un análisis técnico concreto de las cinco limitaciones que hacen que .env sea insuficiente para producción, con código que demuestra cada vulnerabilidad, incidentes reales de exposición de API keys, el costo financiero de keys filtradas, y el path de transición hacia soluciones más robustas.
Al terminar esta cápsula vas a poder articular exactamente por qué tu sistema necesita más que .env, con argumentos que puedes presentar a tu equipo o stakeholders.
Cómo funciona .env (y por qué parece suficiente)
Empecemos con lo que hace .env y por qué se siente como una solución completa:
# .env
OPENAI_API_KEY=sk-proj-abc123def456
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
ANTHROPIC_API_KEY=sk-ant-xyz789
# app.py
import os
from dotenv import load_dotenv
load_dotenv()
openai_key = os.getenv("OPENAI_API_KEY")
db_url = os.getenv("DATABASE_URL")
print(f"OpenAI key loaded: {openai_key[:10]}...")
print(f"Database URL loaded: {db_url[:30]}...")
# Output esperado:
# OpenAI key loaded: sk-proj-ab...
# Database URL loaded: postgresql://user:pass@lo...
Este patrón es popular por razones legítimas:
- ✅ Simple: Un archivo de texto plano
- ✅ Estándar:
python-dotenvtiene millones de descargas - ✅ Separación: Keys fuera del código fuente
- ✅ Portabilidad: Funciona en cualquier entorno
- ✅ 12-Factor compatible: Cumple con el principio de config en env vars
El problema no es que .env sea malo — es que fue diseñado para desarrollo local y tiene limitaciones fundamentales cuando lo usas en producción.
Las 5 limitaciones fatales de .env en producción
Limitación 1: Sin encryption at rest
Tu archivo .env es texto plano. Cualquier persona o proceso con acceso al filesystem puede leer tus secrets:
import os
import stat
env_path = ".env"
with open(env_path, "w") as f:
f.write("OPENAI_API_KEY=sk-proj-super-secret-key-12345\n")
f.write("DATABASE_PASSWORD=my-database-password-67890\n")
file_stat = os.stat(env_path)
permissions = oct(file_stat.st_mode)[-3:]
print(f"File: {env_path}")
print(f"Permissions: {permissions}")
print(f"Readable by owner: {bool(file_stat.st_mode & stat.S_IRUSR)}")
print(f"Readable by group: {bool(file_stat.st_mode & stat.S_IRGRP)}")
print(f"Readable by others: {bool(file_stat.st_mode & stat.S_IROTH)}")
with open(env_path, "r") as f:
content = f.read()
print(f"\nContent (texto plano):\n{content}")
# Output esperado:
# File: .env
# Permissions: 644
# Readable by owner: True
# Readable by group: True
# Readable by others: True
#
# Content (texto plano):
# OPENAI_API_KEY=sk-proj-super-secret-key-12345
# DATABASE_PASSWORD=my-database-password-67890
os.remove(env_path)
Con permisos 644 (el default en la mayoría de sistemas), cualquier usuario del servidor puede leer tus secrets. Incluso con 600, un atacante que escale privilegios o comprometa el proceso de la app tiene acceso directo.
Compara con un secrets manager que encripta at rest:
from cryptography.fernet import Fernet
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)
secret = "sk-proj-super-secret-key-12345"
encrypted = cipher.encrypt(secret.encode())
print(f"Original: {secret}")
print(f"Encrypted: {encrypted[:60]}...")
print(f"Readable? No - necesitas la encryption key")
decrypted = cipher.decrypt(encrypted).decode()
print(f"Decrypted: {decrypted}")
print(f"Match: {secret == decrypted}")
# Output esperado:
# Original: sk-proj-super-secret-key-12345
# Encrypted: gAAAAABn...
# Readable? No - necesitas la encryption key
# Decrypted: sk-proj-super-secret-key-12345
# Match: True
Un secrets manager encripta cada secret con keys que están protegidas por hardware (HSM) o por el proveedor cloud. Leer el storage no te da los secrets.
Limitación 2: Sin rotación automática
Con .env, rotar una API key significa:
- Generar nueva key en el dashboard del proveedor (OpenAI, Anthropic, etc.)
- SSH al servidor de producción
- Editar el archivo
.envmanualmente - Reiniciar la aplicación
- Verificar que funciona
- Revocar la key anterior
Este proceso es manual, propenso a errores, y causa downtime. En la práctica, la mayoría de equipos no rota keys — y cuando una key se filtra, sigue activa indefinidamente:
from datetime import datetime, timedelta
import json
def simulate_key_lifecycle_dotenv():
key_created = datetime(2024, 1, 15)
today = datetime(2026, 3, 13)
days_active = (today - key_created).days
lifecycle = {
"key_name": "OPENAI_API_KEY",
"created": key_created.isoformat(),
"last_rotated": "never",
"days_active": days_active,
"rotation_method": "manual (.env edit + restart)",
"estimated_downtime_per_rotation": "2-5 minutes",
"times_rotated": 0,
"risk_assessment": "HIGH" if days_active > 90 else "MEDIUM",
}
return lifecycle
def simulate_key_lifecycle_secrets_manager():
key_created = datetime(2024, 1, 15)
rotation_interval = timedelta(days=30)
today = datetime(2026, 3, 13)
rotations = 0
current_rotation = key_created
while current_rotation + rotation_interval < today:
current_rotation += rotation_interval
rotations += 1
lifecycle = {
"key_name": "openai-api-key",
"created": key_created.isoformat(),
"last_rotated": current_rotation.isoformat(),
"days_since_rotation": (today - current_rotation).days,
"rotation_method": "automatic (secrets manager)",
"downtime_per_rotation": "0 (zero-downtime dual-key)",
"times_rotated": rotations,
"risk_assessment": "LOW",
}
return lifecycle
dotenv = simulate_key_lifecycle_dotenv()
sm = simulate_key_lifecycle_secrets_manager()
print("=== .env Lifecycle ===")
print(json.dumps(dotenv, indent=2))
print(f"\n=== Secrets Manager Lifecycle ===")
print(json.dumps(sm, indent=2))
# Output esperado:
# === .env Lifecycle ===
# {
# "key_name": "OPENAI_API_KEY",
# "created": "2024-01-15T00:00:00",
# "last_rotated": "never",
# "days_active": 788,
# "rotation_method": "manual (.env edit + restart)",
# "times_rotated": 0,
# "risk_assessment": "HIGH"
# }
#
# === Secrets Manager Lifecycle ===
# {
# "key_name": "openai-api-key",
# "last_rotated": "2026-02-27T00:00:00",
# "rotation_method": "automatic (secrets manager)",
# "downtime_per_rotation": "0 (zero-downtime dual-key)",
# "times_rotated": 26,
# "risk_assessment": "LOW"
# }
788 días sin rotar vs rotación automática cada 30 días. La diferencia es abismal en términos de riesgo.
Limitación 3: Sin audit trail
Con .env, no hay registro de quién accedió a qué secret ni cuándo. Si sospechas que una key fue comprometida, no tienes forma de saber cuándo ni quién la leyó:
import os
import json
from datetime import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
@dataclass
class SecretAccess:
timestamp: str
secret_name: str
accessor: str
action: str
source_ip: Optional[str] = None
success: bool = True
class DotenvNoAudit:
"""Simula cómo funciona .env — sin audit trail."""
def get_secret(self, name: str) -> Optional[str]:
value = os.environ.get(name)
# No hay registro de este acceso
# No sabemos quién llamó, cuándo, ni desde dónde
return value
class SecretsManagerWithAudit:
"""Simula un secrets manager con audit trail."""
def __init__(self):
self._secrets = {
"openai-api-key": "sk-proj-abc123",
"database-password": "db-pass-xyz",
}
self._audit_log: list[SecretAccess] = []
def get_secret(self, name: str, accessor: str = "unknown") -> Optional[str]:
success = name in self._secrets
access = SecretAccess(
timestamp=datetime.utcnow().isoformat(),
secret_name=name,
accessor=accessor,
action="read",
source_ip="10.0.1.50",
success=success,
)
self._audit_log.append(access)
return self._secrets.get(name)
def get_audit_log(self) -> list[dict]:
return [asdict(entry) for entry in self._audit_log]
sm = SecretsManagerWithAudit()
sm.get_secret("openai-api-key", accessor="api-service")
sm.get_secret("database-password", accessor="migration-job")
sm.get_secret("nonexistent-key", accessor="suspicious-process")
print("Audit Trail:")
for entry in sm.get_audit_log():
status = "✅" if entry["success"] else "❌"
print(f" {status} [{entry['timestamp']}] {entry['accessor']} → {entry['secret_name']}")
# Output esperado:
# Audit Trail:
# ✅ [2026-03-13T...] api-service → openai-api-key
# ✅ [2026-03-13T...] migration-job → database-password
# ❌ [2026-03-13T...] suspicious-process → nonexistent-key
Con audit trail, el tercer acceso (proceso sospechoso intentando acceder a un secret inexistente) es visible y puede activar alertas. Con .env, ese acceso es invisible.
Limitación 4: Sin granularidad (least privilege)
Con .env, todos los procesos que corren en el mismo servidor ven todas las variables de entorno. Tu API service, tu worker de background jobs, tu migration script — todos tienen acceso a todas las keys:
import os
from dataclasses import dataclass
@dataclass
class ServicePermissions:
service_name: str
needs: list[str]
has_access_to: list[str]
@property
def over_privileged(self) -> bool:
return set(self.has_access_to) - set(self.needs) != set()
@property
def unnecessary_access(self) -> list[str]:
return list(set(self.has_access_to) - set(self.needs))
all_env_secrets = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DATABASE_URL",
"REDIS_URL",
"STRIPE_SECRET_KEY",
"ADMIN_PASSWORD",
]
services_dotenv = [
ServicePermissions(
service_name="api-service",
needs=["OPENAI_API_KEY", "DATABASE_URL"],
has_access_to=all_env_secrets,
),
ServicePermissions(
service_name="background-worker",
needs=["DATABASE_URL", "REDIS_URL"],
has_access_to=all_env_secrets,
),
ServicePermissions(
service_name="migration-script",
needs=["DATABASE_URL"],
has_access_to=all_env_secrets,
),
]
print("=== .env: Todos ven todo ===")
for svc in services_dotenv:
print(f"\n{svc.service_name}:")
print(f" Needs: {svc.needs}")
print(f" Has access to: {len(svc.has_access_to)} secrets")
print(f" Over-privileged: {svc.over_privileged}")
print(f" Unnecessary access: {svc.unnecessary_access}")
# Output esperado:
# === .env: Todos ven todo ===
#
# api-service:
# Needs: ['OPENAI_API_KEY', 'DATABASE_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['ANTHROPIC_API_KEY', 'REDIS_URL', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
#
# background-worker:
# Needs: ['DATABASE_URL', 'REDIS_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
#
# migration-script:
# Needs: ['DATABASE_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'REDIS_URL', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
Cada servicio tiene acceso a secrets que no necesita. Si un atacante compromete el migration-script, obtiene las API keys de OpenAI y Stripe aunque el script solo necesitaba acceso a la base de datos.
Limitación 5: No escala con equipos
Con .env, distribuir secrets a un nuevo developer significa compartirlos por un canal inseguro. Revocar el acceso de un ex-empleado significa rotar todas las keys que conocía:
from dataclasses import dataclass
@dataclass
class TeamScaling:
method: str
onboarding_steps: list[str]
offboarding_steps: list[str]
risk_level: str
dotenv_scaling = TeamScaling(
method=".env compartido",
onboarding_steps=[
"1. Nuevo dev pide el .env por Slack/email",
"2. Senior dev copia el .env y lo envía",
"3. Nuevo dev lo guarda en su laptop",
"4. El .env contiene TODAS las keys del proyecto",
"5. No hay registro de quién tiene qué",
],
offboarding_steps=[
"1. Dev se va de la empresa",
"2. ¿Borró el .env de su laptop? No sabemos",
"3. ¿Tiene copia en otro lugar? No sabemos",
"4. Solución: rotar TODAS las keys que conocía",
"5. Pero ¿sabemos cuáles conocía? No exactamente",
"6. En la práctica: nadie rota nada 😬",
],
risk_level="HIGH",
)
sm_scaling = TeamScaling(
method="Secrets Manager con policies",
onboarding_steps=[
"1. Admin crea policy para el nuevo dev",
"2. Policy define qué secrets puede leer",
"3. Dev se autentica con SSO/IAM",
"4. Accede solo a los secrets que necesita",
"5. Cada acceso queda registrado en audit log",
],
offboarding_steps=[
"1. Dev se va de la empresa",
"2. Admin revoca su policy/IAM role",
"3. Acceso cortado inmediatamente",
"4. Audit log muestra qué secrets accedió",
"5. Solo se rotan secrets de alto riesgo si necesario",
],
risk_level="LOW",
)
for scaling in [dotenv_scaling, sm_scaling]:
print(f"\n=== {scaling.method} (Risk: {scaling.risk_level}) ===")
print("Onboarding:")
for step in scaling.onboarding_steps:
print(f" {step}")
print("Offboarding:")
for step in scaling.offboarding_steps:
print(f" {step}")
Incidentes reales de exposición de API keys
Estos son patrones documentados de la industria — todos prevenibles con secrets management:
Patrón 1: Key commiteada a Git
import json
incident_1 = {
"pattern": "API key commiteada a repositorio público",
"timeline": [
"T+0min: Developer commitea .env accidentalmente",
"T+2min: Push a GitHub (repo público)",
"T+4min: Bot automatizado detecta la key",
"T+5min: Bot comienza a usar la key",
"T+60min: $2,000 en consumo de API",
"T+180min: Developer nota el error",
"T+185min: Revoca la key manualmente",
"T+190min: git revert (pero la key sigue en el historial)",
],
"total_cost": "$2,000+",
"root_cause": "Secret en texto plano accesible desde el repo",
"prevention": "Secrets en vault/KMS, nunca en archivos del repo",
}
print(json.dumps(incident_1, indent=2, ensure_ascii=False))
Patrón 2: Key en Docker image
incident_2 = {
"pattern": "API key en Dockerfile o docker-compose.yml",
"dockerfile_vulnerable": """
# VULNERABLE: key hardcodeada en el build
FROM python:3.11
ENV OPENAI_API_KEY=sk-proj-real-key-here
COPY . /app
CMD ["python", "app.py"]
""".strip(),
"impact": "Cualquiera que descargue la imagen tiene la key",
"dockerfile_safe": """
# SEGURO: key inyectada en runtime desde secrets manager
FROM python:3.11
COPY . /app
# No ENV con secrets - se inyectan en runtime
CMD ["python", "app.py"]
# docker run -e OPENAI_API_KEY=$(vault read -field=value secret/openai)
""".strip(),
}
print("VULNERABLE Dockerfile:")
print(incident_2["dockerfile_vulnerable"])
print(f"\nSEGURO Dockerfile:")
print(incident_2["dockerfile_safe"])
Patrón 3: Key en logs
import logging
import json
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("demo")
api_key = "sk-proj-abc123-secret-key"
logger.info(f"Connecting to OpenAI with key: {api_key}")
def safe_log_config(config: dict) -> dict:
"""Redacta valores sensibles antes de loggear."""
sensitive_patterns = ["key", "password", "secret", "token"]
safe = {}
for k, v in config.items():
if any(pattern in k.lower() for pattern in sensitive_patterns):
safe[k] = f"{str(v)[:4]}...{str(v)[-4:]}" if len(str(v)) > 8 else "***"
else:
safe[k] = v
return safe
config = {
"openai_api_key": api_key,
"model": "gpt-4o-mini",
"temperature": 0.3,
"database_password": "super-secret-pass",
}
safe_config = safe_log_config(config)
logger.info(f"Config loaded: {json.dumps(safe_config)}")
# Output esperado:
# INFO:demo:Connecting to OpenAI with key: sk-proj-abc123-secret-key ← MALO
# INFO:demo:Config loaded: {"openai_api_key": "sk-p...-key", "model": "gpt-4o-mini", ...} ← BUENO
El costo financiero de API keys de LLM filtradas
Las API keys de LLM son especialmente peligrosas porque el daño es financiero e inmediato:
import json
cost_analysis = {
"openai": {
"model": "gpt-4o",
"input_cost_per_1M": 2.50,
"output_cost_per_1M": 10.00,
"calls_per_minute_max": 500,
"scenario": "Atacante hace llamadas masivas por 1 hora",
"estimated_calls": 500 * 60,
"estimated_tokens_per_call": 4000,
"estimated_cost": round(500 * 60 * 4000 / 1_000_000 * 10.00, 2),
},
"anthropic": {
"model": "claude-sonnet-4-20250514",
"input_cost_per_1M": 3.00,
"output_cost_per_1M": 15.00,
"scenario": "Atacante usa modelo caro por 2 horas",
"estimated_cost": "~$5,000-15,000",
},
"comparison": {
"traditional_api_key_leaked": "Daño limitado (rate limits, usage caps comunes)",
"llm_api_key_leaked": "Daño financiero directo e inmediato",
"key_difference": "LLM APIs cobran por token — consumo ilimitado = costo ilimitado",
},
}
print(json.dumps(cost_analysis, indent=2, ensure_ascii=False))
# Output esperado (parcial):
# "openai": {
# "estimated_calls": 30000,
# "estimated_cost": 1200.0
# }
Comparación técnica: .env vs alternativas
comparison = {
"headers": [
"Característica",
".env + dotenv",
"Env Vars del OS",
"Cloud KMS",
"HashiCorp Vault",
],
"rows": [
{
"feature": "Encryption at rest",
"dotenv": "❌ Texto plano",
"env_vars": "❌ En memoria",
"cloud_kms": "✅ AES-256",
"vault": "✅ AES-256-GCM",
},
{
"feature": "Rotación automática",
"dotenv": "❌ Manual",
"env_vars": "❌ Manual",
"cloud_kms": "✅ Configurable",
"vault": "✅ Dynamic secrets",
},
{
"feature": "Audit trail",
"dotenv": "❌ Ninguno",
"env_vars": "❌ Ninguno",
"cloud_kms": "✅ CloudTrail/Audit",
"vault": "✅ Audit backend",
},
{
"feature": "Least privilege",
"dotenv": "❌ Todo o nada",
"env_vars": "❌ Por proceso",
"cloud_kms": "✅ IAM policies",
"vault": "✅ Policies granulares",
},
{
"feature": "Versioning",
"dotenv": "❌ No",
"env_vars": "❌ No",
"cloud_kms": "✅ Versiones",
"vault": "✅ Versiones",
},
{
"feature": "Team scaling",
"dotenv": "❌ Compartir archivo",
"env_vars": "⚠️ Por servidor",
"cloud_kms": "✅ IAM roles",
"vault": "✅ Auth methods",
},
{
"feature": "Costo",
"dotenv": "✅ Gratis",
"env_vars": "✅ Gratis",
"cloud_kms": "⚠️ ~$0.40/secret/mes",
"vault": "⚠️ Self-hosted o HCP",
},
{
"feature": "Complejidad",
"dotenv": "✅ Mínima",
"env_vars": "✅ Mínima",
"cloud_kms": "⚠️ Moderada",
"vault": "❌ Alta",
},
{
"feature": "Ideal para",
"dotenv": "Dev local",
"env_vars": "Dev/staging",
"cloud_kms": "Prod (cloud)",
"vault": "Enterprise",
},
],
}
header = " | ".join(comparison["headers"])
separator = " | ".join(["---"] * len(comparison["headers"]))
print(f"| {header} |")
print(f"| {separator} |")
for row in comparison["rows"]:
values = [
row["feature"],
row["dotenv"],
row["env_vars"],
row["cloud_kms"],
row["vault"],
]
print(f"| {' | '.join(values)} |")
12-Factor App: Config como primer principio
Los 12-Factor App principles establecen que la configuración debe almacenarse en el entorno, no en el código. .env cumple con la letra pero no con el espíritu:
twelve_factor_compliance = {
"principle": "Store config in the environment",
"dotenv_compliance": {
"letter": True,
"spirit": False,
"explanation": (
".env pone config fuera del código (bien), "
"pero la almacena en texto plano sin protección (mal)"
),
},
"secrets_manager_compliance": {
"letter": True,
"spirit": True,
"explanation": (
"Secrets manager almacena config fuera del código "
"Y la protege con encryption, rotation, y access control"
),
},
"evolution": [
"Nivel 0: Hardcoded en código → NUNCA en producción",
"Nivel 1: .env con .gitignore → OK para dev",
"Nivel 2: Env vars del OS → Mejor, pero sin rotation/audit",
"Nivel 3: Cloud KMS → Producción estándar",
"Nivel 4: Vault con dynamic secrets → Enterprise",
],
}
print("Evolución de secrets management:")
for level in twelve_factor_compliance["evolution"]:
print(f" {level}")
El path de transición: de .env a secrets manager
No necesitas migrar de .env a Vault de un día para otro. Aquí hay un path incremental que mantiene compatibilidad:
Paso 1: Abstracción sobre la fuente de secrets
import os
from abc import ABC, abstractmethod
from typing import Optional
class SecretsProvider(ABC):
"""Interface para obtener secrets — independiente de la fuente."""
@abstractmethod
def get(self, key: str) -> Optional[str]:
pass
@abstractmethod
def provider_name(self) -> str:
pass
class EnvSecretsProvider(SecretsProvider):
"""Lee secrets de variables de entorno / .env."""
def get(self, key: str) -> Optional[str]:
return os.environ.get(key)
def provider_name(self) -> str:
return "environment"
class EncryptedFileProvider(SecretsProvider):
"""Lee secrets de un archivo encriptado — paso intermedio."""
def __init__(self, encryption_key: bytes, filepath: str = ".secrets.enc"):
from cryptography.fernet import Fernet
self.cipher = Fernet(encryption_key)
self.filepath = filepath
self._cache: dict[str, str] = {}
self._load()
def _load(self):
try:
with open(self.filepath, "rb") as f:
encrypted_data = f.read()
decrypted = self.cipher.decrypt(encrypted_data).decode()
for line in decrypted.strip().split("\n"):
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
self._cache[key.strip()] = value.strip()
except FileNotFoundError:
pass
def get(self, key: str) -> Optional[str]:
return self._cache.get(key)
def provider_name(self) -> str:
return "encrypted_file"
class ChainedSecretsProvider(SecretsProvider):
"""Intenta múltiples providers en orden — permite migración gradual."""
def __init__(self, providers: list[SecretsProvider]):
self.providers = providers
def get(self, key: str) -> Optional[str]:
for provider in self.providers:
value = provider.get(key)
if value is not None:
return value
return None
def provider_name(self) -> str:
names = [p.provider_name() for p in self.providers]
return f"chained({', '.join(names)})"
os.environ["OPENAI_API_KEY"] = "sk-from-env"
os.environ["DATABASE_URL"] = "postgresql://from-env"
env_provider = EnvSecretsProvider()
secrets = ChainedSecretsProvider([env_provider])
print(f"Provider: {secrets.provider_name()}")
print(f"OPENAI_API_KEY: {secrets.get('OPENAI_API_KEY')[:10]}...")
print(f"DATABASE_URL: {secrets.get('DATABASE_URL')[:20]}...")
# Output esperado:
# Provider: chained(environment)
# OPENAI_API_KEY: sk-from-en...
# DATABASE_URL: postgresql://from-e...
Paso 2: Agregar encryption al archivo
from cryptography.fernet import Fernet
import json
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)
secrets_data = "OPENAI_API_KEY=sk-proj-encrypted-key\nDATABASE_URL=postgresql://secure"
encrypted = cipher.encrypt(secrets_data.encode())
with open(".secrets.enc", "wb") as f:
f.write(encrypted)
with open(".secrets.enc", "rb") as f:
raw = f.read()
print(f"Archivo encriptado: {raw[:50]}...")
decrypted = cipher.decrypt(raw).decode()
print(f"Contenido: {decrypted}")
# Output esperado:
# Archivo encriptado: gAAAAABn...
# Contenido: OPENAI_API_KEY=sk-proj-encrypted-key
# DATABASE_URL=postgresql://secure
import os
os.remove(".secrets.enc")
Paso 3: Agregar audit logging
import json
import logging
from datetime import datetime
from functools import wraps
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
audit_logger = logging.getLogger("secrets.audit")
def with_audit(func):
"""Decorator que agrega audit logging a accesos de secrets."""
@wraps(func)
def wrapper(self, key: str, *args, **kwargs):
result = func(self, key, *args, **kwargs)
audit_entry = {
"timestamp": datetime.utcnow().isoformat(),
"action": "secret_read",
"key": key,
"provider": self.provider_name(),
"found": result is not None,
"accessor": kwargs.get("accessor", "unknown"),
}
audit_logger.info(json.dumps(audit_entry))
return result
return wrapper
class AuditedEnvProvider(EnvSecretsProvider):
@with_audit
def get(self, key: str, accessor: str = "unknown") -> Optional[str]:
return super().get(key)
import os
os.environ["TEST_KEY"] = "test-value"
provider = AuditedEnvProvider()
provider.get("TEST_KEY", accessor="api-service")
provider.get("MISSING_KEY", accessor="worker")
# Output esperado:
# {"timestamp": "2026-03-13T...", "action": "secret_read", "key": "TEST_KEY", "provider": "environment", "found": true, "accessor": "api-service"}
# {"timestamp": "2026-03-13T...", "action": "secret_read", "key": "MISSING_KEY", "provider": "environment", "found": false, "accessor": "worker"}
El mindset shift: de "archivo" a "servicio"
El cambio fundamental no es técnico — es de mentalidad:
mindset_comparison = {
"dotenv_mindset": {
"secrets_are": "Un archivo que configura mi app",
"access_is": "Leer un archivo del disco",
"rotation_is": "Editar el archivo y reiniciar",
"audit_is": "No existe",
"failure_mode": "Si pierdo el archivo, reconstruyo manualmente",
},
"secrets_manager_mindset": {
"secrets_are": "Un servicio que provee credenciales",
"access_is": "Una API call autenticada y auditada",
"rotation_is": "Un proceso automático con zero downtime",
"audit_is": "Un registro completo de cada acceso",
"failure_mode": "Si el servicio falla, el fallback se activa",
},
}
print("=== Mindset .env ===")
for k, v in mindset_comparison["dotenv_mindset"].items():
print(f" {k}: {v}")
print("\n=== Mindset Secrets Manager ===")
for k, v in mindset_comparison["secrets_manager_mindset"].items():
print(f" {k}: {v}")
Este shift es el punto central: deja de pensar en secrets como archivos y empieza a pensar en ellos como un servicio. Un servicio que provee credenciales bajo demanda, con autenticación, autorización, logging, y lifecycle management.
Troubleshooting
"Mi equipo dice que .env funciona bien"
Pregúntales: "¿Cuándo fue la última vez que rotamos las API keys?" Si la respuesta es "nunca" o "no sé", ese es tu argumento. Muestra el cálculo de costo de una key de LLM filtrada ($1,000+ en minutos) y el patrón de bots que escanean GitHub.
"No tenemos presupuesto para un secrets manager"
AWS Secrets Manager cuesta $0.40 por secret por mes. Con 10 secrets, son $4/mes. Compara con el costo de una key filtrada. Además, muchos servicios tienen free tier suficiente para startups.
"Migrar es mucho trabajo"
El pattern de ChainedSecretsProvider permite migración gradual: agrega el secrets manager como primer provider y .env como fallback. Migras un secret a la vez sin romper nada.
"Docker Compose ya maneja secrets"
Docker Secrets (Swarm) y Docker Compose secrets son un paso adelante sobre .env, pero no tienen rotation automática ni audit trails. Son un buen paso intermedio, no una solución completa.
"GitHub Actions tiene secrets"
Sí, y son excelentes para CI/CD. Pero solo resuelven el almacenamiento en GitHub — tu aplicación en runtime sigue necesitando una forma de obtener los secrets de un lugar seguro. Los GitHub Secrets complementan un secrets manager, no lo reemplazan.
Ejercicios
Ejercicio 1: Audita tu .env actual
Revisa el archivo .env de tu proyecto AI y clasifica cada secret por nivel de riesgo:
Ver solución
risk_levels = {
"CRITICAL": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DATABASE_URL", "STRIPE_SECRET_KEY"],
"HIGH": ["REDIS_URL", "PINECONE_API_KEY", "AWS_SECRET_ACCESS_KEY"],
"MEDIUM": ["SENTRY_DSN", "SMTP_PASSWORD", "WEBHOOK_SECRET"],
"LOW": ["APP_ENV", "LOG_LEVEL", "PORT"],
}
print("Clasificación de secrets por riesgo:")
for level, keys in risk_levels.items():
print(f"\n {level}:")
for key in keys:
print(f" - {key}")
print("\nAcción recomendada:")
print(" CRITICAL → Migrar a secrets manager inmediatamente")
print(" HIGH → Migrar en la próxima iteración")
print(" MEDIUM → Evaluar caso por caso")
print(" LOW → Pueden quedarse en env vars (no son secrets)")
Ejercicio 2: Implementa encryption para tu .env
Escribe un script que encripte tu .env actual y lo lea de forma segura:
Ver solución
from cryptography.fernet import Fernet
import os
def encrypt_env_file(env_path: str, output_path: str) -> bytes:
key = Fernet.generate_key()
cipher = Fernet(key)
with open(env_path, "r") as f:
content = f.read()
encrypted = cipher.encrypt(content.encode())
with open(output_path, "wb") as f:
f.write(encrypted)
print(f"Encrypted {env_path} → {output_path}")
print(f"Encryption key (guárdala segura): {key.decode()}")
return key
def read_encrypted_env(encrypted_path: str, key: bytes) -> dict:
cipher = Fernet(key)
with open(encrypted_path, "rb") as f:
encrypted = f.read()
decrypted = cipher.decrypt(encrypted).decode()
secrets = {}
for line in decrypted.strip().split("\n"):
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
secrets[k.strip()] = v.strip()
return secrets
with open(".env.test", "w") as f:
f.write("OPENAI_API_KEY=sk-test-key\nDB_PASS=secret123\n")
key = encrypt_env_file(".env.test", ".env.test.enc")
secrets = read_encrypted_env(".env.test.enc", key)
print(f"Secrets loaded: {list(secrets.keys())}")
os.remove(".env.test")
os.remove(".env.test.enc")
Ejercicio 3: Construye un audit logger para accesos de secrets
Implementa un audit logger que registre cada acceso a secrets con timestamp, key name, y accessor:
Ver solución
import json
import logging
from datetime import datetime
from typing import Optional
class SecretAuditLogger:
def __init__(self, log_file: str = "secrets_audit.log"):
self.logger = logging.getLogger("secrets_audit")
self.logger.setLevel(logging.INFO)
handler = logging.FileHandler(log_file)
handler.setFormatter(logging.Formatter("%(message)s"))
self.logger.addHandler(handler)
def log_access(
self,
key: str,
accessor: str,
action: str = "read",
success: bool = True,
):
entry = {
"timestamp": datetime.utcnow().isoformat(),
"action": action,
"key": key,
"accessor": accessor,
"success": success,
}
self.logger.info(json.dumps(entry))
audit = SecretAuditLogger("test_audit.log")
audit.log_access("openai-api-key", "api-service", "read", True)
audit.log_access("database-url", "migration", "read", True)
audit.log_access("admin-password", "unknown-process", "read", False)
with open("test_audit.log") as f:
for line in f:
entry = json.loads(line)
status = "✅" if entry["success"] else "❌"
print(f"{status} {entry['accessor']} → {entry['key']}")
import os
os.remove("test_audit.log")
Ejercicio 4: Calcula el costo de una key filtrada
Escribe un script que calcule el costo potencial de una API key de LLM filtrada según diferentes escenarios de ataque:
Ver solución
def calculate_leak_cost(
model: str,
cost_per_1m_tokens: float,
requests_per_minute: int,
avg_tokens_per_request: int,
hours_before_detection: float,
) -> dict:
total_minutes = hours_before_detection * 60
total_requests = requests_per_minute * total_minutes
total_tokens = total_requests * avg_tokens_per_request
total_cost = (total_tokens / 1_000_000) * cost_per_1m_tokens
return {
"model": model,
"detection_time_hours": hours_before_detection,
"total_requests": int(total_requests),
"total_tokens": int(total_tokens),
"estimated_cost": f"${total_cost:,.2f}",
}
scenarios = [
calculate_leak_cost("gpt-4o", 10.0, 100, 2000, 1),
calculate_leak_cost("gpt-4o", 10.0, 100, 2000, 4),
calculate_leak_cost("gpt-4o", 10.0, 500, 4000, 8),
calculate_leak_cost("claude-sonnet-4-20250514", 15.0, 200, 3000, 2),
]
for s in scenarios:
print(f"{s['model']} ({s['detection_time_hours']}h): {s['estimated_cost']}")
Resumen
.envfunciona para desarrollo local pero tiene 5 limitaciones fatales en producción: sin encryption, sin rotación, sin audit trail, sin granularidad, sin escalabilidad de equipo- Las API keys de LLM son targets de alto valor — una key filtrada de OpenAI puede generar miles de dólares de consumo en minutos
- Los incidentes más comunes son prevenibles: keys en Git, keys en Docker images, keys en logs, keys compartidas por Slack
- La comparación técnica entre
.env, env vars, cloud KMS, y Vault muestra que las soluciones escalan con la necesidad — no todos necesitan Vault - El path de transición es incremental: abstracción sobre la fuente → encryption → audit logging → secrets manager completo
- El mindset shift fundamental es de "archivo" a "servicio": los secrets se obtienen bajo demanda, con autenticación, autorización, y logging
- La cápsula 03 te muestra la solución enterprise (Vault) y la 05 las opciones cloud más accesibles
Próxima cápsula: En la cápsula 03 conocerás HashiCorp Vault — la referencia open-source para secrets management. Verás su arquitectura (secrets engines, auth methods, policies), lo levantarás en dev mode, y usarás el cliente hvac de Python para almacenar y recuperar secrets con encryption y audit trail automáticos.
Recursos
- OWASP Secrets Management Cheat Sheet — Guía OWASP con mejores prácticas para gestión de secrets en aplicaciones
- 12-Factor App — Config — Principio fundacional de configuración separada del código
- GitHub Secret Scanning — Cómo GitHub detecta secrets expuestos en repositorios
- IBM Cost of a Data Breach 2024 — Reporte con cifras reales del costo de brechas de seguridad
- Python Cryptography — Fernet — Documentación de Fernet symmetric encryption para encryption local
- GitGuardian State of Secrets Sprawl — Reporte anual sobre exposición de secrets en repositorios públicos
- Docker Secrets Documentation — Docker Swarm secrets como paso intermedio
- python-dotenv Documentation — Documentación de python-dotenv para entender sus limitaciones
Creado: Marzo 2026 Versión: 1.0