AI Pentesting

Auditing the Shipped Artifact. What Four macOS Desktop Clients Revealed

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

What We Found When We Audited Four Shipped Desktop Clients

Every security tool your team runs stops at the same boundary. SAST reads the repository. SCA reads the lockfile. DAST reads the deployed web surface. Secret scanners read commits. All of them stop at the moment the build system takes over.

But your users don't install your repository. They install a signed disk image containing a compiled binary, a set of OS permission grants, an embedded browser engine, a bundled archive of minified JavaScript, and a handful of native modules. That artifact is what runs on their laptop with their microphone, their filesystem, and their session tokens. Almost nobody looks at it.

We spent several weeks looking at it. Four macOS desktop clients from companies ranging from a Series A startup to a division of a Fortune 100. Every one of them had a mature security program. Three of the four had at least one High or Critical finding in the shipped artifact that no tool in their pipeline could have surfaced, because no tool in anyone's pipeline looks there.

This is a writeup of the method, the bug classes that keep recurring, and the part most research writeups skip: how to avoid fooling yourself when you're reading half a million lines of minified code and badly want to find something.

Vendor names, versions and hashes are omitted throughout. Several of these findings are still under coordinated disclosure. Code samples are representative reconstructions of patterns we observed across multiple targets, not verbatim from any one product.

Part 1: The Gap in Application Security Testing

Where SAST, SCA, DAST and Secret Scanning Actually Stop

Consider a typical Electron desktop application and where each class of tool has visibility:

Tool class

Reads

Blind to

SAST

source in the repo

anything the build injects or bundles

SCA / dependency scanning

package.json, lockfile

the framework version actually shipped, native modules, vendored code

Secret scanning

commits, sometimes CI

secrets baked in at build time

DAST

the deployed web app

the desktop shell entirely

Endpoint / EDR

process behaviour at runtime

design defects that look like normal app behaviour

There is a structural reason for the gap. Every one of those tools is positioned at a point in the pipeline where the input is text your engineers wrote. The shipped artifact is a different object: it is the output of a build, a bundler, a signing step and a packaging step, and it contains a great deal that was never in the repository.

None of this shows up in a generated SBOM either. A software bill of materials is built from the repository's declared dependencies (what SCA tools scan for), not from what the bundler and native-module layer actually shipped, so the gap between "what SCA reported" and "what's really in the disk image" stays invisible on paper too.

The most consequential thing the shipped artifact contains is capability. A repository has no microphone access. A signed, notarized desktop application does.

Why a Desktop Client Is a Unique Software Supply Chain Risk

A modern desktop client is an unusual security object because it holds three things simultaneously, and no single team owns the intersection:

  • Operating-system capability. On macOS these are TCC grants and entitlements, microphone, camera, screen recording, Accessibility, full disk. They are granted once, by a user clicking a dialog, and they persist. Anything that can influence what the application does inherits them without asking for anything.

  • Long-lived credentials. Session tokens, refresh tokens, workspace cookies, and increasingly OAuth material for connected third parties. These sit on disk under whatever protection the client chose to implement.

  • A remotely-influenced rendering surface. The application renders content it did not author: messages from other people, calendar invites from outside the organisation, documents, link previews, and, new in the last two years, model output derived from all of the above.

Server-side, these three concerns live in different systems with different trust boundaries. In a desktop client they live in one process. The security of the whole arrangement rests on the client correctly deciding what is allowed to influence what, and that decision is made in code nobody outside the vendor has read.

Why the Blast Radius Is Worse Than "One Machine"

The instinctive objection to desktop client research is that the blast radius is one machine. That's often true and worth saying honestly: most desktop findings are per-victim, not fleet-wide, and a report that implies otherwise will be discounted.

