From be3c1535c6d53dae3e4a791a3eedf6e33f5cba38 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 14 Apr 2026 15:06:37 +0100 Subject: [PATCH] Harden telemetry privacy reporting contract --- docs/PRIVACY.md | 1 + .../internal/subsystems/security-privacy.md | 5 ++ frontend-modern/public/docs/PRIVACY.md | 1 + scripts/telemetry_adoption_report.py | 87 ++++++++++++++++++- .../tests/test_telemetry_adoption_report.py | 35 ++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index c7f1abe20e..fbd16d1007 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -63,6 +63,7 @@ Every field is listed below with the reason it exists — nothing else leaves yo #### Server-side handling and retention - Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis. +- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers or exact API-token counts. - Telemetry rows older than **90 days** are purged automatically. - The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 816214038e..c2f3869b2f 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -155,6 +155,11 @@ such as whether a paid license is active or whether any API tokens exist. Exact license tiers and exact API-token counts are not part of the canonical anonymous telemetry contract and may not be reintroduced without updating this trust boundary and the governed privacy disclosure together. +That same rule also applies at the license-server ingest and storage boundary: +server-side telemetry rows may preserve the canonical normalized version +identity plus those same coarse booleans, but they must not retain legacy +exact commercial tier or exact API-token count fields as first-class analytics +dimensions just because older clients once sent them. That same anonymous telemetry contract also treats `install_id` as a rotating pseudonymous identifier, not a lifetime install handle. The runtime may keep a local rotating UUID so startup and heartbeat pings can still represent an diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index c7f1abe20e..fbd16d1007 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -63,6 +63,7 @@ Every field is listed below with the reason it exists — nothing else leaves yo #### Server-side handling and retention - Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis. +- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers or exact API-token counts. - Telemetry rows older than **90 days** are purged automatically. - The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows. diff --git a/scripts/telemetry_adoption_report.py b/scripts/telemetry_adoption_report.py index f4a8c3bb3e..0ccff6649c 100644 --- a/scripts/telemetry_adoption_report.py +++ b/scripts/telemetry_adoption_report.py @@ -113,6 +113,87 @@ def classify_reported_version(raw: str, published_versions: set[str]) -> Classif ) +def parse_optional_bool(value: Any) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + normalized = str(value).strip().lower() + if normalized == "": + return None + if normalized in {"1", "true", "t", "yes", "y"}: + return True + if normalized in {"0", "false", "f", "no", "n"}: + return False + return None + + +def classify_row_version(row: dict[str, Any], published_versions: set[str]) -> ClassifiedVersion: + raw_version = str(row.get("version") or "") + identity = classify_reported_version(raw_version, published_versions) + + stored_raw = str(row.get("version_raw") or "").strip() + stored_channel = str(row.get("version_channel") or "").strip().lower() + stored_build = str(row.get("version_build") or "").strip() + stored_is_development = parse_optional_bool(row.get("version_is_development")) + stored_is_published = parse_optional_bool(row.get("version_is_published_release")) + + if stored_raw: + identity = ClassifiedVersion( + raw_version=stored_raw, + version=identity.version, + channel=identity.channel, + build=identity.build, + is_development=identity.is_development, + is_published_release=identity.is_published_release, + ) + if stored_channel: + identity = ClassifiedVersion( + raw_version=identity.raw_version, + version=identity.version, + channel=stored_channel, + build=identity.build, + is_development=identity.is_development, + is_published_release=identity.is_published_release, + ) + if stored_build: + identity = ClassifiedVersion( + raw_version=identity.raw_version, + version=identity.version, + channel=identity.channel, + build=stored_build, + is_development=identity.is_development, + is_published_release=identity.is_published_release, + ) + if stored_is_development is not None: + identity = ClassifiedVersion( + raw_version=identity.raw_version, + version=identity.version, + channel=identity.channel, + build=identity.build, + is_development=stored_is_development, + is_published_release=identity.is_published_release, + ) + + if published_versions: + is_published_release = identity.version in published_versions + elif stored_is_published is not None: + is_published_release = stored_is_published + else: + is_published_release = identity.is_published_release + + return ClassifiedVersion( + raw_version=identity.raw_version, + version=identity.version, + channel=identity.channel, + build=identity.build, + is_development=identity.is_development, + is_published_release=is_published_release, + ) + + def parse_received_at(raw: str) -> datetime: return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) @@ -163,7 +244,7 @@ def fetch_rows_local(db_path: str, since_days: int) -> dict[str, Any]: dict(row) for row in conn.execute( """ - SELECT install_id, version, platform, received_at, event + SELECT * FROM telemetry_pings WHERE julianday(received_at) >= julianday('now') - ? ORDER BY received_at DESC @@ -193,7 +274,7 @@ db_stats_sql = ( "FROM telemetry_pings" ) rows_sql = ( - "SELECT install_id, version, platform, received_at, event " + "SELECT * " "FROM telemetry_pings " "WHERE julianday(received_at) >= julianday('now') - ? " "ORDER BY received_at DESC" @@ -246,7 +327,7 @@ def summarize_latest_install_windows( if current_time - received_at > limit: continue platform = str(row.get("platform") or "unknown").strip() or "unknown" - identity = classify_reported_version(str(row.get("version") or ""), published_versions) + identity = classify_row_version(row, published_versions) version_split[identity.version] += 1 platform_split[platform] += 1 target = published_split if identity.is_published_release else non_release_split diff --git a/scripts/tests/test_telemetry_adoption_report.py b/scripts/tests/test_telemetry_adoption_report.py index 9cafcb61c4..8b8e1daae5 100644 --- a/scripts/tests/test_telemetry_adoption_report.py +++ b/scripts/tests/test_telemetry_adoption_report.py @@ -34,12 +34,45 @@ class TelemetryAdoptionReportTest(unittest.TestCase): self.assertEqual(identity.channel, "rc") self.assertFalse(identity.is_published_release) + def test_classify_row_version_uses_stored_identity_fields(self) -> None: + identity = report.classify_row_version( + { + "version": "6.0.0-rc.1", + "version_raw": "v6.0.0-rc.1-45-gABCDEF", + "version_channel": "rc", + "version_build": "git.45.gabcdef", + "version_is_development": 0, + "version_is_published_release": 1, + }, + published_versions={"6.0.0-rc.1"}, + ) + self.assertEqual(identity.version, "6.0.0-rc.1") + self.assertEqual(identity.raw_version, "v6.0.0-rc.1-45-gABCDEF") + self.assertEqual(identity.channel, "rc") + self.assertEqual(identity.build, "git.45.gabcdef") + self.assertFalse(identity.is_development) + self.assertTrue(identity.is_published_release) + + def test_classify_row_version_still_requires_real_published_release(self) -> None: + identity = report.classify_row_version( + { + "version": "6.0.0-rc.2", + "version_channel": "rc", + "version_is_published_release": 1, + }, + published_versions={"6.0.0-rc.1"}, + ) + self.assertEqual(identity.version, "6.0.0-rc.2") + self.assertFalse(identity.is_published_release) + def test_summarize_rows_uses_latest_install_state_and_splits_release_validation(self) -> None: now = datetime.now(timezone.utc).replace(microsecond=0) rows = [ { "install_id": "install-a", "version": "v6.0.0-rc.1", + "version_channel": "rc", + "version_is_published_release": 1, "platform": "binary", "received_at": (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S"), "event": "heartbeat", @@ -47,6 +80,8 @@ class TelemetryAdoptionReportTest(unittest.TestCase): { "install_id": "install-b", "version": "v6.0.0-rc.2", + "version_channel": "rc", + "version_is_published_release": 1, "platform": "docker", "received_at": (now - timedelta(hours=5)).strftime("%Y-%m-%d %H:%M:%S"), "event": "heartbeat",