DEV Community

Cover image for I Audited My 71 Apps; Gatekeeper Doesn't Approve 12
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Audited My 71 Apps; Gatekeeper Doesn't Approve 12

When you open an app on macOS for the first time, the system tells you exactly
one thing: it opens, or it doesn't. Quite a lot happens behind that — the
signature gets checked, Apple's notarization ticket is looked up, the bundle is
verified as unaltered — but all of it reaches you as a two-state door. An app
that gets through that door settles onto your desktop as if it will never be
questioned again.

This evening I knocked on that door myself. I ran spctl against all 71
application bundles
sitting in /Applications, in Utilities, and in the
Applications folder of my home directory, then pulled their signature flags
and entitlements with codesign. What I expected was a boring table, a couple
of old apps, and a verdict of "everything is fine."

Twelve rows in that table were not fine. More interesting still: those twelve
rows had nothing in common with each other.

The measurement

One loop, two commands. You can run the same thing on your own machine:

for a in /Applications/*.app /Applications/Utilities/*.app ~/Applications/*.app; do
  [ -d "$a" ] || continue
  v=$(spctl -a -vvv -t exec "$a" 2>&1 | head -2 | tr '\n' ' ')
  f=$(codesign -dv --verbose=4 "$a" 2>&1 | sed -n 's/^CodeDirectory .*flags=\([^ ]*\).*/\1/p')
  echo "$(basename "$a")|$v|$f"
done
Enter fullscreen mode Exit fullscreen mode

spctl -a applies the system's assessment policy: does the app come from an
identified developer, has it been through Apple's notary service, is the bundle
untouched. codesign -dv gives you the flag field inside the signature — and
the values in that field are defined in Apple's own kernel source, in
cs_blobs.h: CS_RUNTIME 0x10000, the hardened runtime; CS_REQUIRE_LV
0x2000, library validation; CS_ADHOC 0x2, an ad hoc signature.

The machine: macOS 26.6.2 (build 25G83). Every number here is from the evening
of 27 October 2026. One small note: 71 is a count of bundles, not of distinct
applications. I have one copy of Android Studio in each of two folders, and both
were counted as separate bundles; so were the builds that keep an older version
of themselves next to the current one. I'm leaving that in so anyone
reproducing the number sees the same logic.

The headline result: 59 apps accepted, 7 rejected, 5 not assessable at all.
The breakdown of the accepted ones is tidy enough: 41 notarized Developer ID,
17 Mac App Store, 1 of Apple's own system apps.

So far, no story. The story is in what the word "accepted" does not say.

One word, two separate regimes

Of the 71 bundles, 57 carry the hardened runtime flag. At first glance that
reads as "14 apps are unprotected," but once you look at how the flag is
distributed, something else shows up:

Hardened runtime App Sandbox
Notarized Developer ID (41) 41 11
Mac App Store (17) 11 16