But per-victim understates it in three specific ways.

  • The victim is chosen, not random. A desktop client bug that requires luring a user to a page is a targeted-attack primitive. Against a company, that means a link to its staff. The attacker doesn't need all users; they need the finance team.

  • The capability is borrowed, not requested. An attacker exploiting a client-side defect in an application that already holds microphone and Accessibility grants does not need to obtain those grants. They inherit them silently. There is no second consent dialog.

  • Third parties are affected who never consented to anything. This is the part conventional threat models miss entirely. If a meeting-recording client can be made to record without its indicator, the people harmed are the other participants in the call, who are not users of the product, never agreed to its terms, and in two-party-consent jurisdictions have a legal expectation the software exists to satisfy.

That last category is why we think this work matters more than the raw severity numbers suggest.

Part 2: The Method for Auditing a Shipped Desktop Artifact

The whole exercise is a controlled unwrapping. Each layer is a different format with different tooling, and the interesting material is almost always three or four layers down, in code that was never meant to be read.




Stage 1: Acquire and Pin the Exact Build

Hash the disk image. Mount it read-only. Copy the bundle out. Record the version from the bundle metadata, and record a digest over the whole file tree, not just the main executable.

This is not bookkeeping. It is the finding's foundation. A CVE attaches to a version. An incomplete-fix claim compares against a version. And desktop clients auto-update, so the artifact you downloaded and the artifact installed on your own machine are frequently different builds. We hit exactly that on one target and had to re-verify every finding against the installed build, where two of them had shifted module names.

Never work from "latest".

Stage 2: Read macOS Entitlements and Code Signing Before the Code

Before any code, read what the operating system has been asked to permit. On macOS this is the code signature, the notarization state, the hardened-runtime flags, and the entitlements, on the main bundle and on every helper, which frequently differ.

# the signature, the flags, the team identity
codesign -dv --verbose=4 Target.app

# the entitlements, per binary
codesign -d --entitlements - --xml Target.app | plutil -convert xml1 -o - -
# the signature, the flags, the team identity
codesign -dv --verbose=4 Target.app

# the entitlements, per binary
codesign -d --entitlements - --xml Target.app | plutil -convert xml1 -o - -
# the signature, the flags, the team identity
codesign -dv --verbose=4 Target.app

# the entitlements, per binary
codesign -d --entitlements - --xml Target.app | plutil -convert xml1 -o - -

Two flags do most of the work. runtime means the hardened runtime is on. library-validation means only libraries signed by the same team can be loaded, this single flag closes the entire dylib-injection class before you start, and its presence or absence should redirect hours of your effort.

Read the entitlements as a statement of maximum blast radius. An application holding device.audio-input, device.camera and personal-information.location is one where any influence over behaviour becomes an influence over the microphone. Write that sentence down; it is the impact paragraph of every finding you subsequently write.

Stage 3: Unwrap the Asar Archive to Reach the Real Code

Electron applications keep their code in an archive. Extract it in full.

Two traps here, both of which we hit:

  • The archive at the obvious path may be a loader stub, a few kilobytes that select a per-architecture archive and hand off. If your extraction produces a suspiciously small tree, you're holding the stub. Look for sibling archives named per-arch.

  • Directories beginning with a dot do not appear in a plain listing. A build output directory named .webpack will look like an empty package to anyone who runs ls and moves on. Use find.

Stage 4: Navigate Minified JavaScript Using the Export Map

This is where most people stop, and it is where the findings are.

Bundled application code has been minified, tree-shaken and identifier-mangled. Function names are gone. Module boundaries survive as numeric ids. But three things reliably survive minification, and they are enough:

  • String literals survive. Endpoint paths, event names, log messages, flag identifiers, error strings. Minifiers do not touch them. Log messages are the single richest source; developers write log lines that explain the security intent of the code around them. A line like "Preventing navigation to %s" tells you a control exists and roughly where.

  • Object shapes survive. A configuration object literal keeps its keys.

  • Export maps survive, and they are the decoder ring. This is the technique that unlocks everything else. A bundled module ends with a table mapping public export names to internal mangled variables:

// CommonJS form
Object.defineProperty(exports, "aQ", { enumerable: true, get: function () { return Xr } });
Object.defineProperty(exports, "b7", { enumerable: true, get: function () { return Qn } });

