Skip to content

Repository files navigation

TrackerControl for iOS

TrackerControl for iOS is a web service and analyser pipeline for finding trackers in iOS apps. It is inspired by TrackerControl for Android and related research on privacy analysis for mobile apps.

The project has two parts:

  • A Node.js/Express website where people can search App Store apps and view tracker reports.
  • An analyser worker that downloads free App Store apps, analyses them on an actual iPhone, and uploads the results back to the website.

The website also includes jurisdiction analysis, showing which companies and countries control detected tracking infrastructure.

Features

  • Search free iOS apps from the App Store.
  • Queue apps for analysis by popularity and staleness.
  • Detect embedded tracker signatures and declared tracking domains.
  • Store current and historical analysis results.
  • Show tracker, permission, and jurisdiction summaries.
  • Reverse lookup: which apps contain a given tracker or a given company's trackers.
  • An about page explaining how apps are analysed, and what a report does and does not tell you.
  • Sitemap, robots.txt, canonical links, and Open Graph/Twitter card metadata.
  • Run the analyser from macOS or a Raspberry Pi host.

Only free App Store apps are queued for analysis. The queue prioritises apps with more stored App Store reviews, then rechecks stale analyses over time.

Repository Layout

analyser/      App download, install, scan, and upload scripts
lib/           Shared website helpers, including jurisdiction analysis
migrations/    SQL migrations
models/        PostgreSQL access layer
routes/        Express routes and analyser API endpoints
scripts/       Maintenance scripts
views/         Pug templates
public/        Browser assets
static/        Static image assets

Public Pages

Path Purpose
/ Search and headline statistics
/analysis/:appId Per-app tracker, permission, and jurisdiction report
/statistics Aggregate jurisdiction statistics
/trackers, /companies Directories of every tracker and company seen in an analysed app
/tracker/:slug, /company/:slug Reverse lookup: the apps a tracker or company was found in
/about How apps are analysed, what a report means, jurisdiction labels, project background and contact
/sitemap.xml, /robots.txt Crawler metadata

The reverse lookup pages are served from an inverted index built by lib/reverseIndex.js and cached under CACHE_DIR alongside the aggregate site data. It is rebuilt whenever the set of stored analyses changes, so no extra work happens per request.

CACHE_DIR is a persistent volume in production, so cache entries outlive the code that wrote them. An entry is only rebuilt when the set of stored analyses changes, which cannot detect a change in how the cached data is derived. After editing buildSiteData or buildReverseIndex, bump SCHEMA_VERSION in lib/cache.js so the deploy discards entries built by the previous logic — otherwise the old figures are served until the next analysis lands.

Requirements

Website:

  • Node.js
  • npm
  • PostgreSQL
  • DATABASE_URL pointing at an empty PostgreSQL database (the schema is created by running node scripts/migrate.js)

Analyser:

  • An iPhone reachable over SSH
  • trackerscan installed on the iPhone
  • Matching UPLOAD_PASSWORD on the website and analyser

Website Setup

Install dependencies:

npm install

Create a root .env file:

DATABASE_URL=postgres://user:password@host:5432/database
UPLOAD_PASSWORD=change-me
CURRENT_ANALYSIS_VERSION=4
BODY_LIMIT=25mb
PUBLIC_FORM_BODY_LIMIT=100kb
APP_STORE_CACHE_RETENTION_DAYS=90
PORT=3000

BODY_LIMIT applies to authenticated analyser JSON and text uploads. PUBLIC_FORM_BODY_LIMIT is the smaller limit for the public analysis request form. APP_STORE_CACHE_RETENTION_DAYS controls how long cached App Store metadata is kept.

Set SITE_URL in production to the public origin, for example SITE_URL=https://ios.trackercontrol.org. Canonical links, Open Graph URLs, robots.txt, and sitemap.xml use it. Without it, those URLs are derived from the request in development, which yields http:// links when TLS is terminated by a proxy. It is required in production, so an untrusted Host header cannot become a public canonical URL — the server refuses to start without it rather than answering 500 on every route.

Rate limits are applied per IP over a five-minute window, and published pages are budgeted separately from the App Store entry points because sitemap.xml points crawlers at every app, tracker and company URL. RATE_LIMIT_BROWSE_MAX (default 300) covers GET/HEAD of the published pages, which are served from the cached site data. RATE_LIMIT_FORM_MAX (default 20) covers everything else, including /search and /request/:appId — these are GETs so that Cloudflare can challenge them, but each one reaches the App Store, so they are budgeted as the form submissions they are rather than as page views. Authenticated analyser traffic is exempt from both. robots.txt disallows both paths as well, so a crawler neither spends App Store calls nor collects challenge interstitials.

Bot protection

Everything that costs an App Store call is protected by Cloudflare WAF rules rather than by a widget in the page. The app itself carries no challenge code, so nothing can shift the layout and a visitor verifies at most once per Challenge Passage window (30 minutes by default).

Both protected steps are GETs, because a Cloudflare challenge renders an interstitial and replays the original request — which it cannot do for a POST body:

  • Search is GET /search?search=….
  • Requesting an analysis starts at GET /request/:appId, the confirmation page for an app that is not in the database yet. It lives on its own path so a rule can cover it without challenging every published report under /analysis/:appId. Passing it clears the visitor for the POST /analysis/:appId that the page submits.

Because the protection lives at the edge, it only holds while the origin is reachable through Cloudflare alone. The deployment relies on that: Railway serves the site only via the proxied domain, and the analyser reaches it the same way.

