/ tools / PowerShellTool / gitSafety.ts
gitSafety.ts
  1  /**
  2   * Git can be weaponized for sandbox escape via two vectors:
  3   * 1. Bare-repo attack: if cwd contains HEAD + objects/ + refs/ but no valid
  4   *    .git/HEAD, Git treats cwd as a bare repository and runs hooks from cwd.
  5   * 2. Git-internal write + git: a compound command creates HEAD/objects/refs/
  6   *    hooks/ then runs git — the git subcommand executes the freshly-created
  7   *    malicious hooks.
  8   */
  9  
 10  import { basename, posix, resolve, sep } from 'path'
 11  import { getCwd } from '../../utils/cwd.js'
 12  import { PS_TOKENIZER_DASH_CHARS } from '../../utils/powershell/parser.js'
 13  
 14  /**
 15   * If a normalized path starts with `../<cwd-basename>/`, it re-enters cwd
 16   * via the parent — resolve it to the cwd-relative form. posix.normalize
 17   * preserves leading `..` (no cwd context), so `../project/hooks` with
 18   * cwd=/x/project stays `../project/hooks` and misses the `hooks/` prefix
 19   * match even though it resolves to the same directory at runtime.
 20   * Check/use divergence: validator sees `../project/hooks`, PowerShell
 21   * resolves against cwd to `hooks`.
 22   */
 23  function resolveCwdReentry(normalized: string): string {
 24    if (!normalized.startsWith('../')) return normalized
 25    const cwdBase = basename(getCwd()).toLowerCase()
 26    if (!cwdBase) return normalized
 27    // Iteratively strip `../<cwd-basename>/` pairs (handles `../../p/p/hooks`
 28    // when cwd has repeated basename segments is unlikely, but one-level is
 29    // the common attack).
 30    const prefix = '../' + cwdBase + '/'
 31    let s = normalized
 32    while (s.startsWith(prefix)) {
 33      s = s.slice(prefix.length)
 34    }
 35    // Also handle exact `../<cwd-basename>` (no trailing slash)
 36    if (s === '../' + cwdBase) return '.'
 37    return s
 38  }
 39  
 40  /**
 41   * Normalize PS arg text → canonical path for git-internal matching.
 42   * Order matters: structural strips first (colon-bound param, quotes,
 43   * backtick escapes, provider prefix, drive-relative prefix), then NTFS
 44   * per-component trailing-strip (spaces always; dots only if not `./..`
 45   * after space-strip), then posix.normalize (resolves `..`, `.`, `//`),
 46   * then case-fold.
 47   */
 48  function normalizeGitPathArg(arg: string): string {
 49    let s = arg
 50    // Normalize parameter prefixes: dash chars (–, —, ―) and forward-slash
 51    // (PS 5.1). /Path:hooks/pre-commit → extract colon-bound value. (bug #28)
 52    if (s.length > 0 && (PS_TOKENIZER_DASH_CHARS.has(s[0]!) || s[0] === '/')) {
 53      const c = s.indexOf(':', 1)
 54      if (c > 0) s = s.slice(c + 1)
 55    }
 56    s = s.replace(/^['"]|['"]$/g, '')
 57    s = s.replace(/`/g, '')
 58    // PS provider-qualified path: FileSystem::hooks/pre-commit → hooks/pre-commit
 59    // Also handles fully-qualified form: Microsoft.PowerShell.Core\FileSystem::path
 60    s = s.replace(/^(?:[A-Za-z0-9_.]+\\){0,3}FileSystem::/i, '')
 61    // Drive-relative C:foo (no separator after colon) is cwd-relative on that
 62    // drive. C:\foo (WITH separator) is absolute and must NOT match — the
 63    // negative lookahead preserves it.
 64    s = s.replace(/^[A-Za-z]:(?![/\\])/, '')
 65    s = s.replace(/\\/g, '/')
 66    // Win32 CreateFileW per-component: iteratively strip trailing spaces,
 67    // then trailing dots, stopping if the result is `.` or `..` (special).
 68    // `.. ` → `..`, `.. .` → `..`, `...` → '' → `.`, `hooks .` → `hooks`.
 69    // Originally-'' (leading slash split) stays '' (absolute-path marker).
 70    s = s
 71      .split('/')
 72      .map(c => {
 73        if (c === '') return c
 74        let prev
 75        do {
 76          prev = c
 77          c = c.replace(/ +$/, '')
 78          if (c === '.' || c === '..') return c
 79          c = c.replace(/\.+$/, '')
 80        } while (c !== prev)
 81        return c || '.'
 82      })
 83      .join('/')
 84    s = posix.normalize(s)
 85    if (s.startsWith('./')) s = s.slice(2)
 86    return s.toLowerCase()
 87  }
 88  
 89  const GIT_INTERNAL_PREFIXES = ['head', 'objects', 'refs', 'hooks'] as const
 90  
 91  /**
 92   * SECURITY: Resolve a normalized path that escapes cwd (leading `../` or
 93   * absolute) against the actual cwd, then check if it lands back INSIDE cwd.
 94   * If so, strip cwd and return the cwd-relative remainder for prefix matching.
 95   * If it lands outside cwd, return null (genuinely external — path-validation's
 96   * concern). Covers `..\<cwd-basename>\HEAD` and `C:\<full-cwd>\HEAD` which
 97   * posix.normalize alone cannot resolve (it leaves leading `..` as-is).
 98   *
 99   * This is the SOLE guard for the bare-repo HEAD attack. path-validation's
100   * DANGEROUS_FILES deliberately excludes bare `HEAD` (false-positive risk
101   * on legitimate non-git files named HEAD) and DANGEROUS_DIRECTORIES
102   * matches per-segment `.git` only — so `<cwd>/HEAD` passes that layer.
103   * The cwd-resolution here is load-bearing; do not remove without adding
104   * an alternative guard.
105   */
106  function resolveEscapingPathToCwdRelative(n: string): string | null {
107    const cwd = getCwd()
108    // Reconstruct a platform-resolvable path from the posix-normalized form.
109    // `n` has forward slashes (normalizeGitPathArg converted \\ → /); resolve()
110    // handles forward slashes on Windows.
111    const abs = resolve(cwd, n)
112    const cwdWithSep = cwd.endsWith(sep) ? cwd : cwd + sep
113    // Case-insensitive comparison: normalizeGitPathArg lowercased `n`, so
114    // resolve() output has lowercase components from `n` but cwd may be
115    // mixed-case (e.g. C:\Users\...). Windows paths are case-insensitive.
116    const absLower = abs.toLowerCase()
117    const cwdLower = cwd.toLowerCase()
118    const cwdWithSepLower = cwdWithSep.toLowerCase()
119    if (absLower === cwdLower) return '.'
120    if (!absLower.startsWith(cwdWithSepLower)) return null
121    return abs.slice(cwdWithSep.length).replace(/\\/g, '/').toLowerCase()
122  }
123  
124  function matchesGitInternalPrefix(n: string): boolean {
125    if (n === 'head' || n === '.git') return true
126    if (n.startsWith('.git/') || /^git~\d+($|\/)/.test(n)) return true
127    for (const p of GIT_INTERNAL_PREFIXES) {
128      if (p === 'head') continue
129      if (n === p || n.startsWith(p + '/')) return true
130    }
131    return false
132  }
133  
134  /**
135   * True if arg (raw PS arg text) resolves to a git-internal path in cwd.
136   * Covers both bare-repo paths (hooks/, refs/) and standard-repo paths
137   * (.git/hooks/, .git/config).
138   */
139  export function isGitInternalPathPS(arg: string): boolean {
140    const n = resolveCwdReentry(normalizeGitPathArg(arg))
141    if (matchesGitInternalPrefix(n)) return true
142    // SECURITY: leading `../` or absolute paths that resolveCwdReentry and
143    // posix.normalize couldn't fully resolve. Resolve against actual cwd — if
144    // the result lands back in cwd at a git-internal location, the guard must
145    // still fire.
146    if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
147      const rel = resolveEscapingPathToCwdRelative(n)
148      if (rel !== null && matchesGitInternalPrefix(rel)) return true
149    }
150    return false
151  }
152  
153  /**
154   * True if arg resolves to a path inside .git/ (standard-repo metadata dir).
155   * Unlike isGitInternalPathPS, does NOT match bare-repo-style root-level
156   * `hooks/`, `refs/` etc. — those are common project directory names.
157   */
158  export function isDotGitPathPS(arg: string): boolean {
159    const n = resolveCwdReentry(normalizeGitPathArg(arg))
160    if (matchesDotGitPrefix(n)) return true
161    // SECURITY: same cwd-resolution as isGitInternalPathPS — catch
162    // `..\<cwd-basename>\.git\hooks\pre-commit` that lands back in cwd.
163    if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) {
164      const rel = resolveEscapingPathToCwdRelative(n)
165      if (rel !== null && matchesDotGitPrefix(rel)) return true
166    }
167    return false
168  }
169  
170  function matchesDotGitPrefix(n: string): boolean {
171    if (n === '.git' || n.startsWith('.git/')) return true
172    // NTFS 8.3 short names: .git becomes GIT~1 (or GIT~2, etc. if multiple
173    // dotfiles start with "git"). normalizeGitPathArg lowercases, so check
174    // for git~N as the first component.
175    return /^git~\d+($|\/)/.test(n)
176  }