OWASP Top 10 (2021) & OWASP Mobile Top 10 (2024) Assessment — Re-check v3
v1.2.12 · 9 Jun 2026 · Re-check v3 · Commit fde6edfThis is a full re-assessment (Re-check v3) of the DailyCue Android app against the OWASP Top 10 (2021) for web applications and the OWASP Mobile Top 10 (2024) for mobile-specific concerns. The application is a local-first, offline-capable reminder and routine tracker with no network communication — significantly reducing its attack surface.
Overall posture: Low. The app continues to maintain strong cryptographic
controls (SQLCipher + Android Keystore), sensible permissions, and zero network attack
surface. Since Re-check v2, the app has added routine schedule reconciliation
(database v7 with generatedRoutineId tracking), checklist activation gating
via RoutineTaskActivationPolicy, and a 5-minute notification lead option.
Of 25 total findings,
12 have been remediated, with the remaining being medium, low, or informational.
Re-check v3 changes. All 12 prior remediations remain fully closed.
The routine schedule reconciliation system introduced a new code path with several
security-relevant observations. The most significant is a non-atomic reconciliation
cycle (F20 — Medium) where multiple database operations (deletion, insertion,
notification scheduling) execute without a wrapping @Transaction, leaving
the database in a partially-updated state if interrupted. Additional findings include
an unconditional checklist overwrite during reconciliation (F21 — Low), an uncaught
LocalDate.parse exception path (F22 — Low), and incomplete test coverage
for the activation policy time-gating edge cases including DST transitions (F23 — Low).
Two informational findings document the still-silent SecurityException catch
in NotificationHelper (F24) and timezone dependency in retention cleanup (F25).
Dependencies remain current with no changes since v2.
25 findings (0 critical, 0 high, 2 medium, 4 low, 7 informational, 12 remediated)
NotificationBroadcastReceiver.kt (line 79) previously called
e.printStackTrace() in the exception handler. This dumped the
full stack trace to System.err, which appears in logcat on
production devices.
Log.e("DailyCue", "Error processing notification broadcast", e),
which properly routes through Android's logging framework. This follows standard
Android logging conventions and can be stripped in release builds via ProGuard
if desired (-assumenosideeffects).
The keystore.properties file and release keystore binaries
(dailycue-release.keystore, app/keystores/release.keystore)
were present on disk in the project directory. A security review flagged these
as potentially committed to version control.
.gitignore entries
(keystore.properties, *.keystore, *.jks,
app/keystores/)git log --all --full-history confirms none of these files
have ever been tracked in gitbuild.gradle.kts reads signing credentials from these
gitignored files, with a fallback to ~/.gradle/gradle.properties
BackupManager.importFrom() previously read a user-provided
JSON file via SAF and deserialized it directly with kotlinx.serialization
with only a version number check. No size limits, no field validation, no integrity
verification existed.
checksum: String? = null) for backward compatibilityvalidateBackupPayload()
Application settings were previously stored in both SharedPreferences
(settings_prefs.xml) and Jetpack DataStore
(settings.preferences_pb). Neither DataStore nor SharedPreferences are
encrypted at rest. The owner name was stored in plaintext and could
constitute personally identifiable information (PII).
The contactsJson column in the routines table stores phone numbers and contact names. This was already correctly stored inside the encrypted SQLCipher database, so it was never a concern.
app_settings table). The new
RoomSettingsRepository stores every setting field (owner name,
notification preferences, theme colors, etc.) in the encrypted database:
DataStoreSettingsRepository previously wrote theme-related and color settings to both SharedPreferences and DataStore in separate, non-atomic write operations. If the application crashed between writes, the two stores could become inconsistent.
DataStoreSettingsRepository has been replaced with
RoomSettingsRepository, which writes all settings to the SQLCipher-encrypted
Room database as the single source of truth:
settingsDao.upsert()upsertField()
helper that reads the current entity, applies the transform, and upserts atomically
SendRoutineCompletionSmsUseCase opens the user's default SMS
app via Intent.ACTION_SENDTO with a pre-composed message containing
the owner name and routine name. SMS is an unencrypted protocol — messages are
transmitted in plaintext over the cellular network and are readable by carriers,
SS7 intermediaries, and anyone with access to the cellular infrastructure.
Improvements since the previous review:
SEND_SMS permission removed from manifest (commit 3ec7dd0) —
the app no longer declares or requests this permission. SMS sending is handled
entirely via Intent.ACTION_SENDTO, which uses the user's default
SMS app without requiring the SEND_SMS permission.SmsSentReceiver deleted — no longer needed since the app
doesn't send SMS directly (commit 3ec7dd0).While this is an explicit user-facing feature (the user must enable SMS in settings, configure contacts with phone numbers, and confirm each message via dialog), the privacy implications should be documented. The message content is non-sensitive (routine completion notification), but SMS remains an unencrypted transport protocol by design.
Intent.ACTION_SENDTO pattern is correct — it avoids
the SEND_SMS permission entirely and works reliably on Android 15/16
for non-default SMS apps.The JSON backup file previously had no integrity mechanism. The backup envelope contained version and appVersion metadata, but these were not cryptographically verified. A user could accidentally import a corrupted or malicious file without detection beyond the Kotlin Serialization parse step.
checksum: String? = null field was added to
BackupEnvelope. During export, the payload is serialized to JSON
and a SHA-256 hex digest is computed and stored in the envelope. During import,
the digest is re-computed from the parsed payload and compared. A mismatch
raises a SecurityException. The field is optional, so backups
created before this change remain importable — they simply skip the checksum
verification.
All previously outdated dependencies have been updated to current stable releases as part of the Kotlin 2.x migration (commit 479694d):
| Dependency | Previous | Current |
|---|---|---|
| Kotlin | 1.9.24 | 2.1.21 |
| Compose BOM | 2024.06.00 | 2026.04.01 |
| KSP | 1.9.24-1.0.20 | 2.1.21-2.0.2 |
| AGP | 8.4.1 | 8.9.1 |
| Gradle | 8.6 | 8.11.1 |
| Room | 2.6.1 | 2.8.4 |
| Hilt | 2.51.1 | 2.57.2 |
| Navigation | 2.7.7 | 2.9.8 |
| Lifecycle | 2.8.3 | 2.10.0 |
| DataStore | 1.1.1 | 1.2.1 |
| Kotlin Serialization | 1.6.3 | 1.7.3 |
| Compose compiler | 1.5.14 (separate artifact) | Built into Kotlin plugin |
Key migration details:
composeOptions { kotlinCompilerExtensionVersion }
to the org.jetbrains.kotlin.plugin.compose Gradle plugin, which
ships with Kotlin — eliminating version mismatch risk.
NotificationInstallTokenManager previously stored a UUID identifying
this installation in noBackupFilesDir/notification_install_token.txt
as plaintext.
SharedPreferences. Token is now
stored in the app's standard preferences file, which is encrypted at rest by
Android's file-based encryption (FBE) on API 26+. The legacy plaintext file
is no longer created.
DailyCueDatabase previously declared exportSchema = false,
preventing Room from generating schema history files needed for writing and testing
@Migration annotations.
exportSchema set to true. Added KSP arg
room.schemaLocation pointing to app/schemas/.
Schema file 6.json (version 6, 425 lines) generated and verified.
Output directory added to .gitignore.
The ProGuard configuration previously contained broad keep rules that reduced code obfuscation effectiveness and increased APK size:
These rules were removed in commit 1925f52. R8 in Kotlin 2.1.21 + AGP 8.9.1 correctly auto-retains all needed classes:
APK size reduction: 30.9 MB → 24.4 MB (21% decrease) due to improved R8 minification without the overly broad keep rules.
The database previously relied on fallbackToDestructiveMigration(),
risking user data loss during upgrades. All version transitions now have proper
@Migration annotations.
@Migration annotations written (1→2 through 4→5), covering all
schema transitions:checklist_items and routine_steps tablesnotifications tablecontactsJson column to routines
(with PRAGMA guard for idempotency)app_settings table)fallbackToDestructiveMigration() replaced with
fallbackToDestructiveMigration(BuildConfig.DEBUG) — only allows
destructive fallback in debug builds.
Four locations in the codebase used broad exception handlers that silently swallowed errors, making debugging difficult and potentially masking security-relevant failures.
catch (_: Exception) { }catch (e: Exception) { }catch (e: Exception) { }runCatching { WalkthroughScreen.valueOf(screenName) }.getOrNull()Log.w("DailyCue", ...) added to all 4 empty catch blocks. The
BackupManager runCatching now uses
.onFailure { Log.w(...) }. Failures are now observable in logcat
during development and testing.
DatabaseEncryptionManager implements a well-architected two-layer encryption scheme:
setKeySize(256)GCM_TAG_LENGTH_BITS = 128SecureRandomByteArray and passed directly to SQLCipherThis is a model implementation of Android credential storage and the strongest aspect of the application's security posture.
The app contains zero HTTP, socket, or other network communication
code. No INTERNET permission is declared. No WebView, no API calls,
no third-party analytics, no crash reporting SDK. This eliminates the largest
class of mobile app vulnerabilities entirely.
The backup/restore system uses the Storage Access Framework (SAF), requiring the user to explicitly select a file location (export) or file (import) via the system file picker. This means:
WRITE_EXTERNAL_STORAGE or
READ_EXTERNAL_STORAGE permissionsAdditionally, the backup format now includes a SHA-256 integrity checksum and field-level validation, providing defense in depth even if a malicious file is selected through SAF.
Immutable PendingIntents: All PendingIntent
instances across the codebase correctly use FLAG_IMMUTABLE,
preventing intent injection attacks. Every PendingIntent reviewed
(notification open, notification snooze, alarm scheduling, alarm cancellation)
uses FLAG_IMMUTABLE.
Non-Exported Receivers: NotificationBroadcastReceiver
and NotificationActionReceiver are both declared as
android:exported="false" in the manifest. Only
BootReceiver is exported (required for the
BOOT_COMPLETED system broadcast), which is expected and correct.
Install Token Validation: Both the broadcast receiver and the
WorkManager-based notification worker validate that incoming intents carry a
token matching the current install via
NotificationInstallTokenManager.isCurrent(), providing an additional
layer of protection against stale or forged notification intents.
Unique Request Codes: PendingIntent request codes use event IDs and notification IDs, preventing accidental PendingIntent collisions.
All database queries across the codebase use Room's parameterized query syntax
(:paramName). Room binds these as JDBC-style prepared statement
parameters, making SQL injection impossible even if user-supplied data flows
into queries.
No dynamic SQL construction (rawQuery with string concatenation)
exists anywhere in the codebase. The title field is used in WHERE
clauses across several DAO methods (EventDao lines 24–61) — all correctly
parameterized.
rawQuery,
ensure all user-supplied values continue to use parameterized bindings rather
than string interpolation.
NotificationHelper.showNotification() (lines 227–234) wraps the
NotificationManagerCompat.notify() call in a try/catch
for SecurityException, but the catch block is empty:
This is a regression of the pattern addressed in
F13 (Remediated), where 4 similar empty catch blocks across
the codebase were fixed by adding Log.w() calls. While a
SecurityException from notify() is unlikely in normal
operation (permission is requested via system dialog), the silent catch hides
diagnostic information if the permission is unexpectedly revoked at runtime.
Log.w("DailyCue", ...) call
inside the catch block to log the SecurityException. This is consistent with
the F13 remediation pattern and provides visibility into permission-related
failures during development and testing.
RoutineScheduleReconciler.reconcileRoutine() performs multiple
database operations per routine — stale event deletion, event refresh, checklist
item save, new event insertion, notification scheduling — without wrapping them
in a single Room @Transaction:
If the process is killed mid-reconciliation, the database can be left in a partially-updated state: events may have been deleted but not re-inserted, checklist items may be orphaned, and notifications may not match the database state. On the next reconciliation run, the reconciler will attempt to self-heal by detecting duplicates and stale events, but in the interim the user may experience missing or duplicate events and notifications.
@Transaction annotation
at the caller level (e.g., reconcileAll() and reconcileRoutine())reconcile() a suspend function that coordinates
through a single transactional boundary, following the pattern used in
BackupManager.restoreDatabase()RoutineScheduleReconciler.refreshGeneratedEventIfNeeded() (line 208) unconditionally replaces all checklist items for a generated event with fresh copies from the routine definition, even when the event itself has not changed:
Why this matters: Every time reconciliation runs (on every app launch, on every routine edit), the checklist items for every generated event are overwritten. Currently this is harmless because the app does not provide a UI for users to modify individual checklist items on generated routine events. However, if a future feature allows per-event checklist customization (e.g., checking off a step on a specific day), those modifications would be silently discarded on the next reconciliation run.
saveChecklistItemsUseCase call inside the
if (updated != event) guard block
RoutineScheduleReconciler.refreshGeneratedEventIfNeeded() (line 169)
calls LocalDate.parse(occurrenceDate) without error handling:
If the generatedOccurrenceDate column in the database contains a
non-ISO-8601 string (e.g., from data corruption, a failed migration, or manual
editing), this parse call throws DateTimeParseException. Since
refreshGeneratedEventIfNeeded() is called from reconcile()
inside a forEach loop with no per-event error handling, a single
corrupt date would crash the entire reconciliation batch, leaving the routine's
schedule in an unreconciled state.
Compare with the safe patterns used in HistoryRetentionCleanup.kt and
BackupManager.kt, which gracefully handle failures with
runCatching or early returns.
LocalDate.parse(occurrenceDate) in
runCatching { ... }.getOrNull()Log.w() and skip the event if parsing fails,
allowing reconciliation to continue for other eventsRoutineTaskActivationPolicyTest.kt covers 5 scenarios but is missing important edge cases for time-based gating:
notificationMinutesBefore = 0
— should activate exactly at start timestartTimeMillis = null
— should return falsestartOfNextDayMillis
calculation could be off by 1 hourstartOfNextDayMillis
— code uses strict less-than which is correct, but untested
All tests use America/New_York timezone. Adding DST transition
tests would improve regression protection.
notificationMinutesBefore = 0 and
startTimeMillis = null
NotificationHelper.showNotification() (lines 227–234) continues to
use an empty catch block for SecurityException:
Re-check v3 assessment: This finding was originally classified as
F19 (Low) in Re-check v2. After review, the empty catch is intentional by
design: on API 33+, the notification permission can legitimately be denied
by the user, and this is a graceful degradation path. The comment documents the
intent. However, adding a Log.w() call would improve debuggability
by distinguishing expected permission-denied scenarios from unexpected
SecurityException causes (e.g., broadcast permission changes).
Downgraded to Informational from the original Low classification
in Re-check v2, as the pattern is intentional and commented. The recommendation
from F13 remains: add Log.w("DailyCue", "Notification permission denied", e)
for consistency with the rest of the codebase.
HistoryRetentionCleanup.kt (lines 28, 32) and
RoutineScheduleReconciler.kt (line 37) both use
ZoneId.systemDefault() for date arithmetic:
If the user changes their device timezone between runs, date boundaries shift. Events within the retention window in the old timezone could fall outside in the new timezone (or vice versa), causing either premature deletion or extended retention. For the reconciler, the scheduling horizon could shift by a day windowing events in or out unexpectedly.
Risk assessment: Device timezone changes are rare and the impact is bounded (at most a few hours of events affected). Events are stored as UTC epoch millis internally, so only the date boundary calculations are timezone-sensitive. This is a minor design fragility, not a security vulnerability.
FLAG_IMMUTABLEfallbackToDestructiveMigration remains debug-only| Category | Risk Level | Key Findings |
|---|---|---|
| A01 — Broken Access Control | Low | Positive controls (F16, F17). Immutable PendingIntents, SAF, non-exported receivers.
allowBackup="false" prevents data exfiltration via ADB backup. |
| A02 — Cryptographic Failures | Remediated | F4 (plaintext owner name — remediated — now in encrypted Room DB), F6 (SMS plaintext — by design, SEND_SMS permission removed, always shows confirmation dialog), F14 (strong encryption — positive), F2 (keystore — remediated). |
| A03 — Injection | Low | No SQL injection (Room parameterizes all queries — F18). No WebView (no XSS). No OS command execution. No dynamic string evaluation. |
| A04 — Insecure Design | Medium | F5 (dual-write inconsistency — remediated — replaced by RoomSettingsRepository), F13 (broad exception handling — remediated — Log.w() added to all 4 catch blocks). F20 (non-atomic reconciliation — Medium). F21 (unconditional checklist overwrite — Low). F22 (uncaught LocalDate.parse — Low). F23 (incomplete test coverage — Low). F25 (timezone dependency — Info). |
| A05 — Security Misconfiguration | Remediated | F10 (exportSchema — remediated), F11 (ProGuard — remediated). |
| A06 — Vulnerable & Outdated Components | Low | F8 (outdated dependencies — remediated — all deps now current), F12 (destructive migrations — remediated — all @Migration annotations written). |
| A07 — Identification & Authentication Failures | N/A | No authentication required (local-only app with no user accounts). |
| A08 — Software & Data Integrity Failures | Remediated | F3 (backup validation — remediated), F7 (backup checksum — remediated). Both previously open findings resolved in commit 3265b01. |
| A09 — Security Logging & Monitoring Failures | Low | F1 (printStackTrace — remediated), F13 (empty catch blocks — remediated), F24 (NotificationHelper silent SecurityException catch — Info, intentional). No crash reporting or monitoring infrastructure. |
| A10 — SSRF | N/A | No server-side requests. No URL fetching. Zero network code (local-only app). |
| Category | Risk Level | Notes |
|---|---|---|
| M1 — Improper Platform Usage | Low | SEND_SMS has required="false". READ_CONTACTS
is properly scoped to contact picker. SCHEDULE_EXACT_ALARM and
USE_EXACT_ALARM are declared for notification scheduling.
All runtime permissions are checked before use. |
| M2 — Insecure Data Storage | Remediated | Owner name stored in plaintext (F4 — remediated, now in encrypted Room DB). Notification token in plaintext file (F9 — remediated, now in FBE-protected SharedPreferences). Database is encrypted with SQLCipher (excellent — F14). Phone numbers stored in encrypted DB (correct). |
| M3 — Insecure Communication | Low | No network communication at all (F15 — positive). SMS messages are inherently unencrypted (F6 — medium, by design; SEND_SMS permission removed, always shows confirmation dialog, uses Intent.ACTION_SENDTO). |
| M4 — Insecure Authentication | N/A | No authentication in a local-only app. |
| M5 — Insufficient Cryptography | Low | SQLCipher + AES-256-GCM + Android Keystore is excellent (F14 — positive). All cryptographic choices are modern and appropriate. |
| M6 — Insecure Authorization | N/A | Single-user local app with no authorization model needed. |
| M7 — Client Code Quality | Medium | Clean architecture with proper separation of concerns (MVVM + Repository + UseCase). F20 (non-atomic reconciliation — Medium). F21 (unconditional checklist overwrite — Low). F22 (uncaught LocalDate.parse — Low). F23 (incomplete test coverage — Low). F25 (timezone dependency — Info). |
| M8 — Code Tampering | Low | ProGuard/R8 provides basic obfuscation and integrity checking. Keystore and signing credentials properly gitignored (F2 — remediated). No code integrity checks (e.g., SafetyNet Attestation) — acceptable for local app. |
| M9 — Reverse Engineering | Low | ProGuard/R8 enabled for release builds with minification and optimization. 21% APK size reduction from tightening rules (F11 — remediated). SQLCipher's native code layer adds natural obfuscation. |
| M10 — Extraneous Functionality | Low | No debug backdoors, test APIs, hidden activities, or developer-only functionality
in release builds. The AndroidStartup provider is correctly
non-exported and only used for WorkManager initialization. |
| Priority | Finding | Effort | Status |
|---|---|---|---|
| Done | Replace printStackTrace with Log.e | Low | Committed in 39a503d |
| Done | Verify keystore secrets not in git | Low | Verified — already gitignored |
| Done | Add backup import validation (size, schema, fields) | Medium | Committed in 3265b01 |
| Done | Add SHA-256 checksum to backup envelope | Low | Committed in 3265b01 |
| Done | Encrypt owner name — migrate all settings to encrypted Room DB | Low | Committed (current) |
| Done | Eliminate dual-write in DataStoreSettingsRepository | Medium | Committed (current) |
| Done | Update outdated dependencies (Kotlin 2.x, AGP, Room, Hilt, etc.) | High | Committed in 479694d |
| Done | Tighten ProGuard rules — remove broad Compose/Navigation/DataStore keep rules | Low | Committed in 1925f52 |
| P2 | Document SMS privacy implications (unencrypted channel) | Low | Open — SEND_SMS permission removed, auto-send removed, always shows
confirmation dialog (commits 3ec7dd0, 79e41fe) |
| Done | Add logging to empty catch blocks | Low | Committed in 39c88a4 |
| Done | Set exportSchema = true for Room | Low | Committed in 09ea055 |
| Done | Write non-destructive @Migration annotations for Room | Medium | Committed in 13a9b48 |
| Done | Store notification token in SharedPreferences instead of plaintext file | Low | Committed in 07b293e |
| P3 | Document SMS privacy implications in privacy policy | Low | Open — SEND_SMS permission removed, auto-send eliminated, always
shows confirmation dialog (commits 3ec7dd0, 79e41fe) |
| P3 | Add logging to NotificationHelper silent SecurityException catch (F24) | Low | Open — add Log.w() inside catch block, consistent with F13 pattern (F19 reclassified to Info) |
| P2 | Wrap reconciliation cycle in @Transaction (F20) | Medium | Open — per-routine operations not atomic; wrap in Room @Transaction |
| P3 | Guard checklist overwrite in reconciliation (F21) | Low | Open — move saveChecklistItemsUseCase inside if (updated != event) guard |
| P3 | Handle LocalDate.parse exception gracefully (F22) | Low | Open — wrap in runCatching, log and skip on parse failure |
| P3 | Add DST/timezone edge case tests for activation policy (F23) | Low | Open — add boundary tests for DST transitions, midnight, zero notification lead |
| Vector | Present | Notes |
|---|---|---|
| Network (HTTP/WebSocket) | None | Zero network code. No INTERNET permission. |
| WebView / JavaScript | None | No WebView usage anywhere in the app. |
| File I/O (outside SAF) | Minimal | Only private app directories: noBackupFilesDir (token), getDatabasePath (SQLCipher), SharedPreferences, DataStore. All sandboxed by Android. |
| Content Providers | None exported | Only androidx.startup.InitializationProvider (non-exported). |
| Broadcast Receivers | 1 exported | BootReceiver exported (required for BOOT_COMPLETED). Others
non-exported and token-validated. |
| Deep Links / URL Schemes | None | No intent filters with URL schemes or deep links. |
| Backup / Data Export | SAF-gated | User must explicitly select file via system picker. SHA-256 integrity verified on import. Field-level validation applied. |
| SMS (opt-in) | Medium | Explicit user opt-in. No SEND_SMS permission. Always shows
confirmation dialog. Uses Intent.ACTION_SENDTO. Unencrypted channel
(by SMS protocol design). |