If a direct origin URL is ever exposed, set CLOUDFLARE_ORIGIN_SECRET and have a Cloudflare Transform Rule add a matching X-Origin-Verify header to every request for the zone — lib/originGate.js then rejects anything without it, exempting the /healthz probes Railway sends to the container. Unset, the check is inert.

Cloudflare dashboard setup — all on the free plan, on the zone trackercontrol.org. The account-level WAF page is an Enterprise add-on and is not what you want:

  1. Security → WAF → Custom rules, two rules with action Managed Challenge:
    • (http.request.uri.path eq "/search")
    • (starts_with(http.request.uri.path, "/request/")) Optionally a third as a backstop:
    • (http.request.method eq "POST" and starts_with(http.request.uri.path, "/analysis/"))
  2. Security → Settings → Challenge Passage sets how long one solved challenge lasts.

Run migrations:

npm run migrate

Start the website:

npm run watch

For production:

npm run start

Open http://localhost:3000 if PORT=3000 is set.

Analyser Setup

Copy the example config:

cp analyser/.env.example analyser/.env

Set at least:

SERVER=https://your-server.example
UPLOAD_PASSWORD=change-me
ANALYSIS_VERSION=4
ANALYSIS_MODE=trackerscan
TRACKERSCAN_CMD="ssh iphone trackerscan"
TRACKERSCAN_SIGNATURES=/var/mobile/ios_signatures_v2.json
TRACKERSCAN_SIGNATURE_SET=ios-v2

Log in to ipatool once on the analyser host:

ipatool auth login

Run the queue processor:

bash analyser/processQueue.sh

To analyse one app immediately:

ONLY_APP_ID=com.spotify.client bash analyser/processQueue.sh

The default analysis path uses trackerscan and uploads analysis version 4. The legacy Frida flow is still available with:

ANALYSIS_MODE=frida bash analyser/processQueue.sh

The analyser has conservative download and retry limits by default. Tune these in analyser/.env only if the host, network, and storage can handle the extra load.

Raspberry Pi Analyser

The analyser can run on Linux/arm64. A Raspberry Pi needs node, python3, unzip, zip, curl, openssh-client, libimobiledevice tools, ideviceinstaller, and a Linux arm64 ipatool.

Use the installer:

sudo bash scripts/setup-raspi-analyser.sh

See raspberry-pi-analyser.md for the full setup, including systemd, SSH aliases, ipatool, and RAM-backed IPA storage.

Queue And Operations

The website exposes analyser endpoints:

  • GET /queue returns the next app to process in the response body and its one-use assignment token in the X-Analysis-Claim-Token response header.
  • GET /ping marks the analyser online.
  • POST /uploadAnalysis stores successful analysis results when sent with the assignment's X-Analysis-Claim-Token header.
  • POST /reportAnalysisFailure stores failed analysis results when sent with the assignment's X-Analysis-Claim-Token header.

Analyzer requests authenticate with Authorization: Bearer $UPLOAD_PASSWORD. Authentication and assignment tokens serve different purposes: the bearer token authorises the analyser, while the assignment token prevents an expired worker from overwriting a newer result.

Claim-token rollout

The claim-token migration deliberately requeues every in-flight assignment that predates tokens. Use a coordinated deployment:

  1. Stop all analyser queue processors.
  2. Deploy the website and run npm run migrate (or start it with npm run start).
  3. Deploy the matching analyser/processQueue.sh to every analyser host.
  4. Restart the analysers.

The /queue response body remains a plain bundle ID, but older analyser scripts do not return the required claim header. Their completion requests will be rejected and must not remain running after the migration. Any interrupted work is safely available to the updated analysers because the migration requeues it.

Health checks:

  • GET /healthz returns 200 when the website can reach PostgreSQL.
  • GET /healthz/analyser returns 200 when the analyser has pinged in the last hour.

Useful maintenance commands:

npm test
npm run queue-status
npm run priority-report -- --limit=20
npm run backup-db
npm run migrate

Reset one app for a fresh analysis:

npm run reset-app -- --appid=com.google.ios.youtube --apply

Without --apply, the reset command runs as a dry run.

Analysis Versions

Set CURRENT_ANALYSIS_VERSION=4 on the website when version 4 results should be treated as current.

The queue will reprocess apps when:

  • They have never been analysed.
  • Their analysis version is stale.
  • Their analysis is older than STALE_ANALYSIS_DAYS.
  • A previous processing marker has expired after PROCESSING_TIMEOUT_MINUTES.

The default stale window is 180 days. The default processing timeout is 120 minutes.

Tracker Metadata

exodusTrackers.json holds the Exodus Privacy tracker catalogue that report pages use to link a detected tracker to its Exodus entry and show its category badges. It is display data only: detection comes from the analyser signatures in analyser/data/, so a stale file costs a link and some badges, never a missed tracker.

Refresh it with:

node scripts/update-exodus-trackers.js            # fetch and rewrite the file
node scripts/update-exodus-trackers.js --check    # validate only, never write

The script keeps only the fields the views read, sorts keys and categories, and rewrites the file only when the content actually changed — Exodus returns categories in an unstable order, so writing the raw response would bury real changes under dozens of spurious ones. The Update Exodus trackers workflow runs it monthly and opens a PR when something changed, so the data is never fetched during a build or at boot where an Exodus outage could take a deployment down with it.

Credits

License

This project is licensed under AGPLv3.

About

TrackerControl iOS: analysis trackers in apps.

Topics

Resources

Stars

17 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages