DailyCue — iOS Security Review

OWASP Top 10 (2021) & OWASP Mobile Top 10 (2024) Assessment — Re-check v4

v1.2.16 · 9 Jun 2026 Updated
Application
DailyCue
Platform
iOS 15.0+ (SwiftUI + Xcode 26.5)
Architecture
Swift + SwiftUI + GRDB (SQLCipher)
Data Layer
GRDB + SQLCipher + File Protection + Keychain
Codebase
~105 source files
Review Scope
Source code, config, build (full re-check)
Review Timestamp
2026-06-09T14:00:00Z (Re-check v4)

Executive Summary

This is a full re-assessment (Re-check v4) of the DailyCue iOS app. 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 maintains zero network attack surface (INTERNET permission not declared), uses iOS Keychain for cryptographic secrets, applies NSFileProtectionCompleteUnlessOpen to all database files, and uses GRDB parameterized queries throughout — eliminating SQL injection. Since Re-check v3, the app has added routine schedule reconciliation (database v5), a new notification queue reconciler that respects iOS's 64-notification limit, and time-sensitive interruption levels for critical notifications.

All 4 prior remediations remain fully closed. F1 — CLOSED Database encryption remains wired through Configuration.prepareDatabase. F2 — CLOSED Zero print() calls found — all logging uses Logger from OSLog. F3 — CLOSED UIBackgroundModes remains absent from Info.plist. F4 — CLOSED No regression of try?/empty catch patterns in new code; all 7 new try? usages are for non-critical operations (Task.sleep, optional encoding).

New findings. Four new items were identified in the new notification and reconciliation code. Two Low-severity findings: a type mismatch in notification identifiers (Int vs Int64 — N1) and copy-paste-able .public logging patterns (N4). Two Informational findings: a stale hardcoded appVersion in the backup envelope (N2) and a tag-content heuristic for event classification (N3). Existing findings F6 (resetPassphrase latent risk), F12 (updateField design smell), and F13 (UserDefaults protection) remain open with unchanged risk profiles.

4
Remediated
0
High
0
Medium
6
Low
7
Info / Positive

Changes from Previous Review

Changes in Re-check v3

Changes in Re-check v4

Findings

17 findings (0 critical, 0 high, 0 medium, 6 low, 7 informational, 4 remediated)

Remediated Database Encryption Gap Closed with SQLCipher A02 — Cryptographic Failures / M2 — Insecure Data Storage

Status: FULLY REMEDIATED. DatabaseEncryption.swift provides a Keychain-backed 32-byte passphrase, and DailyCueDatabase.swift now applies it during database open via Configuration.prepareDatabase:

// DailyCueDatabase.swift lines 57–59 configuration.prepareDatabase = { db in try db.usePassphrase(passphrase) }

The build configuration (project.yml) points to the local SQLCipher-backed GRDBCipher package. On fresh installs, the database is created encrypted from the start. On upgrades from the plaintext era, migratePlaintextDatabase() handles the migration:

  • Detects plaintext SQLite via magic header bytes ("SQLite format 3\0")
  • Backs up the plaintext file, creates a new encrypted database, restores all records, then deletes the plaintext backup on success
  • On failure, the backup is restored atomically (rollback)

Remaining (minor) concern: The migration involves an async suspension point (try await restoreSnapshot at line 83) between moving the plaintext file (line 77) and deleting the backup (line 82). If the process is killed during this window, a plaintext backup file (dailycue.db.plaintext-backup) could remain on disk. This is mitigated by NSFileProtectionCompleteUnlessOpen on the directory and the backup being left in the application support directory. Consider wrapping the migration in an NSFileCoordinator scope or adding a cleanup check on next launch.

Verification:
  • iOS target resolves local GRDBCipher package (project.yml line 32–38)
  • Database open applies Keychain passphrase through Configuration.prepareDatabase
  • Plaintext migration path is implemented with rollback on failure
  • Build succeeds, #if canImport(SQLCipher) block is active
Remediated Security Events Now Logged via OSLog Instead of print() (6 locations) A09 — Security Logging & Monitoring Failures