// ESM form
export { Xr as aQ, Qn as b7 };
// CommonJS form
Object.defineProperty(exports, "aQ", { enumerable: true, get: function () { return Xr } });
Object.defineProperty(exports, "b7", { enumerable: true, get: function () { return Qn } });

// ESM form
export { Xr as aQ, Qn as b7 };
// CommonJS form
Object.defineProperty(exports, "aQ", { enumerable: true, get: function () { return Xr } });
Object.defineProperty(exports, "b7", { enumerable: true, get: function () { return Qn } });

// ESM form
export { Xr as aQ, Qn as b7 };

With that table you can go in either direction. Given a human-readable concept, find its internal name; given a mangled reference at a call site, recover what it means.

Worked Example: Mapping Feature Flags to Real Read Sites

Here is the full chain we used repeatedly, reduced to its shape. Suppose the application has a feature-flag system and you want to know which flags actually influence behaviour, out of several hundred that merely exist:

# 1. flag definitions carry their id as a string literal — these survive
#    internalVar -> flag id
defs = dict(re.findall(
    r'(?<![\w$])([A-Za-z_$][\w$]*)\s*=\s*\{id:`([a-z0-9_]+)`,defaultValue:', src))

# 2. the export map — exportName -> internalVar
exports = dict(re.findall(
    r'Object\.defineProperty\(exports,"([\w$]+)",'
    r'\{enumerable:!0,get:function\(\)\{return ([\w$]+)\}\}\)', src))

# 3. compose: flag id -> export name
flag_to_export = {defs[v]: e for e, v in exports.items() if v in defs}

# 4. count real read sites in the consuming module, via its import alias
reads = len(re.findall(r'(?<![\w$])mod\.%s(?![\w$])' % export_name, consumer_src))
# 1. flag definitions carry their id as a string literal — these survive
#    internalVar -> flag id
defs = dict(re.findall(
    r'(?<![\w$])([A-Za-z_$][\w$]*)\s*=\s*\{id:`([a-z0-9_]+)`,defaultValue:', src))

# 2. the export map — exportName -> internalVar
exports = dict(re.findall(
    r'Object\.defineProperty\(exports,"([\w$]+)",'
    r'\{enumerable:!0,get:function\(\)\{return ([\w$]+)\}\}\)', src))

# 3. compose: flag id -> export name
flag_to_export = {defs[v]: e for e, v in exports.items() if v in defs}

# 4. count real read sites in the consuming module, via its import alias
reads = len(re.findall(r'(?<![\w$])mod\.%s(?![\w$])' % export_name, consumer_src))
# 1. flag definitions carry their id as a string literal — these survive
#    internalVar -> flag id
defs = dict(re.findall(
    r'(?<![\w$])([A-Za-z_$][\w$]*)\s*=\s*\{id:`([a-z0-9_]+)`,defaultValue:', src))

# 2. the export map — exportName -> internalVar
exports = dict(re.findall(
    r'Object\.defineProperty\(exports,"([\w$]+)",'
    r'\{enumerable:!0,get:function\(\)\{return ([\w$]+)\}\}\)', src))

# 3. compose: flag id -> export name
flag_to_export = {defs[v]: e for e, v in exports.items() if v in defs}

# 4. count real read sites in the consuming module, via its import alias
reads = len(re.findall(r'(?<![\w$])mod\.%s(?![\w$])' % export_name, consumer_src))

On one target this reduced several hundred declared flags to the subset the main process actually reads, and that subset was the finding. It included switches for whether raw audio is written to disk and whether an on-screen recording indicator is displayed.

Two warnings from painful experience. Minified names are reused across modules. A single-letter identifier means one thing in one module and something entirely different two modules later. Always bound your search to the enclosing module before resolving a name; we produced a completely wrong mapping once by searching backwards across a module boundary and matching an unrelated enum.

The export map you find first may not be the operative one. A module can build an internal object with readable names and then export through a second, mangled table. Follow the one the consumer actually imports.

