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