← 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.`);