Stage 5: Run the Shipped Code as an Oracle

The highest-confidence technique we have, and the most underused.

You do not have to reason about what minified code does. You can execute it, offline, with no network, feeding it your own input and observing its output. The shipped bundle is the authoritative answer to what the shipped bundle does.

We used this to settle whether a rendering path would fetch a remote URL. Rather than argue from the code, we imported the shipped rendering module directly, called it the way each call site calls it, and inspected the element tree it returned:

import { t as Renderer } from './assets/<shipped-chunk>.js';

const INPUT = '!x';

// call it exactly as each site does — with and without the override
const cases = {
  'no component overrides'      : Renderer({ children: INPUT }),
  'image neutralised'           : Renderer({ children: INPUT, components: { img: () => null } }),
};

// walk the returned tree; nothing is fetched, we only inspect
for (const [name, tree] of Object.entries(cases))
  console.log(name, findRemoteImages(tree));
import { t as Renderer } from './assets/<shipped-chunk>.js';

const INPUT = '!x';

// call it exactly as each site does — with and without the override
const cases = {
  'no component overrides'      : Renderer({ children: INPUT }),
  'image neutralised'           : Renderer({ children: INPUT, components: { img: () => null } }),
};

// walk the returned tree; nothing is fetched, we only inspect
for (const [name, tree] of Object.entries(cases))
  console.log(name, findRemoteImages(tree));
import { t as Renderer } from './assets/<shipped-chunk>.js';

const INPUT = '!x';

// call it exactly as each site does — with and without the override
const cases = {
  'no component overrides'      : Renderer({ children: INPUT }),
  'image neutralised'           : Renderer({ children: INPUT, components: { img: () => null } }),
};

// walk the returned tree; nothing is fetched, we only inspect
for (const [name, tree] of Object.entries(cases))
  console.log(name, findRemoteImages(tree));

Output, from the vendor's own code, on our machine, with no traffic:

no component overrides   ->  remote <img src>

no component overrides   ->  remote <img src>

no component overrides   ->  remote <img src>

That is a far stronger artifact than a paragraph of reasoning, and it takes about fifteen minutes. Two practical notes: bundles often need a small stub for a browser global they touch at import time (a single document.createElement for entity decoding, in our case), and you must copy the whole import closure, including bare side-effect imports (import "./x.js" with no from), which a naive regex will miss. Ours did, and the harness broke until we fixed it.

Stage 6: Prove the Negative with the Same Rigour

On a well-audited target, most of your output is negatives, and a rigorous negative is a real deliverable. But "I didn't see a check" is not a negative. A negative needs the invariant named, the control located, and an argument for why it covers every path.

The strongest form is an executable negative control. To test whether an unprivileged process on the same machine could read a client's data-encryption key, we wrote a forty-line program that asks for it and observed the refusal:

OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)@{
    (__bridge id)kSecClass:                     (__bridge id)kSecClassGenericPassword,
    (__bridge id)kSecAttrService:                @"<service>",
    (__bridge id)kSecAttrAccessGroup:            @"<team>.<group>",
    (__bridge id)kSecUseDataProtectionKeychain:  @YES,
    (__bridge id)kSecReturnData:                 @YES,
}, &out);
// -> OSStatus -34018  errSecMissingEntitlement
OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)@{
    (__bridge id)kSecClass:                     (__bridge id)kSecClassGenericPassword,
    (__bridge id)kSecAttrService:                @"<service>",
    (__bridge id)kSecAttrAccessGroup:            @"<team>.<group>",
    (__bridge id)kSecUseDataProtectionKeychain:  @YES,
    (__bridge id)kSecReturnData:                 @YES,
}, &out);
// -> OSStatus -34018  errSecMissingEntitlement
OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)@{
    (__bridge id)kSecClass:                     (__bridge id)kSecClassGenericPassword,
    (__bridge id)kSecAttrService:                @"<service>",
    (__bridge id)kSecAttrAccessGroup:            @"<team>.<group>",
    (__bridge id)kSecUseDataProtectionKeychain:  @YES,
    (__bridge id)kSecReturnData:                 @YES,
}, &out);
// -> OSStatus -34018  errSecMissingEntitlement

