diff --git a/CHANGELOG.md b/CHANGELOG.md index ab833e2..bb2d8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), command. - Fails closed on expansions in command position, brace and glob command words, unbalanced quoting, shells reading a script from stdin or a file, and `git -c alias.*` / `GIT_CONFIG_*` aliases. -- Matches `rm` by flag set and operand, with `$HOME` read as `~`. +- Matches `rm` by flag set and operand, with `$HOME` read as `~` and `//`, `/./` and a trailing `/` or `/.` folded. +- Sees through `doas`, `su -c`, `run0`, `pkexec`, `runuser`, `chrt`, `taskset`, `caffeinate`, `arch`, `parallel`, + `script`, `strace`, `ltrace`, `nsenter`, `chroot`, `systemd-run`, `firejail`, `faketime`, `dbus-launch`, + `xvfb-run`, `unbuffer`, `toybox`, `fd -x`, `ssh host …`, `yarn exec`, `bun x`/`bun exec`, `coproc`, `function`, and + zsh's `=cmd`, `noglob`, `nocorrect`, `-` and `[[ … ]] cmd`. +- Reads commands set up to run later: `trap` actions, `alias` values, `git rebase -x`, `git difftool -x`, + `git bisect run`, `git submodule foreach`, `git filter-branch` filters and `git -c` values; denies + `git config alias.*`. +- Counts the words `xargs` and `parallel` add to a command, and its replace-string, as expansions. +- Fails closed on `source` of stdin or a process substitution, and on a line nested deeper than the stack instead of + throwing a `RangeError`. +- Searches the inline program of `python -c`, `node -e`, `perl -e`, `ruby -e`, `php -r`, `osascript -e`, `awk` and + similar for a pattern's words. +- `DEFAULT_DENY` also holds `pkexec`, `run0` and `rm -rf ~/*`. +- Reads attached option values (`-c'code'`), options after `ssh`'s host, GNU `time -f`, `env -a`/`-P`, `..` in `rm` + operands, versioned interpreters (`python3.12`), and `gtimeout`, `fakeroot`, `unshare` and `sandbox-exec`. - Zero runtime dependencies, ESM only, TypeScript declarations, Node 20+. [0.1.0]: https://github.com/devmaxxx/shell-command-guard/releases/tag/v0.1.0 diff --git a/README.md b/README.md index b9b1801..f4751b0 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,21 @@ command the line could run is checked; and anything it cannot read is denied rat ## Features - πŸ” **Follows what bash runs** β€” pipelines, `&&`/`||`/`;` lists, subshells, `{ …; }`, `$(…)`, backquotes, unquoted - here-documents, `bash -c`, `sh -c`, `eval`. -- πŸ§… **Sees through wrappers** β€” `env` (with `-S`), `sudo`, `nohup`, `timeout`, `nice`, `xargs`, `setsid`, `stdbuf`, - `ionice`, `flock`, `watch`, `find -exec`, `busybox`, `command`, `exec`, `npx`, `pnpm exec`, `npm exec`, `yarn dlx`, - `git -C dir`. -- πŸ”’ **Fails closed** β€” an expansion in command position, unbalanced quoting, a stdin-fed shell, a script file or a - `git -c alias.*` returns a reason instead of `null`. + here-documents, `bash -c`, `sh -c`, `eval`, `coproc`, `function` bodies. +- πŸ§… **Sees through wrappers** β€” `env` (with `-S`), `sudo`, `doas`, `su -c`, `run0`, `pkexec`, `runuser`, `nohup`, + `timeout`, `nice`, `chrt`, `taskset`, `caffeinate`, `arch`, `xargs`, `parallel`, `setsid`, `stdbuf`, `ionice`, + `flock`, `watch`, `script`, `strace`, `ltrace`, `nsenter`, `chroot`, `systemd-run`, `firejail`, `faketime`, + `xvfb-run`, `unbuffer`, `find -exec`, `fd -x`, `ssh host …`, `busybox`, `toybox`, `command`, `exec`, `npx`, + `pnpm exec`, `npm exec`, `yarn exec`, `bun x`, `git -C dir`, and zsh's `=cmd`, `noglob` and `nocorrect`. +- ⏱️ **Catches commands set up to run later** β€” `trap '…' EXIT`, `alias`, `git rebase -x`, `git bisect run`, + `git submodule foreach`, `git filter-branch`, and a pager, editor or ssh command set with `git -c`. +- πŸ”’ **Fails closed** β€” an expansion in command position, unbalanced quoting, a stdin-fed shell or `source`, a script + file, a `git -c alias.*` or `git config alias.*`, and a line nested deeper than the stack return a reason instead + of `null` β€” never a throw. - Reads the line as bash, bash 3.2, zsh and sh do, so a command any of them would run is caught. -- `rm` is matched by flag set: `rm -r -f /`, `rm -Rf /` and `rm / -rf` all match `'rm -rf /'`. +- Searches the inline program of `python -c`, `node -e`, `perl -e`, `ruby -e`, `osascript -e`, `awk` and similar for + a pattern's words (`os.system("git push")`), since those can run anything. +- `rm` is matched by flag set and folded path: `rm -r -f /`, `rm -Rf //` and `rm / -rf` all match `'rm -rf /'`. - Word-prefix patterns: `'git push'` matches `git push --force`, not `git pushd`. - A starter deny-list, `DEFAULT_DENY`. - Zero runtime dependencies, ESM only, typed, Node 20+. @@ -143,23 +150,24 @@ effectiveDeny({ deny: ['docker'] }, ['kubectl delete']) A starting list for commands an unattended process should not run on its own: -- privilege escalation β€” `sudo`, `su`, `doas` -- wiping the disk or home directory β€” `rm -rf /`, `rm -rf /*`, `rm -rf ~`, `rm -rf ~/`, `rm -fr /`, `rm -fr ~`, - `mkfs`, `dd` +- privilege escalation β€” `sudo`, `su`, `doas`, `pkexec`, `run0` +- wiping the disk or home directory β€” `rm -rf /`, `rm -rf /*`, `rm -rf ~`, `rm -rf ~/`, `rm -rf ~/*`, `rm -fr /`, + `rm -fr ~`, `mkfs`, `dd` - powering off β€” `shutdown`, `reboot`, `halt`, `poweroff` - publishing beyond the local checkout β€” `git push`, `git reset --hard`, `git clean`, `npm publish`, `pnpm publish`, `yarn publish` -`$HOME` counts as `~`, and each `rm` operand is tried. +`$HOME` counts as `~`, and each `rm` operand is tried as written and with `//`, `/./` and a trailing `/` or `/.` +folded away. ### `DENY_REASONS` | Key | Value | When | | --- | --- | --- | | `expansion` | `'command word comes from an expansion'` | the program name is `$VAR`, `$(…)`, a glob (`/usr/bin/g*`), a brace expansion or similar | -| `unbalanced` | `'unbalanced quoting'` | a quote, `$(…)` or here-document never closes | -| `gitAlias` | `'git -c alias.*'` | a git alias defined with `-c`, `GIT_CONFIG_KEY_n` or `GIT_CONFIG_PARAMETERS` could run anything | -| `stdinScript` | `'shell reads script from stdin'` | a shell runs without `-c` and without a script file (`echo … \| sh`, `bash -s`) | +| `unbalanced` | `'unbalanced quoting'` | a quote, `$(…)` or here-document never closes, or the line nests deeper than the stack can read | +| `gitAlias` | `'git -c alias.*'` | a git alias defined with `-c`, `GIT_CONFIG_KEY_n`, `GIT_CONFIG_PARAMETERS` or `git config alias.*` could run anything | +| `stdinScript` | `'shell reads script from stdin'` | a shell runs without `-c` and without a script file (`echo … \| sh`, `bash -s`), `source` reads stdin or a process substitution, or `parallel` reads its commands from stdin | | `scriptFile` | `'shell runs a script file'` | a shell runs a script file (`bash deploy.sh`) this reading cannot see | ## Recipes @@ -255,12 +263,21 @@ unbash and shell-quote are parsers you could build a guard on; this package is t something other than a policy decision, use unbash. ([bash-parser](https://www.npmjs.com/package/bash-parser) has had no release since 2022.) +Ready-made agent hooks take a different trade-off. Matt Pocock's +[git-guardrails](https://www.aihero.dev/this-hook-stops-claude-code-running-dangerous-git-commands) skill blocks a fixed +set of git commands from a Claude Code hook. [destructive_command_guard](https://github.com/Dicklesworthstone/destructive_command_guard) +(dcg, Rust) matches pattern packs fast and, by its own description, allows unrecognised commands and does not look +inside scripts. Both fail open on what they do not recognise; this package fails closed, so pair it with an allow +rule when a reason such as `'shell runs a script file'` blocks a command you trust. + ## Security model and limits - **Not a sandbox.** It reads a command line before it runs; it does not stop a program from doing anything once it runs. `deniedBy` refuses a shell given a script file or stdin, but a Makefile target, a package script - (`npm run release`), a program that runs its own commands, an alias or a function defined earlier in the session can - all run a denied command that never appears on the line. + (`npm run release`), a file read with `source`, an executable script (`./deploy`), a program that runs its own + commands, an alias or a function defined earlier in the session can all run a denied command that never appears on + the line. An interpreter's inline program is only searched for a pattern's words, so code that builds the command + (`"gi" + "t"`) passes. - **A best-effort deny-list.** Matching is by word prefix, so `rm -rf /` does not match `rm -rf /home`, and only `rm` is matched by flag set: `'git clean -fd'` does not match `git clean -df`. Deny the program or subcommand (`git clean`) when every spelling matters, and allow-list instead of deny-list when the stakes are high. diff --git a/src/defaults.ts b/src/defaults.ts index 8acbf75..f096526 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -9,10 +9,13 @@ export const DEFAULT_DENY: readonly string[] = [ 'sudo', 'su', 'doas', + 'pkexec', + 'run0', 'rm -rf /', 'rm -rf /*', 'rm -rf ~', 'rm -rf ~/', + 'rm -rf ~/*', 'rm -fr /', 'rm -fr ~', 'mkfs', diff --git a/src/judge.ts b/src/judge.ts index 3d02597..183e3d2 100644 --- a/src/judge.ts +++ b/src/judge.ts @@ -2,6 +2,29 @@ import type { Mode } from './types' import { DEFAULT_DENY } from './defaults' import { ASSIGNMENT_WORD, LEADING_ASSIGNMENTS, read, READINGS, tokenize } from './segments' import { EXP, GLOB } from './word-reader' +import { + AWK_OPTS, + AWKS, + FD_EXEC, + FIND_EXEC, + FLOCK_OPTS, + GIT_OPTS, + GIT_SCRIPT_OPTS, + INTERPRETERS, + NONE, + PARALLEL_OPTS, + RUNNER_OPTS, + RUNNER_SUBCOMMANDS, + RUNNERS, + RUNUSER_OPTS, + SCRIPT_OPTS, + SHELL_OPTS, + SHELLS, + SSH_OPTS, + WATCH_OPTS, + WRAPPERS, + XARGS_OPTS, +} from './wrappers' /** * The reasons {@link deniedBy} returns when it cannot tell what a command runs, in place of a matched pattern. @@ -40,99 +63,21 @@ type Next = { tokens: string[] } | { verdict: string | null } const BASH_READING: readonly Mode[] = [{ comments: true, heredocs: 'on' }] const KEYWORDS = new Set(['!', '{', '}', 'if', 'then', 'do', 'else', 'elif', 'while', 'until']) -const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ash', 'ksh', 'mksh', 'fish', 'csh', 'tcsh']) -const NONE: ReadonlySet = new Set() -const SHELL_OPTS: ReadonlySet = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']) -const PNPM_OPTS: ReadonlySet = new Set(['-C', '--dir', '-F', '--filter', '--loglevel', '--reporter']) -const NPM_OPTS: ReadonlySet = new Set(['-w', '--workspace', '--prefix', '--loglevel', '--userconfig']) -const YARN_OPTS: ReadonlySet = new Set(['--cwd']) -const RUNNER_OPTS: ReadonlySet = new Set(['-p', '--package', '-w', '--workspace']) -const WATCH_OPTS: ReadonlySet = new Set(['-n', '--interval', '-q', '--equexit']) -const FLOCK_OPTS: ReadonlySet = new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']) -const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir']) - -// The package runner subcommands that run a command, by the options each runner takes before its subcommand. -const RUNNER_SUBCOMMANDS: Record, subcommands: readonly string[] }> = { - pnpm: { opts: PNPM_OPTS, subcommands: ['exec', 'dlx'] }, - npm: { opts: NPM_OPTS, subcommands: ['exec', 'x'] }, - yarn: { opts: YARN_OPTS, subcommands: ['exec', 'dlx'] }, -} -const RUNNERS = new Set(['npx', 'pnpx', 'bunx']) - -// Options that consume the next token; any other token starting with '-' stands alone. -const ARG_OPTS: Record< - | 'env' - | 'command' - | 'time' - | 'exec' - | 'sudo' - | 'nice' - | 'xargs' - | 'timeout' - | 'git' - | 'setsid' - | 'stdbuf' - | 'ionice' - | 'chronic' - | 'busybox', - ReadonlySet -> = { - env: new Set(['-u', '--unset', '-C', '--chdir']), - command: NONE, - time: NONE, - exec: new Set(['-a']), - sudo: new Set([ - '-u', - '--user', - '-g', - '--group', - '-h', - '--host', - '-p', - '--prompt', - '-C', - '--close-from', - '-D', - '--chdir', - '-R', - '--chroot', - '-r', - '--role', - '-t', - '--type', - '-T', - '--command-timeout', - '-U', - '--other-user', - ]), - nice: new Set(['-n', '--adjustment']), - xargs: new Set([ - '-a', - '--arg-file', - '-d', - '--delimiter', - '-E', - '-I', - '-L', - '--max-lines', - '-n', - '--max-args', - '-P', - '--max-procs', - '-s', - '--max-chars', - ]), - timeout: new Set(['-s', '--signal', '-k', '--kill-after']), - git: new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix']), - setsid: NONE, - stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']), - ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid', '-P', '--pgid', '-u', '--uid']), - chronic: NONE, - busybox: NONE, -} - -function hasArgOpts(exe: string): exe is keyof typeof ARG_OPTS { - return Object.hasOwn(ARG_OPTS, exe) +const PARALLEL_SEPARATOR = /^:{3,4}\+?$/ +// GNU parallel's replacement strings: `{}`, `{.}`, `{/}`, `{//}`, `{/.}`, `{#}`, `{%}` and the positional `{1}`. +const PARALLEL_REPLACEMENT = /\{[\d./#%]*\}/g +const STDIN_FILE = /^(?:-|\/dev\/stdin|\/dev\/fd\/\d+|\/proc\/self\/fd\/\d+)$/ + +function firstHit(items: readonly T[], judgeItem: (item: T) => string | null): string | null { + for (const item of items) { + const hit = judgeItem(item) + + if (hit) { + return hit + } + } + + return null } function needsArg(t: string, takesArg: ReadonlySet): boolean { @@ -196,9 +141,14 @@ function unknowable(judge: Judge, reason: string): string | null { } const MAX_DEPTH = 8 +const FLOCK_SCRIPT_OPTS = ['-c', '--command'] + +function scriptVerdict(script: string, name: string, judge: Judge, depth: number): string | null { + return depth >= MAX_DEPTH ? unknowable(judge, name) : judgeCommand(script, judge, depth + 1) +} function nestedScript(script: string, name: string, judge: Judge, depth: number): Next { - return { verdict: depth >= MAX_DEPTH ? unknowable(judge, name) : judgeCommand(script, judge, depth + 1) } + return { verdict: scriptVerdict(script, name, judge, depth) } } // Every form the command passes through while its wrappers come off is matched, so a list that names a wrapper @@ -250,70 +200,308 @@ function matchTokens(start: string[], outer: Judge, depth: number): string | nul function unwrap(tokens: string[], head: string, judge: Judge, depth: number): Next { const exe = head.slice(head.lastIndexOf('/') + 1) + const wrapper = Object.hasOwn(WRAPPERS, exe) ? WRAPPERS[exe] : undefined const runner = Object.hasOwn(RUNNER_SUBCOMMANDS, exe) ? RUNNER_SUBCOMMANDS[exe] : undefined + // zsh replaces a word starting with `=` by the path of the command it names. + if (judge.strict && head.length > 1 && head.startsWith('=')) { + return { tokens: [head.slice(1), ...tokens.slice(1)] } + } + if (exe !== head) { return { tokens: [exe, ...tokens.slice(1)] } } - if (exe === 'command' && (tokens[1] === '-v' || tokens[1] === '-V')) { + if (exe === 'command' && tokens.slice(1, skipOptions(tokens, 1, NONE)).some(t => /^-\w*v/i.test(t))) { // `command -v` only looks a name up; it runs nothing. return { verdict: null } } - if (exe === 'builtin' || exe === 'nohup') { - return { tokens: tokens.slice(1) } + if (wrapper && (judge.strict || !wrapper.zsh)) { + const end = skipOptions(tokens, 1, wrapper.opts) + (wrapper.operands ?? 0) + return { tokens: (exe === 'env' ? splitEnvString(tokens) : null) ?? tokens.slice(end) } } - if (exe === 'timeout') { - return { tokens: tokens.slice(skipOptions(tokens, 1, ARG_OPTS.timeout) + 1) } - } + if (runner) { + const at = skipOptions(tokens, 1, runner.opts) + const subcommand = tokens[at] ?? '' - if (exe === 'env') { - return { tokens: splitEnvString(tokens) ?? tokens.slice(skipOptions(tokens, 1, ARG_OPTS.env)) } - } + if (runner.run.includes(subcommand)) { + return packageRunner(tokens, at + 1, judge, depth) + } - if (hasArgOpts(exe) && exe !== 'git') { - return { tokens: tokens.slice(skipOptions(tokens, 1, ARG_OPTS[exe])) } + if (runner.shell.includes(subcommand)) { + return nestedScript(tokens.slice(at + 1).join(' '), exe, judge, depth) + } } - if (runner && runner.subcommands.includes(tokens[skipOptions(tokens, 1, runner.opts)] ?? '')) { - return packageRunner(tokens, skipOptions(tokens, 1, runner.opts) + 1, judge, depth) + return unwrapProgram(tokens, exe, judge, depth) +} + +function unwrapProgram(tokens: string[], exe: string, judge: Judge, depth: number): Next { + switch (exe) { + case 'coproc': + return { tokens: tokens.slice(tokens[2] === '{' ? 2 : 1) } + case 'function': + return { tokens: tokens.slice(2) } + case '[[': + return zshShortCondition(tokens, judge) + case 'xargs': + return { tokens: xargs(tokens) } + case 'parallel': + return parallel(tokens, judge, depth) + case 'watch': + return watch(tokens, judge, depth) + case 'flock': + return flock(tokens, judge, depth) + case 'find': + case 'fd': { + const commands = execCommands(tokens, exe === 'find' ? FIND_EXEC : FD_EXEC) + return { verdict: firstHit(commands, sub => matchTokens(sub, judge, depth)) } + } + case 'eval': + return nestedScript(tokens.slice(tokens[1] === '--' ? 2 : 1).join(' '), 'eval', judge, depth) + case 'ssh': + return ssh(tokens, judge, depth) + case 'script': + return script(tokens, judge, depth) + case 'su': + case 'runuser': + return switchUser(tokens, exe, judge, depth) + case 'trap': + return trap(tokens, judge, depth) + case 'alias': + return alias(tokens, judge, depth) + case 'source': + case '.': + return source(tokens, judge) + case 'git': + return git(tokens, judge, depth) + case 'rm': + return { verdict: rmByFlags(tokens, judge) } } if (RUNNERS.has(exe)) { return packageRunner(tokens, 1, judge, depth) } - if (exe === 'watch') { - return watch(tokens, judge, depth) + if (SHELLS.has(exe)) { + return shellScript(tokens, exe, judge, depth) + } + + // `python3.12` and `lua5.4` take the options of `python` and `lua`. + const interpreter = Object.hasOwn(INTERPRETERS, exe) ? exe : exe.replace(/[\d.]+$/, '') + + if (Object.hasOwn(INTERPRETERS, interpreter) || AWKS.has(exe)) { + const program = AWKS.has(exe) ? exe : interpreter + return { verdict: judge.strict ? firstHit(inlinePrograms(tokens, program), p => wordsMatch(p, judge)) : null } + } + + return { verdict: null } +} + +// zsh runs `[[ cond ]] cmd` as a short `if`; bash rejects it, so only the strict reading follows it. +function zshShortCondition(tokens: string[], judge: Judge): Next { + const end = tokens.indexOf(']]') + return judge.strict && end > 0 ? { tokens: tokens.slice(end + 1) } : { verdict: null } +} + +// xargs appends the words it reads to the command, or puts them where its replace-string stands, so a pattern they +// could complete counts. +function xargs(tokens: string[]): string[] { + const end = skipOptions(tokens, 1, XARGS_OPTS) + const replace = replaceString(tokens.slice(1, end)) + const command = end < tokens.length ? tokens.slice(end) : ['echo'] + + return replace === null ? [...command, EXP] : command.map(t => t.replaceAll(replace, EXP)) +} + +function replaceString(options: string[]): string | null { + let replace: string | null = null + + for (let i = 0; i < options.length; i++) { + const t = options[i] ?? '' + + if (t.startsWith('--')) { + if (t === '--replace') { + replace = '{}' + } + else if (t.startsWith('--replace=')) { + replace = t.slice('--replace='.length) + } + + continue + } + + // In a cluster such as -tI{} the first letter that takes an argument consumes the rest of the cluster. + for (let j = 1; j < t.length; j++) { + const letter = t[j] ?? '' + const rest = t.slice(j + 1) + + if (letter === 'I' || letter === 'i') { + replace = rest || (letter === 'i' ? '{}' : (options[++i] ?? null)) + break + } + + if (XARGS_OPTS.has(`-${letter}`)) { + i += rest === '' ? 1 : 0 + break + } + } } - if (exe === 'flock') { - return flock(tokens, judge, depth) + return replace || null +} + +// GNU parallel runs its command through a shell with the input appended; with no command, each input is a command. +function parallel(tokens: string[], judge: Judge, depth: number): Next { + const end = skipOptions(tokens, 1, PARALLEL_OPTS) + const separator = tokens.findIndex((t, i) => i >= end && PARALLEL_SEPARATOR.test(t)) + const command = tokens.slice(end, separator < 0 ? tokens.length : separator) + + if (command.length > 0) { + return nestedScript([...command, EXP].join(' ').replace(PARALLEL_REPLACEMENT, EXP), 'parallel', judge, depth) } - if (exe === 'find') { - return { verdict: findCommands(tokens).reduce((hit, sub) => hit ?? matchTokens(sub, judge, depth), null) } + if (separator < 0) { + return { verdict: unknowable(judge, DENY_REASONS.stdinScript) } } - if (exe === 'eval') { - return nestedScript(tokens.slice(1).join(' '), 'eval', judge, depth) + if (tokens.some(t => t.startsWith('::::'))) { + return { verdict: unknowable(judge, DENY_REASONS.scriptFile) } } - if (SHELLS.has(exe)) { - return shellScript(tokens, exe, judge, depth) + const inputs = tokens.slice(separator).filter(t => !PARALLEL_SEPARATOR.test(t)) + return { verdict: firstHit(inputs, input => scriptVerdict(input, 'parallel', judge, depth)) } +} + +// ssh hands the words after the host to a shell on that host, which reaches the same remotes and registries. +function ssh(tokens: string[], judge: Judge, depth: number): Next { + const afterHost = tokens.slice(skipOptions(tokens, 1, SSH_OPTS) + 1) + // ssh reads options again after the host (`ssh host -t cmd`, `ssh host -- cmd`). + const script = afterHost.slice(skipOptions(afterHost, 0, SSH_OPTS)) + return script.length === 0 ? { verdict: null } : nestedScript(script.join(' '), 'ssh', judge, depth) +} + +// util-linux takes the command as `-c script`; BSD and macOS take it as the words after the typescript file. +function script(tokens: string[], judge: Judge, depth: number): Next { + const scripts = optionValues(tokens, ['-c', '--command']) + + if (scripts.length > 0) { + return { verdict: firstHit(scripts, s => scriptVerdict(s, 'script', judge, depth)) } } - if (exe === 'git') { - return git(tokens, judge) + return { tokens: tokens.slice(skipOptions(tokens, 1, SCRIPT_OPTS) + 1) } +} + +// `su user -c script` hands the script to the user's shell; `runuser -u user command` runs the command itself. +function switchUser(tokens: string[], exe: string, judge: Judge, depth: number): Next { + const scripts = optionValues(tokens, ['-c', '--command']) + + if (scripts.length > 0) { + return { verdict: firstHit(scripts, s => scriptVerdict(s, exe, judge, depth)) } } - if (exe === 'rm') { - return { verdict: rmByFlags(tokens, judge) } + return exe === 'runuser' ? { tokens: tokens.slice(skipOptions(tokens, 1, RUNUSER_OPTS)) } : { verdict: null } +} + +// The action runs later, when the signal arrives or the shell exits, so only the strict reading counts it. +function trap(tokens: string[], judge: Judge, depth: number): Next { + const at = tokens[1] === '--' ? 2 : 1 + const action = tokens[at] + + if (!judge.strict || action === undefined || action.startsWith('-') || tokens.length <= at + 1) { + return { verdict: null } } - return { verdict: null } + return nestedScript(action, 'trap', judge, depth) +} + +// zsh expands aliases even in scripts, and the line after the definition can append any words to the value. +function alias(tokens: string[], judge: Judge, depth: number): Next { + if (!judge.strict) { + return { verdict: null } + } + + const values = tokens.slice(1).filter(t => t.includes('=')).map(t => `${t.slice(t.indexOf('=') + 1)} ${EXP}`) + return { verdict: firstHit(values, value => scriptVerdict(value, 'alias', judge, depth)) } +} + +// A regular file is not followed, like any program that runs its own commands; stdin or a process substitution is a +// script built on this very line. +function source(tokens: string[], judge: Judge): Next { + const file = tokens[tokens[1] === '--' ? 2 : 1] + const fromStdin = file === undefined || STDIN_FILE.test(file) + + return { verdict: fromStdin ? unknowable(judge, DENY_REASONS.stdinScript) : null } +} + +// Where the value starts when the option carries it in its own word: `--eval=code`, `-c=code` or `-c'code'`. A short +// option followed by plain letters is a cluster, whose value is the next word. +function attachedAt(t: string, name: string): number { + if (t.startsWith(`${name}=`)) { + return name.length + 1 + } + + return !name.startsWith('--') && t.startsWith(name) && !/^-[a-z]+$/i.test(t) ? name.length : -1 +} + +// The values of the options that carry a program or script: `-c code`, a cluster ending in it (`-Ic code`, +// `-le code`), `--eval code` and `--eval=code`. +function optionValues(tokens: string[], names: readonly string[]): string[] { + const values: string[] = [] + + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i] ?? '' + const name = names.find(n => + n.startsWith('--') + ? t === n || t.startsWith(`${n}=`) + : t === n || (/^-[a-z]+$/i.test(t) ? t.endsWith(n.charAt(1)) : t.startsWith(n)), + ) + + if (name === undefined) { + continue + } + + const attached = attachedAt(t, name) + const value = attached < 0 ? tokens[++i] : t.slice(attached) + + if (value !== undefined) { + values.push(value) + } + } + + return values +} + +function inlinePrograms(tokens: string[], exe: string): string[] { + if (!AWKS.has(exe)) { + return optionValues(tokens, INTERPRETERS[exe] ?? []) + } + + const end = skipOptions(tokens, 1, AWK_OPTS) + const fromFile = tokens.slice(1, end).some(t => t.startsWith('-f') || isLongOption(t, '--file')) + const program = fromFile ? [] : tokens.slice(end, end + 1) + + return [...optionValues(tokens.slice(0, end), ['-e', '--source']), ...program] +} + +// An interpreter's program can run any command, and its quoting is not the shell's, so its words are searched for a +// pattern wherever they stand. +function wordsMatch(program: string, judge: Judge): string | null { + const words = program.split(/[\s"'`,;()[\]{}|&$=<>]+/).filter(Boolean) + const width = Math.max(0, ...judge.patterns.map(p => p.split(' ').length)) + 1 + const certain = { ...judge, strict: false } + + for (let i = 0; i < words.length; i++) { + const hit = match(words.slice(i, i + width), certain) + + if (hit) { + return hit + } + } + + return null } // `env -S "A=1 git push"` splits its string into words the way a shell would, though it expands only `${NAME}`. @@ -331,7 +519,7 @@ function splitEnvString(tokens: string[]): string[] | null { return splitEnvWords(value, tokens.slice(t.includes('=') ? i + 1 : i + 2)) } - i += ARG_OPTS.env.has(t) ? 1 : 0 + i += t === '--unset' || t === '--chdir' ? 1 : 0 continue } @@ -379,23 +567,25 @@ function watch(tokens: string[], judge: Judge, depth: number): Next { return exec ? { tokens: tokens.slice(end) } : nestedScript(tokens.slice(end).join(' '), 'watch', judge, depth) } -// `flock [options] lock command…` or `flock [options] lock -c script`; with a file descriptor alone it runs nothing. +// `flock [options] lock command…`, `flock [options] lock -c script` or `flock -c script [options] lock`; with a file +// descriptor alone it runs nothing. function flock(tokens: string[], judge: Judge, depth: number): Next { - const rest = tokens.slice(skipOptions(tokens, 1, FLOCK_OPTS) + 1) - - if (rest[0] !== '-c' && rest[0] !== '--command') { - return { tokens: rest } - } - - return rest[1] === undefined ? { verdict: null } : nestedScript(rest[1], 'flock', judge, depth) + const end = skipOptions(tokens, 1, FLOCK_OPTS) + const rest = tokens.slice(end + 1) + const trailing = rest[0]?.startsWith('-') ? optionValues(['flock', ...rest.slice(0, 2)], FLOCK_SCRIPT_OPTS) : [] + const scripts = [...optionValues(tokens.slice(0, end), FLOCK_SCRIPT_OPTS), ...trailing] + + return scripts.length > 0 + ? { verdict: firstHit(scripts, script => scriptVerdict(script, 'flock', judge, depth)) } + : { tokens: rest } } // Each `-exec cmd … ;` runs a command; `+` ends one only right after `{}`. -function findCommands(tokens: string[]): string[][] { +function execCommands(tokens: string[], exec: ReadonlySet): string[][] { const commands: string[][] = [] for (let i = 1; i < tokens.length; i++) { - if (!FIND_EXEC.has(tokens[i] ?? '')) { + if (!exec.has(tokens[i] ?? '')) { continue } @@ -435,18 +625,53 @@ function shellScript(tokens: string[], exe: string, judge: Judge, depth: number) } // Git ignores an alias that shadows a real command, so the subcommand is matched before an alias is suspected. -function git(tokens: string[], judge: Judge): Next { - if ((tokens[1] ?? '').startsWith('-')) { - const end = skipOptions(tokens, 1, ARG_OPTS.git) +function git(tokens: string[], judge: Judge, depth: number): Next { + const subcommand = tokens[1] ?? '' - if (definesGitAlias(tokens.slice(1, end))) { + if (subcommand.startsWith('-')) { + const end = skipOptions(tokens, 1, GIT_OPTS) + const globals = tokens.slice(1, end) + + if (definesGitAlias(globals)) { return { verdict: unknowable(judge, DENY_REASONS.gitAlias) } } - return { tokens: ['git', ...tokens.slice(end)] } + // A pager, editor, ssh command or helper set with `-c` is run by git; any other value read as a script is harmless. + const values = judge.strict ? configValues(globals) : [] + const hit = firstHit(values, v => scriptVerdict(v.replace(/^!/, ''), 'git', judge, depth)) + + return hit ? { verdict: hit } : { tokens: ['git', ...tokens.slice(end)] } } - return { verdict: judge.gitAliasEnv ? unknowable(judge, DENY_REASONS.gitAlias) : null } + if (subcommand === 'config' && tokens.some(t => /^alias\./i.test(t))) { + return { verdict: unknowable(judge, DENY_REASONS.gitAlias) } + } + + if (subcommand === 'bisect' && tokens[2] === 'run') { + return { tokens: tokens.slice(3) } + } + + const hit = firstHit(gitScripts(tokens), script => scriptVerdict(script, 'git', judge, depth)) + return { verdict: hit ?? (judge.gitAliasEnv ? unknowable(judge, DENY_REASONS.gitAlias) : null) } +} + +function configValues(globals: string[]): string[] { + return globals.flatMap((t, i) => { + const pair = globals[i - 1] === '-c' ? t : /^-c./.test(t) ? t.slice(2) : '' + return pair.includes('=') ? [pair.slice(pair.indexOf('=') + 1)] : [] + }) +} + +// The shell commands a subcommand runs: `rebase -x`, `difftool -x`, the `filter-branch` filters, `submodule foreach`. +function gitScripts(tokens: string[]): string[] { + const subcommand = tokens[1] ?? '' + const foreach = subcommand === 'submodule' ? tokens.indexOf('foreach') : -1 + + if (foreach > 0) { + return [tokens.slice(foreach + 1).filter(t => t !== '--recursive').join(' ')] + } + + return Object.hasOwn(GIT_SCRIPT_OPTS, subcommand) ? optionValues(tokens, GIT_SCRIPT_OPTS[subcommand] ?? []) : [] } // An alias can rename any subcommand, so what it runs cannot be known from the command line. @@ -465,7 +690,8 @@ function definesGitAliasEnv(assignment: string): boolean { } // GNU rm takes its options anywhere before `--`, in any order and spelling, so `rm` is matched once more as -// `rm -rf ` for each operand. `$HOME` names the same directory as `~`. +// `rm -rf ` for each operand, as written and with `//`, `/./` and a trailing `/` or `/.` folded away. +// `$HOME` names the same directory as `~`. function rmByFlags(tokens: string[], judge: Judge): string | null { let recursive = false let force = false @@ -495,7 +721,41 @@ function rmByFlags(tokens: string[], judge: Judge): string | null { return null } - return operands.reduce((hit, operand) => hit ?? match(['rm', `-${flags}`, operand], judge), null) + const spellings = operands.flatMap(operand => [operand, foldPath(operand), foldPath(resolveDots(operand))]) + return firstHit(spellings, operand => match(['rm', `-${flags}`, operand], judge)) +} + +// `/tmp/..` and `~/docs/..` name `/` and `~`; a relative path has no known base, so it is left as written. +function resolveDots(path: string): string { + const [root = '', ...segments] = path.split('/') + + if (root !== '' && root !== '~') { + return path + } + + const kept: string[] = [] + + for (const segment of segments) { + if (segment === '..') { + kept.pop() + } + else if (segment !== '' && segment !== '.') { + kept.push(segment) + } + } + + return kept.length > 0 ? [root, ...kept].join('/') : root || '/' +} + +function foldPath(path: string): string { + let folded = path.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/)/g, '') + + for (let prev = ''; prev !== folded;) { + prev = folded + folded = folded.replace(/(?<=.)\/$/, '').replace(/\/\.$/, '') + } + + return folded || '/' } // A matcher blind to quoting: a comment, a here-document or $'…' can put the scanner out of step with the shell, @@ -557,10 +817,11 @@ function judgeCommand(cmd: string, judge: Judge, depth: number): string | null { * * Each pattern is a word prefix (`'git push'`, `'rm -rf /'`) matched against every command the line could run: * each part of a pipeline or `&&`/`;` list, each `$(…)` and backquoted substitution, including those inside double - * quotes and unquoted here-documents, each script given to `bash -c`/`sh -c`/`eval`, and each command behind a - * wrapper such as `env` (and `env -S`), `sudo`, `nohup`, `timeout`, `nice`, `xargs`, `setsid`, `stdbuf`, `ionice`, - * `flock`, `watch`, `find -exec`, `npx`, `pnpm exec`, `npm exec`, `yarn dlx`, `busybox` or `git -C dir`. `rm` is matched - * by its flag set, so `rm -r -f /` matches `'rm -rf /'`. + * quotes and unquoted here-documents, each script given to `bash -c`/`sh -c`/`eval`/`ssh`/`trap`/`git rebase -x`, + * and each command behind a wrapper such as `env` (and `env -S`), `sudo`, `nohup`, `timeout`, `nice`, `xargs`, + * `parallel`, `flock`, `watch`, `find -exec`, `npx`, `pnpm exec`, `busybox` or `git -C dir`. `rm` is matched by its + * flag set, so `rm -r -f /` matches `'rm -rf /'`. The inline program of `python -c`, `node -e` and similar is + * searched for a pattern's words. * * @param cmd - The command line, as a shell would receive it. * @param deny - Word-prefix patterns to deny. diff --git a/src/segments.ts b/src/segments.ts index 24c025c..2ba60a8 100644 --- a/src/segments.ts +++ b/src/segments.ts @@ -22,7 +22,9 @@ export function read(cmd: string, mode: Mode): { segments: string[], complete: b new Reader(cmd, mode, 0, found).commands(0, false) } catch (e) { - if (!(e instanceof Unreadable)) { + // A line nested deeper than the stack allows cannot be read either, and a throw would let a caller that fails + // open run it. + if (!(e instanceof Unreadable || e instanceof RangeError)) { throw e } diff --git a/src/wrappers.ts b/src/wrappers.ts new file mode 100644 index 0000000..c662f29 --- /dev/null +++ b/src/wrappers.ts @@ -0,0 +1,326 @@ +export const NONE: ReadonlySet = new Set() + +function opts(...names: string[]): ReadonlySet { + return new Set(names) +} + +// A wrapper runs the command that follows its options and its `operands` fixed operands (timeout's duration, chroot's +// new root). `opts` are the options that consume the next token; any other token starting with '-' stands alone. +// `zsh` marks a zsh precommand modifier, which bash would run as a program of that name. +export interface Wrapper { + opts: ReadonlySet + operands?: number + zsh?: boolean +} + +export const WRAPPERS: Readonly> = { + 'arch': { opts: opts('-arch', '-e', '-d') }, + 'builtin': { opts: NONE }, + 'busybox': { opts: NONE }, + 'caffeinate': { opts: opts('-t', '-w') }, + 'chronic': { opts: NONE }, + 'chroot': { opts: opts('--userspec', '--groups'), operands: 1 }, + 'chrt': { opts: opts('-T', '--sched-runtime', '-P', '--sched-period', '-D', '--sched-deadline'), operands: 1 }, + 'command': { opts: NONE }, + 'dbus-launch': { opts: NONE }, + 'doas': { opts: opts('-u', '-C') }, + 'env': { opts: opts('-u', '--unset', '-C', '--chdir', '-a', '--argv0', '-P') }, + 'exec': { opts: opts('-a') }, + 'faketime': { opts: opts('-p', '--date-prog'), operands: 1 }, + 'firejail': { opts: NONE }, + 'ionice': { opts: opts('-c', '--class', '-n', '--classdata', '-p', '--pid', '-P', '--pgid', '-u', '--uid') }, + 'ltrace': { opts: opts('-a', '-A', '-D', '-e', '-F', '-l', '-n', '-o', '-p', '-s', '-u', '-w', '-x') }, + 'nice': { opts: opts('-n', '--adjustment') }, + 'nohup': { opts: NONE }, + 'nsenter': { opts: opts('-t', '--target', '-S', '--setuid', '-G', '--setgid') }, + 'pkexec': { opts: opts('--user') }, + 'run0': { + opts: opts( + '-u', + '--user', + '-g', + '--group', + '-D', + '--chdir', + '--nice', + '--setenv', + '--unit', + '--property', + '--description', + '--slice', + '--machine', + '--background', + ), + }, + 'setsid': { opts: NONE }, + 'stdbuf': { opts: opts('-i', '--input', '-o', '--output', '-e', '--error') }, + 'strace': { opts: opts('-a', '-b', '-e', '-E', '-I', '-o', '-O', '-p', '-P', '-s', '-S', '-u', '-U', '-X') }, + 'sudo': { + opts: opts( + '-u', + '--user', + '-g', + '--group', + '-h', + '--host', + '-p', + '--prompt', + '-C', + '--close-from', + '-D', + '--chdir', + '-R', + '--chroot', + '-r', + '--role', + '-t', + '--type', + '-T', + '--command-timeout', + '-U', + '--other-user', + ), + }, + 'systemd-run': { + opts: opts( + '-u', + '--unit', + '-p', + '--property', + '--description', + '--slice', + '--service-type', + '-E', + '--setenv', + '--uid', + '--gid', + '--nice', + '-D', + '--working-directory', + '-M', + '--machine', + '-H', + '--host', + '--on-active', + '--on-boot', + '--on-startup', + '--on-unit-active', + '--on-unit-inactive', + '--on-calendar', + '--timer-property', + '--path-property', + '--socket-property', + ), + }, + 'taskset': { opts: NONE, operands: 1 }, + 'time': { opts: opts('-f', '--format', '-o', '--output') }, + 'timeout': { opts: opts('-s', '--signal', '-k', '--kill-after'), operands: 1 }, + 'toybox': { opts: NONE }, + // GNU coreutils installed on macOS under a `g` prefix. + 'gtimeout': { opts: opts('-s', '--signal', '-k', '--kill-after'), operands: 1 }, + 'gnice': { opts: opts('-n', '--adjustment') }, + 'gnohup': { opts: NONE }, + 'gstdbuf': { opts: opts('-i', '--input', '-o', '--output', '-e', '--error') }, + 'fakeroot': { opts: opts('-i', '-s', '-b', '--lib', '--faked') }, + 'eatmydata': { opts: NONE }, + 'sandbox-exec': { opts: opts('-f', '-n', '-p', '-D') }, + 'unshare': { opts: opts('-S', '--setuid', '-G', '--setgid', '-R', '--root', '-w', '--wd', '--propagation', '--monotonic', '--boottime') }, + 'unbuffer': { opts: NONE }, + 'xvfb-run': { + opts: opts( + '-e', + '--error-file', + '-f', + '--auth-file', + '-n', + '--server-num', + '-p', + '--xauth-protocol', + '-s', + '--server-args', + '-w', + '--wait', + ), + }, + '-': { opts: NONE, zsh: true }, + 'noglob': { opts: NONE, zsh: true }, + 'nocorrect': { opts: NONE, zsh: true }, +} + +export const XARGS_OPTS: ReadonlySet = opts( + '-a', + '--arg-file', + '-d', + '--delimiter', + '-E', + '-I', + '-L', + '--max-lines', + '-n', + '--max-args', + '-P', + '--max-procs', + '-s', + '--max-chars', + '--process-slot-var', +) +export const GIT_OPTS: ReadonlySet = opts( + '-C', + '-c', + '--git-dir', + '--work-tree', + '--namespace', + '--config-env', + '--super-prefix', +) +export const SHELL_OPTS: ReadonlySet = opts('-o', '+o', '-O', '+O', '--rcfile', '--init-file') +export const SSH_OPTS: ReadonlySet = opts( + '-B', + '-b', + '-c', + '-D', + '-E', + '-e', + '-F', + '-I', + '-i', + '-J', + '-L', + '-l', + '-m', + '-O', + '-o', + '-p', + '-Q', + '-R', + '-S', + '-W', + '-w', +) +export const PARALLEL_OPTS: ReadonlySet = opts( + '-a', + '--arg-file', + '-C', + '--colsep', + '-d', + '--delimiter', + '-E', + '-I', + '-j', + '--jobs', + '-L', + '-n', + '--max-args', + '-N', + '-S', + '--sshlogin', + '--delay', + '--halt', + '--header', + '--tagstring', + '--sshloginfile', + '--slf', + '--load', + '--nice', + '--rpl', + '--env', + '--joblog', + '--results', + '--retries', + '--timeout', + '--tmpdir', + '--workdir', +) +export const WATCH_OPTS: ReadonlySet = opts('-n', '--interval', '-q', '--equexit') +export const FLOCK_OPTS: ReadonlySet = opts('-w', '--wait', '--timeout', '-E', '--conflict-exit-code', '-c', '--command') +export const AWK_OPTS: ReadonlySet = opts('-F', '-v', '-f', '-e', '--source', '-i', '--include') +export const RUNNER_OPTS: ReadonlySet = opts( + '-p', + '--package', + '-w', + '--workspace', + '--registry', + '--cache', + '--prefix', + '--userconfig', + '--loglevel', +) + +// The subcommands of a package manager that run a program (`run`) or hand their arguments to a shell (`shell`), by +// the options the manager takes before its subcommand. +export const RUNNER_SUBCOMMANDS: Readonly< + Record, run: readonly string[], shell: readonly string[] }> +> = { + pnpm: { opts: opts('-C', '--dir', '-F', '--filter', '--loglevel', '--reporter'), run: ['exec', 'dlx'], shell: [] }, + npm: { opts: opts('-w', '--workspace', '--prefix', '--loglevel', '--userconfig'), run: ['exec', 'x'], shell: [] }, + yarn: { opts: opts('--cwd'), run: ['dlx'], shell: ['exec'] }, + bun: { opts: opts('--cwd'), run: ['x'], shell: ['exec'] }, +} +export const RUNNERS: ReadonlySet = opts('npx', 'pnpx', 'bunx') + +export const SHELLS: ReadonlySet = opts('bash', 'sh', 'zsh', 'dash', 'ash', 'ksh', 'mksh', 'fish', 'csh', 'tcsh') + +export const FIND_EXEC: ReadonlySet = opts('-exec', '-execdir', '-ok', '-okdir') +export const FD_EXEC: ReadonlySet = opts('-x', '--exec', '-X', '--exec-batch') + +// Interpreters that take a program on the command line, by the options that carry it. awk takes it as its first operand. +export const INTERPRETERS: Readonly> = { + python: ['-c'], + python2: ['-c'], + python3: ['-c'], + node: ['-e', '--eval', '-p', '--print'], + nodejs: ['-e', '--eval', '-p', '--print'], + bun: ['-e', '--eval', '-p', '--print'], + perl: ['-e', '-E'], + ruby: ['-e'], + php: ['-r'], + lua: ['-e'], + osascript: ['-e'], + Rscript: ['-e'], +} +export const AWKS: ReadonlySet = opts('awk', 'gawk', 'mawk', 'nawk') + +export const GIT_SCRIPT_OPTS: Readonly> = { + 'rebase': ['-x', '--exec'], + 'difftool': ['-x', '--extcmd'], + 'filter-branch': [ + '--env-filter', + '--tree-filter', + '--index-filter', + '--parent-filter', + '--msg-filter', + '--commit-filter', + '--tag-name-filter', + ], +} + +export const SCRIPT_OPTS: ReadonlySet = opts( + '-B', + '--log-io', + '-E', + '--echo', + '-F', + '-I', + '--log-in', + '-m', + '--logging-format', + '-O', + '--log-out', + '-o', + '--output-limit', + '-T', + '--log-timing', + '-t', +) +export const RUNUSER_OPTS: ReadonlySet = opts( + '-u', + '--user', + '-g', + '--group', + '-G', + '--supp-group', + '-s', + '--shell', + '-w', + '--whitelist-environment', +) diff --git a/test/bypass.test.ts b/test/bypass.test.ts index aa4dafd..9ee969e 100644 --- a/test/bypass.test.ts +++ b/test/bypass.test.ts @@ -292,3 +292,194 @@ describe('a git alias defined through the environment', () => { expect(deniedBy('GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=cat git log', ['git push'])).toBeNull() }) }) + +describe('wrappers and precommand modifiers beyond the common ones', () => { + it.each([ + 'chrt 10 git push', + 'chrt -T 5 10 git push', + 'taskset 1 git push', + 'taskset -c 0-3 git push', + 'caffeinate -i git push', + 'caffeinate -t 60 git push', + 'arch -arm64 git push', + 'arch -arch x86_64 git push', + 'unbuffer git push', + 'strace -f -o log git push', + 'ltrace git push', + 'nsenter -t 1 -m git push', + 'chroot / git push', + 'pkexec git push', + 'run0 -u ci git push', + 'runuser -u ci git push', + 'systemd-run --user -p X=1 git push', + 'toybox git push', + 'firejail --quiet git push', + 'faketime "2020-01-01" git push', + 'dbus-launch git push', + 'xvfb-run -a git push', + 'doas -u ci git push', + 'coproc git push', + 'coproc p { git push; }', + 'function f { git push; }', + 'script -q /dev/null git push', + 'script -c "git push" /dev/null', + 'su ci -c "git push"', + 'ssh host git push', + 'ssh -p 22 host "cd repo && git push"', + 'fd -x git push', + 'git bisect run git push', + 'git rebase -x "git push" main', + 'git rebase --exec="git push" main', + 'git submodule foreach "git push"', + 'git submodule --quiet foreach --recursive git push', + 'git filter-branch --tree-filter "git push" HEAD', + 'parallel git push ::: a', + 'yarn exec "echo; git push"', + 'bun exec "git push"', + 'bun x git push', + ])('denies and runs git push: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe('git push') + expect(runsCommand(cmd, 'git push')).toBe(true) + }) + + it.each(['=git push', 'noglob git push', 'nocorrect git push', '- git push', '[[ -n x ]] git push'])( + 'denies a zsh-only form that bash would not run: %j', + (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe('git push') + expect(runsCommand(cmd, 'git push')).toBe(false) + }, + ) + + it.each([ + 'git -c core.pager="git push" log', + 'git -c core.sshCommand="sh -c \'git push\'" fetch', + 'git -ccore.editor=\'!git push\' commit', + 'trap "git push" EXIT', + 'trap -- "git push" INT TERM', + 'alias g=\'git push\'\ng', + 'alias g=git\ng push', + 'parallel ::: "git push"', + ])('denies a command set up now and run later: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe('git push') + }) + + it.each(['git config alias.p \'!git push\'', 'git config --global alias.p push'])('denies a stored git alias: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe(DENY_REASONS.gitAlias) + }) + + it.each(['source <(echo git push)', '. /dev/stdin <<< "git push"', 'parallel', 'echo "git push" | parallel'])( + 'fails closed on a script read from stdin: %j', + (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe(DENY_REASONS.stdinScript) + }, + ) + + it('fails closed on commands parallel reads from a file', () => { + expect(deniedBy('parallel :::: jobs.txt', ['git push'])).toBe(DENY_REASONS.scriptFile) + }) + + it.each(['echo push | xargs git', 'echo "git push" | xargs -I{} sh -c {}', 'xargs -I % % push', 'xargs -iX X push'])( + 'counts the words xargs adds: %j', + (cmd) => { + expect(deniedBy(cmd, ['git push'])).not.toBeNull() + expect(runsCommand(cmd, 'git push')).toBe(false) + }, + ) + + it.each([ + 'python3 -c \'import os; os.system("git push")\'', + 'python -Ic \'import subprocess; subprocess.run(["git", "push"])\'', + 'node -e \'require("child_process").execSync("git push")\'', + 'perl -le \'system q(git push)\'', + 'ruby -e \'`git push`\'', + 'osascript -e \'do shell script "git push"\'', + 'awk \'BEGIN { system("git push") }\'', + 'gawk -e \'BEGIN { system("git push") }\'', + ])('denies a pattern named in an interpreter\'s inline program: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe('git push') + expect(runsCommand(cmd, 'git push')).toBe(false) + }) + + it.each([ + 'source .venv/bin/activate && pytest', + '. ~/.nvm/nvm.sh && nvm use', + 'git ls-files | xargs wc -l', + 'python3 -c "print(1 + 1)"', + 'awk \'{ print $1 }\' file', + 'awk -f prog.awk file', + 'git rebase -i main', + 'git -c color.ui=always log', + 'git config user.name x', + 'ssh host uptime', + 'parallel echo ::: a b', + 'trap "rm -f $tmp" EXIT', + 'trap - EXIT', + 'alias ll=\'ls -la\'', + 'git submodule foreach git pull', + 'fd -e ts -x wc -l', + 'script -q /dev/null ls', + 'yarn exec tsc', + 'su -', + ])('allows %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBeNull() + }) + + it('takes `command -pv` for a lookup', () => { + expect(runsCommand('command -pv git', 'git')).toBe(false) + }) +}) + +describe('a line nested deeper than the stack', () => { + it.each([['$(', 20_000], ['"$(', 20_000], ['${', 20_000], ['`$(', 5_000]] as const)( + 'fails closed instead of throwing on %j repeated %i times', + (open, times) => { + const cmd = open.repeat(times) + expect(() => deniedBy(cmd, ['git push'])).not.toThrow() + expect(deniedBy(cmd, ['git push'])).toBe(DENY_REASONS.unbalanced) + expect(runsCommand(cmd, 'git push')).toBe(false) + expect(() => commandSegments(cmd)).not.toThrow() + }, + ) +}) + +describe('options that take a value are skipped before the wrapped command', () => { + it.each([ + '/usr/bin/time -f %e git push', + 'time -o out git push', + 'env -P /usr/bin git push', + 'env -a name git push', + 'env --argv0 name git push', + 'gtimeout 5 git push', + 'fakeroot git push', + 'unshare -r git push', + 'sandbox-exec -p "(version 1)" git push', + 'systemd-run --service-type notify git push', + 'parallel --halt now,fail=1 git push ::: a', + 'parallel --header : git push ::: a', + 'parallel --tagstring x git push ::: a', + 'npx --registry https://registry.example git push', + 'xargs -tI{} git push {}', + 'xargs -0I@ git @', + 'flock -c "git push" /tmp/lock', + 'flock -n --command "git push" /tmp/lock', + 'flock /tmp/lock --command="git push"', + 'ssh host -t git push', + 'ssh host -- git push', + 'eval -- git push', + ])('denies git push: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).not.toBeNull() + }) + + it.each([ + 'python -c\'import os; os.system("git push")\'', + 'python3.12 -c \'import os; os.system("git push")\'', + 'perl -e\'system("git push")\'', + 'git rebase -x\'git push\' main', + ])('reads a program given in the same word as its option: %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBe('git push') + }) + + it.each(['flock /tmp/lock git status', 'flock -n 9', 'ssh host ls -l', 'xargs -n 1 echo'])('allows %j', (cmd) => { + expect(deniedBy(cmd, ['git push'])).toBeNull() + }) +}) diff --git a/test/deny.test.ts b/test/deny.test.ts index 641b5a3..5059d80 100644 --- a/test/deny.test.ts +++ b/test/deny.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { commandSegments, DEFAULT_DENY, deniedBy, effectiveDeny } from '../src' // The behaviour below is pinned against a fixed list, so changing DEFAULT_DENY cannot change what these cases mean. @@ -325,3 +325,25 @@ it.each(['${x#y} \\git push', '${x#y}\\git push', '! \\r ; ${x% } \\git push #c expect(deniedBy(cmd, DENY)).toBe('command word comes from an expansion') }, ) + +describe('rm operands are folded before matching', () => { + it.each([ + ['rm -rf //', 'rm -rf /'], + ['rm -rf /./', 'rm -rf /'], + ['rm -rf /.', 'rm -rf /'], + ['rm -rf //*', 'rm -rf /*'], + ['rm -rf ~/.', 'rm -rf ~'], + ['rm -rf ~//', 'rm -rf ~'], + ['rm -rf ~/*', 'rm -rf ~/*'], + ['rm -rf ~/', 'rm -rf ~/'], + ['rm -rf /tmp/..', 'rm -rf /'], + ['rm -rf /usr/../', 'rm -rf /'], + ['rm -rf ~/docs/..', 'rm -rf ~'], + ])('denies %j', (cmd, pattern) => { + expect(deniedBy(cmd, DEFAULT_DENY)).toBe(pattern) + }) + + it.each(['rm -rf ./dist', 'rm -rf ~/tmp/x', 'rm -rf /tmp/build', 'rm -rf /tmp/a/../b'])('allows %j', (cmd) => { + expect(deniedBy(cmd, DEFAULT_DENY)).toBeNull() + }) +})