-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sentry): trim native scope per privacy tier and filter native metrics #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5d16c72
feat(sentry): trim native scope per privacy tier and filter native me…
gmaclennan b810e08
fix(sentry): address PR review — errors keep full scope, lower bucket…
claude 8e198bb
fix(sentry): qualify global log() in SentryNativeBridge.countMetric
claude f736e1f
fix(sentry): annotate SentrySpan test data maps to satisfy Kotlin
claude 58978f4
test(sentry): return default values for android stubs in JVM unit tests
claude a70b3a8
refactor(sentry): drop the forbidden-metric log on the native paths
claude 17a435a
Merge remote-tracking branch 'origin/main' into claude/code-review-pe…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
49 changes: 49 additions & 0 deletions
49
android/src/main/java/com/comapeo/core/SentryMetricScrub.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package com.comapeo.core | ||
|
|
||
| /** | ||
| * Forbidden-name/value gate for metrics emitted through the native SDK, | ||
| * mirroring the `isForbiddenMetric` check the JS/Node paths run. | ||
| * Hand-mirrored from `src/sentry-scrub.ts` and `backend/before-send.js` | ||
| * (three module systems, so no build-time copy is practical) — keep the | ||
| * lists in lock-step; each file points at the others. | ||
| */ | ||
| internal object SentryMetricScrub { | ||
| private val FORBIDDEN_METRIC_TAG_NAMES = setOf( | ||
| "device.model", | ||
| "device.id", | ||
| "device.manufacturer", | ||
| "os.version", | ||
| "screen.resolution", | ||
| "screen.density", | ||
| "screen.dpi", | ||
| "locale", | ||
| "timezone", | ||
| "project_id", | ||
| "peer_id", | ||
| "peer_count", | ||
| "rootkey", | ||
| ) | ||
|
|
||
| private val FORBIDDEN_METRIC_VALUE_PATTERNS = listOf( | ||
| Regex( | ||
| """\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?""", | ||
| RegexOption.IGNORE_CASE, | ||
| ), | ||
| ) | ||
|
|
||
| /** | ||
| * `true` when a metric should be dropped: its name or any attribute name | ||
| * is on the forbidden list, or any attribute value matches a forbidden | ||
| * pattern (defensive gate). | ||
| */ | ||
| fun isForbiddenMetric(name: String, attributes: Map<String, String>): Boolean { | ||
| if (name in FORBIDDEN_METRIC_TAG_NAMES) return true | ||
| for ((tagName, tagValue) in attributes) { | ||
| if (tagName in FORBIDDEN_METRIC_TAG_NAMES) return true | ||
| if (FORBIDDEN_METRIC_VALUE_PATTERNS.any { it.containsMatchIn(tagValue) }) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| } |
129 changes: 129 additions & 0 deletions
129
android/src/main/java/com/comapeo/core/SentryScopeTier.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| package com.comapeo.core | ||
|
|
||
| import io.sentry.EventProcessor | ||
| import io.sentry.Hint | ||
| import io.sentry.SentryBaseEvent | ||
| import io.sentry.SentryEvent | ||
| import io.sentry.SentryLevel | ||
| import io.sentry.protocol.App | ||
| import io.sentry.protocol.Device | ||
| import io.sentry.protocol.OperatingSystem | ||
| import io.sentry.protocol.SentryTransaction | ||
|
|
||
| /** | ||
| * Trims the fields sentry-android attaches to every event down to what each | ||
| * privacy tier promises (docs/sentry-integration-plan.md §9b.3 / §9b.4). | ||
| * | ||
| * The diagnostic (default) tier keeps coarse hardware identity plus OS | ||
| * name/version and app id/version/build; the fingerprint-friendly extras | ||
| * (kernel version, `Build.DISPLAY`, app name, locale + timezone, screen | ||
| * metrics) only ship when the user opts into `applicationUsageData`. | ||
| * | ||
| * Contexts are rebuilt from an allowlist rather than nulling a denylist, so a | ||
| * field added by a future SDK version is dropped by default instead of shipped | ||
| * by accident. | ||
| * | ||
| * Error and fatal events are exempt: full device context is most valuable | ||
| * exactly when something crashed, so they keep the SDK's complete device/os/app | ||
| * scope. Only `culture` (locale + timezone), a fingerprint surface with no | ||
| * debugging value, is still dropped from them at the diagnostic tier. | ||
| */ | ||
| internal class TierScopeEventProcessor( | ||
| private val applicationUsageData: Boolean, | ||
| ) : EventProcessor { | ||
|
|
||
| override fun process(event: SentryEvent, hint: Hint): SentryEvent { | ||
| if (isError(event)) { | ||
| if (!applicationUsageData) event.contexts.remove("culture") | ||
| } else { | ||
| trimContexts(event) | ||
| } | ||
| return event | ||
| } | ||
|
|
||
| override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction { | ||
| trimContexts(transaction) | ||
| if (!applicationUsageData && transaction.transaction == "comapeo.boot") { | ||
| slimBootTransaction(transaction) | ||
| } | ||
| return transaction | ||
| } | ||
|
|
||
| /** Error/fatal captures keep the full native scope — see the class doc. */ | ||
| private fun isError(event: SentryEvent): Boolean = | ||
| event.level == SentryLevel.ERROR || event.level == SentryLevel.FATAL | ||
|
|
||
| private fun trimContexts(event: SentryBaseEvent) { | ||
| event.contexts.device?.let { event.contexts.setDevice(allowedDevice(it)) } | ||
| event.contexts.operatingSystem?.let { | ||
| event.contexts.setOperatingSystem(allowedOs(it)) | ||
| } | ||
| event.contexts.app?.let { event.contexts.setApp(allowedApp(it)) } | ||
| if (!applicationUsageData) { | ||
| // Locale + timezone are high-entropy fingerprint surfaces. | ||
| event.contexts.remove("culture") | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * §9b.4: boot transactions stay always-on but carry only phase timings at | ||
| * the diagnostic tier — `boot.kind` reveals foreground/background state | ||
| * and span data (e.g. rootkey `generated`) is install-lifecycle shape. | ||
| */ | ||
| private fun slimBootTransaction(transaction: SentryTransaction) { | ||
| transaction.removeTag(SentryTags.BOOT_KIND) | ||
| transaction.spans.forEach { it.setData(null) } | ||
| } | ||
|
|
||
| private fun allowedDevice(device: Device): Device = Device().apply { | ||
| manufacturer = device.manufacturer | ||
| brand = device.brand | ||
| model = device.model | ||
| modelId = device.modelId | ||
| family = device.family | ||
| archs = device.archs | ||
| isSimulator = device.isSimulator | ||
| processorCount = device.processorCount | ||
| memorySize = device.memorySize | ||
| storageSize = device.storageSize?.let { bucketStorageSize(it) } | ||
| if (applicationUsageData) { | ||
| screenWidthPixels = device.screenWidthPixels | ||
| screenHeightPixels = device.screenHeightPixels | ||
| screenDensity = device.screenDensity | ||
| screenDpi = device.screenDpi | ||
| timezone = device.timezone | ||
| } | ||
| } | ||
|
|
||
| private fun allowedOs(os: OperatingSystem): OperatingSystem = | ||
| OperatingSystem().apply { | ||
| name = os.name | ||
| version = os.version | ||
| if (applicationUsageData) { | ||
| kernelVersion = os.kernelVersion | ||
| build = os.build | ||
| } | ||
| } | ||
|
|
||
| private fun allowedApp(app: App): App = App().apply { | ||
| appIdentifier = app.appIdentifier | ||
| appVersion = app.appVersion | ||
| appBuild = app.appBuild | ||
| if (applicationUsageData) { | ||
| appName = app.appName | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val GB = 1L shl 30 | ||
|
|
||
| /** Round up to a standard marketed size (8/16/32/…/1024 GB) so exact | ||
| * formatted-capacity bytes can't fingerprint a device. Starts at 8 GB | ||
| * because devices with 8/16 GB of storage are still in the field. */ | ||
| internal fun bucketStorageSize(bytes: Long): Long { | ||
|
gmaclennan marked this conversation as resolved.
|
||
| var bucket = 8 * GB | ||
| while (bucket < bytes && bucket < 1024 * GB) bucket = bucket shl 1 | ||
| return bucket | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package com.comapeo.core | ||
|
|
||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
|
|
||
| /** Mirror of the `isForbiddenMetric` cases in `src/__tests__/sentry-scrub.test.js` | ||
| * and `backend/lib/before-send.test.mjs` — the list is hand-mirrored across | ||
| * the four layers, so the assertions are too. */ | ||
| class SentryMetricScrubTest { | ||
|
|
||
| @Test | ||
| fun forbiddenMetricNameIsRejected() { | ||
| assertTrue(SentryMetricScrub.isForbiddenMetric("device.model", emptyMap())) | ||
| } | ||
|
|
||
| @Test | ||
| fun forbiddenAttributeNameIsRejected() { | ||
| assertTrue( | ||
| SentryMetricScrub.isForbiddenMetric( | ||
| "comapeo.app.exit", | ||
| mapOf("project_id" to "abc123"), | ||
| ), | ||
| ) | ||
| assertTrue( | ||
| SentryMetricScrub.isForbiddenMetric( | ||
| "comapeo.app.exit", | ||
| mapOf("locale" to "es_PE"), | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun coordinateShapedAttributeValueIsRejected() { | ||
| assertTrue( | ||
| SentryMetricScrub.isForbiddenMetric( | ||
| "comapeo.app.exit", | ||
| mapOf("note" to "lat=-12.05, lng=-77.03"), | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun ordinaryExitMetricAttributesPass() { | ||
| assertFalse( | ||
| SentryMetricScrub.isForbiddenMetric( | ||
| ExitReasonsCollector.METRIC_NAME, | ||
| mapOf( | ||
| SentryTags.EXIT_REASON to "anr", | ||
| SentryTags.EXIT_PROCESS_STATE to "foreground", | ||
| SentryTags.EXIT_SEVERITY to "error", | ||
| SentryTags.EXIT_INTENTIONAL to "false", | ||
| SentryTags.OEM_KILLER_SUSPECTED to "false", | ||
| SentryTags.UPTIME_BUCKET to "1h-6h", | ||
| ), | ||
| ), | ||
| ) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.