That result, from an ad-hoc-signed binary with no team identity, running as the same user with no elevation, proves the control holds, and proves why it holds: the data-protection keychain enforces the access group against the code signature, and unlike the legacy keychain there is no user-promptable path around it. That is a negative you can defend.

Part 3: The Recurring Desktop Client Bug Classes

Across four unrelated codebases, the same shapes kept appearing. These are not exotic; they are what happens when a security control is written once and the surface it protects keeps growing.

Bug Class 1: Origin-less Custom URL Schemes Wired to State Mutation

The dominant finding class, and the one we'd tell any desktop team to audit first.

Desktop applications register custom URL schemes. Any web page can navigate to one. Critically, a custom scheme carries no origin, the receiving application is handed a string with no indication of who sent it, and the browser's only defence is a generic "Open this application?" prompt that names the app but never the action.

That would be acceptable if deep links only performed navigation. In practice we found them wired to configuration changes, session establishment, and capture control. The pattern in the abstract:

// the gate — a string prefix test, no origin, no nonce
function isSettingsLink(url) { return /^app:\/\/setting/i.test(url || ""); }

// the handler — no confirmation on this path
pipe(
  filter(({ url }) => isSettingsLink(url)),
  map(({ url }) => JSON.parse(coerceToJson(new URL(url).searchParams.get("update")))),
  map(obj => pick(obj, ALLOWED_KEYS)),      // real control
  map(obj => schema.validate(obj) ? {} : obj), // real control
  dispatch(UPDATE_SETTINGS)
)
// the gate — a string prefix test, no origin, no nonce
function isSettingsLink(url) { return /^app:\/\/setting/i.test(url || ""); }

// the handler — no confirmation on this path
pipe(
  filter(({ url }) => isSettingsLink(url)),
  map(({ url }) => JSON.parse(coerceToJson(new URL(url).searchParams.get("update")))),
  map(obj => pick(obj, ALLOWED_KEYS)),      // real control
  map(obj => schema.validate(obj) ? {} : obj), // real control
  dispatch(UPDATE_SETTINGS)
)
// the gate — a string prefix test, no origin, no nonce
function isSettingsLink(url) { return /^app:\/\/setting/i.test(url || ""); }

// the handler — no confirmation on this path
pipe(
  filter(({ url }) => isSettingsLink(url)),
  map(({ url }) => JSON.parse(coerceToJson(new URL(url).searchParams.get("update")))),
  map(obj => pick(obj, ALLOWED_KEYS)),      // real control
  map(obj => schema.validate(obj) ? {} : obj), // real control
  dispatch(UPDATE_SETTINGS)
)

Note that the allowlist and the schema are genuine controls, and on one target they held up well. The defect is upstream of them: the decision that an origin-less external channel may address configuration at all.

Worse variants we observed: a consent parameter supplied in the same attacker-controlled URL that disables the confirmation dialog; and a capture-start path whose "enabled" parameter defaulted to on, so that a parameterless link began recording.

The class-level fix is a single rule: deep links may address navigation; they must never address configuration, capture, credentials or server-side state. Audit every registered scheme handler against that sentence.

Bug Class 2: Measure-only Controls That Report but Never Enforce

The most interesting class, because it is invisible to code review.

A control is written. It computes the correct verdict. It emits telemetry naming the verdict. And then it does not enforce.

// an egress allowlist that permits everything
webRequest.onBeforeRequest(filter, (details, callback) => {
  const verdict = checkAllowlist(details.url);
  if (verdict) reportBlocked(verdict);   // event literally named "...blocked"
  callback({ cancel: false });           // ...and allows the request
});

