Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class ComapeoCoreService : Service() {
// part deferred to ensureBackendInitialized() to keep off the FGS deadline.
effectiveSentryConfig?.let { cfg ->
sentryUserId = prefs.deriveSentryUserId(applicationUsageData)
SentryFgsBridge.init(applicationContext, cfg, sentryUserId)
SentryFgsBridge.init(applicationContext, cfg, sentryUserId, applicationUsageData)
}

logCrumb(SentryCategories.FGS, "ComapeoCoreService.onCreate")
Expand Down
16 changes: 14 additions & 2 deletions android/src/main/java/com/comapeo/core/SentryFgsBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,16 @@ object SentryFgsBridge {

/** Idempotent. Caller must pass a non-null `SentryConfig`; skip the call
* entirely when `loadFromManifest` returns null. `userId` is the derived
* Sentry user.id (monthly or permanent hash — never the root ID). */
* Sentry user.id (monthly or permanent hash — never the root ID).
* `applicationUsageData` selects the scope tier (see TierScopeEventProcessor). */
@JvmStatic
@JvmOverloads
fun init(context: Context, config: SentryConfig, userId: String? = null) {
fun init(
context: Context,
config: SentryConfig,
userId: String? = null,
applicationUsageData: Boolean = false,
) {
if (initialized) return
try {
// Parse once here rather than in the event processor on every capture.
Expand Down Expand Up @@ -96,6 +102,9 @@ object SentryFgsBridge {
// `comapeo.boot` match the main-process value rather than
// splitting the dashboard.
options.addEventProcessor(NormalizeDeviceFamilyProcessor)
// After NormalizeDeviceFamilyProcessor so the trimmed device
// context keeps the normalised family value.
options.addEventProcessor(TierScopeEventProcessor(applicationUsageData))
}

// SentryOptions has no "set context at init" hook; ride a configureScope after init.
Expand Down Expand Up @@ -216,6 +225,9 @@ object SentryFgsBridge {
) {
if (!initialized) return
try {
// Silently drop a metric carrying a forbidden name/attribute — an
// expected, innocuous gate that isn't worth a log line.
if (SentryMetricScrub.isForbiddenMetric(name, attributes)) return
val attrs = attributes.entries
.map { (k, v) -> SentryAttribute.stringAttribute(k, v) }
.toTypedArray()
Expand Down
49 changes: 49 additions & 0 deletions android/src/main/java/com/comapeo/core/SentryMetricScrub.kt
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 android/src/main/java/com/comapeo/core/SentryScopeTier.kt
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(
Comment thread
gmaclennan marked this conversation as resolved.
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 {
Comment thread
gmaclennan marked this conversation as resolved.
var bucket = 8 * GB
while (bucket < bytes && bucket < 1024 * GB) bucket = bucket shl 1
return bucket
}
}
}
45 changes: 44 additions & 1 deletion android/src/test/java/com/comapeo/core/SentryFgsBridgeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,37 @@ class SentryFgsBridgeTest {
assertEquals(1, transport.envelopes.size)
}

// ── countMetric forbidden-tag filter (issue #191) ──────────────

@Test
fun countMetricWithForbiddenAttributeIsDropped() {
val recorded = mutableListOf<String>()
initBridgeViaSentryOptions(onMetric = { recorded.add(it) })
SentryFgsBridge.countMetric(
"comapeo.app.exit",
attributes = mapOf("project_id" to "abc123"),
)
assertTrue(
"metric with a forbidden attribute must not reach the SDK",
recorded.isEmpty(),
)
}

@Test
fun countMetricWithOrdinaryExitAttributesReachesTheSdk() {
val recorded = mutableListOf<String>()
initBridgeViaSentryOptions(onMetric = { recorded.add(it) })
SentryFgsBridge.countMetric(
"comapeo.app.exit",
attributes = mapOf(
SentryTags.EXIT_REASON to "anr",
SentryTags.EXIT_SEVERITY to "error",
SentryTags.EXIT_INTENTIONAL to "false",
),
)
assertEquals(listOf("comapeo.app.exit"), recorded)
}

// ── NormalizeDeviceFamilyProcessor tests ───────────────────────
//
// Pure-function tests against the processor singleton. They catch
Expand Down Expand Up @@ -310,7 +341,10 @@ class SentryFgsBridgeTest {
* Used by every post-init test in place of
* [SentryFgsBridge.init] (which requires an Android Context).
*/
private fun initBridgeViaSentryOptions(tracesSampleRate: Double = 1.0) {
private fun initBridgeViaSentryOptions(
tracesSampleRate: Double = 1.0,
onMetric: ((name: String) -> Unit)? = null,
) {
transport = RecordingTransport()
Sentry.init { options: SentryOptions ->
options.dsn = "https://abc@sentry.io/1"
Expand All @@ -320,6 +354,15 @@ class SentryFgsBridgeTest {
options.tracesSampleRate = tracesSampleRate
options.setTag("proc", "fgs")
options.setTag("layer", "native")
if (onMetric != null) {
// Runs synchronously inside the client's captureMetric, so a
// recorded name proves the bridge forwarded the emission —
// no need to wait out the metrics batch window.
options.metrics.setBeforeSend { metric, _ ->
onMetric(metric.name)
metric
}
}
}
SentryFgsBridge.markInitializedForTests()
}
Expand Down
59 changes: 59 additions & 0 deletions android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt
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",
),
),
)
}
}
Loading