From b038c8b580d5c6ef2b8a9b90f0619a0c769015a9 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 6 Aug 2026 15:46:17 -0400 Subject: [PATCH 01/14] fix(extension): resolve symlinks in containment check --- src/extension/installer.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 11b1a07c..204fb600 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -22,7 +22,7 @@ * The derived entrypoint is validated to sit within the install directory. */ -import { access, chmod, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { access, chmod, constants, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join, isAbsolute, resolve } from 'node:path' import { spawnSync } from 'node:child_process' @@ -189,12 +189,23 @@ async function discoverGithubEntrypoint (installDir: string, baseName: string): ) } -/** Asserts the entrypoint path is within the install directory (prevents symlink/config injection). */ -function assertWithinInstallDir (entrypoint: string, installDir: string): void { - const rel = entrypoint.startsWith(installDir + '/') - if (!rel) { +/** + * Asserts the entrypoint path is within the install directory (prevents symlink/config + * injection). `resolve()` alone only normalizes `.`/`..` segments and does not follow + * symlinks, so a symlinked entrypoint pointing outside the install directory would pass + * a plain string-prefix check while still executing arbitrary code from elsewhere on + * disk. Both paths are resolved with `realpath` (which does follow symlinks) before the + * comparison so the check reflects what will actually run. + */ +async function assertWithinInstallDir (entrypoint: string, installDir: string): Promise { + const [realEntrypoint, realInstallDir] = await Promise.all([ + realpath(entrypoint), + realpath(installDir), + ]) + const within = realEntrypoint === realInstallDir || realEntrypoint.startsWith(realInstallDir + '/') + if (!within) { throw new Error( - `Resolved entrypoint "${entrypoint}" is outside the install directory "${installDir}". ` + + `Entrypoint "${entrypoint}" resolves to "${realEntrypoint}", which is outside the install directory "${realInstallDir}". ` + 'Refusing to register this extension.' ) } @@ -249,7 +260,7 @@ export async function installExtension (source: string): Promise<{ entry: Instal entrypoint = found } - assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + await assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) const entry: InstalledExtension = { name: parsed.name, @@ -317,6 +328,8 @@ export async function createLocalExtension (name: string, targetPath?: string): entrypoint = defaultEntrypoint } + await assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + const entry: InstalledExtension = { name, source: `local:${installDir}`, From db9c80816e49f9a9edac3f0209fae4f91d114364 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 6 Aug 2026 15:46:17 -0400 Subject: [PATCH 02/14] test(extension): cover symlink containment bypass --- test/extension/installer.test.ts | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts index 90bfd88c..b0d92aab 100644 --- a/test/extension/installer.test.ts +++ b/test/extension/installer.test.ts @@ -14,7 +14,7 @@ import { describe, it, before, after, afterEach } from 'node:test' import assert from 'node:assert/strict' -import { mkdtemp, rm, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, mkdir, readFile, stat, writeFile, symlink, chmod } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { createLocalExtension, installExtension, uninstallExtension, upgradeExtension, upgradeAllExtensions, _testSetExtensionsDir } from '../../src/extension/installer.ts' @@ -145,6 +145,41 @@ describe('installer', () => { it('rejects names with path traversal characters', async () => { await assert.rejects(createLocalExtension('../escape'), /invalid characters/) }) + + it('rejects a --path entrypoint that is a symlink escaping the install directory (#500)', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + const payload = join(outsideDir, 'payload.sh') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD RAN FROM OUTSIDE\n', { mode: 0o755 }) + await chmod(payload, 0o755) + + const targetDir = join(tmpDir, 'symlink-escape-ext') + await mkdir(targetDir, { recursive: true }) + await symlink(payload, join(targetDir, 'elastic-symlinktest')) + + await assert.rejects( + createLocalExtension('symlinktest', targetDir), + /outside the install directory/ + ) + + // Refusing to register also means the store stays empty. + assert.deepEqual(await readExtensions(), []) + + await rm(outsideDir, { recursive: true, force: true }) + }) + + it('accepts a --path entrypoint that is a real (non-symlink) file inside the install directory', async () => { + const targetDir = join(tmpDir, 'real-entrypoint-ext') + await mkdir(targetDir, { recursive: true }) + const entrypointPath = join(targetDir, 'elastic-realtest') + await writeFile(entrypointPath, '#!/bin/sh\necho hi\n', { mode: 0o755 }) + await chmod(entrypointPath, 0o755) + + const { entry } = await createLocalExtension('realtest', targetDir) + assert.equal(entry.entrypoint, entrypointPath) + const extensions = await readExtensions() + assert.equal(extensions.length, 1) + assert.equal(extensions[0]!.entrypoint, entrypointPath) + }) }) describe('upgradeExtension', () => { From 38657f2ae0bff825be83628d652413d9338c597f Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Fri, 7 Aug 2026 16:32:07 -0400 Subject: [PATCH 03/14] fix: use native realpath for windows short path names --- src/extension/installer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 204fb600..1ac756f8 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -22,13 +22,21 @@ * The derived entrypoint is validated to sit within the install directory. */ -import { access, chmod, constants, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { access, chmod, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { realpath as realpathCb } from 'node:fs' import { homedir } from 'node:os' import { join, isAbsolute, resolve } from 'node:path' import { spawnSync } from 'node:child_process' +import { promisify } from 'node:util' import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' import type { InstalledExtension } from './store.ts' +// fs.promises.realpath does not expand Windows 8.3 short names (e.g. `RUNNER~1`), +// so two paths that are the same directory can resolve to different strings and +// fail a containment check that should pass. realpath.native calls +// GetFinalPathNameByHandle on Windows, which does expand them. +const realpath = promisify(realpathCb.native) + // --------------------------------------------------------------------------- // Test seams // --------------------------------------------------------------------------- From 3607e9ffaa32d52086a91daa31fc4e3062cc19f7 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Fri, 7 Aug 2026 16:32:07 -0400 Subject: [PATCH 04/14] ci: add ecr fallback for trivy db download --- .mega-linter.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.mega-linter.yml b/.mega-linter.yml index 1320af10..1ea8d83f 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -42,5 +42,11 @@ YAML_YAMLLINT_FILTER_REGEX_EXCLUDE: "(codegen/functional/test/fixtures/|node_mod COPYPASTE_JSCPD_CONFIG_FILE: .jscpd.json +# trivy's default DB registries (mirror.gcr.io, ghcr.io) have been hitting +# TOOMANYREQUESTS during vulnerability DB download. Add the AWS ECR Public +# mirror as a third fallback (setting this overrides the defaults, so they're +# listed explicitly too) since it draws from a separate rate-limit pool. +TRIVY_DB_REPOSITORY: "mirror.gcr.io/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2" + # shellcheck: warn only until active script work stabilises BASH_SHELLCHECK_DISABLE_ERRORS: true From 1d9703d6839ac8414a5c5b3333d43e9d9bcc7d79 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Mon, 10 Aug 2026 14:45:57 -0400 Subject: [PATCH 05/14] ci: fix mirror.gcr.io trivy db namespace --- .mega-linter.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.mega-linter.yml b/.mega-linter.yml index 0a4dc19a..a4822a70 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -43,7 +43,10 @@ COPYPASTE_JSCPD_CONFIG_FILE: .jscpd.json # TOOMANYREQUESTS during vulnerability DB download. Add the AWS ECR Public # mirror as a third fallback (setting this overrides the defaults, so they're # listed explicitly too) since it draws from a separate rate-limit pool. -TRIVY_DB_REPOSITORY: "mirror.gcr.io/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2" +# Note: mirror.gcr.io proxies Docker Hub, whose org for this image is +# "aquasec" (not "aquasecurity" like GHCR/ECR); using the wrong namespace +# here causes a permanent MANIFEST_UNKNOWN, not a transient rate limit. +TRIVY_DB_REPOSITORY: "mirror.gcr.io/aquasec/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2" # shellcheck: warn only until active script work stabilises BASH_SHELLCHECK_DISABLE_ERRORS: true From e6f5d816ccf78d3f27c83a58af6fec87c0b8f99b Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Mon, 10 Aug 2026 14:46:05 -0400 Subject: [PATCH 06/14] fix(extension): fix windows path separator in containment check --- src/extension/installer.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 1ac756f8..842a482c 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -25,7 +25,7 @@ import { access, chmod, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { realpath as realpathCb } from 'node:fs' import { homedir } from 'node:os' -import { join, isAbsolute, resolve } from 'node:path' +import { join, isAbsolute, resolve, relative } from 'node:path' import { spawnSync } from 'node:child_process' import { promisify } from 'node:util' import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' @@ -206,11 +206,27 @@ async function discoverGithubEntrypoint (installDir: string, baseName: string): * comparison so the check reflects what will actually run. */ async function assertWithinInstallDir (entrypoint: string, installDir: string): Promise { - const [realEntrypoint, realInstallDir] = await Promise.all([ - realpath(entrypoint), - realpath(installDir), - ]) - const within = realEntrypoint === realInstallDir || realEntrypoint.startsWith(realInstallDir + '/') + let realEntrypoint: string + let realInstallDir: string + try { + [realEntrypoint, realInstallDir] = await Promise.all([ + realpath(entrypoint), + realpath(installDir), + ]) + } catch (err) { + // Both callers create installDir and write/verify the entrypoint before reaching + // this check, so ENOENT here means a caller invariant broke, not a normal outcome. + // Surface a clear message instead of letting a raw ENOENT bubble up. + const path = (err as NodeJS.ErrnoException).path ?? entrypoint + throw new Error(`Cannot verify entrypoint containment: "${path}" does not exist.`, { cause: err }) + } + // Use path.relative rather than a hardcoded "/" separator so this works on + // Windows too, where realpath returns backslash-separated paths. A target + // is contained when the relative path doesn't escape upward (doesn't start + // with "..") and isn't absolute (which relative() returns when the paths + // are on different drives on Windows, i.e. no relative path exists). + const rel = relative(realInstallDir, realEntrypoint) + const within = rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) if (!within) { throw new Error( `Entrypoint "${entrypoint}" resolves to "${realEntrypoint}", which is outside the install directory "${realInstallDir}". ` + From 605ecb08c0342775d9b835cb22625126c7c7bbef Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Mon, 10 Aug 2026 14:46:11 -0400 Subject: [PATCH 07/14] test(extension): fix outsideDir cleanup leak --- test/extension/installer.test.ts | 36 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts index b0d92aab..a913fd6f 100644 --- a/test/extension/installer.test.ts +++ b/test/extension/installer.test.ts @@ -148,23 +148,25 @@ describe('installer', () => { it('rejects a --path entrypoint that is a symlink escaping the install directory (#500)', async () => { const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) - const payload = join(outsideDir, 'payload.sh') - await writeFile(payload, '#!/bin/sh\necho PAYLOAD RAN FROM OUTSIDE\n', { mode: 0o755 }) - await chmod(payload, 0o755) - - const targetDir = join(tmpDir, 'symlink-escape-ext') - await mkdir(targetDir, { recursive: true }) - await symlink(payload, join(targetDir, 'elastic-symlinktest')) - - await assert.rejects( - createLocalExtension('symlinktest', targetDir), - /outside the install directory/ - ) - - // Refusing to register also means the store stays empty. - assert.deepEqual(await readExtensions(), []) - - await rm(outsideDir, { recursive: true, force: true }) + try { + const payload = join(outsideDir, 'payload.sh') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD RAN FROM OUTSIDE\n', { mode: 0o755 }) + await chmod(payload, 0o755) + + const targetDir = join(tmpDir, 'symlink-escape-ext') + await mkdir(targetDir, { recursive: true }) + await symlink(payload, join(targetDir, 'elastic-symlinktest')) + + await assert.rejects( + createLocalExtension('symlinktest', targetDir), + /outside the install directory/ + ) + + // Refusing to register also means the store stays empty. + assert.deepEqual(await readExtensions(), []) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } }) it('accepts a --path entrypoint that is a real (non-symlink) file inside the install directory', async () => { From 2d2713e7db228b68aaac0e5d7f256d6912ca4597 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Wed, 12 Aug 2026 18:18:45 -0400 Subject: [PATCH 08/14] fix(extension): check symlink containment in upgradeExtension --- src/extension/installer.ts | 1 + test/extension/installer.test.ts | 34 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 842a482c..5179f562 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -398,6 +398,7 @@ export async function upgradeExtension (name: string): Promise { try { const payload = join(outsideDir, 'payload.sh') await writeFile(payload, '#!/bin/sh\necho PAYLOAD RAN FROM OUTSIDE\n', { mode: 0o755 }) - await chmod(payload, 0o755) const targetDir = join(tmpDir, 'symlink-escape-ext') await mkdir(targetDir, { recursive: true }) @@ -188,6 +188,38 @@ describe('installer', () => { it('throws when the extension is not installed', async () => { await assert.rejects(upgradeExtension('nonexistent'), /not installed/) }) + + it('rejects a post-pull entrypoint that is a symlink escaping the install directory (#500)', async () => { + const remoteDir = await mkdtemp(join(tmpdir(), 'elastic-remote-')) + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + const extPath = join(extDir, 'elastic-symupgrade') + try { + // Bootstrap a local git remote so git pull --ff-only succeeds (already up to date). + const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t.com', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t.com' } + spawnSync('git', ['init', remoteDir], { encoding: 'utf-8' }) + spawnSync('git', ['-C', remoteDir, 'commit', '--allow-empty', '-m', 'init'], { encoding: 'utf-8', env: gitEnv }) + spawnSync('git', ['clone', remoteDir, extPath], { encoding: 'utf-8' }) + + // Place a symlink whose target is outside the install dir — simulates a + // malicious commit pulled in by git pull. + const payload = join(outsideDir, 'elastic-symupgrade') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD\n', { mode: 0o755 }) + await symlink(payload, join(extPath, 'elastic-symupgrade')) + + const entry: InstalledExtension = { + name: 'symupgrade', + source: 'github:elastic/elastic-symupgrade', + path: extPath, + entrypoint: join(extPath, 'elastic-symupgrade'), + } + await writeExtensions([entry]) + + await assert.rejects(upgradeExtension('symupgrade'), /outside the install directory/) + } finally { + await rm(remoteDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) }) describe('upgradeAllExtensions', () => { From db9c99df7a27d42c7423f9ccb677409d55d87037 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 14:33:01 -0400 Subject: [PATCH 09/14] fix: recheck symlink containment after npm update --- src/extension/installer.ts | 3 +++ test/extension/installer.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 5179f562..cf113d55 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -404,6 +404,9 @@ export async function upgradeExtension (name: string): Promise { await rm(outsideDir, { recursive: true, force: true }) } }) + + it('rejects a stored entrypoint that is a symlink escaping the install directory after npm update (#500)', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'elastic-outside-')) + const extPath = join(extDir, 'elastic-npmupgrade') + try { + await mkdir(extPath, { recursive: true }) + await writeFile(join(extPath, 'package.json'), JSON.stringify({ name: 'elastic-npmupgrade', version: '1.0.0' }), 'utf-8') + + // Simulates a symlink left behind under node_modules/.bin by npm update. + const payload = join(outsideDir, 'payload.sh') + await writeFile(payload, '#!/bin/sh\necho PAYLOAD\n', { mode: 0o755 }) + await symlink(payload, join(extPath, 'elastic-npmupgrade')) + + const entry: InstalledExtension = { + name: 'npmupgrade', + source: 'npm:elastic-npmupgrade', + path: extPath, + entrypoint: join(extPath, 'elastic-npmupgrade'), + } + await writeExtensions([entry]) + + await assert.rejects(upgradeExtension('npmupgrade'), /outside the install directory/) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } + }) }) describe('upgradeAllExtensions', () => { From 2c00aa4b38a57dd7e82ef830f9d5966c5654f17f Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 14:36:20 -0400 Subject: [PATCH 10/14] test: create entrypoint file in npm ignore scripts upgrade test --- test/extension/installer.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts index 58c7842f..6ce4228a 100644 --- a/test/extension/installer.test.ts +++ b/test/extension/installer.test.ts @@ -337,12 +337,14 @@ describe('installer', () => { const extPath = join(extDir, 'elastic-npmupgrade') await mkdir(extPath, { recursive: true }) + const ep = join(extPath, 'index.js') + await writeFile(ep, '#!/usr/bin/env node\n', 'utf-8') const entry: InstalledExtension = { name: 'npmupgrade', source: 'npm:elastic-npmupgrade', path: extPath, - entrypoint: join(extPath, 'index.js'), + entrypoint: ep, } await writeExtensions([entry]) From 4a12a2621cc1704497910c6cafeb654d04f19795 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 14:44:09 -0400 Subject: [PATCH 11/14] chore: regenerate NOTICE.txt with correct schemas version --- NOTICE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NOTICE.txt b/NOTICE.txt index 42637af5..2b73f677 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -221,7 +221,7 @@ released to the public npm registry; depend on it through the workspace. ------------------------------------------------------------------------ -@elastic/schemas@0.5.1 +@elastic/schemas@0.6.2 License: Apache-2.0 Repository: https://github.com/elastic/schemas-js Publisher: Elastic Client Library Maintainers From c62335909597a0826b89175a1fcec98de421dba1 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 15:05:52 -0400 Subject: [PATCH 12/14] chore: add cross spawn dependency --- NOTICE.txt | 132 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 20 ++++--- package.json | 6 ++- 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/NOTICE.txt b/NOTICE.txt index 2b73f677..22a4cb8b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -831,6 +831,36 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +cross-spawn@7.0.6 +License: MIT +Repository: https://github.com/moxystudio/node-cross-spawn +Publisher: André Cruz +------------------------------------------------------------------------ + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ------------------------------------------------------------------------ csv-parse@7.0.1 License: MIT @@ -1144,6 +1174,30 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +isexe@2.0.0 +License: ISC +Repository: https://github.com/isaacs/isexe +Publisher: Isaac Z. Schlueter (http://blog.izs.me/) +------------------------------------------------------------------------ + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ------------------------------------------------------------------------ json-schema-traverse@0.4.1 License: MIT @@ -1463,6 +1517,24 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +path-key@3.1.1 +License: MIT +Repository: https://github.com/sindresorhus/path-key +Publisher: Sindre Sorhus (sindresorhus.com) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ------------------------------------------------------------------------ punycode@2.3.1 License: MIT @@ -1553,6 +1625,42 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------ +shebang-command@2.0.0 +License: MIT +Repository: https://github.com/kevva/shebang-command +Publisher: Kevin Mårtensson (github.com/kevva) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +------------------------------------------------------------------------ +shebang-regex@3.0.0 +License: MIT +Repository: https://github.com/sindresorhus/shebang-regex +Publisher: Sindre Sorhus (sindresorhus.com) +------------------------------------------------------------------------ + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ------------------------------------------------------------------------ skin-tone@2.0.0 License: MIT @@ -1754,6 +1862,30 @@ THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRA The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. +------------------------------------------------------------------------ +which@2.0.2 +License: ISC +Repository: https://github.com/isaacs/node-which +Publisher: Isaac Z. Schlueter (http://blog.izs.me) +------------------------------------------------------------------------ + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + ------------------------------------------------------------------------ wrap-ansi@7.0.0 License: MIT diff --git a/package-lock.json b/package-lock.json index 140e4e93..1fd5919d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "ajv": "^6.14.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "cross-spawn": "^7.0.6", "csv-parse": "^7.0.0", "marked": "^14.1.4", "marked-terminal": "^7.3.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@types/cross-spawn": "^6.0.6", "@types/marked-terminal": "6.1.1", "@types/node": "25.9.4", "@yao-pkg/pkg": "6.22.0", @@ -1840,6 +1842,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "dev": true, @@ -3033,7 +3045,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4349,7 +4362,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/js-tokens": { @@ -5831,7 +5843,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6473,7 +6484,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6484,7 +6494,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7373,7 +7382,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 35d88669..cc1da7d2 100644 --- a/package.json +++ b/package.json @@ -64,17 +64,19 @@ "ajv": "^6.14.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "cross-spawn": "^7.0.6", "csv-parse": "^7.0.0", "marked": "^14.1.4", "marked-terminal": "^7.3.0", "yaml": "^2.8.3" }, "devDependencies": { - "@yao-pkg/pkg": "6.22.0", "@eslint/js": "10.0.1", - "esbuild": "0.28.1", + "@types/cross-spawn": "^6.0.6", "@types/marked-terminal": "6.1.1", "@types/node": "25.9.4", + "@yao-pkg/pkg": "6.22.0", + "esbuild": "0.28.1", "eslint": "10.6.0", "license-checker": "25.0.1", "mega-linter-runner": "9.5.0", From 591f441995b6d0f63814e5a2a9a3a181808d9b0a Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 15:05:53 -0400 Subject: [PATCH 13/14] fix: use cross spawn so npm resolves on windows --- src/extension/installer.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 806b2f2c..f13bc827 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -18,7 +18,11 @@ * If the repo/package is not prefixed with `elastic-`, the full name is used. * * Security: - * All child processes are spawned with shell: false and an explicit args array. + * All child processes are spawned with an explicit args array, never a shell- + * interpreted string. On Windows, npm is a `.cmd` shim that Node's own + * spawnSync cannot invoke without shell:true, so `run()` uses `cross-spawn`, + * which resolves `.cmd`/`.bat` shims and escapes arguments itself instead of + * relying on unsafe shell string concatenation. * The derived entrypoint is validated to sit within the install directory. */ @@ -26,7 +30,7 @@ import { access, chmod, constants, mkdir, readFile, rm, writeFile } from 'node:f import { realpath as realpathCb } from 'node:fs' import { homedir } from 'node:os' import { join, isAbsolute, resolve, relative } from 'node:path' -import { spawnSync } from 'node:child_process' +import { sync as spawnSync } from 'cross-spawn' import { promisify } from 'node:util' import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' import type { InstalledExtension } from './store.ts' @@ -125,7 +129,9 @@ function parseSource (source: string): ParsedSource { } /** - * Runs a command with an explicit args array (never shell: true). + * Runs a command with an explicit args array. Uses `cross-spawn` so `.cmd`/`.bat` + * shims (e.g. npm on Windows) resolve correctly without falling back to an + * unescaped shell string. * Throws a descriptive error if the process exits non-zero or fails to start. */ function run (cmd: string, args: string[], cwd: string): void { @@ -138,7 +144,6 @@ function run (cmd: string, args: string[], cwd: string): void { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', windowsHide: true, - shell: false, }) if (result.error != null) { throw new Error(`Failed to run ${cmd}: ${result.error.message}`) From 55ca6212da0ef32c1fe701edfc2e15026497d23e Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 13 Aug 2026 15:54:51 -0400 Subject: [PATCH 14/14] docs: note toctou limit in containment check --- src/extension/installer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index f13bc827..0596f353 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -221,6 +221,11 @@ async function discoverGithubEntrypoint (installDir: string, baseName: string): * a plain string-prefix check while still executing arbitrary code from elsewhere on * disk. Both paths are resolved with `realpath` (which does follow symlinks) before the * comparison so the check reflects what will actually run. + * + * Not airtight: this only checks the target at call time. Nothing prevents the + * entrypoint from being replaced with a symlink between this check and the + * later execution (TOCTOU). It closes the specific bypasses covered by the + * tests here, not every possible race. */ async function assertWithinInstallDir (entrypoint: string, installDir: string): Promise { let realEntrypoint: string