{"slug": "my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it", "title": "My app's landing page says 'Zero Telemetry'. So I wiretapped my own app to prove it.", "summary": "A solo developer of the macOS network-security app RoamSwitch wiretapped his own app to prove its 'Zero Telemetry' claim, using tcpdump and free tools to capture and analyze outbound traffic. The procedure, documented in a blog post, verifies that only four whitelisted destinations are contacted and can be re-run for every release to catch regressions.", "body_md": "*A Japanese version of this is on Zenn.*\n\nI build and sell a macOS network-security app as a solo dev. The landing page says:\n\nZero Telemetry— no outbound traffic, no data collection\n\nWords are cheap, and that's *just a claim*. To a user, this is \"an unknown solo dev's app that rewrites your firewall with root, saying 'trust me, I send nothing.'\" There's no reason to believe it.\n\nAnd this app sees sensitive stuff, constantly: the gateway MAC and fingerprint, listening ports, the Wi-Fi SSID you're on, FileVault / SIP / firewall state, files it scanned, URLs it checked. If any of that leaked, it would betray exactly the people who picked it *for* privacy.\n\nSo I decided to turn the claim into a measurement. tcpdump and free tools only — wiretap my own app. This post is the method and the places I tripped.\n\n\"The MCP server and detection logic are open source — just read them.\" A few holes:\n\n**1. The main app isn't open source.** Only the read-only MCP server and detection logic are public; the privileged helper, `pf`\n\ncontrol, and licensing stay closed (that surface helps attackers). Code alone doesn't cover the whole thing.\n\n**2. No App Sandbox.** To be upfront: RoamSwitch does not run inside App Sandbox — it controls the firewall and sharing daemons, so it can't. The consequence is that **entitlements don't restrict outbound traffic**. `codesign -d --entitlements`\n\nshowing \"no `network-client`\n\nkey\" is *not* proof it can't send. You have to show behaviour.\n\n**3. Regressions.** Even if this build is truly silent, a future version could add an analytics SDK, a crash reporter, a \"check for news\" feature. A one-time check is worthless — this needs to be a **procedure you can re-run every release**.\n\nSo instead of \"read the code,\" I hand people \"run this procedure.\"\n\nThe only traffic allowed out is the four paths documented in the whitepaper (§7):\n\n| # | destination | process | when |\n|---|---|---|---|\n| 1 | `lafine.net/api/v1/license/*` |\nRoamSwitch | license activate / deactivate only |\n| 2 | `lafine.net/updates/appcast.xml` |\nRoamSwitch | Sparkle (launch + every 24 h) |\n| 3 | ClamAV mirrors |\n`freshclam` (not the app) |\nonly with ClamAV installed |\n| 4 |\n`HEAD` to a target URL |\nRoamSwitch | only when the in-app Link Safety sheet is used |\n\nAnything else out of `RoamSwitch`\n\n/ `RoamSwitchHelper`\n\n/ `RoamSwitchMCPServer`\n\nis a **fail**.\n\nI don't own Little Snitch, so:\n\n`brew install wireshark`\n\n) — pull destinations and TLS SNI from the pcapLuLu is a fine Little Snitch substitute, and it's by a well-known name in Mac security — which, for a write-up, only helps.\n\nI'd convinced myself that \"Sparkle's periodic check is every 24 h, so I have to capture for 25 h straight to span it.\" I set up for an overnight run.\n\nThen I looked at `Info.plist`\n\n: `SUScheduledCheckInterval = 86400`\n\n, and `SULastCheckTime`\n\nsits in `defaults`\n\nin the clear. So there's no waiting:\n\n```\nBID=com.tetsuharu.RoamSwitch\ndefaults delete $BID SULastCheckTime\nosascript -e 'quit app \"RoamSwitch\"'; sleep 2; open -a RoamSwitch\n# launch sees \"overdue\" and checks within seconds to a couple of minutes\n```\n\nThe appcast HTTPS GET is byte-for-byte identical whether it comes from this, the \"Check for Updates\" menu item, or a fresh first launch.\n\nThe only reason to run *long* is to catch a wall-clock \"once a day\" telemetry beacon — an unusual design, and there's no analytics SDK in the app anyway (open source, checkable). So a few hours of idle is plenty. Total active work: ~3–4 h, one sitting.\n\ntcpdump tells you \"a SYN went to 140.82.112.x.\" It does **not** tell you whether RoamSwitch, Chrome, Dropbox, or the OS sent it.\n\nOn a daily-driver machine, even idle, there's a flood of non-Apple traffic: Safe Browsing, component updaters, cloud sync, chat keep-alives. Picking RoamSwitch's packets out of that by timestamp and port is genuinely error-prone — you end up waving things away as \"probably Chrome.\"\n\nSo you make the machine **silent** first. A dedicated account or spare Mac is ideal; quieting the same login also works:\n\n`launchctl bootout`\n\nthe third-party agents (reverts on reboot)`softwareupdate --schedule off`\n\n, analytics off, Spotlight suggestions offThen, before launching RoamSwitch, run tcpdump for 10 minutes and check that **every remaining destination is Apple** (`17.0.0.0/8`\n\n, etc.). If a non-Apple destination is still there, something's still running — find it with `lsof -nP -iTCP@<IP>`\n\n, stop it, start over.\n\nOnly once that baseline is clean can you lean on \"any non-Apple traffic after this ≈ RoamSwitch.\"\n\nDon't open this procedure in a browser on the test machine. Chrome especially is a pile of non-Apple traffic (Safe Browsing, a GCM keep-alive). Use a second device, print it, or pre-cache the page and use Safari.\n\nReproduce the situation RoamSwitch is built for: you join a café / public Wi-Fi and the app locks down automatically.\n\nAt max lockdown, RoamSwitch's `pf`\n\nblocks other apps' egress too, so the capture is even quieter and its own traffic stands out. Crucially, **appcast and license still get through** — RoamSwitch puts *its own* traffic in a `pf`\n\nexception. Part of the point of the test is to confirm that exception is minimal and only points where it's supposed to (i.e. it didn't quietly whitelist a telemetry endpoint for itself).\n\n```\n# layer 1: full capture (local / DNS / mDNS filtered out)\nsudo tcpdump -i any -n -w cap-%Y%m%d-%H%M.pcap -G 3600 -W 72 -Z root \\\n  '(ip or ip6) and not net 127.0.0.0/8 and not net 169.254.0.0/16 \\\n   and not net 224.0.0.0/4 and not port 5353 and not port 53 and not port 67 and not port 68'\n\n# layer 2: socket -> process, sampled every 5s, in a root shell\nsudo bash -c 'while :; do ts=$(date -u +%FT%TZ); \\\n  lsof -nP -iTCP -sTCP:ESTABLISHED +c0 2>/dev/null \\\n  | awk -v ts=\"$ts\" \"NR>1{print ts,\\$1,\\$2,\\$9}\"; sleep 5; done' | tee lsof.log\n\n# layer 3: LuLu events from the unified log\nlog stream --style syslog --predicate 'subsystem BEGINSWITH \"com.objective-see\"' > lulu.log &\n```\n\nThe MCP server only speaks over stdio, so I drive every tool through it and separately confirm its PID never opens an outbound socket.\n\nAfter the idle hold and the phases (run every feature by hand once; deliberately fire the four known paths), pull destinations from the pcap and cross-reference with the process log by timestamp:\n\n```\nfor f in cap-*.pcap; do\n  tshark -r \"$f\" -Y 'tcp.flags.syn==1 && tcp.flags.ack==0' -T fields -e ip.dst\ndone | sort -u\n\nawk '$2 ~ /^RoamSwitch/ {for(i=1;i<=NF;i++) if($i ~ /->/){split($i,a,\"->\"); print $2,a[2]}}' \\\n  lsof.log | grep -vE '127\\.0\\.0\\.1|::1' | sort -u\n```\n\nPass criteria:\n\n`RoamSwitchHelper`\n\n— `RoamSwitchMCPServer`\n\n— `RoamSwitch`\n\n— nothing outside `lafine.net`\n\n(and the URL you hit for path #4)`freshclam`\n\n, not the appVerdict: **PASS**. Across a ~2-hour capture (forced Sparkle check, every feature exercised once, DNS Threat Guard toggled, a network transition, security level pinned to Maximum Lockdown):\n\n**Zero non-local outbound connections attributed to RoamSwitch, RoamSwitchHelper, or RoamSwitchMCPServer.**\n\nThe only sockets the MCP server opened were `127.0.0.1:5000`\n\nand `127.0.0.1:7000`\n\n— the `get_exposed_ports`\n\ntool checking HTTP headers on locally-listening dev servers. Nothing left the machine.\n\nHere is *every* HTTPS destination in the whole capture (TLS SNI):\n\n```\nbag.itunes.apple.com       gspe35-ssl.ls.apple.com    proxy-safebrowsing.googleapis.com\nconfiguration.apple.com    init.push.apple.com        setup.icloud.com\ndns.quad9.net              mask.icloud.com            swdist.apple.com\ngateway.icloud.com         mesu.apple.com             swscan.apple.com\ngsa.apple.com              ocsp2.apple.com            tether.edge.apple\np117-contacts.icloud.com   courier.push.apple.com     swallow.apple.com\nfbs.smoot.apple.com\n\nlafine.net    <- the only non-OS destination\n```\n\nStrip out the OS's own services (iCloud, push, software update, OCSP, Safari Safe Browsing) and one destination remains: `lafine.net`\n\n— Sparkle fetching `appcast.xml`\n\n(egress path #2). `dns.quad9.net`\n\nshows up because the DNS Threat Guard switched the system resolver; the encrypted lookups are `mDNSResponder`\n\n's, not RoamSwitch's. `swallow.apple.com`\n\n/ `fbs.smoot.apple.com`\n\nsit on AWS IPs but are Apple services.\n\nentitlements:\n\n``` bash\n$ codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchHelper\n<dict></dict>\n$ codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer\n<dict></dict>\n```\n\nEmpty — not even `com.apple.security.network.client`\n\n(which, as noted, wouldn't *enforce* anything without App Sandbox, hence the behavioural check).\n\nLuLu logged no RoamSwitch-related outbound events for the duration.\n\nIt's all in one shell script, in the `audit/`\n\nfolder of the `roamswitch-support`\n\nrepo. `./rs-zerotel-audit.sh all --idle 2h --clam`\n\nruns prep, capture, forced Sparkle check, MCP exercise, analysis, a PASS/FAIL verdict, and a shareable `FINDINGS.md`\n\n. The only manual bits are helper approval, pinning max lockdown, one network transition (a Wi-Fi off/on is enough), and a few menu actions.\n\nBeing upfront:\n\nBut \"a claim\" versus \"a claim + a procedure anyone can run + an honest statement of the limits\" is a very different weight of trust. The more skeptical someone is, the more they want something they can check themselves.\n\n*Script and run sheets (EN/JA): github.com/lafine1211/roamswitch-support, audit/*", "url": "https://wpnews.pro/news/my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it", "canonical_source": "https://dev.to/lafine_systemsdesign/my-apps-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it-52kl", "published_at": "2026-08-29 14:09:01+00:00", "updated_at": "2026-08-29 14:50:15.770041+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["RoamSwitch", "tcpdump", "Wireshark", "LuLu", "Sparkle", "ClamAV", "lafine.net"], "alternates": {"html": "https://wpnews.pro/news/my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it", "markdown": "https://wpnews.pro/news/my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it.md", "text": "https://wpnews.pro/news/my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it.txt", "jsonld": "https://wpnews.pro/news/my-app-s-landing-page-says-zero-telemetry-so-i-wiretapped-my-own-app-to-prove-it.jsonld"}}