Status: FULLY REMEDIATED. All 6 print() locations across the codebase have been replaced with structured Logger calls from OSLog:

  • BackupManager.swift:114–115 — HMAC verification failure: logger.warning("Backup HMAC mismatch...")
  • HomeViewModel.swift:154 — Batch checklist fetch failure: logger.error("[loadChecklistItems] Batch fetch failed...")
  • SettingsViewModel.swift:337 — Settings persistence failure: Logger(...).error("Settings persistence failed...")
  • QuickAddViewModel.swift:149 — Weekly event save failure: Logger(...).error("[QuickAdd] Failed to save...")
  • QuickAddViewModel.swift:155 — Informational log: Logger(...).info("[QuickAdd] Saved...")

Each file now imports OSLog and uses the appropriate log level:

  • .warning for security events (HMAC mismatch, file not found)
  • .error for unexpected operational failures
  • .info for informational events

The messages are now captured in the unified log system, persist across sessions, and can be inspected via log show or the Console app. Each logger is categorized by subsystem (com.dailycueplanner) and component (backup, home, settings, quickadd, soundpreview).

Verification:
  • All 6 print() calls replaced with Logger calls
  • import OSLog added to each affected file
  • Appropriate log levels used: .error, .warning, .info
  • Build succeeds with no compilation errors
Remediated UIBackgroundModes: audio Removed from Info.plist M1 — Improper Platform Usage / M10 — Extraneous Functionality

Status: FULLY REMEDIATED. The UIBackgroundModes: audio declaration has been removed from Info.plist. This capability was previously declared without functional justification — the app has no feature requiring background audio playback.

SoundPreviewPlayer (SettingsView.swift) uses AVAudioPlayer to preview notification sounds interactively in the settings UI — this works correctly without the background audio entitlement. Notification sounds delivered via UNNotificationSound are handled by the system notification service and do not require it either.

The removal eliminates:

  • App Store rejection risk — Apple reviews background mode usage and may reject apps that declare capabilities without using them
  • Unnecessary battery impact — the system may grant extra background execution time when audio mode is declared
  • Attack surface reduction — removing an unnecessary entitlement reduces the potential impact of audio stack vulnerabilities
Verification:
  • UIBackgroundModes key removed entirely from Info.plist
  • SoundPreviewPlayer confirmed working via AVAudioPlayer in the foreground
  • No remaining references to background audio in the codebase
Remediated Silent Error Swallowing via try? and Empty catch Blocks (~55 locations) A09 — Security Logging & Monitoring Failures

The original review identified 5 locations. The re-assessment found ~55 locations across 17 files using try? or empty catch blocks that silently discard errors. Key categories:

Category 1: AsyncStream catch with silent finish (9 locations)

  • ScheduleRepository.swift:44–46, 67–69catch { continuation.finish() }
  • RoutineRepository.swift:28–30catch { continuation.finish() }
  • SettingsRepository.swift:27–29 — same pattern
  • ChecklistRepository.swift:30–32 — same pattern
  • NotificationRepository.swift:28–30 — same pattern
  • StatsRepository.swift:42–44 — same pattern
  • ObserveRoutinesUseCase.swift:27–29 — same pattern

Category 2: try? discarding errors at call sites (20+ locations)

  • DailyCueApp.swift:115–120let settings = try? await deps.settingsRepository.fetchSettings() — settings fetch failure silently defaults
  • DailyCueApp.swift:135_ = try? await deps.notificationManager.requestAuthorization() — authorization failure silently ignored
  • HomeViewModel.swift:225–234 — checklist + event update failures silently ignored
  • HomeViewModel.swift:241–246 — event deletion failure silently ignored
  • HomeViewModel.swift:252–257, 264–273 — appointment/routine deletion failures silently ignored
  • RoutineLibraryViewModel.swift:98, 104 — update/delete failures silently ignored
  • SettingsViewModel.swift:80–86, 205–210 — fetch/cleanup failures silently ignored
  • EditEventViewModel.swift:115–118, 195–200 — various operation failures silently ignored
  • QuickAddViewModel.swift:85–93, 148–153 — notification scheduling failures silently ignored
  • RoutineRecord.swift:74, 113 — contacts JSON encoding/decoding failures silently ignored
  • DailyCueDatabase.swift:86–95 — cleanup during migration failure silently ignored

While many of these are non-security operations (UI updates, cleanup), the pervasive pattern makes debugging difficult and can mask subtle data integrity issues. The try? pattern for settings fetch (F111) and notification authorization (F125) are particularly concerning because they affect core app functionality.