// a binding check whose enforcement is behind a flag that ships off
const matches = deviceCodeMatches(url.searchParams.get("device_code"));
const mode    = getFlag("enforce-device-code").variant;   // shipped default: "off"
if (!matches && mode === "hard") return reject();          // never fires
establishSession(url.searchParams.get("access_token"));    // proceeds
// an egress allowlist that permits everything
webRequest.onBeforeRequest(filter, (details, callback) => {
  const verdict = checkAllowlist(details.url);
  if (verdict) reportBlocked(verdict);   // event literally named "...blocked"
  callback({ cancel: false });           // ...and allows the request
});

// a binding check whose enforcement is behind a flag that ships off
const matches = deviceCodeMatches(url.searchParams.get("device_code"));
const mode    = getFlag("enforce-device-code").variant;   // shipped default: "off"
if (!matches && mode === "hard") return reject();          // never fires
establishSession(url.searchParams.get("access_token"));    // proceeds
// an egress allowlist that permits everything
webRequest.onBeforeRequest(filter, (details, callback) => {
  const verdict = checkAllowlist(details.url);
  if (verdict) reportBlocked(verdict);   // event literally named "...blocked"
  callback({ cancel: false });           // ...and allows the request
});

// a binding check whose enforcement is behind a flag that ships off
const matches = deviceCodeMatches(url.searchParams.get("device_code"));
const mode    = getFlag("enforce-device-code").variant;   // shipped default: "off"
if (!matches && mode === "hard") return reject();          // never fires
establishSession(url.searchParams.get("access_token"));    // proceeds

We found this shape in two unrelated products in the same month. In both, the telemetry event name asserted enforcement that the code did not perform. A reviewer skimming for "is there an allowlist?" finds one. A dashboard showing "blocked requests" shows a number. Neither is true.

If you ship a control in report-only mode, which is legitimate, say so in the identifier. ..._observed, ..._would_block. Reserve the word blocked for code paths that block.

Bug Class 3: Incomplete Fixes That Never Propagated Across Sinks

A vendor discloses a rendering vulnerability, fixes it, publishes a post-mortem. Eighteen months later the fix is present at the sink named in the report and absent at six others added since.

The mechanism is always the same: safety was implemented as a parameter at the call site rather than a property of the component. The protected sink passes an override; every sink written afterwards by a different team doesn't know it needs to.

// the sink that got the fix
<Markdown components={{ img: InertImage, a: GuardedLink }}  />

// six sinks added later, in three other files
<Markdown  />                    // default renderer: fetches remote images
// the sink that got the fix
<Markdown components={{ img: InertImage, a: GuardedLink }}  />

// six sinks added later, in three other files
<Markdown  />                    // default renderer: fetches remote images
// the sink that got the fix
<Markdown components={{ img: InertImage, a: GuardedLink }}  />

// six sinks added later, in three other files
<Markdown  />                    // default renderer: fetches remote images

The class-level fix is to make the unsafe thing unreachable: export exactly one wrapper with the policy baked in, strip caller overrides for the dangerous keys inside it, and lint against importing the underlying library directly. Then a new sink is safe by default and a developer has to work to make it unsafe.

When you report this class, say plainly that it is an incomplete fix of the prior issue and name it. That framing is worth more to the vendor than the individual sink list.

Bug Class 4: Migration Residue Left by Filename Enumeration

A product adds encryption at rest. The migration deletes the old plaintext files. The deletion enumerates filenames.

for (const name of ["cache-v3.json", "cache-v4.json", "cache-v5.json"])
  try { rmSync(path(name)) } catch {}
for (const name of ["cache-v3.json", "cache-v4.json", "cache-v5.json"])
  try { rmSync(path(name)) } catch {}
for (const name of ["cache-v3.json", "cache-v4.json", "cache-v5.json"])
  try { rmSync(path(name)) } catch {}

What this misses: the .tmp siblings produced by the atomic-write path the application used before it migrated, and any file whose name the author forgot. On one machine this left a several-hundred-kilobyte plaintext cache, containing well over a hundred external contact addresses across dozens of outside organisations, meeting titles, and conference dial-in credentials, sitting beside the encrypted file that had replaced it, six months later.

Filename enumeration is the defect. Migration should be a property of the storage layer: on first successful encrypted write, the store unlinks its own predecessor and every sibling it owns. Then a new store is correct by construction.

Bug Class 5: Guards Shipped With the Default Flag Turned Off

Related to measure-only, but distinct: the control is fully implemented and enforcing, and the shipped default disables it.

The tell is a fallback configuration table in the client, listing every feature flag with a compiled-in default for use before the flag service responds. Read that table. It is the state every installation is in at launch, and on more than one occasion it is the state they stay in.

Bug Class 6: Unauthenticated Local IPC Over Distributed Notifications

Less glamorous, and it produced our single cleanest confirmed finding.

Privileged background processes need to receive instructions. On macOS, NSDistributedNotificationCenter is a tempting mechanism, it is simple and it works. It is also unauthenticated and system-wide: any process on the machine can post any notification, and the receiver is given no sender identity by default.

We found a root-privileged updater daemon registering observers with a nil object filter, taking an array of paths from the notification payload and passing them to a file-removal API, as root, with no validation beyond a type cast. An unsigned binary running as an ordinary user could delete arbitrary root-owned files and directories. We confirmed it with a minimal proof of concept, verified the negative control (rm on the same path was refused), and restored the machine.

The rule: distributed notifications are a broadcast bus, not an IPC channel. Anything privileged needs an XPC connection with auditToken-based code-signature validation of the peer.

Part 4: How Not to Fool Yourself in Security Research

This is the part that determines whether your research programme is an asset or a liability. Against a large vendor's security team, one non-reproducing claim costs more credibility than three good findings earn.

We got things wrong. Here is what they had in common.

Never Infer Absence From a String Search

We reported that a client had no cleanup for a directory of recorded audio, reasoning from the fact that the directory name appeared exactly once in the entire bundle. The premise was true. The inference was wrong: a cleanup routine existed and reached the directory through a path-helper function rather than re-writing the literal.

function audioDir() { return join(userData(), "audio_files"); }   // the only literal

async function sweep() {                                          // the cleanup we missed
  for (const f of await readdir(audioDir()))
    if (Date.now() - (await stat(f)).mtimeMs > SEVEN_DAYS) await unlink(f);
}
function audioDir() { return join(userData(), "audio_files"); }   // the only literal

async function sweep() {                                          // the cleanup we missed
  for (const f of await readdir(audioDir()))
    if (Date.now() - (await stat(f)).mtimeMs > SEVEN_DAYS) await unlink(f);
}
function audioDir() { return join(userData(), "audio_files"); }   // the only literal

async function sweep() {                                          // the cleanup we missed
  for (const f of await readdir(audioDir()))
    if (Date.now() - (await stat(f)).mtimeMs > SEVEN_DAYS) await unlink(f);
}

Search for behaviour, not for spelling. If you want to claim nothing deletes a thing, search for the deletion primitives, unlink, rm, removeItem and read what each one operates on.

Never Generalise From a Sample

We claimed an application used no raw HTML injection sinks in its own code, having grepped for the sink, printed the first dozen matches, and found them all inside the framework. The output happened to be ordered by file. Further down, in application code, were nine more, four of them rendering server-supplied notification content.

Count first, then read. Aggregate by file before you look at any individual hit. If your conclusion is "all N are X", you must have examined N.

Kill Your Own Best Hypothesis

The discipline that most improves output quality: when you find the finding you want, spend the next twenty minutes trying to destroy it.

We believed a remotely-settable flag would enable network logging that captured authentication headers to disk, a credential-capture primitive. Then we read the call:

netLog.startLogging(path);   // path only — no options object
netLog.startLogging(path);   // path only — no options object
netLog.startLogging(path);   // path only — no options object

Electron defaults to a capture mode that strips cookies and authorization headers. No credentials. The strongest impact story for that finding evaporated, and we wrote that down in the report rather than leaving the implication standing.

