Files
hechakuaa_bot/main.py
markosbenitez fa72ced5a6 security: hardening contra 7 vectores de ataque identificados
Análisis ofensivo → defensas implementadas:

1. /health/history sin auth (CRÍTICO)
   - Requiere Bearer token (HEALTH_AUTH_TOKEN en .env)
   - chat_ids enmascarados como hash SHA256[:8] opaco ("usr_a3b4c5d6")
   - Accesos denegados loggeados con IP + User-Agent

2. /health revela inteligencia operacional (MEDIO)
   - Sin auth: solo {"status", "timestamp"} — sin conteo de tenants ni timing
   - Con auth: respuesta completa con métricas internas
   - Misma URL, auth desbloquea detalle

3. Variables sensibles en proceso env (CRÍTICO)
   - Después de cargar Settings(), se limpian FERNET_KEY, TELEGRAM_BOT_TOKEN,
     PJ_USUARIO_ENC, PJ_PASSWORD_ENC, WEBHOOK_SECRET, HEALTH_AUTH_TOKEN, INVITE_CODE
   - Protege contra /proc/<pid>/environ y herencia por subprocesos

4. Race condition TOCTOU en invite codes (MEDIO)
   - SELECT+UPDATE reemplazado por UPDATE WHERE usado=0
   - rowcount=0 → False; imposible que dos requests simultáneos consuman el mismo código

5. Timing oracle en invite codes (BAJO-MEDIO)
   - Delay mínimo de 800ms en _paso_codigo() independientemente del resultado
   - secrets.compare_digest() para comparación del código del .env

6. Information disclosure en respuestas a no-registrados (BAJO)
   - _rechazar_no_registrado: "Para usar este bot escribí /start." (sin revelar comandos)
   - handle_texto_libre: silencio total para usuarios sin registro (no confirmar que el bot existe)

7. Nginx rate limiting (DEPLOY.md)
   - limit_req_zone pedrito_webhook: 30r/s, burst=50
   - limit_req_zone pedrito_health: 10r/m, burst=5
   - Headers de seguridad: X-Content-Type-Options, X-Frame-Options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:06:36 -03:00

204 lines
6.0 KiB
Python

"""
main.py — Entrypoint de Pedrito Hechakuaa.
Arranca el bot de Telegram, el poller y el servidor HTTP de health check en paralelo.
Al arrancar: aplica migraciones y migra el tenant principal del .env si no existe.
"""
from __future__ import annotations
import asyncio
import os
import signal
import sys
import structlog
from telegram.ext import Application
import health as health_mod
import vault as vault_mod
from config import get_settings
from database import get_tenant, run_migrations, save_tenant
from health import HealthServer, record_poll
from onboarding import OnboardingFlow
from poller import Poller
from telegram_bot import PedritoBot
# Configurar structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer() if sys.stderr.isatty() else structlog.processors.JSONRenderer(),
]
)
logger = structlog.get_logger(__name__)
async def _migrar_tenant_principal(settings) -> None:
"""
Migra el tenant principal del .env a la DB si todavía no existe.
Permite que el dueño del bot siga funcionando sin re-registrarse.
"""
chat_id = settings.telegram_chat_id
existente = await get_tenant(chat_id)
if existente:
logger.info("tenant_principal_ya_existe", chat_id=chat_id)
return
logger.info("migrando_tenant_principal_del_env", chat_id=chat_id)
usuario = settings.descifrar_usuario()
password = settings.descifrar_password()
try:
credentials_enc = vault_mod.cifrar_credenciales(
chat_id=chat_id,
usuario=usuario,
password=password,
fernet_key=settings.fernet_key,
)
finally:
del usuario, password
await save_tenant(
chat_id,
nombre_preferido="Admin",
tono="cotidiano",
timezone="America/Asuncion",
credentials_enc=credentials_enc,
estado="activo",
consentimiento_aceptado=1,
)
logger.info("tenant_principal_migrado", chat_id=chat_id)
def _limpiar_env_sensibles() -> None:
"""
Elimina variables sensibles del entorno del proceso después de cargar Settings.
Protege contra:
- cat /proc/<pid>/environ (cualquier usuario con acceso al PID)
- herencia accidental de env vars por subprocesos
- stack traces que vuelquen os.environ
NO protege contra 'docker inspect' (que lee la config del contenedor, no el env live).
Para eso usar Docker secrets con Swarm mode.
"""
_sensibles = (
"FERNET_KEY",
"TELEGRAM_BOT_TOKEN",
"PJ_USUARIO_ENC",
"PJ_PASSWORD_ENC",
"WEBHOOK_SECRET",
"HEALTH_AUTH_TOKEN",
"INVITE_CODE",
)
for var in _sensibles:
os.environ.pop(var, None)
async def main() -> None:
settings = get_settings()
_limpiar_env_sensibles()
logger.info(
"pedrito_hechakuaa_iniciando",
poll_interval_minutes=settings.poll_interval_minutes,
horario=f"{settings.poll_hora_inicio}:00-{settings.poll_hora_fin}:00",
chat_id=settings.telegram_chat_id,
webhook_mode=bool(settings.webhook_url),
)
# Migraciones y tenant principal
await run_migrations(settings.db_path)
await _migrar_tenant_principal(settings)
# Bot de Telegram
onboarding = OnboardingFlow(settings)
pedrito_bot = PedritoBot(
token=settings.telegram_bot_token,
settings=settings,
onboarding=onboarding,
)
app: Application = pedrito_bot.build()
# Poller
poller = Poller(
settings=settings,
bot=pedrito_bot,
on_poll_done=record_poll,
)
pedrito_bot.set_notif_callback(
lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True)
)
# Manejo de señales para shutdown limpio
loop = asyncio.get_running_loop()
health_server: HealthServer | None = None
def handle_shutdown(sig: int) -> None:
logger.info("shutdown_recibido", signal=sig)
poller.detener()
loop.stop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, handle_shutdown, sig)
except NotImplementedError:
pass # Windows no soporta add_signal_handler
async with app:
await app.initialize()
await app.start()
logger.info("bot_telegram_iniciado")
# Servidor HTTP de health check (y webhook si está configurado)
health_server = HealthServer(settings=settings, application=app)
await health_server.start()
if settings.webhook_url:
# Registrar el webhook con Telegram
webhook_url = f"{settings.webhook_url.rstrip('/')}/webhook"
await app.bot.set_webhook(
url=webhook_url,
secret_token=settings.webhook_secret or None,
drop_pending_updates=True,
)
logger.info("webhook_registrado", url=webhook_url)
telegram_task = asyncio.create_task(asyncio.sleep(0)) # placeholder
else:
# Long-polling
telegram_task = asyncio.create_task(
app.updater.start_polling(drop_pending_updates=True)
)
poller_task = asyncio.create_task(poller.run_forever())
logger.info("pedrito_hechakuaa_corriendo")
try:
if settings.webhook_url:
await poller_task
else:
await asyncio.gather(telegram_task, poller_task)
except asyncio.CancelledError:
pass
finally:
if health_server:
await health_server.stop()
if settings.webhook_url:
try:
await app.bot.delete_webhook()
except Exception:
pass
else:
await app.updater.stop()
await app.stop()
logger.info("pedrito_hechakuaa_detenido")
if __name__ == "__main__":
asyncio.run(main())