Skip to content

fix(cli): strip com.apple.quarantine from downloaded CLI binary (DEVA11Y-752) - #36

Merged
Crash0v3rrid3 merged 5 commits into
mainfrom
fix/DEVA11Y-752-strip-quarantine
Aug 19, 2026
Merged

fix(cli): strip com.apple.quarantine from downloaded CLI binary (DEVA11Y-752)#36
Crash0v3rrid3 merged 5 commits into
mainfrom
fix/DEVA11Y-752-strip-quarantine

Conversation

@Crash0v3rrid3

@Crash0v3rrid3 Crash0v3rrid3 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

DEVA11Y-752 — a customer on MDM-managed macOS reports the binary downloaded by the setup script (scripts/*/cli.sh) is immediately killed by Gatekeeper:

ASP: Security policy would not allow process

The file carries the com.apple.quarantine attribute and our CLI binary is not Developer ID signed / notarized, so Gatekeeper refuses to run it. Their MDM removes the manual "Allow Anyway" override, so the tool is unusable without per-machine IT intervention.

Root cause

curl and the SDK-runtime download path do not normally set com.apple.quarantine, so most users never hit this. In managed environments an MDM/security agent stamps the attribute onto network-written files — that is what this customer is hitting. Full notarization + stapling is the long-term fix but heavy; this PR is the accepted near-term workaround (automating what the customer's IT does by hand, which is proven to work under their MDM).

Change

Add a guarded strip_quarantine step to the bash / zsh / fish cli.sh scripts that removes the attribute right after the binary is extracted and made executable:

if [[ "$OS" == "macos" ]] && command -v xattr >/dev/null 2>&1; then
  xattr -d com.apple.quarantine "$BINARY_PATH" 2>/dev/null || true
fi
  • No-op on non-macOS hosts and when the attribute is absent → unaffected users see no behavior change.

Testing

  • bash -n passes on all three modified scripts.
  • Behavior verified by inspection: strip runs only after a successful download + chmod, and swallows errors so it can never break the scan.

🤖 Generated with Claude Code

…11Y-752)

macOS Gatekeeper kills the downloaded browserstack-cli binary with
"ASP: Security policy would not allow process" when the file carries the
com.apple.quarantine attribute. Some managed environments (MDM/security
tooling) stamp this attribute on network-written files, and their policy
removes the manual "Allow Anyway" override, making the CLI unusable
without IT intervention.

Add a guarded strip_quarantine step that removes the attribute after the
binary is extracted and made executable, in the bash/zsh/fish cli.sh
scripts. It is a no-op on non-macOS hosts and when the attribute is
absent, so unaffected users see no change. spm.sh is unchanged because
the SPM plugin path manages the binary internally and never writes a
standalone binary for the script to strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3
Crash0v3rrid3 marked this pull request as ready for review August 19, 2026 09:34
@Crash0v3rrid3
Crash0v3rrid3 requested a review from a team as a code owner August 19, 2026 09:34
Crash0v3rrid3 and others added 3 commits August 19, 2026 15:05
…edit (DEVA11Y-752)

Self-update verifies each launcher script against its committed sidecar,
so the sidecars must be regenerated whenever the script changes; the
verify-selfupdate-checksums CI gate enforces this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…752)

Apply the same Gatekeeper workaround to the SPM launcher scripts. The
swift-package plugin caches browserstack-cli under
${XDG_CACHE_HOME:-$HOME/.cache}/browserstack/devtools/spm-plugin; strip
the quarantine attribute from any cached binary before invoking the
plugin so a previously-downloaded binary is not killed by Gatekeeper in
MDM-managed environments. Guarded to a no-op off macOS and when the
cache or attribute is absent. Sidecars regenerated for verify-sidecars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (DEVA11Y-752)

The quarantine strip derived the cache dir from the outer shell's
XDG_CACHE_HOME, but the plugin is invoked under `env -i` which does not
pass XDG_CACHE_HOME through. The plugin therefore always resolves its
cache to $HOME/.cache (Plugins/.../BrowserStackAccessibilityLint.swift,
lines 57-68). Any developer with XDG_CACHE_HOME set to a custom path
would have had the strip target the wrong directory, missing the
plugin's real binary and leaving Gatekeeper to kill it.

Use $HOME/.cache unconditionally so the strip matches the plugin's
runtime environment. Sidecars regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3

Copy link
Copy Markdown
Collaborator Author

/stack:pr-review — 🟢 green

Automated review pass over all 4 commits (3e4f6fc8e23091) against main. No blocking issues; correct, minimal, idempotent.

Verified:

  • cli.shstrip_quarantine runs only after bsdtar … && chmod … &&, so $BINARY_PATH is guaranteed to exist; extract/chmod failure still short-circuits and propagates. Uses the pre-set $OS/$BINARY_PATH.
  • spm.sh — hardcodes $HOME/.cache, which provably matches the plugin: it's launched via env -i HOME="$HOME" …, and the plugin's resolver (BrowserStackAccessibilityLint.swift:57-68) reads XDG_CACHE_HOME from its own (cleared) env → falls back to HOME/.cache. (Fixes the one substantive bug: the earlier revision read the outer shell's XDG_CACHE_HOME.)
  • Error hygiene — xattr -d on a file lacking the attribute is swallowed (2>/dev/null || true), never aborts the scan, not on a set -e path.
  • CI green, including verify-sidecars (all six .sha256 sidecars in sync).

Non-blocking (documented, no change requested): the spm.sh strip runs before the plugin, so a first run (empty cache) or a same-process new-binary download+exec can't be stripped pre-exec — only repeat runs are covered. cli.sh has no such gap. Fully closing it needs the strip inside the plugin's ensureLatestBinary or Developer-ID notarization+stapling.

@maunilm maunilm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — DEVA11Y-752 quarantine strip

Reviewed the full diff (12 files, +87/−9) plus Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift to verify the claims in the comments.

Verified correct (these are non-obvious and the reasoning holds up):

  • All 6 .sha256 sidecars regenerated correctly — I recomputed shasum -a 256 on every script at head 8e23091 and all six match the committed hashes. Self-update won't abort.
  • The env -i reasoning in the spm.sh comment is accurate. spm.sh invokes the plugin as env -i HOME=… XCODE_VERSION_ACTUAL=… BROWSERSTACK_* … PATH=… swift package plugin, which does drop XDG_CACHE_HOME, so packageCacheRoot() falls through to HOME/.cache. Hardcoding $HOME/.cache/… rather than honoring the outer XDG_CACHE_HOME is the right call, and the comment explaining why is worth keeping.
  • Cache path and binary name both match the plugin exactly: packageCacheRoot() builds <base>/browserstack/devtools/spm-plugin, and executableFileName() returns browserstack-cli on non-Windows.
  • $OS == "macos" is the correct guard — get_os() maps Darwin*macos in all three cli.sh variants.
  • xattr -d com.apple.quarantine (rather than xattr -c) is correctly minimal, and there's no set -e in these scripts, so the && chain and || true behave as intended.
  • download_binary is the only site that writes $BINARY_PATH, and it re-extracts on every invocation — so the cli.sh path strips on every run and self-heals an already-quarantined binary. That's the robust shape.

Main concern: the spm.sh half can't cover the first run, which is the case the ticket describes. Details inline. One P2 on binary integrity and a nit, also inline.

PR description is stale and contradicts the diff. It states:

spm.sh is intentionally unchanged: the SPM plugin path manages the binary internally and never writes a standalone binary for the shell script to strip.

But all three spm.sh files are changed (+15 lines each), and that's the riskiest part of the diff. Anyone triaging from the description will skip exactly the part that needs the most scrutiny. Worth updating before this merges. The "Testing" section is also now understated — bash -n on "all three modified scripts" is really six files.

Testing: a regression test for the real MDM behavior isn't practical, but the first-run gap below is cheaply testable in spm-smoke-test.yml: stamp the attribute manually with xattr -w com.apple.quarantine '0081;0;;' <cached-binary> and assert the scan still succeeds — once with a warm cache (passes today) and once with a cold cache (fails today).

Comment thread scripts/bash/spm.sh Outdated
Comment thread scripts/bash/cli.sh Outdated
Comment thread scripts/bash/spm.sh Outdated
…mod 0755 (DEVA11Y-752)

Address PR review (maunilm):

- P1: remove the com.apple.quarantine strip from the three spm.sh scripts.
  The SPM plugin downloads AND execs the binary within a single
  `swift package plugin scan` invocation, so a shell-level strip before
  that call cannot cover a cold cache or a CLI version bump — the first
  run in an MDM environment would still fail. Rather than ship a partial
  mitigation that reads as full coverage, spm.sh is reverted to main; the
  plugin-side fix (strip at the download site) / notarization is tracked
  as a follow-up.

- P2b: cli.sh downloads a binary that nothing else verifies and Gatekeeper
  no longer gates, so tighten its permissions from 0775 (group-writable —
  the macOS primary group `staff` includes every local user) to 0755.

The cli.sh strip is unchanged and remains full coverage: download_binary
re-extracts and strips on every invocation, so the strip always lands
after the write. Sidecars regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crash0v3rrid3

Crash0v3rrid3 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-ups filed as DEVA11Y-753 (relates to DEVA11Y-752), covering the two items deferred from this PR:

  • P1 — plugin-side quarantine strip at the download site (or Developer-ID notarization) to cover the SPM first-run/cold-cache path.
  • P2a — CLI binary integrity verification (have download_cli publish a checksum, verify before chmod/strip).

This PR now scopes the strip to cli.sh only (spm.sh reverted to main) and tightens the downloaded binary to chmod 0755. All three review threads addressed.

@maunilm maunilm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — re-review of e766aa1

All three round-1 findings are properly addressed. I re-verified each rather than taking the commit message at face value.

Verified resolved

# Finding Resolution Verified how
P1 spm.sh strip couldn't cover cold cache / version bump spm.sh reverted to main All 6 files ({bash,zsh,fish}/spm.sh + their .sha256) are byte-identical to main (bea914e) by blob SHA. Clean revert — no partial leftovers.
P2b chmod 0775 group-writable executable 0755 Confirmed on all three cli.sh. Also checked the blast radius: CACHE_ROOT="${HOME}/.cache/browserstack/devtools/cli/" is per-user, so dropping group-write can't regress a shared-cache scenario. Strictly an improvement.
P2a CLI artifact has no integrity verification Deferred to DEVA11Y-753 Ticket exists and is specific — names the download_cli checksum dependency and the verify-before-chmod/strip ordering. Correctly scoped as a server-side dependency rather than force-fit into this PR.
P3 cli_cache not local Moot Removed with the spm.sh revert.

Also re-verified: the three cli.sh.sha256 sidecars match the post-0755 content (recomputed shasum -a 256), and spm.sh is still self-consistent with its own reverted sidecar — so script_self_update won't abort on either path. That's the failure mode a revert-plus-sidecar change most easily introduces, and it's clean.

On the P1 call: reverting rather than shipping the partial strip was the right judgment. A shell-level strip that silently no-ops on exactly the cold-cache case would have read as coverage in the merged diff while leaving the reported failure intact. DEVA11Y-753 captures the real fix at the plugin download site.

The diff is now minimal and well-scoped: 6 files, one guarded function, one permission tightening.

One thing left, and it's the PR description (P2)

This bullet is still in the body and is now factually wrong in a way that matters:

  • spm.sh is intentionally unchanged: the SPM plugin path manages the binary internally and never writes a standalone binary for the shell script to strip.

The conclusion (spm.sh unchanged) is accurate again, but the stated reason isn't. The plugin does write a standalone binary — publishVersionDirectory lands it at ~/.cache/browserstack/devtools/spm-plugin/<version>/browserstack-cli, which is exactly the path the removed strip was globbing. The real reason is the one in your commit message and in DEVA11Y-753: download and exec happen inside a single swift package plugin scan, so a shell-level strip before that call can't cover a cold cache or a version bump.

Why this isn't just pedantry: as written, the description tells a future reader the SPM path has no problem. The truth is the SPM path is still broken under MDM and the fix is tracked elsewhere. With DEVA11Y-752 sitting in review, that's a realistic path to closing the parent ticket while SPM users remain blocked. Suggested replacement:

  • spm.sh is reverted to main: the SPM plugin downloads and executes the CLI within one swift package plugin scan invocation, so a shell-level strip before that call cannot cover a cold cache or a CLI version bump (the cache is keyed per-version). The SPM path therefore remains affected under MDM — the plugin-side strip and CLI binary integrity verification are tracked in DEVA11Y-753.

Two smaller description edits while you're in there:

  • Link DEVA11Y-753 in the body so the deferred scope is visible from the PR, not only from the commit message and review threads.
  • The trailing > Draft — leaving open for the outstanding diagnostic (xattr -l ...) banner is still present but the PR isn't a draft. Either flip it back to draft or drop the banner — as-is it's ambiguous whether this is mergeable.

Verdict

Ready to merge once the description bullet is corrected. No code changes requested — the remaining item is documentation, but I'd fix it before merge rather than after, since the wrong rationale is what survives in the permanent record and it's the piece that could cause DEVA11Y-752 to be closed prematurely.

@Crash0v3rrid3
Crash0v3rrid3 merged commit e4bb5dc into main Aug 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants