man bash-gate

A tour in ten lessons, each with a terminal running the library in your browser. Edit any command, policy or file and the verdict follows. The colours under the command mark which part of it each decision is about.

01One check

check(source, policy) reads a command line and returns a verdict: allow, ask or deny. Every program the line may run is judged on its own, and the most severe decision wins: deny beats ask, ask beats allow.

import { check, recommended } from 'bash-gate';

check('git status', recommended).decision; // 'allow'

recommended is the policy the library ships as a starting point: reading and everyday git run, destructive forms are denied, and deleting, the network, privilege and code it can't see ask. Try chaining something onto git status.

02Everything that runs

Under the verdict is an analyze pass, which you can call on its own. It finds each invocation: every program the line may start, including the ones a wrapper starts for it. sudo runs env, which runs bash -c, whose string is parsed and walked as Bash.

  • shape: the command in canonical form, values elided: rm --force --recursive <arg>.
  • via: the wrappers it runs under, outermost first.
  • in: how it's reached from the top: $(…), bash -c, eval, a trap.
  • effects: what running it can do beyond printing: fs:delete, network, privilege…

03Options, however they're spelled

Each known program has a spec, read the way the program reads its own argv: clusters (-fr), attached values, unique prefixes of long options, --, and GNU or POSIX ordering. Options come out under one canonical name, so a rule that names --recursive catches -r, -R and -fr.

Some meaning isn't in an option at all: a + in a git refspec forces the push, so it's read as --force.

A prefix that fits several options isn't guessed. git push --forc could be any of three --force… options, so it stays an unknown option, as git itself would refuse it.

04What only the run knows

A word the analysis can't settle, like a variable it never saw set, is kept as a dynamic argument with a reason. Rules then have three outcomes: they match, they don't, or they may. A deny rule that only may match asks.

Set dir first and the word is known. Or set strict: true in the policy below, and a deny rule that may match denies.

05Shell state

The walk keeps the state Bash keeps: variables (across branches, and through loops until they settle), functions, aliases, hash -p, enable and the working directory. Then it dispatches as Bash does: alias, function, builtin, program.

Aliases expand only where Bash would expand them: with expand_aliases, from the next line on. Try the alias example with the alias and its use on one line.

06Code inside strings

eval, trap, bash -c, ssh host cmd, watch, git aliases and prompt strings like PS4 all run a string as code, so the string is parsed and walked too. A trap action may run between any two commands, so it's walked at each of those points.

When the string itself is only known at run time, there's nothing to walk: that's a finding, which asks. So is code for another shell: zsh -c is never parsed as Bash.

07Your own policy

A policy is a list of rules and a default for what no rule decides. Each rule has a decision and conditions:

  • command: the program, then subcommands; alternatives after |, * for any.
  • options: deny and ask match if any is given; allow matches only if every option given is listed.
  • args, writes: globs over positionals and the files written, redirections included.
  • effects: any of fs:read, fs:write, fs:delete, network, privilege, exec:*, shell-state.

The policy below is plain JavaScript with allow, ask, deny and recommended in scope. head is allowed; try wc, then allow it.

08Paths, from where it starts

Path globs match each path as written and as resolved against the working directory the walk tracks, so cd .. && echo hi > notes.txt writes outside, though notes.txt looks local.

The optional Environment says where the script starts and what's in its environment. With an absolute cwd, an absolute path inside it counts as inside; with HOME known, ~ globs match the absolute paths under it. Delete the cwd and see what changes.

09With a filesystem

Hand the analysis a FileSystem (two callbacks: readFile and realpath) and it reads what the command would run: git hooks, git config and its includes, npm scripts, SSH config. Each is walked as code, under the program that runs it.

Below is a small repository at /repo. Its pre-commit hook pipes a download into sh. Change it to echo ok, or add [alias] with st = !rm -rf / to the config and run git st.

Answers are about the filesystem before the command runs. A file the script may write before it's read is unknown, which is why the hooks after a sh that can write anything become unknown too. Nothing else may write before the run ends, so run commands one at a time, each once everything the last one started has exited. Where the filesystem is only reachable asynchronously, checkAsync fetches what the analysis needs and goes again.

10Under the supervisor

Static analysis can be wrong, so there's a third layer. bash-gate/trace runs the command with every exec in its process tree stopped by a ptrace and seccomp supervisor, and checks each against the analysis. In enforce mode an exec the analysis didn't predict fails with EPERM; in trace mode it runs and is reported, for tests and red teams. It needs x86_64 Linux and a C compiler, so it can't run here.

import { analyze } from 'bash-gate';
import { run } from 'bash-gate/trace';

const result = await run('ls; cat secret', { analysis: analyze('ls'), cwd: '/repo' });
result.stderr; // 'bash: line 1: /usr/bin/cat: Operation not permitted'
result.execs;
// [{ path: '/usr/bin/ls', argv: ['ls'], via: [], covered: true, refused: false },
//  { path: '/usr/bin/cat', …, covered: false, refused: true }]

Pass the analysis you checked, so the run is held to what the policy saw. Where the promises end, and which test checks each, is in the threat model.

$ npm install bash-gate