From 21c777078bbe0e9feb0b3fabe67a36f3ca09bd6f Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Fri, 29 May 2026 16:49:43 +0200 Subject: [PATCH 1/2] fix(fix): honor path exclusions during .gitignore discovery and skip unreadable dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit socket fix and socket scan create walk the project tree for .gitignore files to build their ignore set before collecting manifests. That walk ran through fast-glob with only the default ignore list — neither --exclude-paths nor socket.yml projectIgnorePaths were applied to it. A directory the running user cannot enter (a postgres pgdata dir owned by another uid, an unreadable Docker volume mount, etc.) therefore aborted the whole command with EACCES: permission denied, scandir '.../data/postgres/pgdata' during this discovery walk, before any exclusion could take effect — which is why excluding the path did not help. Thread projectIgnorePaths and --exclude-paths into the .gitignore discovery walk so explicit exclusions govern the entire discovery, and set suppressErrors on both that walk and the main package walk so an unreadable directory is skipped rather than crashing the run. A directory the user cannot read cannot contain manifests they could scan anyway. Add a regression test that reproduces the crash with a real chmod 000 directory (skipped under root and on Windows), and cut 1.1.112. --- CHANGELOG.md | 5 ++++ package.json | 2 +- src/utils/glob.mts | 56 ++++++++++++++++++++++++++++------------- src/utils/glob.test.mts | 54 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f65049a..6c86f24e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **Bazel diagnostics** — `socket manifest bazel --verbose` now emits bounded subprocess traces with argv, cwd, duration, exit status, output sizes, and failure stderr tails to make customer log-only triage safer and faster. +## [1.1.112](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.112) - 2026-05-29 + +### Fixed +- `socket fix` and `socket scan create` no longer abort with `EACCES: permission denied, scandir` when the project contains a directory the running user cannot read (for example a postgres `pgdata` data directory owned by another uid, or a Docker volume mount). Manifest discovery walks a project for `.gitignore` files before applying any path exclusions; that walk now honors `--exclude-paths` and `socket.yml` `projectIgnorePaths`, and skips unreadable directories rather than crashing. This makes `--exclude-paths` effective for unreadable directories — previously the crash happened before the exclusion was ever applied. + ## [1.1.111](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.111) - 2026-05-29 ### Changed diff --git a/package.json b/package.json index 8fccb371f..862d49138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.111", + "version": "1.1.112", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT AND OFL-1.1", diff --git a/src/utils/glob.mts b/src/utils/glob.mts index dd89f37ef..0e534704d 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -232,23 +232,48 @@ export async function globWithGitIgnore( const ignores = new Set(IGNORED_DIR_PATTERNS) + // CLI-supplied `additionalIgnores` are already anchored minimatch — they + // must not pass through the `ignore` package (whose gitignore "match + // anywhere" semantics would re-interpret a bare `tests` to match + // `subdir/tests/foo.json`). Keep them in fast-glob's ignore list across + // both paths; only gitignore-translated entries go into the `ig` matcher. + const cliMinimatchIgnores = additionalIgnores ?? [] + const projectIgnorePaths = socketConfig?.projectIgnorePaths - if (Array.isArray(projectIgnorePaths)) { - const ignorePatterns = ignoreFileLinesToGlobPatterns( - projectIgnorePaths, - path.join(cwd, '.gitignore'), - cwd, - ) - for (const pattern of ignorePatterns) { - ignores.add(pattern) - } + const projectIgnoreGlobs = Array.isArray(projectIgnorePaths) + ? ignoreFileLinesToGlobPatterns( + projectIgnorePaths, + path.join(cwd, '.gitignore'), + cwd, + ) + : [] + for (const pattern of projectIgnoreGlobs) { + ignores.add(pattern) } + // The .gitignore discovery walk has to honor the same directory exclusions + // as the package walk below. Otherwise an unreadable subtree (e.g. a + // postgres `pgdata` dir owned by another uid, or a Docker volume mount) makes + // fast-glob throw `EACCES: permission denied, scandir` *here* — before + // --exclude-paths (`cliMinimatchIgnores`) or projectIgnorePaths are ever + // applied to the main walk, which is why excluding the path did not help. + // `suppressErrors` is the backstop: a directory the user simply cannot read + // cannot contain manifests they could scan anyway, so skip it instead of + // aborting the whole `socket fix` / `socket scan` run. Negated patterns are + // dropped — for a discovery walk they could only re-include a subtree (never + // prevent a crash), and fast-glob treats `!` ignore entries inconsistently. const gitIgnoreStream = fastGlob.globStream(['**/.gitignore'], { absolute: true, cwd, dot: true, - ignore: DEFAULT_IGNORE_FOR_GIT_IGNORE, + ignore: [ + ...DEFAULT_IGNORE_FOR_GIT_IGNORE, + ...projectIgnoreGlobs, + ...cliMinimatchIgnores, + ] + .filter(p => p.charCodeAt(0) !== 33 /*'!'*/) + .map(stripTrailingSlash), + suppressErrors: true, }) for await (const ignorePatterns of transform( gitIgnoreStream, @@ -273,13 +298,6 @@ export async function globWithGitIgnore( } } - // CLI-supplied `additionalIgnores` are already anchored minimatch — they - // must not pass through the `ignore` package (whose gitignore "match - // anywhere" semantics would re-interpret a bare `tests` to match - // `subdir/tests/foo.json`). Keep them in fast-glob's ignore list across - // both paths; only gitignore-translated entries go into the `ig` matcher. - const cliMinimatchIgnores = additionalIgnores ?? [] - const globOptions = { __proto__: null, absolute: true, @@ -288,6 +306,10 @@ export async function globWithGitIgnore( ignore: hasNegatedPattern ? [...defaultIgnore, ...cliMinimatchIgnores] : [...ignores, ...cliMinimatchIgnores].map(stripTrailingSlash), + // Skip directories the running user cannot read rather than aborting the + // whole walk on the first `EACCES` (see the .gitignore discovery walk + // above for the full rationale). + suppressErrors: true, ...additionalOptions, } as GlobOptions diff --git a/src/utils/glob.test.mts b/src/utils/glob.test.mts index fdec8a636..f403306cd 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -1,4 +1,13 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -269,6 +278,49 @@ describe('glob utilities', () => { `${mockFixturePath}/package.json`, ]) }) + + // Reproduces the reported `socket fix` crash: a project containing a + // directory the running user cannot enter (e.g. a postgres `pgdata` dir + // owned by another uid, mode drwx------) made fast-glob throw + // `EACCES: permission denied, scandir` during manifest discovery. Uses the + // real filesystem because mock-fs only enforces permissions for non-root + // uids; skipped under root (perm checks bypassed) and on Windows (no POSIX + // directory perms). + const skipUnreadableDirTest = + process.platform === 'win32' || + (typeof process.getuid === 'function' && process.getuid() === 0) + it.skipIf(skipUnreadableDirTest)( + 'skips an unreadable directory instead of throwing EACCES', + async () => { + const realTmp = mkdtempSync(path.join(tmpdir(), 'socket-glob-perm-')) + const unreadable = path.join(realTmp, 'data/postgres/pgdata') + try { + mkdirSync(unreadable, { recursive: true }) + writeFileSync(path.join(realTmp, 'package.json'), '{}') + // Files inside the directory must never surface — the user cannot + // read them, so they cannot be scanned. + writeFileSync(path.join(unreadable, 'PG_VERSION'), '17') + // drwx------ : owner-only, and the running test user is not the owner + // in the field report; locally dropping all bits has the same effect + // of making scandir fail for the current user. + chmodSync(unreadable, 0o000) + + const results = await globWithGitIgnore(['**/*'], { + cwd: realTmp, + }) + + expect(results.map(normalizePath)).toEqual([ + normalizePath(path.join(realTmp, 'package.json')), + ]) + } finally { + // Restore perms so recursive cleanup can descend into the locked dir. + try { + chmodSync(unreadable, 0o755) + } catch {} + rmSync(realTmp, { force: true, recursive: true }) + } + }, + ) }) describe('createSupportedFilesFilter()', () => { From 7fbb2d4772311407775880fe2f9a62e8c61f54d1 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Fri, 29 May 2026 17:11:53 +0200 Subject: [PATCH 2/2] fix(glob): pin suppressErrors after caller options so it can't be overridden suppressErrors is the EACCES backstop for manifest discovery, not a tunable. Placing it before ...additionalOptions let a caller's options bag flip it back to false and re-introduce the crash. Move it after the spread so the safety invariant always holds. Flagged in review; no current caller passes suppressErrors, so behavior is unchanged. --- src/utils/glob.mts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/glob.mts b/src/utils/glob.mts index 0e534704d..03824c16e 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -306,11 +306,14 @@ export async function globWithGitIgnore( ignore: hasNegatedPattern ? [...defaultIgnore, ...cliMinimatchIgnores] : [...ignores, ...cliMinimatchIgnores].map(stripTrailingSlash), + ...additionalOptions, // Skip directories the running user cannot read rather than aborting the // whole walk on the first `EACCES` (see the .gitignore discovery walk - // above for the full rationale). + // above for the full rationale). Pinned after `...additionalOptions` so a + // caller's options bag cannot accidentally flip it back to `false` and + // re-introduce the crash — `suppressErrors` is a safety invariant here, not + // a tunable. suppressErrors: true, - ...additionalOptions, } as GlobOptions // When no filter is provided and no negated patterns exist, use the fast path.