diff --git a/README.md b/README.md index 4fb070c..82af9b6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Scripts and generators for deploying Endor Labs configuration via MDM. Generates self-contained MDM scripts that configure developer machines to route package installations through the [Endor Package Firewall](https://docs.endorlabs.com/integrations/package-firewall). +Covers JavaScript, Python, Go, Maven, and VS Code extensions. + ### [`agent-governance/`](agent-governance/README.md) Generates MDM-deployable Endor Labs audit hooks for every AI coding agent on your fleet. \ No newline at end of file diff --git a/package-firewall/README.md b/package-firewall/README.md index 5fa25df..1cfc86b 100644 --- a/package-firewall/README.md +++ b/package-firewall/README.md @@ -25,6 +25,18 @@ See each directory's README for generation and deployment instructions. | Python | pip, uv, poetry | | Go | go modules (via GOPROXY) | | Java | Maven (via `~/.m2/settings.xml` mirror); Gradle when it reads `~/.m2` | +| VS Code extensions | VS Code and VS Code Insiders (via `product.json` extension gallery), including `code --install-extension` | + +VS Code has prerequisites the other ecosystems do not — a macOS App Management (TCC) grant, +and a credential that necessarily lands in a world-readable file. Read the +[VS Code prerequisites](bash/README.md#vs-code-prerequisites) before deploying it. + +VS Code is configured by patching `product.json` rather than through VS Code's +`ExtensionGalleryServiceUrl` enterprise policy. That policy looks like the natural fit but is +gated behind a GitHub Copilot Business/Enterprise entitlement, expects a different document +shape, and does not reach `code --install-extension` — +[docs/vscode-enterprise-policy.md](docs/vscode-enterprise-policy.md) records the details and +what would change that. --- @@ -41,9 +53,14 @@ shared/blocks/ ├── pipconf.txt ← pip.conf / pip.ini content ├── uvtoml.txt ← uv.toml content ├── goenv.txt ← go env file content (GOPROXY) -└── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +├── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +└── vscodegallery.txt ← product.json extensionsGallery overrides (key-level merge) ``` +`vscodegallery.txt` is the one block that is not written verbatim into a config file: it is +applied as a key-level merge into VS Code's `product.json`, so keys it does not mention are +left exactly as VS Code shipped them. Its syntax is documented in the file itself. + Edit these files to customise what gets written to developer machines. The orchestration scripts (`templates/*.sh` / `templates/*.ps1`) control which files get written and in what order. --- @@ -59,8 +76,25 @@ Running either generator produces these scripts in `out//`: | `endor-go.*` | Configure Go modules only | | `endor-maven.*` | Configure Maven only | | `endor-all.*` | Configure all package managers (single-script deploy) | +| `endor-vscode.*` | Configure VS Code + Insiders extension gallery. **Not** included in `endor-all.*` — deploy alongside it | +| `endor-vscode-repatch.*` | Installed by `endor-vscode.*` and run by the OS after VS Code updates. Written to `out/` only so you can read it; do not upload it | | `endor-remove.*` | Strip all Endor configuration from a machine | +`endor-vscode.*` is deliberately kept out of `endor-all.*`: it is the only script that writes +inside an application bundle and the only one that installs a persistent daemon, so folding it +in would silently widen the blast radius of every existing `endor-all` deployment. + Each generated script is fully self-contained — no external files or dependencies at runtime. > **Security**: add `out/` to `.gitignore`. Generated scripts contain API credentials in plaintext. + +## Tests + +```sh +cd package-firewall/tests && ./run-all.sh +``` + +Covers the VS Code ecosystem: the JSON editing primitives, patch/restore byte fidelity, the +update watcher, and the generated scripts end to end. No root, no network, and nothing touches +an installed VS Code. See [tests/README.md](tests/README.md) for what is and is not covered — +notably, the Windows-only surfaces are reported as skipped rather than passed. diff --git a/package-firewall/bash/README.md b/package-firewall/bash/README.md index 6398c3a..c48a3c7 100644 --- a/package-firewall/bash/README.md +++ b/package-firewall/bash/README.md @@ -17,6 +17,7 @@ bash/ │ ├── python.sh ← orchestration: pip / uv config file writes │ ├── go.sh ← orchestration: go env file write │ ├── maven.sh ← orchestration: ~/.m2/settings.xml write (XML-aware) +│ ├── vscode.sh ← orchestration: product.json gallery patch (JSON-aware) │ └── remove.sh ← orchestration: sentinel block removal └── out/ ← generated scripts (gitignore this) └── / @@ -25,6 +26,8 @@ bash/ ├── endor-go.sh ├── endor-maven.sh ├── endor-all.sh + ├── endor-vscode.sh + ├── endor-vscode-repatch.sh └── endor-remove.sh ../shared/blocks/ ← edit these to customise what gets written to config files @@ -35,7 +38,8 @@ bash/ ├── pipconf.txt ← pip.conf content ├── uvtoml.txt ← ~/.config/uv/uv.toml content ├── goenv.txt ← go env file content (path resolved via `go env GOENV`) -└── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +├── mavensettings.txt ← ~/.m2/settings.xml fragment (Maven mirror + server) +└── vscodegallery.txt ← product.json extensionsGallery overrides (key-level merge) ``` --- @@ -92,7 +96,10 @@ Each script in `out/-/` is **fully self-contained** — no exter | `endor-go.sh` | Team uses Go only | | `endor-maven.sh` | Team uses Java / Maven only | | `endor-all.sh` | Team uses multiple ecosystems — single-script deploy | +| `endor-vscode.sh` | Team uses VS Code and/or VS Code Insiders extensions. **Not** part of `endor-all.sh` — deploy it alongside. See [VS Code prerequisites](#vs-code-prerequisites) first. | +`endor-vscode-repatch.sh` is *not* uploaded. `endor-vscode.sh` embeds and installs it; it +is written out only so you can read what gets installed. Upload the script file. Ensure it runs as **root** — the script detects the logged-in console user internally and writes config files to the correct home directory. @@ -107,15 +114,23 @@ All scripts share a common credential architecture: export ENDOR_API_KEY_ID="..." export ENDOR_API_SECRET="..." export ENDOR_AUTH_B64="..." # base64(key:secret) — used by npm/pnpm/yarn/bun +export ENDOR_ATTR_USER="..." # attributed username — @ +export ENDOR_API_SECRET_B64="..." # base64(secret) export ENDOR_NPM_REGISTRY_URL="..." # used by npm, yarn 2+ -export ENDOR_PYPI_URL="..." # used by uv export POETRY_HTTP_BASIC_ENDOR_FIREWALL_USERNAME="..." export POETRY_HTTP_BASIC_ENDOR_FIREWALL_PASSWORD="..." ``` +pip, uv, go and VS Code are **not** in that list: none of them can expand env vars in +their config, so those four get literal credentials baked in at install time. + Shell profiles (`.zshrc`, `.bash_profile`, `.bashrc`) each get a one-line sentinel block that sources this file. Config files reference env vars rather than baking credentials — except pip, which cannot expand env vars. -**Credential rotation**: update `env.sh` on target machines (redeploy MDM script). No config file changes needed. +**Credential rotation**: update `env.sh` on target machines (redeploy MDM script). No config +file changes needed — except for the four consumers that cannot use env vars (pip, uv, go, +VS Code), which the redeployed script rewrites in place. `endor-vscode.sh` detects a rotated +credential as `stale`, restores the original `product.json` gallery, then re-patches it, so +the captured original is never lost across rotations. --- @@ -162,11 +177,11 @@ Writes `~/.config/endor/env.sh` and an Endor-managed block to: | `~/.pip/pip.conf` | pip (legacy path) | Literal — pip cannot expand env vars | | `~/.config/pip/pip.conf` | pip (XDG / Linux standard) | Literal | | `~/Library/Application Support/pip/pip.conf` | pip (macOS primary) | Literal | -| `~/.config/uv/uv.toml` | uv (does **not** read pip.conf) | `${ENDOR_PYPI_URL}` env var ref | +| `~/.config/uv/uv.toml` | uv (does **not** read pip.conf) | literal credential (uv cannot expand env vars) | Key behaviour: - **pip**: uses a named `[endor-firewall]` section — preserves any existing `[global]` settings; credentials are literal (pip limitation) -- **uv**: uv ignores pip.conf entirely; `~/.config/uv/uv.toml` is the user-level global config; references `${ENDOR_PYPI_URL}` +- **uv**: uv ignores pip.conf entirely; `~/.config/uv/uv.toml` is the user-level global config; carries a literal credential baked in at install time - **poetry**: credentials are in `env.sh` as `POETRY_HTTP_BASIC_ENDOR_FIREWALL_*` — no separate write step > For poetry, developers still need to add the source to `pyproject.toml` (URL only, no credentials): @@ -197,6 +212,111 @@ Key behaviour: --- +### `endor-vscode.sh` + +Patches the `extensionsGallery` object in VS Code's `product.json` so extension search, +install, update **and `code --install-extension`** all resolve through the firewall. + +| Install path | Editions | +|---|---| +| `/Applications/Visual Studio Code{, - Insiders}.app/Contents/Resources/app/product.json` | macOS, system | +| `~/Applications/Visual Studio Code{, - Insiders}.app/...` | macOS, per-user | +| `/usr/share/code{,-insiders}/resources/app/product.json` | Linux (.deb / .rpm) | +| `/opt/visual-studio-code{,-insiders}/resources/app/product.json` | Linux (tarball / AUR) | + +Exactly one key is set and one removed; every other key is left as VS Code shipped it, +including keys added by future VS Code versions: + +| Key | Action | Why | +|---|---|---| +| `serviceUrl` | set | VS Code derives both `${serviceUrl}/extensionquery` (search) and `${serviceUrl}/vscode/{publisher}/{name}/latest` (install/update) from it | +| `extensionUrlTemplate` | **removed** | It is the fallback VS Code uses when the resource API returns 5xx. Left in place, a firewall outage would silently resolve versions from `www.vscode-unpkg.net` — bypassing the firewall at precisely the wrong moment. With the key absent that failure retries the firewall's own `extensionquery` instead, and fails the install if that fails too. Fail-closed. | +| `controlUrl` | untouched | Microsoft's malicious-extension revocation list — free defense in depth alongside Endor | +| `resourceUrlTemplate`, `itemUrl`, `publisherUrl`, `nlsBaseUrl`, `mcpUrl`, `accessSKUs` | untouched | README/changelog rendering, marketplace web views, language packs, Copilot entitlement | + +**How enforcement works.** Blocked versions are filtered out of the gallery response, so +they are never offered and cannot be selected. Extension *downloads* still come from +Microsoft's CDN by design — the asset URLs come from the gallery response, and the firewall +filters rather than rewrites them. Consequences worth knowing: + +- Keep `*.vsassets.io` and `*.vscode-unpkg.net` reachable through any egress proxy, or + installs break. +- Enforcement is **discovery-time**. Extensions already installed, sideloaded `.vsix` + files, and anything installed before the patch landed are not retroactively caught. + +**Managed marker instead of a sentinel block.** `product.json` is JSON, so it can carry +neither a `#` comment nor an `${ENDOR_*}` reference. The script adds one top-level key: + +```json +"_endorPackageFirewall": {"schema":1,"namespace":"…","appVersion":"…","via":"awk","originalExtensionsGalleryB64":"…"} +``` + +`originalExtensionsGalleryB64` is the original `extensionsGallery` block verbatim, so +`endor-remove.sh` restores it byte-for-byte. It travels with the file and cannot desync +from it. Re-running is a no-op when already current; a changed credential or namespace is +detected as `stale`, which restores the original first and then re-patches — the script +never patches on top of a patch. + +**The update watcher.** VS Code replaces `product.json` on every update — roughly monthly +for stable, **nightly for Insiders** — on a schedule unrelated to MDM check-in. So +`endor-vscode.sh` also installs a re-apply hook: + +| Platform | Hook | +|---|---| +| macOS | `/Library/LaunchDaemons/com.endorlabs.pkgfirewall.vscode.plist` — `WatchPaths` on each `product.json` *and* its parent directory (the updater swaps the whole directory), plus an hourly `StartInterval` backstop | +| Linux | `endor-vscode-firewall.{service,path,timer}` under systemd; `/etc/cron.hourly/endor-vscode-firewall` when systemd is absent | + +The race is not fully closable: if a developer relaunches VS Code before the watcher fires, +that one session talks to the public marketplace. It is therefore made *countable* rather +than invisible — each re-apply bumps `repatch_count` in the sidecar state, and subsequent +runs print it (`watcher has re-applied the patch 4× (last: …)`). Pass +`--no-vscode-watcher` to skip the hook; the script then says so loudly but still exits 0, +because failing every MDM check-in over a deliberate setting is just alert fatigue. + +Sidecar state (not inside the app bundle, `0600`, root-owned): +`/Library/Application Support/Endor/package-firewall/vscode/` on macOS, +`/var/lib/endor/package-firewall/vscode/` on Linux. + + +#### VS Code prerequisites — read before deploying + +1. **macOS Ventura+ needs the App Management TCC grant.** Writing inside an `.app` bundle + signed by another developer is gated by `SystemPolicyAppBundles`, and **root is not + exempt**. Grant your MDM agent App Management (or Full Disk Access) via a PPPC profile. + Without it the script fails loudly with an explanation rather than silently no-op'ing. +2. **The gallery token is world-readable, and that is unavoidable.** `product.json` is + `root:wheel 0644` and must stay readable by every user who runs VS Code. Because the + credential is a URL path segment, any local user can read a working firewall token, and + it also appears in VS Code's own logs. VS Code offers no env-var indirection in + `product.json` — this is a property of the only delivery channel it gives us. Mitigate + blast radius, not exposure: **use a dedicated, separately revocable API key for VS + Code**, never the same one as npm/PyPI/Maven. +3. **`codesign --verify` will report the bundle as modified.** Expected — `product.json` is + inside the `CodeResources` seal. VS Code still runs (this is how every Open VSX + deployment works), though it may re-prompt for Keychain access once. **Do not re-sign to + "fix" it**: ad-hoc re-signing strips the hardened-runtime entitlements and changes the + designated requirement, which *would* durably break stored GitHub auth. +4. **snap and flatpak installs cannot be patched.** Their payload is mounted read-only. The + script detects them by path, explains, and warns rather than failing silently. Use the + `.deb`/`.rpm`/tarball build instead. +5. **Restart VS Code** after install or removal — `product.json` is read once at startup. + +#### Troubleshooting + +A blocked extension produces an **error line in the Output → Window channel** even though +enforcement is working correctly: + +``` +Error while getting the latest version for the extension . +``` + +That is the expected path: the firewall answers `/latest` with `400`, VS Code classifies it +as a client error and retries `extensionquery`, where the blocked version is filtered out. +Set `Developer: Set Log Level… → Trace` and filter on `[Marketplace]` to see it. There is no +"Marketplace" output channel — those strings are view names. + +--- + ## Customising To change what gets written to a config file on target machines, edit the relevant file in `../shared/blocks/` directly: @@ -211,6 +331,7 @@ To change what gets written to a config file on target machines, edit the releva | `../shared/blocks/uvtoml.txt` | `~/.config/uv/uv.toml` | | `../shared/blocks/goenv.txt` | `~/.config/go/env` | | `../shared/blocks/mavensettings.txt` | `~/.m2/settings.xml` | +| `../shared/blocks/vscodegallery.txt` | VS Code `product.json` → `extensionsGallery` (merged key-by-key, not written verbatim) | To change orchestration logic (which files get written, in what order, with what warnings), edit the relevant `templates/*.sh` file directly. @@ -221,7 +342,14 @@ Both support `{{PLACEHOLDER}}` substitution at generation time and `${ENDOR_VAR} | `{{PLACEHOLDER}}` | Generation time by `generate.sh` | Values baked into the config file (e.g. registry host in a key position) | | `${ENDOR_VAR}` | Runtime by the tool reading the config file | Credential values — kept out of config files, resolved from `env.sh` | -Available placeholders: `{{API_KEY_ID}}`, `{{API_SECRET}}`, `{{NPM_REGISTRY_URL}}`, `{{NPM_REGISTRY_HOST}}`, `{{NPM_AUTH_B64}}`, `{{PYPI_URL}}`, `{{PIP_INDEX_URL}}`, `{{TRUSTED_HOST}}`, `{{GO_PROXY_URL}}`, `{{MAVEN_REGISTRY_URL}}`, `{{NAMESPACE}}`, `{{FQDN}}` +There are in fact **two** rounds of `{{...}}` substitution, because per-developer +attribution (`@`) does not exist until the script runs on the +developer's machine: + +| Placeholder | Resolved | +|---|---| +| `{{NAMESPACE}}`, `{{FQDN}}`, `{{FQDN_HOST}}`, `{{API_KEY_ID}}`, `{{API_SECRET}}`, `{{API_SECRET_B64}}`, `{{NPM_REGISTRY_URL}}`, `{{NPM_REGISTRY_HOST}}`, `{{PYPI_URL}}`, `{{TRUSTED_HOST}}`, `{{MAVEN_REGISTRY_URL}}`, `{{VSCODE_GALLERY_BASE}}` | Generation time, by `substitute()` in `generate.sh` | +| `{{ATTR_USER}}`, `{{NPM_AUTH_B64}}`, `{{PIP_INDEX_URL}}`, `{{ENDOR_PYPI_URL}}`, `{{GO_PROXY_URL}}`, `{{VSCODE_GALLERY_URL}}` | **Install time**, by the orchestration template on the developer's machine | --- @@ -254,7 +382,15 @@ always-auth=true ## Removing the configuration -To remove the Endor firewall configuration from a machine, delete the sentinel block from each file — everything between and including the `BEGIN` and `END` marker lines. +Deploy `endor-remove.sh`. It strips the sentinel block from every managed config file and, +for VS Code, removes the update watcher, restores the original `extensionsGallery` from the +marker, drops the marker, and deletes the sidecar state. Restart VS Code afterwards. + +For the sentinel-block files you can also do it by hand — delete everything between and +including the `BEGIN` and `END` marker lines. `product.json` is the exception: it has no +sentinel block, so restoring it by hand means base64-decoding +`_endorPackageFirewall.originalExtensionsGalleryB64` back over the `extensionsGallery` +object, or simply reinstalling VS Code. You can deploy a removal script that does this automatically: @@ -289,4 +425,7 @@ remove_block() { | `~/.m2/settings.xml` | Contains `${env.*}` references only — no credentials baked in. File is `chmod 600`. | | Shell profiles | Contain a single `source ~/.config/endor/env.sh` line. No credentials. | | API secret in MDM | The generated scripts contain the API key and secret in plaintext (used to write `env.sh`). Restrict access to the MDM policy and the generated `out/` directory. | +| VS Code `product.json` | **Contains the gallery token in a world-readable file (`0644`)** and must, because VS Code reads it as the user and offers no indirection. Any local user can read a working firewall credential, and it also lands in VS Code's own logs. Use a **dedicated, separately revocable API key** for VS Code so rotation and revocation do not disturb the other ecosystems. | +| VS Code sidecar state | `/Library/Application Support/Endor/package-firewall/vscode/` (macOS) or `/var/lib/endor/package-firewall/vscode/` (Linux). Holds the rendered gallery URL for the watcher. `0600`, root-owned — strictly better protected than `product.json` itself. | +| `--dry-run` output | Redacts the VS Code `_ak/` path segment. The other ecosystems echo full credentialed URLs; VS Code deviates deliberately, because this token is a bearer credential in a URL *path* and MDM consoles retain script output for far more people than can read the target file. | | `out/` directory | Add to `.gitignore`. Do not commit generated scripts to source control. | diff --git a/package-firewall/bash/generate.sh b/package-firewall/bash/generate.sh index ec5cd66..a5a792d 100755 --- a/package-firewall/bash/generate.sh +++ b/package-firewall/bash/generate.sh @@ -66,6 +66,10 @@ NPM_REGISTRY_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/npm/" NPM_REGISTRY_HOST="${FQDN_HOST}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/npm/" PYPI_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/pypi/simple/" MAVEN_REGISTRY_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/maven/" +# VS Code carries its credential as a URL path segment (_ak/) rather than +# in userinfo, so only the base is known here; the token is appended at install +# time once the attribution label exists. +VSCODE_GALLERY_BASE="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/vscode" API_SECRET_B64=$(printf '%s' "${ENDOR_API_SECRET}" | base64 | tr -d '\n') # ─── Output directory ───────────────────────────────────────────────────────── @@ -85,7 +89,8 @@ substitute() { -e "s|{{NPM_REGISTRY_HOST}}|${NPM_REGISTRY_HOST}|g" \ -e "s|{{PYPI_URL}}|${PYPI_URL}|g" \ -e "s|{{TRUSTED_HOST}}|${TRUSTED_HOST}|g" \ - -e "s|{{MAVEN_REGISTRY_URL}}|${MAVEN_REGISTRY_URL}|g" + -e "s|{{MAVEN_REGISTRY_URL}}|${MAVEN_REGISTRY_URL}|g" \ + -e "s|{{VSCODE_GALLERY_BASE}}|${VSCODE_GALLERY_BASE}|g" } # inline_common @@ -121,6 +126,7 @@ emit_all_blocks() { emit_block_assignment "UV_BLOCK" "$SHARED_BLOCKS_DIR/uvtoml.txt" emit_block_assignment "GO_BLOCK" "$SHARED_BLOCKS_DIR/goenv.txt" emit_block_assignment "MAVEN_BLOCK" "$SHARED_BLOCKS_DIR/mavensettings.txt" + emit_block_assignment "VSCODE_GALLERY_BLOCK" "$SHARED_BLOCKS_DIR/vscodegallery.txt" echo "# ─────────────────────────────────────────────────────────────────────────────" echo "" } @@ -129,11 +135,13 @@ arg_parsing_block() { cat << 'ARGBLOCK' # ── Argument parsing ────────────────────────────────────────────────────────── DRY_RUN=0 +VSCODE_WATCHER=1 _ENDOR_WARNED=0 for _arg in "$@"; do case "$_arg" in --dry-run) DRY_RUN=1 ;; - *) echo "[endor] Unknown argument: $_arg (supported: --dry-run)" >&2; exit 1 ;; + --no-vscode-watcher) VSCODE_WATCHER=0 ;; + *) echo "[endor] Unknown argument: $_arg (supported: --dry-run, --no-vscode-watcher)" >&2; exit 1 ;; esac done unset _arg @@ -179,6 +187,13 @@ ENDOR_AUTH_B64="$(printf '%s:%s' "$ENDOR_ATTR_USER" "$ENDOR_API_SECRET" | endor_ ENDOR_PYPI_URL="https://$(endor_urlenc_b64 "$ENDOR_ATTR_USER"):$(endor_urlenc_b64 "$ENDOR_API_SECRET")@{{FQDN_HOST}}/v1/namespaces/{{NAMESPACE}}/firewall/pypi/simple/" ENDOR_GO_PROXY_URL="https://$(endor_urlenc_b64 "$ENDOR_ATTR_USER"):$(endor_urlenc_b64 "$ENDOR_API_SECRET")@{{FQDN_HOST}}/v1/namespaces/{{NAMESPACE}}/firewall/go/,direct" +# VS Code cannot send Basic auth for the gallery and cannot expand env vars in +# product.json, so the credential travels as a base64url path segment instead. +# Same attributed username as every other ecosystem — the firewall runs +# applyUserAttribution after resolving the _ak path token. +ENDOR_VSCODE_TOKEN="$(printf '%s:%s' "$ENDOR_ATTR_USER" "$ENDOR_API_SECRET" | endor_b64url)" +ENDOR_VSCODE_GALLERY_URL="{{VSCODE_GALLERY_BASE}}/_ak/${ENDOR_VSCODE_TOKEN}" + # No exports — every consumer is same-process template code inlined below. echo "[endor] user attribution → ${ENDOR_ATTR_LABEL}" @@ -207,22 +222,114 @@ script_header() { echo "" } -# build_script