Publishing the hypothesis you disproved is not weakness. It is the thing that makes the claims you do make believable.

Separate What You Proved From What You Reasoned

Every finding should carry an explicit boundary. Ours look like:

Proven: the OS routes the crafted URL to the application; the parser accepts arbitrary keys; the no-confirmation branch is unguarded; the write is server-side and durable. Not executed: the final API call, which would modify a live account. One function call downstream of behaviour already demonstrated.

A reader can act on that. "We found an RCE" with a paragraph of inference behind it gets closed as unreproducible, and the next report from the same address gets read less carefully.

Know When the Finding Isn't There

On the most heavily audited of our four targets, a chat client with a decade-old bounty programme, we went deep and came back with one Low.

The window factory locked its security preferences by spreading them last, so a caller could not override them. A runtime hook destroyed any window created without sandbox or context isolation. Domain validation compared host === domain || host.endsWith("." + domain), with the leading dot, so the classic suffix bypass was absent.

That is a real result. Reporting a manufactured Critical there would have cost us the ability to report anything else.

Part 5: The Desktop Client Security Checklist

For teams shipping desktop clients, in the order we'd audit them:

  1. Enumerate every registered URL scheme handler and ask of each: does this mutate state? If yes, it needs an application-state-derived nonce, not a parameter from the incoming URL.

  2. Grep your own codebase for measure-only controls. Find every place that computes a security verdict and check that the enforcement branch is reachable in the shipped default configuration.

  3. Make dangerous rendering unreachable rather than opt-out. One wrapper, caller overrides stripped, a lint rule on the raw import.

  4. Move at-rest migration into the storage layer. No filename lists.

  5. Read your own fallback flag table as the configuration every installation launches in.

  6. Replace distributed notifications in any privileged path with XPC and peer code-signature validation.

  7. Diff your shipped artifact against your repository. Everything the build adds is unreviewed by construction.

What to Ask in a Vendor Security Assessment

For teams buying desktop software, one question cuts through: what OS-level permissions does this application hold, and what in the client can influence what it does with them? The entitlements are readable in one command on any Mac, and no vendor questionnaire currently asks.

Why This Is a Structural Problem, Not a Competence Problem

The four applications we examined were built by competent teams. We found careful work everywhere: hash-pinned model downloads, entitlement-gated keychain items, path validators that correctly resolve symlinked parent directories, code-signature verification on the update path. These are not sloppy products.

The defects were all in the same place: at the boundary where a control written for one surface met a surface that grew after it. A rendering fix that didn't propagate to new sinks. A migration that enumerated the filenames that existed when it was written. A deep-link handler that was navigation-only until someone added a settings case.

That is a structural problem, not a competence problem. It's also, put plainly, a supply chain problem: your code security posture is only as complete as the least-reviewed layer between the repository and the thing your users double-click. SAST, SCA, SBOM and secret scanning cover the layers before the build. Nothing covers the artifact the build produces, unless something is built specifically to keep reading after the pipeline stops.

Not a pattern scanner. None of the findings above are patterns. Something that reads a control, reads the surface it protects, and catches the moment the two drift apart, on the same shipped binary an attacker would actually download.

That is the boundary we are extending CodeAnt AI's code security platform to cover next, and it is the boundary every desktop-shipping team should be asking their own security program about today.

Findings referenced here are anonymised and several remain under coordinated disclosure. Code samples are representative reconstructions of patterns observed across multiple products, not verbatim from any single one.

FAQs

What is the difference between SAST and auditing a shipped artifact?

Why can't SCA or software composition analysis catch desktop client bugs?

How do you analyse minified JavaScript in an Electron app?

What macOS entitlements should you check first in a desktop client?

Is a desktop client vulnerability really only one machine?

Start Your 14-Day Free Trial

AI code reviews, security and quality trusted by modern engineering teams.

Table of Content
No headings found on page
Ship clean & secure code faster

Get Pentest Report

NO CC REQUIRED