Recommendation:
  • Add os_log(.error, ...) before each silent recovery path
  • For critical paths (settings fetch, notification auth), use do/try/catch with explicit user-facing error handling
  • For AsyncStream catch blocks, log the error before calling continuation.finish()
  • For contacts JSON in RoutineRecord, consider a non-fatal log rather than silent try?

☑ Status: FULLY REMEDIATED. All ~55 try?/empty catch locations across 17 files have been converted to do/try/catch with structured Logger error/warning logging. The remediation covers:

  • 6 Repository AsyncStream observerscatch blocks now log via logger.error("Stream failed: ...") before continuation.finish()
  • ObserveRoutinesUseCase — same pattern added
  • DailyCueApp.swift (init) — settings fetch + notification auth converted to do/catch with logger.error
  • HomeViewModel.swift — 8 DB write sites converted to do/catch with logger.error; 3 Task.sleep sites left as try? (cancellation expected)
  • SettingsViewModel.swift — 3 try? sites converted to do/catch with logger.warning/logger.error
  • EditEventViewModel.swift — 7 try? sites converted with logger.error for notification scheduling failures
  • QuickAddViewModel.swift — 4 try? sites converted with Logger for settings fetch + notification scheduling
  • RoutineLibraryViewModel.swift — 2 try? sites converted with logger.error for checklist/event cleanup
  • ScheduleRoutineViewModel.swift — 2 try? sites converted with logger.warning for settings fetch
  • DailyCueDatabase.swift — migration cleanup catch converted to do/catch with logger.error; isPlaintextSQLiteDatabase now logs on read failure
  • RoutineRecord.swift — contacts JSON encoding/decoding converted to do/catch with Logger warning/error
Verification:
  • import OSLog added to all affected files
  • Logger instances use subsystem: "com.dailyq" with appropriate category strings
  • Critical init paths use do/catch (not try?)
  • Operational fallback paths use logger.warning
  • Unexpected failures use logger.error
  • Build succeeds with no compilation errors
Low Backup Payload Deserialization: Nested Depth Risk + Missing Strict Mode A08 — Software & Data Integrity Failures

