VYPR
Medium severityGHSA Advisory· Published Nov 20, 2025· Updated Apr 15, 2026

CVE-2025-13437

CVE-2025-13437

Description

When zx is invoked with --prefer-local=<path>, the CLI creates a symlink named ./node_modules pointing to <path>/node_modules. Due to a logic error in src/cli.ts (linkNodeModules / cleanup), the function returns the target path instead of the alias (symlink path). The later cleanup routine removes what it received, which deletes the target directory itself. Result: zx can delete an external <path>/node_modules outside the current working directory.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
zxnpm
< 8.8.58.8.5

Affected products

1

Patches

2
a4d1bc2467f3

fix: checks `node_modules` ref on linking (#1355)

https://github.com/google/zxAnton GolubOct 14, 2025via ghsa
4 files changed · +127 59
  • build/cli.cjs+19 16 modified
    @@ -218,11 +218,9 @@ function main() {
       });
     }
     var rmrf = (p) => {
    +  var _a2;
       if (!p) return;
    -  try {
    -    import_index.fs.lstatSync(p).isSymbolicLink() ? import_index.fs.unlinkSync(p) : import_index.fs.rmSync(p, { force: true, recursive: true });
    -  } catch (e) {
    -  }
    +  ((_a2 = lstat(p)) == null ? void 0 : _a2.isSymbolicLink()) ? import_index.fs.unlinkSync(p) : import_index.fs.rmSync(p, { force: true, recursive: true });
     };
     function runScript(script, scriptPath, tempPath) {
       return __async(this, null, function* () {
    @@ -238,16 +236,7 @@ function runScript(script, scriptPath, tempPath) {
           }
           const cwd = import_index.path.dirname(scriptPath);
           if (typeof argv.preferLocal === "string") {
    -        linkNodeModules(cwd, argv.preferLocal);
    -        try {
    -          const aliasPath = import_index.path.resolve(cwd, "node_modules");
    -          if (import_index.fs.existsSync(aliasPath) && import_index.fs.lstatSync(aliasPath).isSymbolicLink()) {
    -            nmLink = aliasPath;
    -          } else {
    -            nmLink = "";
    -          }
    -        } catch (e) {
    -        }
    +        nmLink = linkNodeModules(cwd, argv.preferLocal);
           }
           if (argv.install) {
             yield (0, import_deps.installDeps)((0, import_deps.parseDeps)(script), cwd, argv.registry);
    @@ -264,9 +253,23 @@ function linkNodeModules(cwd, external) {
       const nm = "node_modules";
       const alias = import_index.path.resolve(cwd, nm);
       const target = import_index.path.basename(external) === nm ? import_index.path.resolve(external) : import_index.path.resolve(external, nm);
    -  if (import_index.fs.existsSync(alias) || !import_index.fs.existsSync(target)) return "";
    +  const aliasStat = lstat(alias);
    +  const targetStat = lstat(target);
    +  if (!(targetStat == null ? void 0 : targetStat.isDirectory()))
    +    throw new import_index.Fail(
    +      `Can't link node_modules: ${target} doesn't exist or is not a directory`
    +    );
    +  if ((aliasStat == null ? void 0 : aliasStat.isDirectory()) && alias !== target)
    +    throw new import_index.Fail(`Can't link node_modules: ${alias} already exists`);
    +  if (aliasStat) return "";
       import_index.fs.symlinkSync(target, alias, "junction");
    -  return target;
    +  return alias;
    +}
    +function lstat(p) {
    +  try {
    +    return import_index.fs.lstatSync(p);
    +  } catch (e) {
    +  }
     }
     function readScript() {
       return __async(this, null, function* () {
    
  • .size-limit.json+1 1 modified
    @@ -66,7 +66,7 @@
           "README.md",
           "LICENSE"
         ],
    -    "limit": "874.6 kB",
    +    "limit": "874.70 kB",
         "brotli": false,
         "gzip": false
       }
    
  • src/cli.ts+22 28 modified
    @@ -128,22 +128,19 @@ export async function main(): Promise<void> {
       await runScript(script, scriptPath, tempPath)
     }
     
    -// Short & safe remove: unlink symlinks; recurse only for real dirs/files
     const rmrf = (p: string) => {
       if (!p) return
    -  try {
    -    fs.lstatSync(p).isSymbolicLink()
    -      ? fs.unlinkSync(p)
    -      : fs.rmSync(p, { force: true, recursive: true })
    -  } catch {}
    -}
     
    +  lstat(p)?.isSymbolicLink()
    +    ? fs.unlinkSync(p)
    +    : fs.rmSync(p, { force: true, recursive: true })
    +}
     async function runScript(
       script: string,
       scriptPath: string,
       tempPath: string
     ): Promise<void> {
    -  let nmLink = '' // will hold the alias path (./node_modules) ONLY if it's a symlink
    +  let nmLink = ''
       const rmTemp = () => {
         rmrf(tempPath)
         rmrf(nmLink)
    @@ -154,25 +151,9 @@ async function runScript(
           await fs.writeFile(tempPath, script)
         }
         const cwd = path.dirname(scriptPath)
    -
         if (typeof argv.preferLocal === 'string') {
    -      // Keep original behaviour: linkNodeModules returns TARGET (unchanged API)
    -      linkNodeModules(cwd, argv.preferLocal)
    -
    -      // For cleanup, compute ALIAS and only unlink if it's a symlink
    -      try {
    -        const aliasPath = path.resolve(cwd, 'node_modules')
    -        if (
    -          fs.existsSync(aliasPath) &&
    -          fs.lstatSync(aliasPath).isSymbolicLink()
    -        ) {
    -          nmLink = aliasPath
    -        } else {
    -          nmLink = ''
    -        }
    -      } catch {}
    +      nmLink = linkNodeModules(cwd, argv.preferLocal)
         }
    -
         if (argv.install) {
           await installDeps(parseDeps(script), cwd, argv.registry)
         }
    @@ -194,12 +175,25 @@ function linkNodeModules(cwd: string, external: string): string {
         path.basename(external) === nm
           ? path.resolve(external)
           : path.resolve(external, nm)
    +  const aliasStat = lstat(alias)
    +  const targetStat = lstat(target)
     
    -  if (fs.existsSync(alias) || !fs.existsSync(target)) return ''
    +  if (!targetStat?.isDirectory())
    +    throw new Fail(
    +      `Can't link node_modules: ${target} doesn't exist or is not a directory`
    +    )
    +  if (aliasStat?.isDirectory() && alias !== target)
    +    throw new Fail(`Can't link node_modules: ${alias} already exists`)
    +  if (aliasStat) return ''
     
       fs.symlinkSync(target, alias, 'junction')
    -  // Keep behaviour stable: return TARGET (not alias)
    -  return target
    +  return alias
    +}
    +
    +function lstat(p: string) {
    +  try {
    +    return fs.lstatSync(p)
    +  } catch {}
     }
     
     async function readScript() {
    
  • test/cli.test.js+85 14 modified
    @@ -175,28 +175,99 @@ describe('cli', () => {
         }
       })
     
    -  test('supports --prefer-local to load modules', async () => {
    -    const cwd = tmpdir()
    -    const external = tmpdir()
    -    await fs.outputJson(path.join(external, 'node_modules/a/package.json'), {
    +  describe('`--prefer-local`', () => {
    +    const pkgIndex = `export const a = 'AAA'`
    +    const pkgJson = {
           name: 'a',
           version: '1.0.0',
           type: 'module',
           exports: './index.js',
    -    })
    -    await fs.outputFile(
    -      path.join(external, 'node_modules/a/index.js'),
    -      `
    -export const a = 'AAA'
    -`
    -    )
    +    }
         const script = `
     import {a} from 'a'
     console.log(a);
     `
    -    const out =
    -      await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`
    -    assert.equal(out.stdout, 'AAA\n')
    +
    +    test('true', async () => {
    +      const cwd = tmpdir()
    +      await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex)
    +      await fs.outputJson(
    +        path.join(cwd, 'node_modules/a/package.json'),
    +        pkgJson
    +      )
    +
    +      const out =
    +        await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}`
    +      assert.equal(out.stdout, 'AAA\n')
    +      assert.ok(await fs.exists(path.join(cwd, 'node_modules/a/index.js')))
    +    })
    +
    +    test('external dir', async () => {
    +      const cwd = tmpdir()
    +      const external = tmpdir()
    +      await fs.outputFile(
    +        path.join(external, 'node_modules/a/index.js'),
    +        pkgIndex
    +      )
    +      await fs.outputJson(
    +        path.join(external, 'node_modules/a/package.json'),
    +        pkgJson
    +      )
    +
    +      const out =
    +        await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`
    +      assert.equal(out.stdout, 'AAA\n')
    +      assert.ok(await fs.exists(path.join(external, 'node_modules/a/index.js')))
    +    })
    +
    +    test('external alias', async () => {
    +      const cwd = tmpdir()
    +      const external = tmpdir()
    +      await fs.outputFile(
    +        path.join(external, 'node_modules/a/index.js'),
    +        pkgIndex
    +      )
    +      await fs.outputJson(
    +        path.join(external, 'node_modules/a/package.json'),
    +        pkgJson
    +      )
    +      await fs.symlinkSync(
    +        path.join(external, 'node_modules'),
    +        path.join(cwd, 'node_modules'),
    +        'junction'
    +      )
    +
    +      const out =
    +        await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}`
    +      assert.equal(out.stdout, 'AAA\n')
    +      assert.ok(await fs.exists(path.join(cwd, 'node_modules')))
    +    })
    +
    +    test('throws if exists', async () => {
    +      const cwd = tmpdir()
    +      const external = tmpdir()
    +      await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex)
    +      await fs.outputFile(
    +        path.join(external, 'node_modules/a/index.js'),
    +        pkgIndex
    +      )
    +      assert.rejects(
    +        () =>
    +          $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`,
    +        /node_modules already exists/
    +      )
    +    })
    +
    +    test('throws if not dir', async () => {
    +      const cwd = tmpdir()
    +      const external = tmpdir()
    +      await fs.outputFile(path.join(external, 'node_modules'), pkgIndex)
    +      assert.rejects(
    +        () =>
    +          $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`,
    +        /node_modules doesn't exist or is not a directory/
    +      )
    +    })
       })
     
       test('scripts from https 200', async () => {
    
9ef6d3c9962c

fix(cli): prevent external node_modules deletion with --prefer-local (#1349)

https://github.com/google/zxthesmartshadowOct 14, 2025via ghsa
3 files changed · +48 7
  • build/cli.cjs+17 2 modified
    @@ -217,7 +217,13 @@ function main() {
         yield runScript(script, scriptPath, tempPath);
       });
     }
    -var rmrf = (p) => p && import_index.fs.rmSync(p, { force: true, recursive: true });
    +var rmrf = (p) => {
    +  if (!p) return;
    +  try {
    +    import_index.fs.lstatSync(p).isSymbolicLink() ? import_index.fs.unlinkSync(p) : import_index.fs.rmSync(p, { force: true, recursive: true });
    +  } catch (e) {
    +  }
    +};
     function runScript(script, scriptPath, tempPath) {
       return __async(this, null, function* () {
         let nmLink = "";
    @@ -232,7 +238,16 @@ function runScript(script, scriptPath, tempPath) {
           }
           const cwd = import_index.path.dirname(scriptPath);
           if (typeof argv.preferLocal === "string") {
    -        nmLink = linkNodeModules(cwd, argv.preferLocal);
    +        linkNodeModules(cwd, argv.preferLocal);
    +        try {
    +          const aliasPath = import_index.path.resolve(cwd, "node_modules");
    +          if (import_index.fs.existsSync(aliasPath) && import_index.fs.lstatSync(aliasPath).isSymbolicLink()) {
    +            nmLink = aliasPath;
    +          } else {
    +            nmLink = "";
    +          }
    +        } catch (e) {
    +        }
           }
           if (argv.install) {
             yield (0, import_deps.installDeps)((0, import_deps.parseDeps)(script), cwd, argv.registry);
    
  • .size-limit.json+2 2 modified
    @@ -33,7 +33,7 @@
           "build/globals.js",
           "build/deno.js"
         ],
    -    "limit": "816.65 kB",
    +    "limit": "817.2 kB",
         "brotli": false,
         "gzip": false
       },
    @@ -66,7 +66,7 @@
           "README.md",
           "LICENSE"
         ],
    -    "limit": "874.15 kB",
    +    "limit": "874.6 kB",
         "brotli": false,
         "gzip": false
       }
    
  • src/cli.ts+29 3 modified
    @@ -128,13 +128,22 @@ export async function main(): Promise<void> {
       await runScript(script, scriptPath, tempPath)
     }
     
    -const rmrf = (p: string) => p && fs.rmSync(p, { force: true, recursive: true })
    +// Short & safe remove: unlink symlinks; recurse only for real dirs/files
    +const rmrf = (p: string) => {
    +  if (!p) return
    +  try {
    +    fs.lstatSync(p).isSymbolicLink()
    +      ? fs.unlinkSync(p)
    +      : fs.rmSync(p, { force: true, recursive: true })
    +  } catch {}
    +}
    +
     async function runScript(
       script: string,
       scriptPath: string,
       tempPath: string
     ): Promise<void> {
    -  let nmLink = ''
    +  let nmLink = '' // will hold the alias path (./node_modules) ONLY if it's a symlink
       const rmTemp = () => {
         rmrf(tempPath)
         rmrf(nmLink)
    @@ -145,9 +154,25 @@ async function runScript(
           await fs.writeFile(tempPath, script)
         }
         const cwd = path.dirname(scriptPath)
    +
         if (typeof argv.preferLocal === 'string') {
    -      nmLink = linkNodeModules(cwd, argv.preferLocal)
    +      // Keep original behaviour: linkNodeModules returns TARGET (unchanged API)
    +      linkNodeModules(cwd, argv.preferLocal)
    +
    +      // For cleanup, compute ALIAS and only unlink if it's a symlink
    +      try {
    +        const aliasPath = path.resolve(cwd, 'node_modules')
    +        if (
    +          fs.existsSync(aliasPath) &&
    +          fs.lstatSync(aliasPath).isSymbolicLink()
    +        ) {
    +          nmLink = aliasPath
    +        } else {
    +          nmLink = ''
    +        }
    +      } catch {}
         }
    +
         if (argv.install) {
           await installDeps(parseDeps(script), cwd, argv.registry)
         }
    @@ -173,6 +198,7 @@ function linkNodeModules(cwd: string, external: string): string {
       if (fs.existsSync(alias) || !fs.existsSync(target)) return ''
     
       fs.symlinkSync(target, alias, 'junction')
    +  // Keep behaviour stable: return TARGET (not alias)
       return target
     }
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.