← back to Homesonspec
apps/mobile/scripts/tracker-drift-check.mjs
72 lines
#!/usr/bin/env node
/**
* tracker-drift-check — guards the "Data Not Collected" App Privacy label.
*
* WHY THIS EXISTS (TK-11155, 2026-09-04): the Browse WebView blocks trackers with a
* DENYLIST (lib/tracker-policy.ts, 7 hosts). That makes the label's truth contingent on
* homesonspec.com's tag set: add a pixel whose host is not on the denylist — a new ad
* network, a self-hosted fbevents.js, or a server-side-GTM first-party proxy — and it
* passes silently, with no app rebuild to catch it, quietly making the filed label FALSE.
* On an account already cited under Guideline 5.6 for a declaration that did not match a
* binary, that is the drift we cannot afford. This flags it.
*
* BASELINE-AWARE (house canary doctrine): a canary that fires on the steady state is
* noise and gets ignored. Known-benign third-party hosts (listing-photo CDNs) live in
* tracker-drift-baseline.json and are reported but do NOT fail. Exit 1 fires only on a
* host that is NEW since the baseline — i.e. an actual worsening transition.
*
* READ-ONLY. Exit 0 = no new hosts, 1 = NEW uncovered host. node scripts/tracker-drift-check.mjs
* --accept after verifying a flagged host is a harmless asset/CDN, add it to the baseline
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, writeFileSync } from 'node:fs';
const here = dirname(fileURLToPath(import.meta.url));
const policy = readFileSync(join(here, '..', 'lib', 'tracker-policy.ts'), 'utf8');
const DENYLIST = [...policy.matchAll(/'([a-z0-9.-]+\.[a-z]{2,})'/g)].map(m => m[1]);
const BASELINE_PATH = join(here, 'tracker-drift-baseline.json');
const baseline = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf8'))
: { acceptedHosts: [], note: '' };
const SITE = process.env.HOS_SITE || 'https://homesonspec.com';
const FIRST_PARTY = new URL(SITE).hostname.replace(/^www\./, '');
const onDenylist = h => DENYLIST.some(d => h === d || h.endsWith('.' + d));
const isFirstParty = h => h === FIRST_PARTY || h.endsWith('.' + FIRST_PARTY);
const html = await fetch(SITE, { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' } }).then(r => r.text());
const hosts = [...new Set([...html.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)].map(m => m[1].toLowerCase()))];
// A host is a RISK if it is third-party and NOT already covered by the denylist:
// the blocker would let it through, so it could transmit while we declare no collection.
const uncovered = hosts.filter(h => !isFirstParty(h) && !onDenylist(h));
const covered = hosts.filter(onDenylist);
const accepted = uncovered.filter(h => baseline.acceptedHosts.includes(h));
const novel = uncovered.filter(h => !baseline.acceptedHosts.includes(h));
if (process.argv.includes('--accept')) {
const merged = [...new Set([...baseline.acceptedHosts, ...uncovered])].sort();
writeFileSync(BASELINE_PATH, JSON.stringify({ ...baseline, acceptedHosts: merged }, null, 2) + '\n');
console.log(`baseline updated — ${merged.length} accepted host(s): ${merged.join(', ')}`);
process.exit(0);
}
console.log(`site: ${SITE}`);
console.log(`denylist (${DENYLIST.length}): ${DENYLIST.join(', ')}`);
console.log(`\nBLOCKED trackers present on the page (${covered.length}): ${covered.join(', ') || '(none)'}`);
console.log(`accepted asset hosts, baselined (${accepted.length}): ${accepted.join(', ') || '(none)'}`);
if (novel.length) {
console.log(`\nNEW uncovered third-party host(s) — NOT blocked, NOT baselined (${novel.length}):`);
novel.forEach(h => console.log(` \u26a0\ufe0f ${h}`));
console.log(`\nVERDICT: FAIL — a third-party host appeared that the blocker does not cover.`);
console.log(`If it transmits user/usage data, "Data Not Collected" is no longer accurate:`);
console.log(`add it to TRACKER_HOSTS and REBUILD, or amend the App Privacy label.`);
console.log(`If it is a harmless asset/CDN/font host, verify it then run with --accept.`);
process.exit(1);
}
console.log(`\nVERDICT: PASS — every tracker on the page is blocked, and no new third-party host has appeared.`);