From 4cceef5d553912818818e69c02b87d6d844e3bae Mon Sep 17 00:00:00 2001 From: Jake Sanders Date: Tue, 4 Aug 2026 15:33:03 -0700 Subject: [PATCH] package-firewall: add the product.json re-apply watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third code chunk. Nothing installs this yet — the generator wiring is a later change. product.json is the first config target in this repo that a third party rewrites on its own schedule. VS Code replaces it on every update: roughly monthly for stable, *nightly* for Insiders. That schedule is uncorrelated with MDM check-in, so an MDM-check-in-only design would leave an Insiders box unprotected for part of every day. vscode_install_watcher / vscode_remove_watcher _vscode_watcher_launchd macOS _vscode_watcher_linux systemd, falling back to cron vscode_state_set / _get / _report Per platform: macOS launchd plist, WatchPaths on each product.json *and* its parent directory, plus StartInterval 3600 and RunAtLoad. The parent directory matters because Squirrel replaces the whole bundle rather than editing in place, so a file-only vnode watch goes stale. root:wheel 644 or launchd refuses to load it. Linux .service (oneshot) + .path (PathModified on files and parents) + .timer (OnUnitActiveSec=1h). No systemd -> /etc/cron.hourly, with no filename extension, because run-parts silently skips dotted names — a job that installs and never runs looks exactly like success. Both a sidecar KEY=VALUE state file (not JSON — this stays jq-free), 600, outside the app bundle. Never write new files *inside* the bundle: an added unsealed resource is worse for codesign --verify than a modified one. The residual race is not closable. If a developer relaunches VS Code between the update and the watcher firing, that session talks to the public marketplace. What the state file buys is making it *countable*: each re-apply bumps repatch_count and stamps last_repatch, and a later run reports "watcher has re-applied the patch 4x (last: ...)". An invisible race is the thing to avoid; a counted one is a fact an admin can act on. The plist and unit files are emitted with printf, not heredocs, because generate.sh inlines this lib through `grep -v '^# ' | sed '/^ *$/d'` — a heredoc body would have its blank lines and #-prefixed lines silently stripped. Tests: 22 assertions. The plist is linted with plutil and read back with PlistBuddy; the systemd units and the cron job are checked for the properties that make them work at all (4 PathModified entries, oneshot, hourly backstop, no filename extension). launchctl and systemctl are stubbed — loading a real system daemon needs root and would touch the host — so what is under test is the content of the files the lib writes. Co-Authored-By: Claude Opus 5 (1M context) --- package-firewall/bash/lib/common.sh | 248 ++++++++++++++++++++++++- package-firewall/tests/README.md | 1 + package-firewall/tests/bash/watcher.sh | 102 ++++++++++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100755 package-firewall/tests/bash/watcher.sh diff --git a/package-firewall/bash/lib/common.sh b/package-firewall/bash/lib/common.sh index e791531..871a753 100644 --- a/package-firewall/bash/lib/common.sh +++ b/package-firewall/bash/lib/common.sh @@ -27,12 +27,14 @@ # endor_b64url / endor_b64d — base64url encode (stdin) / decode # endor_json_* — dependency-free depth-1 JSON editors # vscode_install_paths / vscode_managed_state / vscode_patch / vscode_unpatch +# vscode_install_watcher / vscode_remove_watcher # # NOTE for anyone adding to this file: generate.sh inlines it via # grep -v '^# ' lib/common.sh | sed '/^[[:space:]]*$/d' # so every column-0 comment and every blank line is stripped from the generated # scripts. Nothing here may depend on a blank line or a '# '-prefixed line *inside* -# a heredoc — use printf for any block whose content matters. +# a heredoc — which is why the launchd plist and systemd units below are emitted +# with printf rather than heredocs. # Sentinel markers — identical across all config files so re-runs and remove work reliably ENDOR_BLOCK_START="# ===== BEGIN ENDOR PACKAGE FIREWALL (managed — do not edit) =====" @@ -50,6 +52,15 @@ ENDOR_XML_BLOCK_END="" # marker is the only record of the original extensionsGallery. Do not change it. ENDOR_JSON_MARKER_KEY="_endorPackageFirewall" +# launchd / systemd identifiers for the product.json re-apply watcher. +# The three directories are overridable purely so the generated plist and unit +# files can be inspected and linted without root; deployments use the defaults. +ENDOR_VSCODE_LABEL="com.endorlabs.pkgfirewall.vscode" +ENDOR_VSCODE_LOG="${ENDOR_VSCODE_LOG:-/var/log/endor-vscode-firewall.log}" +ENDOR_VSCODE_LAUNCHD_DIR="${ENDOR_VSCODE_LAUNCHD_DIR:-/Library/LaunchDaemons}" +ENDOR_VSCODE_SYSTEMD_DIR="${ENDOR_VSCODE_SYSTEMD_DIR:-/etc/systemd/system}" +ENDOR_VSCODE_CRON_DIR="${ENDOR_VSCODE_CRON_DIR:-/etc/cron.hourly}" + # ── User attribution helpers ────────────────────────────────────────────────── # Encode @ into the Basic-auth username. The firewall # decodes the label, auths with the real API key, and logs it as "User". @@ -1170,3 +1181,238 @@ vscode_unpatch() { return 0 } +# ── product.json re-apply watcher ───────────────────────────────────────────── +# VS Code replaces product.json wholesale on every update — monthly for stable, +# nightly for Insiders — on a schedule uncorrelated with MDM check-in. Waiting for +# the next check-in would leave the fleet unfiltered for part of every day once +# Insiders is in scope, so a watcher is the default rather than an extra. +# +# The race is not fully closable: if the user relaunches VS Code before the +# watcher fires, that session talks to the public marketplace. Both platforms +# therefore watch the file *and* its parent directory (the updater swaps the whole +# directory, so a file-only vnode watch goes stale) and keep an hourly trigger as +# the real backstop. repatch_count in the state file makes the races countable +# instead of invisible. + +# endor_vscode_state_dir — sidecar dir for watcher telemetry and the repatch +# payload. Never inside the app bundle. Overridable for the same reason as the +# watcher directories above: so it can be exercised without root. +endor_vscode_state_dir() { + if [[ -n "${ENDOR_VSCODE_STATE_DIR:-}" ]]; then + printf '%s' "$ENDOR_VSCODE_STATE_DIR" + elif [[ "$(uname -s)" == "Darwin" ]]; then + printf '%s' "/Library/Application Support/Endor/package-firewall/vscode" + else + printf '%s' "/var/lib/endor/package-firewall/vscode" + fi +} + +# vscode_state_set — KEY=VALUE sidecar, deliberately not JSON so the +# removal path needs no parser. +vscode_state_set() { + local dir key="$1" value="$2" f tmp + dir=$(endor_vscode_state_dir); f="$dir/state" + mkdir -p "$dir" 2>/dev/null || return 0 + tmp=$(mktemp) + [[ -f "$f" ]] && grep -v "^${key}=" "$f" > "$tmp" 2>/dev/null + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$f" 2>/dev/null && chmod 600 "$f" 2>/dev/null + return 0 +} + +# vscode_state_get +vscode_state_get() { + local f + f="$(endor_vscode_state_dir)/state" + [[ -f "$f" ]] || return 1 + sed -n "s/^${1}=//p" "$f" | tail -1 +} + +# vscode_state_report — surface watcher activity into MDM logs, so an admin can +# see that updates really are clobbering product.json on this fleet. +vscode_state_report() { + local n last + n=$(vscode_state_get repatch_count 2>/dev/null) || n="" + last=$(vscode_state_get last_repatch 2>/dev/null) || last="" + if [[ -n "$n" && "$n" != "0" ]]; then + echo "[endor-vscode] watcher has re-applied the patch ${n}× (last: ${last:-unknown})" + fi + return 0 +} + +# vscode_install_watcher +vscode_install_watcher() { + if [[ "${DRY_RUN:-0}" == "1" ]]; then + if [[ "$(uname -s)" == "Darwin" ]]; then + echo "[dry-run] watcher: ${ENDOR_VSCODE_LAUNCHD_DIR}/${ENDOR_VSCODE_LABEL}.plist (WatchPaths + hourly)" + else + echo "[dry-run] watcher: systemd endor-vscode-firewall.{service,path,timer}, or /etc/cron.hourly fallback" + fi + echo "[dry-run] repatch: $1" + return 0 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + _vscode_watcher_launchd "$1" "$2" + else + _vscode_watcher_linux "$1" "$2" + fi +} + +# _vscode_watcher_launchd +# Built with printf, not a heredoc: inline_common() strips blank and '# '-prefixed +# lines, which would silently mangle heredoc content. +_vscode_watcher_launchd() { + local script="$1" pathsfile="$2" plist p dir + plist="${ENDOR_VSCODE_LAUNCHD_DIR}/${ENDOR_VSCODE_LABEL}.plist" + + { + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' + printf '\tLabel%s\n' "$ENDOR_VSCODE_LABEL" + printf '%s\n' ' ProgramArguments' + printf '%s\n' ' ' + printf '\t\t/bin/bash\n' + printf '\t\t%s\n' "$script" + printf '%s\n' ' ' + printf '%s\n' ' RunAtLoad' + printf '%s\n' ' StartInterval3600' + printf '%s\n' ' WatchPaths' + printf '%s\n' ' ' + while IFS= read -r p; do + [[ -n "$p" ]] || continue + dir=$(dirname "$p") + printf '\t\t%s\n' "$p" + printf '\t\t%s\n' "$dir" + done < "$pathsfile" + printf '%s\n' ' ' + printf '\tStandardOutPath%s\n' "$ENDOR_VSCODE_LOG" + printf '\tStandardErrorPath%s\n' "$ENDOR_VSCODE_LOG" + printf '%s\n' '' + printf '%s\n' '' + } > "$plist" + + chown root:wheel "$plist" 2>/dev/null + chmod 644 "$plist" 2>/dev/null + + launchctl bootout "system/${ENDOR_VSCODE_LABEL}" 2>/dev/null || true + if ! launchctl bootstrap system "$plist" 2>/dev/null; then + launchctl load -w "$plist" 2>/dev/null || { + echo "[endor-vscode] WARNING: could not load the update watcher ($plist)." >&2 + echo "[endor-vscode] The patch is in place but will be lost on the next VS Code update." >&2 + _ENDOR_WARNED=1 + return 1 + } + fi + echo "[endor-vscode] update watcher installed → $plist" + return 0 +} + +# _vscode_watcher_linux +# systemd .path units track vnodes just like launchd WatchPaths, so both the file +# and its parent directory are watched; the .timer is the backstop. Without +# systemd, run-parts drives an hourly cron job — the filename must carry no +# extension or run-parts skips it. +_vscode_watcher_linux() { + local script="$1" pathsfile="$2" p unit + if command -v systemctl &>/dev/null && [[ -d "$ENDOR_VSCODE_SYSTEMD_DIR" ]]; then + { + printf '%s\n' '[Unit]' + printf '%s\n' 'Description=Re-apply Endor Package Firewall settings to VS Code product.json' + printf '%s\n' '[Service]' + printf '%s\n' 'Type=oneshot' + printf 'ExecStart=/bin/bash %s\n' "$script" + printf 'StandardOutput=append:%s\n' "$ENDOR_VSCODE_LOG" + printf 'StandardError=append:%s\n' "$ENDOR_VSCODE_LOG" + } > "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.service" + { + printf '%s\n' '[Unit]' + printf '%s\n' 'Description=Watch VS Code product.json for updater overwrites' + printf '%s\n' '[Path]' + while IFS= read -r p; do + [[ -n "$p" ]] || continue + printf 'PathModified=%s\n' "$p" + printf 'PathModified=%s\n' "$(dirname "$p")" + done < "$pathsfile" + printf '%s\n' 'Unit=endor-vscode-firewall.service' + printf '%s\n' '[Install]' + printf '%s\n' 'WantedBy=multi-user.target' + } > "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.path" + { + printf '%s\n' '[Unit]' + printf '%s\n' 'Description=Hourly backstop for the Endor VS Code product.json patch' + printf '%s\n' '[Timer]' + printf '%s\n' 'OnBootSec=1min' + printf '%s\n' 'OnUnitActiveSec=1h' + printf '%s\n' 'Unit=endor-vscode-firewall.service' + printf '%s\n' '[Install]' + printf '%s\n' 'WantedBy=timers.target' + } > "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.timer" + chmod 644 "$ENDOR_VSCODE_SYSTEMD_DIR"/endor-vscode-firewall.* 2>/dev/null + systemctl daemon-reload 2>/dev/null + systemctl enable --now endor-vscode-firewall.path endor-vscode-firewall.timer 2>/dev/null || { + echo "[endor-vscode] WARNING: systemd units written but could not be enabled." >&2 + _ENDOR_WARNED=1 + return 1 + } + echo "[endor-vscode] update watcher installed → systemd endor-vscode-firewall.{path,timer}" + return 0 + fi + + unit="${ENDOR_VSCODE_CRON_DIR}/endor-vscode-firewall" + if [[ -d "$ENDOR_VSCODE_CRON_DIR" ]]; then + printf '#!/bin/sh\nexec /bin/bash %s >> %s 2>&1\n' "$script" "$ENDOR_VSCODE_LOG" > "$unit" + chmod 755 "$unit" + echo "[endor-vscode] update watcher installed → $unit (no systemd; hourly cron)" + return 0 + fi + + echo "[endor-vscode] WARNING: no systemd and no ${ENDOR_VSCODE_CRON_DIR} — cannot install the update watcher." >&2 + echo "[endor-vscode] The patch will be lost on the next VS Code update. Re-push on check-in," >&2 + echo "[endor-vscode] or schedule $script yourself." >&2 + _ENDOR_WARNED=1 + return 1 +} + +# vscode_remove_watcher — offboarding; safe to call when nothing is installed. +vscode_remove_watcher() { + local plist="${ENDOR_VSCODE_LAUNCHD_DIR}/${ENDOR_VSCODE_LABEL}.plist" + + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] action : REMOVE update watcher (launchd/systemd/cron) and sidecar state" + return 0 + fi + + if [[ "$(uname -s)" == "Darwin" ]]; then + if [[ -f "$plist" ]]; then + launchctl bootout "system/${ENDOR_VSCODE_LABEL}" 2>/dev/null \ + || launchctl unload -w "$plist" 2>/dev/null || true + rm -f "$plist" + echo "[endor-remove] watcher removed : $plist" + else + echo "[endor-remove] skip (no watcher) : $plist" + fi + else + if [[ -f "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.path" ]]; then + systemctl disable --now endor-vscode-firewall.path endor-vscode-firewall.timer 2>/dev/null || true + rm -f "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.service" \ + "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.path" \ + "${ENDOR_VSCODE_SYSTEMD_DIR}/endor-vscode-firewall.timer" + systemctl daemon-reload 2>/dev/null || true + echo "[endor-remove] watcher removed : systemd endor-vscode-firewall.*" + fi + if [[ -f "${ENDOR_VSCODE_CRON_DIR}/endor-vscode-firewall" ]]; then + rm -f "${ENDOR_VSCODE_CRON_DIR}/endor-vscode-firewall" + echo "[endor-remove] watcher removed : ${ENDOR_VSCODE_CRON_DIR}/endor-vscode-firewall" + fi + fi + + local dir + dir=$(endor_vscode_state_dir) + if [[ -d "$dir" ]]; then + rm -rf "$dir" + echo "[endor-remove] sidecar removed : $dir" + fi + return 0 +} diff --git a/package-firewall/tests/README.md b/package-firewall/tests/README.md index 3840288..b875b20 100644 --- a/package-firewall/tests/README.md +++ b/package-firewall/tests/README.md @@ -42,6 +42,7 @@ byte-level fidelity wrong: | `bash/harness.sh` | paths, assertion helpers, the stripped-lib loader, fixture installs | | `bash/json-primitives.sh` | the awk JSON editing primitives in isolation | | `bash/lib.sh` | `vscode_*` lifecycle: discovery, state machine, both writers, failure modes | +| `bash/watcher.sh` | launchd plist, systemd units, cron fallback, sidecar telemetry | ## The fixture, and why not a real install diff --git a/package-firewall/tests/bash/watcher.sh b/package-firewall/tests/bash/watcher.sh new file mode 100755 index 0000000..71ba5b0 --- /dev/null +++ b/package-firewall/tests/bash/watcher.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# The update watcher: launchd on macOS, systemd or cron on Linux, plus the sidecar +# telemetry that makes the update race countable. +# +# VS Code replaces product.json on every update — monthly for stable, nightly for +# Insiders — on a schedule uncorrelated with MDM check-in, so the watcher is what +# keeps the patch applied. It is also the only part of this ecosystem that installs +# a persistent daemon, so its unit files are linted rather than assumed. +# +# The daemon is never actually loaded (that needs root and would touch the host), so +# launchctl and systemctl are stubbed. What is genuinely under test is the content +# of the files the lib writes. +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/harness.sh" + +W=$(mktemp -d) +trap 'rm -rf "$W"' EXIT + +# Every writable location the watcher code touches is overridable precisely so this +# suite can run without root. +export ENDOR_VSCODE_LAUNCHD_DIR="$W/LaunchDaemons" +export ENDOR_VSCODE_SYSTEMD_DIR="$W/systemd" +export ENDOR_VSCODE_CRON_DIR="$W/cron.hourly" +export ENDOR_VSCODE_LOG="$W/endor.log" +export ENDOR_VSCODE_STATE_DIR="$W/state" +mkdir -p "$ENDOR_VSCODE_LAUNCHD_DIR" "$ENDOR_VSCODE_SYSTEMD_DIR" "$ENDOR_VSCODE_CRON_DIR" "$W/bin" +printf '#!/bin/sh\nexit 0\n' > "$W/bin/launchctl"; chmod +x "$W/bin/launchctl" +printf '#!/bin/sh\nexit 0\n' > "$W/bin/systemctl"; chmod +x "$W/bin/systemctl" + +source_stripped_lib "$W" + +# Both editions, because the watcher has to cover more than one install. +printf '%s\n%s\n' \ + "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" \ + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/product.json" > "$W/paths" + +echo "== launchd plist ==" +if [ "$(uname -s)" = "Darwin" ]; then + PATH="$W/bin:$PATH" _vscode_watcher_launchd "$W/repatch.sh" "$W/paths" >/dev/null 2>&1 + PLIST="$ENDOR_VSCODE_LAUNCHD_DIR/com.endorlabs.pkgfirewall.vscode.plist" + [ -f "$PLIST" ] && ok "plist written" || bad "plist missing" + plutil -lint "$PLIST" >/dev/null 2>&1 && ok "plutil -lint passes" \ + || bad "plutil -lint FAILED: $(plutil -lint "$PLIST" 2>&1)" + # Squirrel replaces the whole bundle rather than editing product.json in place, so + # a watch on the file alone goes stale on the vnode that no longer exists. Both + # the files and their parent directories have to be watched. + n=$(/usr/libexec/PlistBuddy -c 'Print :WatchPaths' "$PLIST" 2>/dev/null | grep -c 'product.json\|/app$') + chk "watches both files and both parent directories" "$n" "4" + chk "hourly backstop present" \ + "$(/usr/libexec/PlistBuddy -c 'Print :StartInterval' "$PLIST" 2>/dev/null)" "3600" + chk "label correct" \ + "$(/usr/libexec/PlistBuddy -c 'Print :Label' "$PLIST" 2>/dev/null)" "com.endorlabs.pkgfirewall.vscode" + /usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$PLIST" 2>/dev/null | grep -q 'repatch.sh' \ + && ok "invokes the repatch script" || bad "repatch script not referenced" + # launchd refuses to load a plist that is group- or world-writable. + chk "plist mode is 644" "$(stat -f '%Lp' "$PLIST")" "644" +else + skip "launchd plist (needs macOS for plutil/PlistBuddy)" +fi + +echo "== systemd units ==" +PATH="$W/bin:$PATH" _vscode_watcher_linux "$W/repatch.sh" "$W/paths" >/dev/null 2>&1 +for u in service path timer; do + [ -f "$ENDOR_VSCODE_SYSTEMD_DIR/endor-vscode-firewall.$u" ] && ok "$u unit written" || bad "$u unit missing" +done +chk "4 PathModified entries (both files, both parent dirs)" \ + "$(grep -c '^PathModified=' "$ENDOR_VSCODE_SYSTEMD_DIR/endor-vscode-firewall.path")" "4" +grep -q '^OnUnitActiveSec=1h' "$ENDOR_VSCODE_SYSTEMD_DIR/endor-vscode-firewall.timer" \ + && ok "hourly timer backstop" || bad "timer interval missing" +grep -q '^Type=oneshot' "$ENDOR_VSCODE_SYSTEMD_DIR/endor-vscode-firewall.service" \ + && ok "service is oneshot" || bad "service type wrong" + +echo "== cron fallback, for a Linux box without systemd ==" +rm -rf "$ENDOR_VSCODE_SYSTEMD_DIR" "$W/bin/systemctl" +_vscode_watcher_linux "$W/repatch.sh" "$W/paths" >/dev/null 2>&1 +C="$ENDOR_VSCODE_CRON_DIR/endor-vscode-firewall" +[ -f "$C" ] && ok "cron job written" || bad "cron job missing" +[ -x "$C" ] && ok "cron job is executable" || bad "cron job not executable" +# run-parts skips any name containing a dot, so an extension here would mean the +# job is installed and never runs — a failure that looks like success. +case "$(basename "$C")" in + *.*) bad "basename has an extension; run-parts would silently skip it" ;; + *) ok "basename has no extension (run-parts safe)" ;; +esac +grep -q 'repatch.sh' "$C" && ok "cron job invokes the repatch script" || bad "cron job does not reference repatch" + +echo "== sidecar telemetry ==" +# The update race cannot be closed — if a developer relaunches VS Code before the +# watcher fires, that session talks to the public marketplace. Counting re-applies +# is what turns it from invisible into visible in an MDM log. +vscode_state_set repatch_count 4 +vscode_state_set last_repatch 2026-08-04T12:00:00Z +chk "state round-trips" "$(vscode_state_get repatch_count)" "4" +vscode_state_set repatch_count 5 +chk "a key is replaced, not appended" "$(vscode_state_get repatch_count)" "5" +chk "exactly one repatch_count line after the update" \ + "$(grep -c '^repatch_count=' "$ENDOR_VSCODE_STATE_DIR/state")" "1" +vscode_state_report | grep -q '5×' && ok "report surfaces the count for MDM logs" || bad "report empty" +chk "state file is not world-readable" "$(stat -f '%Lp' "$ENDOR_VSCODE_STATE_DIR/state" 2>/dev/null \ + || stat -c '%a' "$ENDOR_VSCODE_STATE_DIR/state")" "600" + +summarize