Logs seguros — lo más olvidado
Los logs son visibles con adb logcat en cualquier dispositivo conectado a una computadora. En builds de debug esto es útil — en producción es un vector de fuga de información.
// MAL — visible en producción con adb logcat
Log.d("Auth", "Token recibido: $token")
Log.d("User", "Email: ${usuario.email}, Password: ${usuario.password}")
// BIEN — usar un wrapper que deshabilita en release
object Timber {
fun d(mensaje: String) {
if (BuildConfig.DEBUG) Log.d("App", mensaje)
}
}
// O usar Timber (la librería estándar para esto):
// En debug: planta DebugTree (logs visibles)
// En release: no plantar nada, o plantar CrashReportingTree solo para errores
class MiApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(CrashReportingTree()) // solo errores a Crashlytics
}
}
}
class CrashReportingTree : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority == Log.VERBOSE || priority == Log.DEBUG) return
Firebase.crashlytics.log("$tag: $message")
t?.let { Firebase.crashlytics.recordException(it) }
}
}
Detección de debugger adjunto
// Detectar si hay un debugger conectado
fun hayDebuggerConectado(): Boolean {
return Debug.isDebuggerConnected() || Debug.waitingForDebugger()
}
// Detectar si la app fue firmada con un keystore de debug
fun esFirmadaConDebugKey(context: Context): Boolean {
return try {
val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNING_CERTIFICATES
).signingInfo.apkContentsSigners[0]
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNATURES
).signatures[0]
}
// El debug keystore de Android Studio tiene un SHA-1 conocido
val md = MessageDigest.getInstance("SHA-1")
val hash = md.digest(signature.toByteArray())
.joinToString("") { "%02X".format(it) }
// "C8:A2:..." ← el SHA-1 del debug keystore por defecto
hash.contains("C8A2") // simplificado — verificar el hash completo
} catch (e: Exception) { false }
}
// Verificación al iniciar la app (solo en release)
if (!BuildConfig.DEBUG) {
if (hayDebuggerConectado()) {
// Un debugger en una build de release es sospechoso
finishAffinity() // cerrar la app
return
}
}
Bloquear capturas de pantalla
En pantallas con información sensible (balance bancario, documentos privados), podés impedir que se tomen capturas de pantalla:
// Bloquear screenshots en una Activity específica
class PantallaSeguraActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// FLAG_SECURE impide:
// - Capturas de pantalla del usuario
// - Aparición en el selector de apps recientes (muestra pantalla en blanco)
// - Grabación de pantalla
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
setContentView(binding.root)
}
}
// O habilitarlo/deshabilitarlo dinámicamente:
fun bloquearPantalla(bloquear: Boolean) {
if (bloquear) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
// En Compose:
@Composable
fun PantallaSegura() {
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
// contenido de la pantalla...
}
Portapapeles seguro
Los datos copiados al portapapeles son accesibles por otras apps (hasta Android 10) y pueden persistir. Para campos de contraseña o datos sensibles:
// Limpiar el portapapeles después de un tiempo
fun copiarDatoSensible(context: Context, texto: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("", texto))
// Limpiar automáticamente después de 30 segundos
Handler(Looper.getMainLooper()).postDelayed({
clipboard.clearPrimaryClip()
}, 30_000)
}
// Marcar el campo como sensitivo para que Android no lo sugiera
// en autofill ni lo muestre en la UI de portapapeles:
binding.etPassword.apply {
setTextIsSelectable(false)
importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO
// En XML: android:importantForAutofill="no"
}
Componentes exported — superficie de ataque
<!-- Revisar TODOS los componentes del Manifest -->
<!-- MAL: Activity exportada sin restricción -->
<activity android:name=".AdminActivity" android:exported="true" />
<!-- BIEN: Solo accesible desde tu propia firma -->
<activity
android:name=".AdminActivity"
android:exported="true"
android:permission="ar.pensa.miapp.ADMIN_ACCESS" />
<!-- BIEN: Solo accesible desde dentro de la propia app -->
<activity android:name=".AdminActivity" android:exported="false" />
<!-- Content Providers — especialmente peligrosos -->
<provider
android:name=".MiProvider"
android:exported="false" <!-- nunca exportar a menos que sea necesario -->
android:grantUriPermissions="false" />
<!-- Proteger BroadcastReceivers de broadcasts externos -->
<receiver
android:name=".MiReceiver"
android:exported="false" />
Checklist completo de seguridad Android
# ── ALMACENAMIENTO ──────────────────────────────────────────
# ✓ No hay datos sensibles en SharedPreferences sin cifrar
# ✓ Tokens, claves y contraseñas usan EncryptedSharedPreferences
# ✓ Archivos sensibles usan EncryptedFile o cifrado con Keystore
# ✓ No se usa getExternalStorageDirectory() para datos privados
# ✓ android:allowBackup configurado correctamente (datos sensibles excluidos)
# ── RED ─────────────────────────────────────────────────────
# ✓ cleartextTrafficPermitted="false" en Network Security Config
# ✓ Respuestas HTTP se validan antes de procesar
# ✓ Certificate pinning en dominios críticos (con pin de backup)
# ✓ Tokens de autenticación en headers, no en query params ni URLs
# ── CÓDIGO ──────────────────────────────────────────────────
# ✓ minifyEnabled = true en release
# ✓ No hay claves API hardcodeadas en el código fuente
# ✓ No hay URLs de staging/debug en el build de release
# ✓ Logs deshabilitados o controlados en release (Timber sin DebugTree)
# ✓ Strings sensibles en BuildConfig (ofuscados por R8)
# ── MANIFEST ─────────────────────────────────────────────────
# ✓ android:exported declarado explícitamente en todos los componentes
# ✓ Activities, Services, Receivers y Providers con el menor exported posible
# ✓ Permisos declarados son los mínimos necesarios
# ✓ android:debuggable="false" (no ponerlo, por default es false en release)
# ── RUNTIME ──────────────────────────────────────────────────
# ✓ FLAG_SECURE en pantallas con datos sensibles
# ✓ No se loggea información sensible (emails, tokens, contraseñas)
# ✓ Detección de root/emulador en apps enterprise
# ✓ Play Integrity API para verificación de dispositivo en backend
# ✓ Manejo seguro del portapapeles en campos sensibles
# ── SERVIDOR (complementario) ────────────────────────────────
# ✓ Tokens con expiración corta (access token) + refresh token
# ✓ Rate limiting en endpoints de autenticación
# ✓ Verificación de Play Integrity en operaciones críticas
# ✓ Logs de seguridad en el backend (intentos fallidos, IPs sospechosas)