My Mac app can't link networking code. It still went online.
Last week I wrote about how Stillroom's build fails if the app links networking code. Stillroom is an encrypted vault for the Mac that Lis Shala and I make, and its whole promise is that it never goes online. The build c
Last week I wrote about how Stillroom's build fails if the app links networking code. Stillroom is an encrypted vault for the Mac that Lis Shala and I make, and its whole promise is that it never goes online. The build check reads the compiled binary with otool and nm and refuses any socket, DNS or URL-loading call.
Alex Shev left a comment under that post that I kept thinking about. A build-time boundary makes the claim checkable, he said, but pair it with a test that actually tries the network, otherwise the exception becomes the new hidden dependency.
He was right, and the first run of that test proved it the same day.
What a link check can't see
The build check answers one question: can Stillroom's own code reach the network? It can't. But a Mac app doesn't only run its own code. Show a PDF and PDFKit draws it. Preview a file Stillroom has no viewer for and Quick Look does. Render an email and WebKit does. Each of those can run in a helper process that macOS starts for you, with its own networking code, and none of that is in Stillroom's binary for nm to find.
So "doesn't link networking code" and "never goes online" are two different claims. The second one can only be tested by running the app and watching.
The test: open everything, watch everything
check-quiet.sh builds a test copy of Stillroom with its own name and preferences, gives it a throwaway vault, and opens it fifteen times, a few seconds each, on a different screen every time: the lock screen (which checks the Mac for things like screen sharing before you unlock), the vault list, a note, a secret with a two-factor code, Settings, and a preview of every kind of file the app shows.
The vault is built to provoke. It holds a PDF, a JPEG, a WAV and a CSV, but also files that want to go online:
- an email whose HTML points images, a stylesheet, a
dns-prefetch, apreconnect, an iframe and a video poster at real addresses - the same HTML saved as a web page
- an SVG that
@imports a stylesheet and draws a remote image - an RTF with an
INCLUDEPICTUREfield that fetches a picture - a saved Safari link (
.webloc) and a Windows shortcut (.url), both pointing at example.com
While that runs, the script records every network flow on the Mac, and at the end it fails if any came from Stillroom.
"From Stillroom" is the hard part
Watching the network is easy: nettop will list every process with a socket. Deciding which of those processes count as Stillroom is not.
When Stillroom shows a web page, the work happens in com.apple.WebKit.WebContent and com.apple.WebKit.Networking. When it hands a file to Quick Look, a preview extension runs it. None of these are Stillroom's children. launchd starts them, so their parent is process 1, and a parent-pid check finds nothing.
macOS does track who a helper works for. It calls it the responsible process, and it's how the privacy prompts know which app to name. There's no public API for it, but macOS exports a private function, responsibility_get_pid_responsible_for_pid, which is fine for a test script that never ships:
pid_t responsibility_get_pid_responsible_for_pid(pid_t);
// every 20 ms while Stillroom runs: print each process it is responsible for, once
while (kill(target, 0) == 0) {
int n = proc_listallpids(pids, sizeof pids);
for (int i = 0; i < n; i++)
if (!seen[pids[i]] && responsibility_get_pid_responsible_for_pid(pids[i]) == target) {
seen[pids[i]] = 1;
printf("%d\n", pids[i]);
}
usleep(20000);
}
My first run watched zero processes and reported a clean pass, which should always make you suspicious. I'd started the app from the shell, and an app started from a shell isn't responsible for anything: the terminal is. Launching it through Launch Services instead (open -n --env ...), the way Finder would, fixed it. Stillroom became responsible for itself and its helpers, and the next run watched 40 processes.
The first real run caught a leak
On that run, one screen failed: the preview of the saved Safari link. A com.apple.WebKit.Networking process that Stillroom was responsible for had moved about 8 KB in and 4 KB out.
Here's what happened. Stillroom has its own viewers for PDFs, pictures, text and email, and anything else goes to Quick Look. A .webloc is a tiny plist holding a URL, so it went to Quick Look, and Quick Look previews a link by loading the page in a hidden Safari view. Open a saved link in the vault, and your Mac fetched that website.
Nothing from the vault went anywhere. But the site saw the Mac's IP address, and "never goes online" was false for that one file type, and had been since the first release. The build check couldn't have seen it, because none of that code is Stillroom's.
The fix was small. A link file (.webloc, .inetloc, .url) is recognised by its type or extension before anything else, and the card shows its address as plain text. It never reaches Quick Look. An automated review then pointed out that a link file over 64 MB still slipped through, because the size check ran before the type check, so the type check moved first. That shipped as 1.1.1.
Making the watcher harder to fool
A test that only reports what it notices is only as good as its noticing. The first version had two weak spots, so 1.2.0 tightened both.
Sampling. nettop reports once a second. A lookup and a quick request can come and go in between. So the script now also streams macOS's own log, which records every name lookup in mDNSResponder (with the pid that asked) and every connection made through Apple's network stack:
log stream --level debug --style ndjson \
--predicate 'subsystem BEGINSWITH "com.apple.network" OR subsystem == "com.apple.CFNetwork" OR process == "mDNSResponder"'
These are events, not samples, so however short a connection is, it leaves lines. Any line from a watched process fails the run, except a short list of start-up chatter that every app emits, like cookie storage initialising. Unknown lines fail too: if a new macOS starts saying something new, I look at it rather than letting it through. nettop stays on for the one thing the log can miss, a raw socket opened straight at an IP address.
Proving the watchers work. Before the app opens at all, the script makes its own connections and refuses to continue unless it sees them. A curl that takes a fraction of a second has to show up in the log and be judged a failure by the same code that judges Stillroom. A connection held open for three seconds has to show up in nettop. If you're offline or a watcher broke, the check says it can't vouch for anything rather than passing.
I also checked it fails when it should. With the email viewer's lockdown removed on purpose, it catches WebKit's lookups straight away.
The release script runs all of this before every build it signs. The 1.2.0 release run watched 74 processes across the 15 screens and saw no network activity.
What it still doesn't prove
- It only covers what it opens. Fifteen screens, ten kinds of file plus a note and a secret, is most of the app, not all of it. A new viewer needs a new file in the test vault.
- It runs a debug copy, because the debug build's demo hooks can open a screen without anyone typing. The release is built from the same source minutes later, but it isn't the identical binary.
-
nettopsamples once a second, so a raw-IP connection that bypasses Apple's network stack and lasts less than that could slip past. Stillroom's own code can't open one (the build check), so this is about helpers. - It says nothing about macOS itself, and it depends on a private function and on log messages Apple can change. When they change, the check fails loudly rather than quietly passing.
The lesson
Checking what your binary links is cheap and catches the mistakes you make yourself. It doesn't catch what the system does for you. If your app promises it stays offline, run it, make it handle the files most likely to reach out, and watch every process acting on its behalf, not just its own.
Thanks to Alex for the push. If you test something similar a different way, with a network extension, a sandbox profile or something else, I'd like to hear how. Stillroom's threat model and file format are public at stillroomvault.com.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.