From 907d818bf5103ce31534009c0e09b680df0ed58f Mon Sep 17 00:00:00 2001 From: Perry Dale Date: Sun, 23 Aug 2026 01:44:23 +0000 Subject: [PATCH] Fix the Walk & Test privacy disclosure, and pin it with a test The in-app disclosure list still named www.facebook.com and omitted www.atlassian.com after the destination swap. The README table was updated in that commit; the modal was not. So the app was telling users it contacts a host it no longer touches, and not telling them about one it does. The list is a contract, and the rule that a new probe endpoint gets disclosed in the same commit is enforced by discipline alone, which is how it slipped: prose does not typecheck. Adds PrivacySafetyModal.test.ts, which does. Every WALK_TARGETS host and every FAMILY_ENDPOINTS host must appear in the disclosures, no removed host may still appear, and every row must actually say what its host receives. Both directions matter: a missing host understates what leaves the browser, and a stale one erodes the list's credibility until nobody reads it. Also corrects the request-volume figure, which predated the one-second interval option and understated the busiest case. --- src/components/PrivacySafetyModal.test.ts | 66 +++++++++++++++++++++++ src/components/PrivacySafetyModal.tsx | 15 +++--- 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 src/components/PrivacySafetyModal.test.ts diff --git a/src/components/PrivacySafetyModal.test.ts b/src/components/PrivacySafetyModal.test.ts new file mode 100644 index 0000000..ee5ae71 --- /dev/null +++ b/src/components/PrivacySafetyModal.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { THIRD_PARTY_DISCLOSURES } from './PrivacySafetyModal'; +import { WALK_TARGETS } from '../utils/walkTest'; +import { FAMILY_ENDPOINTS } from '../utils/dualStack'; + +/** + * The disclosure list is a contract with the user, and the rule is that a new + * probe endpoint gets disclosed in the same commit that adds it. + * + * A rule enforced by discipline alone is a rule that eventually slips, and it + * did: swapping Facebook for Atlassian in `WALK_TARGETS` updated the README + * table and left the in-app modal naming a host the browser no longer contacts + * while omitting one it now does. Nothing failed, because prose does not + * typecheck. These tests are the thing that fails instead. + * + * They deliberately check both directions. A missing host understates what + * leaves the browser, which is the serious one. A stale host overstates it, + * which is the kind of error that quietly erodes the list's credibility until + * nobody reads it. + */ + +const allDisclosedText = THIRD_PARTY_DISCLOSURES.map((d) => `${d.host} ${d.receives}`).join('\n'); +const allDisclosedHosts = THIRD_PARTY_DISCLOSURES.map((d) => d.host).join(', '); + +describe('THIRD_PARTY_DISCLOSURES', () => { + it('names every host Walk & Test probes', () => { + for (const target of WALK_TARGETS) { + expect(allDisclosedHosts, `${target.host} is probed but not disclosed`).toContain( + target.host, + ); + } + }); + + it('does not name a Walk & Test host that was removed from the target list', () => { + // Meta domains were dropped because blocklists made them a false alarm. If + // the disclosure still claims the browser contacts them, the list is + // describing an app that no longer exists. + const dropped = ['www.facebook.com', 'www.instagram.com']; + for (const host of dropped) { + expect(allDisclosedHosts, `${host} is disclosed but no longer probed`).not.toContain(host); + } + }); + + it('names every dual-stack probe host', () => { + for (const endpoint of FAMILY_ENDPOINTS) { + expect(allDisclosedHosts, `${endpoint.host} is probed but not disclosed`).toContain( + endpoint.host, + ); + } + }); + + it('gives every row a host and a description of what that host receives', () => { + for (const row of THIRD_PARTY_DISCLOSURES) { + expect(row.host.trim().length).toBeGreaterThan(0); + // A row that names a host without saying what it gets is decoration. + expect(row.receives.trim().length).toBeGreaterThan(20); + } + }); + + it('says what the walk sends, not just who it sends it to', () => { + // The specifics a reader needs to judge the trade: how often, how much, and + // whether they are identifiable while it happens. + expect(allDisclosedText).toContain('Walk & Test'); + expect(allDisclosedText).toMatch(/no cookies|credentials/i); + }); +}); diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx index c5650d1..2b5d0aa 100644 --- a/src/components/PrivacySafetyModal.tsx +++ b/src/components/PrivacySafetyModal.tsx @@ -64,15 +64,16 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [ }, { host: - 'www.google.com, www.youtube.com, www.netflix.com, www.facebook.com, www.amazon.com, ' + - 'outlook.office365.com, teams.microsoft.com, zoom.us, login.salesforce.com, slack.com', + 'www.google.com, www.youtube.com, www.netflix.com, www.amazon.com, ' + + 'outlook.office365.com, teams.microsoft.com, zoom.us, login.salesforce.com, slack.com, ' + + 'www.atlassian.com', receives: 'Your IP, repeatedly, for as long as a Walk & Test run lasts — every one of them is probed ' + - 'once per round, so a ten-minute walk at the default interval is roughly two hundred ' + - 'requests to each. Each request is a HEAD for one small public file and carries no cookies ' + - '(`credentials: \'omit\'`), so these hosts see an address and a TLS handshake rather than a ' + - 'logged-in user. Several of them are advertising businesses; the IP and the timing pattern ' + - 'are still theirs to log.', + 'once per round, so a ten-minute walk is a few hundred requests to each, up to roughly six ' + + 'hundred at the one-second interval. Each request is a HEAD for one small public file and ' + + 'carries no cookies (credentials: omit), so these hosts see an address and a TLS handshake ' + + 'rather than a logged-in user. Several of them are advertising businesses; the IP and the ' + + 'timing pattern are still theirs to log.', }, { host: 'api.github.com, httpbin.org, cloudflare.com, dns.google',