privacy: scrub identifiers inside raw shell output
Running the Shizuku tier for the first time uploaded every MAC address on the local network to the server at the balanced level - fourteen of them, router and all. The probes embed raw command output verbatim (ip neigh, ip route), which is good evidence and also a complete household device inventory, and the anonymizer could not see it: classification is by field name and whole-value shape, and ip_neigh is one long string that is itself neither a MAC nor an address. measurement-schema.md flagged raw dumps as hard to anonymize and proposed dropping them from exports. Scrubbing is better: identifiers inside unclassified strings are replaced in place with the same pseudonyms used elsewhere, so a MAC appearing in both a parsed field and a raw dump still reads as one device, and the dump stays readable - neighbour-table shape, host count, RFC1918 addresses and vendor prefixes all survive. Dropping it would have protected the same data by destroying the reason for collecting it. One pass, not three: sequential passes re-process their own output. Once a MAC became 78:9a:18:xx:yy:zz the IPv6 pattern matched it - six hex groups separated by colons is an address - and destroyed the vendor prefix the MAC rule had just preserved. Ordered alternation resolves each position once, MAC first. RealDocumentTest runs the anonymizer over a captured run when ECHOLOT_REAL_RUN points at one and fails on any surviving MAC; it self-skips otherwise so no one's network lands in the repo. Against the document that leaked: 14 in, 0 out. Also: the Settings preview button did nothing, reading UiState.history which is empty until the History screen has been opened - same root cause as the "0 run(s)" count. It reads the archive now, and says when there is nothing to show. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d04babff51
commit
c19f382640
@@ -965,3 +965,40 @@ one session measures itself rather than inheriting the first one's packets. The
|
|||||||
Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with
|
Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with
|
||||||
`measures_network: false` — correct, since what arrived matched what was offered, so the path was
|
`measures_network: false` — correct, since what arrived matched what was offered, so the path was
|
||||||
never the constraint.
|
never the constraint.
|
||||||
|
|
||||||
|
### Raw shell dumps leaked the whole LAN (2026-08-01)
|
||||||
|
Found by running the Shizuku shell tier for the first time. The tier works — `tiers.shizuku: true`,
|
||||||
|
`exec_path: UserService` (so the UserService binds on the OnePlus, as recorded), `runs_as
|
||||||
|
shell(2000)`, 7/7 commands — and the run promptly uploaded **every MAC address on the local
|
||||||
|
network** to fmr at the `balanced` level: router, phones, whatever else was on the wifi. Fourteen
|
||||||
|
of them.
|
||||||
|
|
||||||
|
The probes embed raw command output verbatim (`ip neigh`, `ip route`, `id`), which is genuinely
|
||||||
|
good evidence and also a complete household device inventory. The anonymizer could not see it:
|
||||||
|
classification is by field name and by whole-value shape, and `ip_neigh` is one long string that is
|
||||||
|
itself neither a MAC nor an address. measurement-schema.md §9 item 2 had flagged raw dumps as "hard
|
||||||
|
to anonymize" and proposed dropping them from exports; nothing enforced either.
|
||||||
|
|
||||||
|
**Scrubbing beats dropping.** Identifiers inside any unclassified string are now replaced in place,
|
||||||
|
using the same pseudonyms as everywhere else — so a MAC that appears both in a parsed field and in
|
||||||
|
a raw dump still reads as one device. The dump stays readable and auditable: you can still see the
|
||||||
|
neighbour table's shape, the host count, RFC1918 addresses and vendor prefixes. Dropping the
|
||||||
|
evidence would have protected the same data while destroying the reason for collecting it.
|
||||||
|
|
||||||
|
Two implementation notes worth keeping:
|
||||||
|
- **One pass, not three.** Sequential passes re-process their own output: once a MAC became
|
||||||
|
`78:9a:18:xx:yy:zz`, the IPv6 pattern matched it — six hex groups separated by colons *is* an
|
||||||
|
address — and destroyed the vendor prefix the MAC rule had just preserved. Ordered alternation
|
||||||
|
resolves each position once, MAC first.
|
||||||
|
- The patterns are conservative on purpose. A missed address gets caught by another rule or not at
|
||||||
|
all; an over-eager one mangles timestamps and version strings, corrupting evidence to protect
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
`RealDocumentTest` runs the anonymizer over a captured run when `ECHOLOT_REAL_RUN` points at one,
|
||||||
|
and fails on any MAC that survives. It self-skips otherwise, so no one's network is committed to the
|
||||||
|
repo. Against the actual leaked document: **14 MACs in, 0 surviving.**
|
||||||
|
|
||||||
|
Also fixed: the Settings *Preview what an upload would send* button did nothing. It read
|
||||||
|
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
||||||
|
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
||||||
|
preview rather than silently ignoring the tap.
|
||||||
|
|||||||
@@ -101,11 +101,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
onApplyRetention = vm::applyRetention,
|
onApplyRetention = vm::applyRetention,
|
||||||
onDeleteAll = vm::deleteAllRuns,
|
onDeleteAll = vm::deleteAllRuns,
|
||||||
onPreviewUpload = {
|
onPreviewUpload = {
|
||||||
// Preview the newest run, since that is the one the user just made
|
// Straight from the archive: the newest run is the one the user just
|
||||||
// and the one they are deciding about.
|
// made and the one they are deciding about. Always shows something,
|
||||||
vm.state.history.firstOrNull()?.let { r ->
|
// even when there is nothing to preview yet.
|
||||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
lifecycleScope.launch { preview = vm.previewNewestRun() }
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onCheckServer = vm::checkServer,
|
onCheckServer = vm::checkServer,
|
||||||
onEnroll = vm::enroll,
|
onEnroll = vm::enroll,
|
||||||
|
|||||||
@@ -194,6 +194,21 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
store.read(id)?.let { store.redactedForUpload(it) }
|
store.read(id)?.let { store.redactedForUpload(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview of the most recent run, read from the archive rather than from [UiState.history].
|
||||||
|
*
|
||||||
|
* The history list is only populated once the History screen has been opened, so a preview
|
||||||
|
* driven from it did nothing at all on a freshly-opened Settings screen — a button that
|
||||||
|
* silently does nothing is worse than one that says why.
|
||||||
|
*/
|
||||||
|
suspend fun previewNewestRun(): String = withContext(Dispatchers.IO) {
|
||||||
|
val newest = store.list().firstOrNull()
|
||||||
|
?: return@withContext "No archived runs yet. Run a measurement first, then this will " +
|
||||||
|
"show exactly what an upload would send."
|
||||||
|
store.read(newest.id)?.let { store.redactedForUpload(it) }
|
||||||
|
?: "That run could not be read back from the archive."
|
||||||
|
}
|
||||||
|
|
||||||
fun archivedBytes(): Long = store.totalBytes()
|
fun archivedBytes(): Long = store.totalBytes()
|
||||||
|
|
||||||
/** Counted from the archive itself, not from [UiState.history], which is empty until the
|
/** Counted from the archive itself, not from [UiState.history], which is empty until the
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ kotlin {
|
|||||||
}
|
}
|
||||||
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
tasks.test { useJUnitPlatform() }
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
// Opt-in: point this at a captured run to check the anonymizer against real data.
|
||||||
|
System.getenv("ECHOLOT_REAL_RUN")?.let { environment("ECHOLOT_REAL_RUN", it) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||||
null -> value
|
// Unclassified strings still get their *embedded* identifiers scrubbed. A whole-value
|
||||||
|
// check cannot see them: raw shell output is one long string that is neither a MAC nor an
|
||||||
|
// address, so it sailed through both the name table and the shape check carrying every
|
||||||
|
// MAC on the user's LAN.
|
||||||
|
null -> scrubEmbedded(value)
|
||||||
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
|
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
|
||||||
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
||||||
LogicalType.IP4 -> ip4(value)
|
LogicalType.IP4 -> ip4(value)
|
||||||
@@ -129,6 +133,38 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
|
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces addresses and MACs found *inside* a longer string.
|
||||||
|
*
|
||||||
|
* Shizuku probes embed raw command output verbatim — `ip neigh`, `ip route`, `dumpsys` — which
|
||||||
|
* is genuinely valuable evidence and also a complete inventory of every device on the user's
|
||||||
|
* network, with hardware addresses. measurement-schema.md §9 flagged these as "hard to
|
||||||
|
* anonymize" and proposed dropping them from exports.
|
||||||
|
*
|
||||||
|
* Scrubbing beats dropping: the output stays readable and auditable — you can still see the
|
||||||
|
* shape of the neighbour table and how many hosts there were — while the identifiers become
|
||||||
|
* the same pseudonyms used everywhere else in the document. So a MAC appearing both in a
|
||||||
|
* parsed field and in a raw dump still reads as one device.
|
||||||
|
*
|
||||||
|
* Only addresses and MACs are touched, for the same reason as [Classification.inferFromValue]:
|
||||||
|
* they are the patterns that cannot be mistaken for something else in free text.
|
||||||
|
*/
|
||||||
|
private fun scrubEmbedded(value: String): String {
|
||||||
|
// Cheap bail-out: the overwhelming majority of strings are short and contain neither.
|
||||||
|
if (value.length < 7 || (!value.contains(':') && !value.contains('.'))) return value
|
||||||
|
// One pass, not three. Sequential passes re-process their own output: after a MAC became
|
||||||
|
// 78:9a:18:xx:yy:zz the IPv6 pattern matched it — six hex groups separated by colons is
|
||||||
|
// exactly an address — and mangled the vendor prefix that the MAC rule had just taken
|
||||||
|
// care to preserve. Ordered alternation resolves each position once, MAC first.
|
||||||
|
return EMBEDDED.replace(value) { m ->
|
||||||
|
when {
|
||||||
|
m.groups[1] != null -> macPreservingOui(m.value)
|
||||||
|
m.groups[2] != null -> ip6(m.value)
|
||||||
|
else -> ip4(m.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- per-type transforms -------------------------------------------------------------
|
// ---- per-type transforms -------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,7 +294,7 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
|
|
||||||
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
||||||
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
||||||
cache.getOrPut("$domain | |||||||