(The table covers only 58 of the accepted bundles; the five remaining carriers
of the runtime flag are in the group that didn't pass, which I get to below.)

What this shows is two separate paths. Apple's notarization document says the
hardened runtime must be enabled before you can send an app to the notary
service — and nothing in the measurement contradicts that: 41 out of 41 carry
the flag. (That isn't a verification; the sample selects itself, since all 41
have already been notarized.) The same document also says you don't need to
notarize software you distribute through the Mac App Store, because the store's
own submission process already includes equivalent checks. So an app arriving by
the App Store route lands in the sandbox but isn't obliged to carry the runtime
flag; an app arriving by the Developer ID route carries the flag, and enters the
sandbox 11 times out of 41 — a bit over a quarter.

The clearest example on my machine is Safari and Xcode. Neither carries the
CS_RUNTIME flag, both carry CS_REQUIRE_LV, and both were written by Apple —
yet spctl puts them in different buckets: Safari is a system app, Xcode comes
from the Mac App Store. Xcode is also the only one of the 17 store apps that
isn't sandboxed. Reading hardening as "is the flag there" misleads for exactly
this reason; the real question is which mechanism closes which gap. All four
combinations exist on my machine: 34 apps with the runtime and no sandbox, 23
with both, 6 with the sandbox only, 8 with neither.

Gatekeeper says the same word to all four of those states.

Twenty-seven apps switch a protection back off

The hardened runtime isn't a closed door; it's a room with windows. The
developer can reopen the capabilities it restricts, one at a time — Apple calls
these runtime exceptions, and the documentation asks that you use only the
entitlements that are absolutely necessary.

Twenty-seven of my 71 bundles declare at least one exception. The
distribution:

Entitlement Apps
allow-jit 22
disable-library-validation 15
allow-unsigned-executable-memory 15
allow-dyld-environment-variables 7
disable-executable-page-protection 2
get-task-allow 2
debugger 1

One distinction: get-task-allow is not technically a hardened runtime
exception — its name isn't from the com.apple.security.cs.* family, and Apple
doesn't list it among the exceptions in the hardened runtime document. It sits
in the table because I counted whatever came out of the signature; keep it apart
as you read. And one app in the list has no protection to switch off in the
first place: it declares three cs.* entitlements while its flag field reads
0x0(none), meaning the hardened runtime was never turned on at all.

allow-jit didn't surprise me; anything carrying a JavaScript engine needs it.
It was disable-library-validation that stopped me. It turns off library
validation: normally a program can only load frameworks, plug-ins and libraries
that are either signed by Apple or signed with its own Team ID. With this
entitlement that requirement disappears. Apple's own documentation calls library
validation an important security-hardening feature, and says Gatekeeper runs
extra checks on programs that disable it.

allow-unsigned-executable-memory isn't quiet either: Apple's documentation
warns outright that the entitlement exposes the app to common vulnerabilities in
memory-unsafe languages.

I should say something plainly here, because my first reaction was wrong: these
15 apps are not "breaking the rules." An app that hosts plug-ins doesn't have
many other options. The notarization document explains this too: plug-ins don't
declare their own entitlements, they inherit the host app's. So an app that runs
third-party plug-ins has to request, up front, every permission those plug-ins
might need. Third-party plug-in support comes with library validation as the
bill.

What stays interesting is this: Apple's platform security documentation says
Gatekeeper also protects against malicious plug-ins distributed alongside benign
apps — using the app can trigger the loading of a malicious plug-in without the
user's knowledge. I missed a detail on the first read: the defence that document
names for that scenario is not library validation. When necessary, Gatekeeper
opens apps from randomized, read-only locations, and the stated purpose is to
prevent the automatic loading of plug-ins distributed alongside the app. Library
validation is a separate wall — the one that inspects the signature of code
trying to load. I have 15 apps that disable it, and before this evening I didn't
know which ones they were.

Keep "randomized, read-only locations" in the back of your mind; a few sections
from now we'll meet an app that writes inside its own bundle.

The 12 whose seal doesn't hold

Now to the real question: the 12 apps that didn't pass assessment.

Nine of them are easy. These nine have no Developer ID signature at all — eight
ad hoc signed, one signed with an Apple Development certificate. Local builds
that never went through a distribution chain; that's the only thing the
signature tells me, and it doesn't answer the question of who built them.
spctl declining to approve them is exactly it doing its job. One of them has
the flag value 0x20002 — ad hoc plus CS_LINKER_SIGNED: codesign was never
run against that bundle at all, the linker signed it on its own during the
build. That single bit is the sharpest evidence for "never entered a
distribution chain." Another carries the hardened runtime flag despite being ad
hoc signed: identity-less yet hardened, an odd in-between state.

The remaining three are third-party apps, and all three were signed with their
own developer's Developer ID certificate. Their reasons turned out to be
completely different from one another.

Diagram

First: the app broke its own seal. An app I installed this morning.
codesign reports "file added" for 84 files; all of them .pyc, all of them
inside the __pycache__ directories of the CPython 3.12 runtime the app carries
within itself. The timestamps are 27 October 07:58 (83 files) and 08:08 (1 file).

I got the arithmetic wrong here for a moment, and the correction turned out to
be the instructive part. The bundle's own modification time reads 26 October
20:48; I was about to take that for the install moment and write the gap up as
eleven hours. But that stamp is left over from the vendor's packaging run,
preserved through the copy. Spotlight holds the actual install moment:
kMDItemDateAdded is 27 October 07:55:11. The first .pyc was written at
07:58:20. So three minutes and nine seconds after landing in
/Applications, the app used its own embedded interpreter to write compiled
bytecode inside its own bundle, and invalidated the file list Apple had sealed.
The notarization ticket is still valid — stapler validate sees no problem.
What's invalid is the bundle itself.

This isn't malicious behaviour, it's the consequence of a layout decision: an
embedded interpreter sitting in a writable location writes its cache next to
itself the first time it runs. But the result is that Apple's "this bundle is
untouched" assurance can no longer be verified for this copy.

Second: a link inside the framework dangles. A browser. spctl doesn't even
reject this bundle, it can't assess it: "bundle format unrecognized, invalid, or
unsuitable." I thought I'd found the reason: under the framework's Versions/
there are two of them, 137.0.6036.63 and 138.0.6044.24. The self-updating app
didn't remove the old one — explanation ready.

The control group demolished that explanation. There's a second browser on my
machine carrying the exact same signature flag set: Chrome, again 0x12a00 —
kill, restrict, library validation and hardened runtime, all four on. Its
framework also holds two versions side by side, 155.0.8059.86 and
156.0.8078.48. Yet Chrome gets "accepted" from spctl.

The difference is a single symbolic link. Chrome's Versions/ directory
contains Current -> 156.0.8078.48, and the three links at the top of the
framework — Google Chrome Framework, Resources, Helpers — follow it and
find their target. The other browser's Versions/ directory has no Current;
its three top-level links all point at Versions/Current/... and all three fall
into nothing. codesign can't read the bundle behind broken links, so it says
"bundle format unrecognized."

Both apps carry the strictest flag set on my machine, both update themselves,
both leave the old version behind. Whether a bundle with four protections turned
on is assessable at all comes down to whether the updater put one symbolic link
back in place.

Third: the mismatch predates my machine. An image editor. 79 files are
marked "file modified," all of them .pyc. But the timestamps change the
picture: all 79 of them read 26 February 2025, 11:31. A single bulk event.
I added this app to my Mac on 1 September 2026 — the mismatch is eighteen
months
older than my installation. These files were not written on this
machine; the bundle arrived in this state. Spotlight has no launch record for
this app either; kMDItemLastUsedDate is empty.

This third app also has no ticket stapled to it: stapler validate says it
"does not have a ticket stapled to it." Because Apple publishes tickets online
as well, that alone doesn't mean "it wasn't notarized" — it's the only signal I
have and it isn't enough, so I can't settle this copy's notarization status from
here. The other two have valid tickets, and their signatures carry fresh secure
timestamps: one 20 October 2026, the other 26 October 2026. The third app's
stamp reads 14 May 2024.

The rule's condition

There's an item in the notarization requirements that looks absolute: don't
include the com.apple.security.get-task-allow entitlement with its value set
to any variation of true. Apple's document on resolving notarization issues
gives the reason too: the entitlement circumvents certain security checks to
make debugging easier, and in a shipping app it can let an attacker inject code
at runtime. Xcode strips it automatically when it signs through the standard
workflow.

Two apps on my machine declare this entitlement, and both have the value
<true/>. My first reaction was "so they slipped past the list." That was
wrong — and where I was wrong is the real lesson of the evening.

A few lines further down, the same document defines a conditional exception: an
app may declare get-task-allow in order to debug a plug-in in the context of
the host executable — if it also declares disable-library-validation. I
went back to the entitlement output: both apps declare it alongside. So they
aren't breaking the rule, they're sitting squarely inside a documented
exception.

That changed my "if I see get-task-allow I'll be alarmed" instinct. The item
isn't absolute, it's conditional; the right reflex is to check whether library
validation is switched off next to it. But the condition isn't free, and Apple
writes that part too: don't disable library validation for executables that
don't host plug-ins, because that validation is what protects them from loading
untrusted code. That's exactly the price of the exception — to open yourself to
a debugger, you also accept loading plug-ins whose signature nobody checks.

One of the two gets "accepted" from spctl, with the source "Notarized
Developer ID" and a valid ticket: balenaEtcher, signed on behalf of Balena Ltd.
The same app also declares disable-executable-page-protection — in Apple's own
description, that disables all code signing protections while the app launches
and while it runs. Only two apps on my machine declare it. I don't know why a
USB writing tool needs that, and I'm not going to speculate. What I do know:
that information was sitting inside the signature, and no interface told me
about it until I thought to look.

So how do these keep running

The question that nags at me here: these apps whose seal doesn't hold are
installed and running. How?

The answer is in Apple's platform security documentation, in a single sentence:
by default, all software on macOS, regardless of how it arrived on the Mac,
is checked for known malicious content the first time it's opened. The first
time. What I ran this evening, spctl -a, is today's assessment — the same
check repeated with the bundle changed in place. The gap in between is fifty-six
days for the image editor and three minutes for the app that wrote bytecode.

Whether that "first time it's opened" door ever gets knocked on again is decided
by the quarantine attribute. Eleven of my 71 bundles still carry
com.apple.quarantine — the trace of their downloaded origin. The app that
broke its own seal doesn't have it; xattr shows only com.apple.macl and
com.apple.provenance. So that bundle, having written its own bytecode inside
itself, will never visit the first-launch door again. The image editor still
carries quarantine and its kMDItemLastUsedDate is empty: for that one, the
door hasn't been knocked on even once.

I'd already seen how incompletely that ledger is kept when I
read the quarantine records line by line;
not one of those 1,945 rows carried a download address. I couldn't find the
meaning of the fields inside the flag in any publicly available Apple document
either, so I'm not going to try to decode it — but the number 11 says this much:
for most of the apps on my machine, that trace was either never kept or has been
removed.

The frame I ended up with

I didn't come out of this measurement with a list that says "delete these apps,"
because what I found isn't a fault list. It turned into three questions:

  1. Which regime am I in? Whether an app is in the sandbox, in the hardened runtime, in both, or in neither is told by codesign -dv and the entitlement output. Gatekeeper's single word doesn't tell you.
  2. Which windows are open? If I see disable-library-validation in the output of codesign -d --entitlements -, it means the signatures of the plug-ins that app loads are now vetted by me, not by the operating system. Installing a plug-in knowing that is different from installing it unaware.
  3. Does the seal hold today? The verdict at install time says nothing about the state two months later — let alone when the files written inside the bundle are eighteen months older than the install. spctl -a and codesign -vv give today's answer, and across 71 bundles they said "no" 12 times.

The third question is the one that's new to me. An app's signature is a
photograph taken at the moment of installation; as the app writes inside itself
and its updater forgets to put a symbolic link back, that photograph drifts away
from reality. Most of that drift is innocent. But telling the innocent from the
rest requires knowing when the photograph was taken and what has changed since.

I'd asked before how much of the trust I place in the software on my Mac comes
from measurement and how much from habit, back when I
counted my root certificates.
The answer came out much the same: once the door has been opened, we go on
assuming the door is still there. What I learned this evening is that the
assumption can be tested with a single command.

Official Sources

Top comments (0)