Correction from previous review: The previous review incorrectly stated that JSONDecoder() defaults to .allowJSON5. In iOS 15.0+ (the app's minimum target), allowsJSON5 defaults to false, so the decoder is already strict about JSON syntax. This finding has been corrected.

BackupManager.importFrom() uses JSONDecoder() to decode user-provided backup files. While the checksum and HMAC provide integrity verification after JSON parsing, the decoder itself has two remaining concerns:

  • Deeply nested JSON DoS: The 10 MB file size limit (BackupManager.swift:86) provides a baseline, but an attacker could craft a deeply nested JSON structure within that limit that causes excessive memory or CPU consumption during parsing (e.g., millions of nested arrays). JSONDecoder has no built-in max depth limit.
  • Extra fields silently ignored: The BackupEnvelopeCodable and BackupPayloadCodable types use synthesized Codable initializers, which ignore extra fields in the JSON. While not a vulnerability, this means a malformed but valid-JSON backup could be partially decoded without error.
Recommendation:
  • Wrap JSON parsing in a task with a timeout to prevent resource exhaustion (e.g., Task.timeout = 5.0 using a cooperative timeout)
  • Validate payload structure before full decoding — check that the envelope has expected top-level keys using JSONSerialization
  • Consider adding a max nesting depth check via JSONSerialization.isValidJSONObject() as a pre-filter
Low Unused resetPassphrase() Method Could Orphan Database (Latent Risk) A02 — Cryptographic Failures

DatabaseEncryption.swift:21–25 defines resetPassphrase() which deletes the existing Keychain-stored passphrase and generates a new one:

static func resetPassphrase() throws -> String { try deletePassphrase() return try getOrCreatePassphrase() }

If this method were called after the database has already been opened with the old passphrase, the database file would become permanently inaccessible:

  • The old passphrase is deleted from the Keychain
  • A new, unrelated passphrase is generated and stored
  • The SQLCipher-encrypted database file is still encrypted with the old passphrase — there is no PRAGMA rekey call
  • On next launch, the app tries the new passphrase and fails to open the database — all user data is lost

Mitigating factor: resetPassphrase() is not called anywhere in the current codebase. However, it is a public API on DatabaseEncryption and could be called inadvertently by future code.

Recommendation: Either remove resetPassphrase() entirely, or implement it properly with a matching SQLCipher PRAGMA rekey operation that re-encrypts the database with the new passphrase. If kept for future use, add documentation warning that re-keying must follow.
Positive iOS Keychain Used for Cryptographic Secrets A02 — Cryptographic Failures

DatabaseEncryption.swift and BackupKeyStore.swift correctly use the iOS Keychain for cryptographic material:

  • Database passphrase: 32-byte (256-bit) random value generated via SecRandomCopyBytes, stored in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly — meaning the passphrase cannot be extracted from backups or when the device is locked
  • Backup HMAC key: 256-bit SymmetricKey stored in the Keychain as a generic key with kSecAttrAccessibleWhenUnlockedThisDeviceOnly protection, preventing backup replay from other devices
  • Passphrase generation uses proper cryptographic random bytes (not UUID or Date) and encodes as hex for usability
  • Error handling distinguishes between "item not found" and actual Keychain errors, propagating OSStatus in the DatabaseError / BackupError types
  • In-memory caching (cachedKey in BackupKeyStore) reduces Keychain lookups while still loading from the secure store on first access

This is a model implementation of iOS credential storage, now fully wired to the database encryption layer (see Finding 1 — remediated).

Positive Zero Network Attack Surface A01 — Broken Access Control / M3 — Insecure Communication

The iOS app contains zero HTTP, socket, or other network communication code. No INTERNET permission is declared in the Info.plist. No URLSession, WKWebView, or NWConnection usage exists anywhere in the codebase. There are no third-party analytics, crash reporting SDKs, or network-dependent dependencies (GRDB is a local SQLite wrapper). This eliminates the largest class of mobile app vulnerabilities entirely and mirrors the Android app's strong posture.

Recommendation: Maintain this property. If cloud features are added later, ensure they are optional and clearly communicated to users. Consider adding an App Transport Security exception list only if explicitly needed.
Positive SQL Injection Prevention via GRDB Parameterized Queries (w/ minor design smell) A03 — Injection

All database queries across the codebase use GRDB's query builder or parameterized SQL (positional ? placeholders). GRDB binds these as SQLite prepared statement parameters, making SQL injection impossible even if user-supplied data flows into queries. Examples:

// GRDB query builder (type-safe, no injection risk): EventRecord .filter(Column("title").like(pattern)) .filter(Column("startTimeMillis") >= millis) .fetchAll(db) // Raw SQL with parameterized bindings: try db.execute(sql: "DELETE FROM events WHERE title = ? AND tags NOT LIKE '%routine%'", arguments: [title])

All repository code uses parameterized queries exclusively. No dynamic SQL construction via string interpolation exists anywhere in the codebase — with one exception.

Design smell — SettingsRepository.updateField(): SettingsRepository.swift:107–119 uses string interpolation for the column name:

private func updateField(_ field: String, value: ...) async throws { try db.execute( sql: "UPDATE app_settings SET \(field) = ? WHERE id = 1", arguments: [value] ) }

Why this is currently safe: The updateField method is private and all 18 callers pass hardcoded string literals for column names (e.g., "reminderDurationMinutes"). There is no path where user input reaches the field parameter. The value portion is properly parameterized via ? bindings.

Why it's a design smell: If a future developer adds a new caller that passes a dynamically-constructed column name (e.g., from user input or a config key), this would become a SQL injection vector. Column names cannot be parameterized in SQLite — they must be either interpolated or whitelisted. The GRDB query builder (Column("name")) would prevent this entirely.

Recommendation:
  • Replace the updateField() pattern with a GRDB query builder approach or a whitelist of permitted column names
  • If the current pattern is kept, add documentation warning that field must only receive hardcoded literals
  • If future features require raw SQL, continue using the arguments: parameter with positional bindings rather than string interpolation for values
Positive Backup Integrity Protection with SHA-256 + HMAC A08 — Software & Data Integrity Failures

BackupManager.swift implements a two-layer backup integrity scheme:

  • SHA-256 checksum — computed from the serialized payload and stored in the envelope. On import, the checksum is recomputed and compared. A mismatch raises a hard error (BackupError.integrityError)
  • HMAC-SHA256 signing — computed with a device-specific Keychain-stored key via CryptoKit.HMAC. On import, the HMAC is verified. A mismatch triggers a warning (not a hard error — to support cross-device migration)
  • File size limit — rejects files larger than 10 MB before any parsing begins
  • Version validation — rejects backup envelopes with version numbers outside the supported range (1 only)
  • Security-scoped resource access — correctly uses startAccessingSecurityScopedResource / stopAccessingSecurityScopedResource with defer for file importer URLs
  • Atomic writes on export — uses Data.WritingOptions.atomic to prevent partial file writes
  • Notification rescheduling — after import, NotificationRescheduler cancels and re-creates all pending notifications to match the restored schedule

The BackupKeyStore implementation correctly stores the HMAC key in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, making the key device-specific and unrecoverable from backups.

Positive Safe SMS Handling via MFMessageComposeViewController M1 — Improper Platform Usage / M3 — Insecure Communication

SmsComposerPresenter.swift uses Apple's MFMessageComposeViewController API for sending routine completion SMS messages. This is the correct and only supported approach on iOS:

  • No SEND_SMS permission needed — unlike Android's SEND_SMS, iOS has no such permission. MFMessageComposeViewController presents the system Messages composer and the user must tap Send manually
  • Device capability checkcanSendText() is checked before presenting, gracefully handling devices without SMS capability (iPad, iPod touch, simulator)
  • User always confirms — the system Messages sheet appears with the pre-filled message and the user must explicitly tap Send. No automatic/silent SMS sending is possible
  • Contact picker — uses CNContactPickerViewController with a predicate for contacts with phone numbers. The NSContactsUsageDescription in Info.plist explains the purpose
  • Phone numbers are stored in the database (contactsJson column in routines), which is now protected by both NSFileProtectionCompleteUnlessOpen at rest and SQLCipher database encryption

SMS messages are inherently unencrypted (SMS protocol limitation). The message content is non-sensitive ("DailyCue notification: [name] just completed the [routine] routine! ✅"), and the feature requires explicit user opt-in (SMS must be enabled in settings, contacts must be added to routines).

Low SettingsRepository.updateField() Dynamic Column SQL Injection Design Smell A03 — Injection / A04 — Insecure Design

SettingsRepository.swift:107–119 defines a private helper updateField() that interpolates the column name directly into the SQL string:

private func updateField(_ field: String, value: (any DatabaseValueConvertible & Sendable)?) async throws { let pool = try await db.dbPool try await pool.write { db in if try SettingsRecord.fetchOne(db, key: 1) == nil { var record = SettingsRecord() try record.insert(db) } try db.execute( sql: "UPDATE app_settings SET \(field) = ? WHERE id = 1", arguments: [value] ) } }

Why this is currently safe (mitigating factors):

  • The method is private — only accessible within SettingsRepository itself
  • All 18 callers pass hardcoded string literals for column names (e.g. "reminderDurationMinutes", "themeName", "ownerName")
  • The value portion is properly bound via positional ? parameterization — no user data reaches the column name
  • No public API exposes this method or accepts arbitrary field names

Why it's a design smell:

  • Column names cannot be parameterized in SQL — they must be interpolated or whitelisted. String interpolation for any SQL fragment bypasses GRDB's type-safe query builder
  • If the method visibility is widened (e.g., to internal or public) or a future caller accepts a field name from user input, this becomes an exploitable SQL injection vector
  • GRDB's query builder (Column("name")) handles column references safely and prevents typos — switching to it would eliminate this risk category entirely
  • A comparable pattern led to real CVEs in other iOS apps where column names were derived from user-facing filters
Recommendation:
  • Refactor updateField() to use a column name whitelist (e.g., a dictionary mapping operation types to column names) instead of raw string interpolation
  • Alternatively, replace individual updateField callers with direct GRDB property updates on SettingsRecord (e.g., record.reminderDurationMinutes = minutes; try record.update(db))
  • At minimum, add a comment warning that the field parameter must only accept hardcoded literals
Low UserDefaults Used Without Explicit File Protection for Walkthrough & Seeding State M2 — Insecure Data Storage

WalkthroughRepository.swift and PredefinedRoutinesSeeder.swift use UserDefaults.standard for persisting walkthrough progress and first-launch seeding state:

  • Walkthrough state — 5 keys per screen (version, completed, skipped, last step index) stored as boolean/integer values in UserDefaults XML plist
  • Seeding flaghas_seeded_predefined_routines boolean in UserDefaults

This is an inconsistency in the app's data protection strategy. The database files receive NSFileProtectionCompleteUnlessOpen (DailyCueDatabase.swift:114–117) and SQLCipher encryption, while Keychain items use kSecAttrAccessibleWhenUnlockedThisDeviceOnly. In contrast, UserDefaults plist files are stored without an explicit file protection level and rely solely on the device's keybag encryption.

Risk assessment:

  • No sensitive data — walkthrough completion status and boolean seed flags carry no personal or confidential information
  • iOS default protection — UserDefaults files on iOS 15+ inherit NSFileProtectionCompleteUntilFirstUserAuthentication from the app's sandbox by default, providing moderate at-rest protection
  • Physical access scenario — on a device without a passcode, or with an attacker who has filesystem-level access, the plist contents are readable
  • No sensitive data exposure — even if read, the data reveals nothing about the user's schedule, contacts, or personal information
Recommendation:
  • This is acceptable for the current data stored (non-sensitive). However, if future features store sensitive preferences in UserDefaults, consider migrating to the Keychain or applying explicit NSFileProtectionCompleteUntilFirstUserAuthentication to the UserDefaults plist
  • For consistency with the rest of the app's security posture, audit any future UserDefaults usage to ensure no sensitive data is stored there
Low Notification Identifier Type Mismatch (Int vs Int64) Creates Latent Consistency Risk A04 — Insecure Design

NotificationManager.swift accepts Int parameters for scheduleNotification(id:) (line 54) and scheduleSnoozedNotification(id:) (line 77) notification identifiers, but event IDs throughout the codebase are typed as Int64. The identifier string is built via string interpolation:

// NotificationManager.swift:54 identifier: "dailycue-\(id)" // id is Int // NotificationQueueReconciler.swift:53 let desiredIdentifiers = Set(desired.map { "dailycue-\($0.eventId)" }) // eventId is Int64

On 64-bit iOS, Int and Int64 are the same width, so the truncation risk is theoretical. However, the type mismatch means the API surface communicates the wrong contract — a caller passing an Int from a truncated source (e.g., a 32-bit value cast to Int) would silently produce a different identifier than expected, leading to duplicate notifications or failed cancellation.

Recommendation:
  • Change scheduleNotification(id:) and scheduleSnoozedNotification(id:) parameter types from Int to Int64 for consistency with the rest of the codebase
  • Alternatively, use a dedicated notification identifier type (e.g., a struct wrapping String) to prevent type confusion
Info Stale Hardcoded appVersion in Backup Envelope A05 — Security Misconfiguration

BackupManager.exportTo() (line 77) embeds a hardcoded version string in the backup envelope:

let envelope = BackupEnvelopeCodable( appVersion: "1.2.13", // <-- stale; app is now 1.2.16 ... )

This string was "1.2.6" during the original review, was updated to "1.2.13" during Re-check v3 work but has not been kept in sync with the actual app version (currently 1.2.16). The backup integrity is independently protected by SHA-256 checksum and HMAC signing, so this does not affect security. However, it could confuse cross-version restore debugging and is a maintenance hygiene concern.

Recommendation:
  • Read the app version from Bundle.main.infoDictionary at runtime (e.g., Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")) instead of hardcoding
  • At minimum, update the hardcoded string on each version bump
Info LIKE '%routine%' Tag Heuristic for Event Classification A04 — Insecure Design

ScheduleRepository.swift differentiates routine vs. appointment events by checking whether the tags column contains the substring "routine":

// ScheduleRepository.swift lines 211, 216, 220, 235, 240, 244 .filter(Column("tags").like("%routine%"))

This heuristic appears in both deleteRoutinesByTitle() and deleteFutureAppointmentsByTitle(). While tags are set to "routine" during event creation for routine-generated events, the Event model makes tags a user-editable string field. A user who creates an appointment with the word "routine" in the tags (e.g., "daily routine checkup") would have that event incorrectly classified during bulk operations.

Recommendation:
  • Use the new generatedRoutineId column (added in database v5) for routine identification instead of the tag heuristic
  • If tag-based classification must be retained, use exact match (tags = "routine") rather than LIKE '%routine%'
Low Copy-Paste-able .public Logging Pattern for Context Strings A09 — Security Logging & Monitoring Failures

NotificationRescheduler.swift (lines 75-76, 137) uses privacy: .public for reconciliation context strings:

// NotificationRescheduler.swift:75-76 logger.info("Reconciliation started: context=\(context, privacy: .public)") // NotificationRescheduler.swift:137 logger.info("Reconciliation complete: context=\(context, privacy: .public)")

The context values themselves are simple identifiers ("launch", "settings", "delete-event") and do not contain sensitive data. However, the .public pattern could be copy-pasted into future logging statements where the interpolated value is sensitive (e.g., user names, event titles, phone numbers). Swift's OSLog by default redacts dynamic values — .public must be explicitly opted into.

Recommendation:
  • Replace .public with .private (or omit the privacy parameter entirely, which defaults to .private on iOS 15+) for any future logging of user-supplied values
  • For the current context strings, add a comment explaining why .public is appropriate for these specific values
  • Consider a code review rule: .public must be justified with a comment explaining why the logged value is non-sensitive

OWASP Top 10 (2021) Cross-Reference

CategoryRisk LevelKey Findings
A01 — Broken Access Control Low Positive controls: zero network (F8), no deep links, no exported activities/components, notification tap identifiers are internal ("dailycue-<eventId>"). No auth required (local-only app).
A02 — Cryptographic Failures Low F1 (database encryption — remediated, now SQLCipher-backed), F7 (Keychain usage — positive), F6 (resetPassphrase latent risk — Low). Database at rest protected by both SQLCipher and NSFileProtection. HMAC signing is correctly implemented. F13 (UserDefaults without explicit file protection — Low, non-sensitive data only).
A03 — Injection Low No SQL injection risk from parameterized queries (F9 — GRDB). Minor design smell: SettingsRepository.updateField() uses string interpolation for column names (F12 — Low, currently safe with private scope and hardcoded callers). No WebView (no XSS). No dynamic string evaluation.
A04 — Insecure Design Low Clean MVVM + Repository + UseCase architecture with Swift actor isolation. Single source of truth for settings (GRDB). F12 (SQL column interpolation — Low), F14 (notification ID type mismatch — Low), F16 (tag heuristic — Info).
A05 — Security Misconfiguration Low F3 (UIBackgroundModes: audio — remediated, removed from Info.plist). F15 (stale appVersion in backup — Info). No exposed debug endpoints or backdoors.
A06 — Vulnerable & Outdated Components Low Single dependency: GRDB/SQLCipher (local package). Swift 6.0 compiler. Xcode 26.5. No transitive dependencies with known vulnerabilities.
A07 — Identification & Authentication Failures N/A No authentication required (local-only app with no user accounts).
A08 — Software & Data Integrity Failures Low F5 (JSONDecoder — nested depth risk, Low), F10 (SHA-256 + HMAC backup integrity — positive). Backup import has validation (size, version, checksum, HMAC). Atomic writes on export.
A09 — Security Logging & Monitoring Failures Low F2 (print() → os_log — remediated), F4 (try? → Logger — remediated), F17 (.public logging pattern — Low, pattern risk).
A10 — SSRF N/A No server-side requests. No URL fetching. Zero network code (F8).

OWASP Mobile Top 10 (2024) Cross-Reference

CategoryRisk LevelNotes
M1 — Improper Platform Usage Low F3 (UIBackgroundModes: audio — remediated, removed from Info.plist). Contact picker uses CNContactPickerViewController correctly. SMS uses MFMessageComposeViewController correctly (F11). Notification sound authorization requested on first launch.
M2 — Insecure Data Storage Low F1 remediated — SQLCipher encryption now active on database. All user data (owner name, phone numbers, routine/event details) encrypted at rest. NSFileProtectionCompleteUnlessOpen applied as defense in depth. Keychain secrets stored with accessibleWhenUnlockedThisDeviceOnly (F7 — positive). F13 (UserDefaults without explicit file protection — Low, non-sensitive data only).
M3 — Insecure Communication Low No network communication at all (F8 — positive). SMS messages are inherently unencrypted (by protocol design, not app flaw — F11).
M4 — Insecure Authentication N/A No authentication in a local-only app.
M5 — Insufficient Cryptography Low F1 remediated — database now encrypted via SQLCipher. HMAC-SHA256 for backups is correct (F7, F10). SecRandomCopyBytes for passphrase generation. Keychain accessibility settings are appropriate. F6 (resetPassphrase latent risk — Low, no rekey implementation). F13 (UserDefaults without explicit file protection — Low, non-sensitive data).
M6 — Insecure Authorization N/A Single-user local app with no authorization model needed.
M7 — Client Code Quality Low Clean MVVM + Repository + UseCase architecture. F4 (~55 try?/empty catch blocks — remediated). F12 (SQL column interpolation — Low). F14 (notification ID type mismatch — Low). F16 (tag heuristic — Info). Consistent patterns across the codebase (Swift 6.0, modern concurrency, actor isolation).
M8 — Code Tampering Low Code signing uses standard Xcode automatic signing. No code integrity checks (acceptable for local app). Backup HMAC provides tampering detection for backup files (F10).
M9 — Reverse Engineering Low Swift code compiles to native binaries with default debug symbol stripping for release builds. No explicit obfuscation (Swift has no ProGuard equivalent). Acceptable for a local-only app with no API secrets.
M10 — Extraneous Functionality Low F3 (UIBackgroundModes: audio — remediated, removed from Info.plist). F6 (resetPassphrase defined but unused — Low). F12 (updateField dynamic SQL — Low design smell). No debug backdoors, test endpoints, or developer-only functionality present in the codebase.

Attack Surface Summary

VectorPresentNotes
Network (HTTP/WebSocket)NoneZero network code. No INTERNET permission or URLSession usage.
WebView / JavaScriptNoneNo WKWebView or UIWebView usage anywhere in the app.
File I/OMinimalDatabase in Application Support directory (NSFileProtection + SQLCipher). Backup accessed via security-scoped URL (SAF-equivalent). No direct file path handling for user data.
Deep Links / URL SchemesNoneNo declared URL schemes or universal links. No intent filters.
Notification Deep LinksRemovedNotification deep-link parsing removed; all notification taps now navigate to Home. Reduces attack surface from identifier parsing.
Backup / Data ExportSAF-gatedUser selects file via UIDocumentPickerViewController. SHA-256 + HMAC verified on import. 10 MB size limit. Version validation. Atomic writes.
SMS (opt-in)LowMFMessageComposeViewController (user must tap Send). No SEND_SMS permission. Device capability check. Explicit user opt-in (settings + contacts).
Database EncryptionRemediatedSQLCipher encryption active. NSFileProtectionCompleteUnlessOpen as defense in depth. Plaintext migration path implemented.
Background ModesNoneUIBackgroundModes: audio removed from Info.plist. No background modes declared. SoundPreviewPlayer works in the foreground only.
UserDefaultsLowUsed for walkthrough state + seeding flags. No explicit file protection (inherits iOS default: CompleteUntilFirstUserAuthentication). Non-sensitive data only (F13).

Remediation Priority

PriorityFindingEffortRecommendation
DONE Wire DatabaseEncryption passphrase into GRDB (F1) Medium Remediated. SQLCipher passphrase applied via Configuration.prepareDatabase. Plaintext migration path implemented. Consider adding cleanup check for partial migration artifacts.
DONE Replace all print() with os_log (F2 — 6 locations) Low Remediated. All print() calls replaced with Logger from OSLog. Structured logging with proper levels.
DONE Remove or justify UIBackgroundModes: audio (F3) Low Remediated. Key removed from Info.plist.
DONE Add os_log to all silent catch/try? sites (F4 — ~55 locations in 17 files) Medium Remediated. All ~55 locations converted to do/try/catch with Logger from OSLog. Critical init paths use do/catch with error logging; operational paths use logger.warning/error. Imports OSLog in all affected files. Build verified.
P2 Harden backup JSON parsing (F5) Low Add JSON nesting depth check or parsing timeout. Pre-validate with JSONSerialization.
P3 Remove or fix resetPassphrase() (F6 — latent risk) Low Remove if unused, or implement PRAGMA rekey if needed in future.
P3 Refactor SettingsRepository.updateField() dynamic SQL (F12 — design smell) Low Replace string interpolation with GRDB query builder or column name whitelist.
P3 Audit UserDefaults usage for sensitive data (F13 — UserDefaults protection) Low No action required for current data (non-sensitive). Ensure future UserDefaults usage is audited for sensitive preference storage.
P3 Fix notification identifier type mismatch (F14) Low Change scheduleNotification(id:) parameter from Int to Int64
P3 Update stale appVersion in backup envelope (F15) Low Read from Bundle.main.infoDictionary at runtime instead of hardcoding
P3 Replace LIKE '%routine%' tag heuristic (F16) Low Use generatedRoutineId column for routine identification; or use exact tag match
P3 Document .public logging justification (F17) Low Add comments explaining why .public is used for specific context values