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 offs: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