¿Qué datos sensibles suelen filtrarse en logs y reportes de crash, y cómo lo evitás?
El bug que filtra una contraseña casi nunca es la pantalla de login — es la línea de log de debug, el interceptor HTTP y el breadcrumb del crash reporter que nadie se acordó de apagar antes de publicar.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué datos sensibles suelen filtrarse en logs y reportes de crash, y cómo lo evitás?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dónde terminan los secretos por accidente
La mayoría de las filtraciones de datos hacia logs y reportes de crash vienen de herramientas que nunca fueron pensadas como adversarias — son ayudas de depuración que quedaron encendidas. Los sospechosos habituales: un Log.d/print suelto con una contraseña o un token de auth durante la cacería de un bug de login, que nunca se borra; un interceptor de logging HTTP configurado para loguear el cuerpo completo de requests y responses, que en producción escribe calladamente cada header Authorization y cada campo de PII en el log del dispositivo; eventos de analytics que pasan un objeto de usuario entero en vez de una lista blanca explícita de campos, así que un toString() termina incluyendo email o el DNI; y reportadores de crash cuyos breadcrumbs capturan por defecto las llamadas de red recientes o variables locales, a veces junto con una "clave personalizada" que un desarrollador puso con un token real "para ayudar a depurar el crash".
// ANTIPATRÓNLog.d("LoginActivity", "Logging in with password=$password, token=$authToken")
// ANTIPATRÓNprint("Token de acceso recibido: \(response.accessToken)")
#Logging de debug vs. logging de release
La solución no es "borrar cada sentencia de log" — es asegurarse de que los logs de nivel debug y los sensibles categóricamente no puedan existir en un build de release, en vez de confiar en que cada PR futuro se acuerde de borrar una línea a mano. En Android, ProGuard/R8 puede eliminar las llamadas de logging por completo con -assumenosideeffects, y un wrapper delgado deja esa intención explícita en el code review:
object SafeLog {fun d(tag: String, message: String) {if (BuildConfig.DEBUG) Log.d(tag, message)}}fun redactarTarjeta(numero: String): String ="**** **** **** ${numero.takeLast(4)}"
# proguard-rules.pro-assumenosideeffects class android.util.Log {public static int d(...);public static int v(...);}
La misma disciplina aplica en iOS: poné todo lo que no sea un log grueso y no sensible detrás de #if DEBUG, y acordate de que la salida de NSLog — e incluso de print — históricamente pudo leerse desde afuera de la app (el log del sistema, Console.app, los paquetes de sysdiagnose que se adjuntan a tickets de soporte) — tratá "va a la consola" como "va a algún lugar que no controlás".
#Los interceptores de logging HTTP son un culpable común y evitable
Un interceptor de logging configurado al nivel más verboso es una de las fuentes de filtración más rentables justamente porque no parece "una sentencia de log" — es infraestructura que se configura una vez y se olvida. En nivel BODY, el HttpLoggingInterceptor de OkHttp escribe el request y el response completos, incluyendo el header Authorization y cualquier PII del payload JSON, directo al logcat.
val logging = HttpLoggingInterceptor().apply {level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODYelse HttpLoggingInterceptor.Level.NONEredactHeader("Authorization")redactHeader("Cookie")}val client = OkHttpClient.Builder().addInterceptor(logging).build()
#Los reportadores de crash tienen su propio radio de impacto
Crashlytics, Sentry y herramientas similares son genuinamente útiles y genuinamente capaces de filtrar justo los datos que estabas tratando de proteger, porque todo su trabajo es capturar la mayor cantidad de contexto posible alrededor de una falla — líneas de log recientes, breadcrumbs, a veces variables locales en crashes nativos. Los dos errores que aparecen una y otra vez: poner un secreto real en una "clave personalizada" pensada para ayudar a depurar un crash puntual, y publicar el comportamiento por defecto de breadcrumbs/captura de red de un SDK de crash sin revisar qué registra realmente.
// ANTIPATRÓN — esto envía el token a tu dashboard de crashes, en texto plano, en cada crashFirebaseCrashlytics.getInstance().setCustomKey("auth_token", authToken)// Mejor: identificar la situación, no el secretoFirebaseCrashlytics.getInstance().setCustomKey("user_tier", user.tier)
// Limpiar PII antes de que Sentry siquiera envíe el eventoSentryAndroid.init(context) { options ->options.beforeSend = SentryOptions.BeforeSendCallback { event, _ ->event.request?.cookies = nullevent.contexts.remove("user_pii")event}}
#Pantallas, capturas y el selector de apps
Hay dos vías de filtración fáciles de olvidar porque no son logs en absoluto: la captura de pantalla que el sistema operativo toma automáticamente para el selector de apps, y la grabación de pantalla por otra app o un servicio de accesibilidad. En Android, FLAG_SECURE en la ventana de una pantalla sensible bloquea ambas cosas, y reemplaza la miniatura en la lista de recientes por una en blanco. En iOS no hay un flag directo equivalente, así que el patrón habitual es cubrir el contenido con una vista de blur o un placeholder apenas la app deja de estar activa/entra en background, y quitarla al volver a foreground.
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
func applicationDidEnterBackground(_ application: UIApplication) {let blur = UIVisualEffectView(effect: UIBlurEffect(style: .dark))blur.frame = window?.bounds ?? .zeroblur.tag = 999window?.addSubview(blur)}func applicationWillEnterForeground(_ application: UIApplication) {window?.viewWithTag(999)?.removeFromSuperview()}