fix(reporting): scrub PII from crash stack traces + move ReportStore scan off the main thread #316

Merged
JMR-dev merged 7 commits from fix-reporting-pii-mainthread into main 2026-07-04 09:00:17 +00:00
JMR-dev commented 2026-07-04 07:18:44 +00:00 (Migrated from github.com)

Two Phase-3 reporting review findings.

#294 (HIGH/security) — scrub PII from crash stack traces

DiagnosticsCollector captured throwable.stackTraceToString() verbatim, so mail/network exceptions (Jakarta Mail, java.net) could embed server host:port tokens and account emails/usernames into the report's stackTrace field — the one unscrubbed free-text field, violating the PII-free-reports constraint.

New StackTraceScrubber, applied in DiagnosticsCollector before the trace enters toSubmissionPayload()/toStorageJson():

  • keeps the non-PII value that makes a report useful — exception class names and every frame (at pkg.Class.method(File.kt:42));
  • drops the free-text message from each exception header line (the only place a hostname/username appears — a regex alone can't tell a hostname from a dotted class name);
  • as defense-in-depth, redacts any residual email or host:port left on a wrapped continuation line.

Frame lines are never touched, so a frame's File.kt:42 is never mistaken for a host:port.

Before → after (first line of a ConnectException):
java.net.ConnectException: Failed to connect to imap.example.com/93.184.216.34:993 for user@example.com → java.net.ConnectException (frames below it kept intact).

#296 (MEDIUM/perf) — move ReportStore's initial scan off the main thread

ReportStore did MutableStateFlow(scan()) in its constructor (dir list + read + JSON-parse of every stored report). As an eager @Singleton dep of CrashReporter, whose install() runs on the main thread in Application.onCreate(), this was main-thread disk I/O that grows with the 30-day retention. The flow is now seeded empty and the initial scan dispatched to an injectable scope (Dispatchers.IO by default); reactive consumers update when it lands, and writes still re-scan synchronously so a crash-time save is never lost.

Tests

  • StackTraceScrubberTest: host/ip/port/email dropped from a ConnectException + auth-failure trace while classes/frames survive; regex redaction of a continuation line; null-message trace preserved verbatim.
  • DiagnosticsCollectorTest: end-to-end scrub through the collector.
  • ReportStoreTest: empty-seed + off-thread populate driven by a StandardTestDispatcher; existing tests use an Unconfined scope to keep their synchronous reopen semantics.

Gate: :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt all green (no emulator per scope).

Closes #294
Closes #296

🤖 Generated with Claude Code

Two Phase-3 reporting review findings. ## #294 (HIGH/security) — scrub PII from crash stack traces `DiagnosticsCollector` captured `throwable.stackTraceToString()` verbatim, so mail/network exceptions (Jakarta Mail, `java.net`) could embed server `host:port` tokens and account emails/usernames into the report's `stackTrace` field — the one unscrubbed free-text field, violating the PII-free-reports constraint. New `StackTraceScrubber`, applied in `DiagnosticsCollector` before the trace enters `toSubmissionPayload()`/`toStorageJson()`: - keeps the non-PII value that makes a report useful — exception **class names** and every **frame** (`at pkg.Class.method(File.kt:42)`); - **drops the free-text message** from each exception header line (the only place a hostname/username appears — a regex alone can't tell a hostname from a dotted class name); - as defense-in-depth, **redacts** any residual email or `host:port` left on a wrapped continuation line. Frame lines are never touched, so a frame's `File.kt:42` is never mistaken for a `host:port`. Before → after (first line of a `ConnectException`): `java.net.ConnectException: Failed to connect to imap.example.com/93.184.216.34:993 for user@example.com` → `java.net.ConnectException` (frames below it kept intact). ## #296 (MEDIUM/perf) — move ReportStore's initial scan off the main thread `ReportStore` did `MutableStateFlow(scan())` in its constructor (dir list + read + JSON-parse of every stored report). As an eager `@Singleton` dep of `CrashReporter`, whose `install()` runs on the **main thread** in `Application.onCreate()`, this was main-thread disk I/O that grows with the 30-day retention. The flow is now seeded empty and the initial scan dispatched to an injectable scope (`Dispatchers.IO` by default); reactive consumers update when it lands, and writes still re-scan synchronously so a crash-time save is never lost. ## Tests - `StackTraceScrubberTest`: host/ip/port/email dropped from a `ConnectException` + auth-failure trace while classes/frames survive; regex redaction of a continuation line; null-message trace preserved verbatim. - `DiagnosticsCollectorTest`: end-to-end scrub through the collector. - `ReportStoreTest`: empty-seed + off-thread populate driven by a `StandardTestDispatcher`; existing tests use an Unconfined scope to keep their synchronous reopen semantics. Gate: `:app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt` all green (no emulator per scope). Closes #294 Closes #296 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.