privacy: fix a real leak - global IPv6 addresses were uploaded verbatim
Setting out to build the machine-readable schema, the first step was checking whether the anonymizer covers the fields the schema declares sensitive. It did not, and five identifying values were going out at the `balanced` level: networks[].link.addresses[].addr the device's own global IPv6 address networks[].link.routes[].gateway the ISP allocation networks[].link.dns.servers[] the configured resolver private_dns_hostname an internal hostname search_domains[] the internal domain The settings screen describes that level as pseudonymizing addresses. Root cause: classification keyed on field names, and the schema's actual names were never added to the table. Every existing test passed, because each checked a field somebody had remembered to write a case for - an unfalsifiable design for a privacy control. So beyond adding the names, classification now falls back to the *value* when the name is unknown: anything shaped like an IPv4/IPv6 address or a MAC is treated as one. Hostnames deliberately are not inferred by shape, since train.udp_updown is indistinguishable from a domain and mangling a test type would corrupt the document to protect nothing. LeakTest is the guard, and is written to fail for fields nobody thought of: it plants identifying values wherever one can occur and asserts none survive. It also pins that RFC1918 addresses stay readable, so it cannot pass by over-redacting. Route prefixes and :: needed care - 0.0.0.0/0 must stay itself or a routing table becomes unreadable for no privacy gain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e7afc2210f
commit
172afb421d
@@ -848,3 +848,48 @@ itself to table rows, so the prose can keep explaining which codes were retired
|
||||
|
||||
Six tests: uniqueness, declared-vs-listed, prefix↔category agreement, naming convention, a
|
||||
word-order-anagram check (the shape the duplication actually took), and the document agreement.
|
||||
|
||||
### A real privacy leak, found by starting on the machine-readable schema (2026-08-01)
|
||||
The intent was `measurement.schema.json` (§8's promised companion). The first step — checking
|
||||
whether the anonymizer actually covers the fields the schema declares as sensitive — found that it
|
||||
did not, so that became the work.
|
||||
|
||||
**At the `balanced` level, five identifying values were being uploaded verbatim:**
|
||||
|
||||
| value | field | why it matters |
|
||||
|---|---|---|
|
||||
| `2001:…::150` | `networks[].link.addresses[].addr` | the device's own global IPv6 address — a strong, geolocatable device identifier |
|
||||
| `2a02:…::1` | `networks[].link.routes[].gateway` | identifies the ISP allocation |
|
||||
| `203.0.113.77` | `networks[].link.dns.servers[]` | the configured resolver |
|
||||
| `nas.example.lan` | `private_dns_hostname` | an internal hostname |
|
||||
| `example.lan` | `search_domains[]` | the internal domain |
|
||||
|
||||
The settings screen describes that level as pseudonymizing addresses. It was not.
|
||||
|
||||
**Root cause:** classification keyed on field *names*, and the schema's actual names (`addr`,
|
||||
`gateway`, `dst`, `servers`, `search_domains`, `private_dns_hostname`) had never been added to the
|
||||
table. Not a subtle bug — just an unfalsifiable design. The existing tests all passed, because each
|
||||
one checked a field somebody had remembered to write a case for.
|
||||
|
||||
**Two fixes, one of them structural:**
|
||||
1. The missing names were added.
|
||||
2. More importantly, a **shape-based backstop**: when a field name is unrecognised, the *value* is
|
||||
inspected, and anything shaped like an IPv4/IPv6 address or a MAC is treated as one. A name
|
||||
table can only protect fields someone thought of, which is precisely the wrong property for a
|
||||
privacy control. Hostnames are deliberately *not* inferred by shape — `train.udp_updown` is
|
||||
indistinguishable from a domain, and mangling a test type would corrupt the document to protect
|
||||
nothing.
|
||||
|
||||
`LeakTest` is the new guard and is written to fail for fields nobody has considered: it plants
|
||||
identifying values wherever one can actually occur and asserts none survive, rather than checking
|
||||
a list of known cases. It also pins that RFC1918 addresses still come through readable, so the
|
||||
test cannot pass by over-redacting everything.
|
||||
|
||||
Route prefixes and the unspecified address needed care in the transform: `0.0.0.0/0` and `::/0`
|
||||
must stay themselves, or a routing table becomes unreadable for no privacy gain.
|
||||
|
||||
**Still outstanding:** `measurement.schema.json` itself. Worth noting what this episode implies for
|
||||
it — much of a document's payload lives in `evidence`/`metrics`/`params`, which are per-test-type
|
||||
`JsonObject` by design and therefore *outside* any schema. A schema-driven anonymizer would have
|
||||
less coverage there than the name-plus-shape one now does, so the schema should be built for
|
||||
validation and external tooling, not as a replacement for the classifier.
|
||||
|
||||
@@ -108,8 +108,14 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
||||
is JsonObject -> walkObject(v, path)
|
||||
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
|
||||
is JsonPrimitive ->
|
||||
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
|
||||
else v
|
||||
if (v.isString) {
|
||||
// Name first (it is precise), then shape (it is exhaustive). A field nobody
|
||||
// classified must not be a field that leaks.
|
||||
val type = Classification.typeOf(key, path) ?: Classification.inferFromValue(v.content)
|
||||
JsonPrimitive(transform(type, v.content))
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||
@@ -147,6 +153,11 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
||||
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
|
||||
*/
|
||||
private fun ip4(value: String): String {
|
||||
// A route destination carries a prefix length; pseudonymize the address and put it back,
|
||||
// or "0.0.0.0/0" turns into nonsense and the routing table becomes unreadable.
|
||||
value.substringAfter('/', "").takeIf { it.isNotEmpty() && value.contains('/') }?.let { len ->
|
||||
return ip4(value.substringBefore('/')) + "/" + len
|
||||
}
|
||||
val o = value.split(".")
|
||||
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
|
||||
val n = o.map { it.toInt() }
|
||||
@@ -167,7 +178,15 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
||||
* is a device fingerprint, especially with EUI-64.
|
||||
*/
|
||||
private fun ip6(value: String): String {
|
||||
// Dotted quads reach here through the family-agnostic field names (addr, gateway, dst);
|
||||
// hand them to the IPv4 path rather than mangling them as if they were v6.
|
||||
if (value.count { it == ':' } < 2) return ip4(value)
|
||||
if (value.contains('/')) {
|
||||
return ip6(value.substringBefore('/')) + "/" + value.substringAfter('/')
|
||||
}
|
||||
val v = value.lowercase(Locale.ROOT)
|
||||
// The unspecified address and the default route are not identities; mangling them would
|
||||
// make a routing table unreadable for no privacy gain.
|
||||
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
|
||||
val groups = v.substringBefore('%').split(":")
|
||||
if (groups.size < 3) return v
|
||||
|
||||
@@ -32,6 +32,16 @@ object Classification {
|
||||
"link_local", "ra_source", "prefix",
|
||||
).forEach { put(it, LogicalType.IP6) }
|
||||
|
||||
// Family-agnostic address fields — the names the models actually use (Address.addr,
|
||||
// Route.gateway, Route.dst, DnsConfig.servers). Their absence here was a real leak: the
|
||||
// device's own global IPv6 address went out verbatim at the level whose description
|
||||
// promises addresses are pseudonymized. Typed IP6 because the transform detects the
|
||||
// family from the value, falling through to the IPv4 path for a dotted quad.
|
||||
listOf(
|
||||
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
||||
"next_hop", "via", "public_ip", "observed_ip",
|
||||
).forEach { put(it, LogicalType.IP6) }
|
||||
|
||||
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
|
||||
.forEach { put(it, LogicalType.MAC) }
|
||||
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
|
||||
@@ -40,6 +50,8 @@ object Classification {
|
||||
listOf(
|
||||
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
|
||||
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
|
||||
// Plural and prefixed variants the models actually use.
|
||||
"search_domains", "private_dns_hostname", "domains", "hostnames",
|
||||
).forEach { put(it, LogicalType.FQDN) }
|
||||
|
||||
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
|
||||
@@ -78,6 +90,49 @@ object Classification {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort classification from the *value*, when the field name is unrecognised.
|
||||
*
|
||||
* A name table can only protect fields somebody remembered to add, which is the wrong
|
||||
* property for a privacy control: the dangerous field is the one nobody thought of. This
|
||||
* exists because that failed once already — `addresses[].addr` holds the device's own global
|
||||
* IPv6 address, the table had never heard of the name, and it went out verbatim.
|
||||
*
|
||||
* Only addresses and MACs are inferred, because only those have shapes that cannot be
|
||||
* mistaken for something else. Hostnames deliberately are not: `train.udp_updown` is
|
||||
* indistinguishable from a domain by shape, and mangling a test type would corrupt the
|
||||
* document to protect nothing.
|
||||
*/
|
||||
fun inferFromValue(value: String): LogicalType? {
|
||||
val v = value.trim()
|
||||
if (v.isEmpty() || v.length > 64) return null
|
||||
if (looksLikeMac(v)) return LogicalType.MAC
|
||||
if (looksLikeIp6(v)) return LogicalType.IP6
|
||||
if (looksLikeIp4(v)) return LogicalType.IP4
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
|
||||
private fun looksLikeMac(v: String): Boolean {
|
||||
val parts = v.split(':', '-')
|
||||
return parts.size == 6 && parts.all { p -> p.length == 2 && p.all(::isHex) }
|
||||
}
|
||||
|
||||
private fun looksLikeIp4(v: String): Boolean {
|
||||
val parts = v.substringBefore('/').split('.')
|
||||
return parts.size == 4 && parts.all { p ->
|
||||
p.isNotEmpty() && p.length <= 3 && p.all(Char::isDigit) && p.toInt() <= 255
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeIp6(v: String): Boolean {
|
||||
val core = v.substringBefore('/').substringBefore('%')
|
||||
// Two colons minimum, so a time or a MAC fragment does not qualify, and nothing but the
|
||||
// characters an address may contain.
|
||||
return core.count { it == ':' } >= 2 && core.all { it == ':' || isHex(it) }
|
||||
}
|
||||
|
||||
fun dropAtBalanced(path: List<String>): Boolean {
|
||||
if (path.isNotEmpty() && path.last() in droppedKeys) return true
|
||||
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app.echo_lot.privacy
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The blunt instrument: build a document with identifying values in every place one can actually
|
||||
* occur, anonymize it, and assert none of them survive.
|
||||
*
|
||||
* [AnonymizerTest] checks that the fields the classification table knows about are handled
|
||||
* correctly. This checks the other half — the fields it does *not* know about. A per-field test
|
||||
* can only fail for a field someone remembered to write a case for, which is exactly the wrong
|
||||
* property for a privacy check: the dangerous field is the one nobody thought of.
|
||||
*
|
||||
* Concretely, this is written the way it is because the schema's own field names disagree with
|
||||
* the classifier's. `Address.addr` carries an IP and is documented as such in
|
||||
* measurement-schema.md §8, but the classifier keys on names like `ip4` and `gateway_ip4` and had
|
||||
* never heard of `addr`.
|
||||
*/
|
||||
class LeakTest {
|
||||
|
||||
private val json = Json { prettyPrint = false }
|
||||
private val salt = Salt.perRun(ByteArray(32) { 3 })
|
||||
|
||||
/**
|
||||
* Every string here is something that identifies a person, a household or a device, placed
|
||||
* where the real models actually put it (`core-measurement`'s Network/Link/Address/DnsConfig).
|
||||
*/
|
||||
private val secrets = listOf(
|
||||
"Rambossek WLAN", // ssid
|
||||
"78:9a:18:aa:bb:cc", // bssid
|
||||
"aa:bb:cc:dd:ee:11", // gateway mac
|
||||
"2001:1ad0:c4fe:6767::150", // global v6 address on the interface
|
||||
"2a02:1748:dead:beef::1", // v6 default gateway
|
||||
"203.0.113.77", // public v4
|
||||
"nas.rambossek.lan", // private-dns hostname
|
||||
"rambossek.lan", // search domain
|
||||
"Anna's Chromecast", // neighbour name
|
||||
"kitchen table", // free-text note
|
||||
)
|
||||
|
||||
private fun document(): String = """
|
||||
{
|
||||
"schema": "echolot/measurement",
|
||||
"run": {
|
||||
"id": "run-1", "trigger": "manual", "notes": "${secrets[9]}",
|
||||
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
|
||||
},
|
||||
"networks": [{
|
||||
"id": "net-1", "transport": "wifi",
|
||||
"link": {
|
||||
"mtu": 1500,
|
||||
"addresses": [
|
||||
{"addr": "${secrets[3]}", "prefix_len": 64, "scope": "global"},
|
||||
{"addr": "192.168.1.44", "prefix_len": 24, "scope": "global"}
|
||||
],
|
||||
"routes": [
|
||||
{"dst": "::/0", "gateway": "${secrets[4]}", "iface": "wlan0"},
|
||||
{"dst": "0.0.0.0/0", "gateway": "192.168.1.1", "iface": "wlan0"}
|
||||
],
|
||||
"dns": {
|
||||
"servers": ["${secrets[5]}", "192.168.1.1"],
|
||||
"private_dns_hostname": "${secrets[6]}",
|
||||
"search_domains": ["${secrets[7]}"]
|
||||
}
|
||||
},
|
||||
"wifi": {"ssid": "${secrets[0]}", "bssid": "${secrets[1]}"},
|
||||
"neighbors": [{"name": "${secrets[8]}", "mac": "${secrets[2]}"}]
|
||||
}],
|
||||
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
|
||||
"metrics": {"rtt_ms_avg": 12.4}}],
|
||||
"findings": [],
|
||||
"summary": {"verdict": "ok"}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun anonymized(level: PrivacyLevel): String =
|
||||
json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
Anonymizer(level, salt).anonymize(json.parseToJsonElement(document()).jsonObject),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun nothingIdentifyingSurvivesBalanced() {
|
||||
val out = anonymized(PrivacyLevel.BALANCED)
|
||||
val leaked = secrets.filter { out.contains(it) }
|
||||
assertTrue(
|
||||
leaked.isEmpty(),
|
||||
"these identifying values were uploaded verbatim at BALANCED: $leaked\n\n$out",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nothingIdentifyingSurvivesStrict() {
|
||||
val out = anonymized(PrivacyLevel.STRICT)
|
||||
val leaked = secrets.filter { out.contains(it) }
|
||||
assertTrue(leaked.isEmpty(), "leaked at STRICT: $leaked\n\n$out")
|
||||
}
|
||||
|
||||
// Private addresses are kept on purpose — they describe the topology and not the person — so
|
||||
// this pins that the leak test above is not passing by accident of over-redaction.
|
||||
@Test
|
||||
fun privateAddressesAreStillReadable() {
|
||||
val out = anonymized(PrivacyLevel.BALANCED)
|
||||
assertTrue(out.contains("192.168.1.1"), "RFC1918 gateway should survive: $out")
|
||||
assertTrue(out.contains("192.168.1.44"), "RFC1918 interface address should survive: $out")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user