diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d72d7d..e7c401b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,9 @@ on: push: branches: - main + # Every PR, whatever its base, so a PR stacked on another PR's branch is tested too. pull_request: - branches: - - main + workflow_dispatch: jobs: lint: diff --git a/README.md b/README.md index f4751b0..947f7b5 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,35 @@ Sees through `sudo`/`env`/`xargs`/`bash -c`/heredocs/`$(…)` and fails closed [![bundle size](https://img.shields.io/bundlephobia/minzip/shell-command-guard)](https://bundlephobia.com/package/shell-command-guard) [![license MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) +[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [FAQ](#faq) · +[Security model](#security-model-and-limits) + +Terminal demo: deniedBy allows grep "git push" notes.md but denies git push hidden behind env, bash -c, xargs, quoting, a stdin shell and python -c, and denies rm -r -f / + +
+Table of contents + +- [Why](#why) +- [Features](#features) +- [Install](#install) +- [Quick start](#quick-start) +- [API](#api) + - [`deniedBy`](#deniedbycmd-deny) · [`runsCommand`](#runscommandcmd-command) · + [`commandSegments`](#commandsegmentscmd) · [`effectiveDeny`](#effectivedenypolicy-extra) · + [`DEFAULT_DENY`](#default_deny) · [`DENY_REASONS`](#deny_reasons) +- [Recipes](#recipes) +- [How it works](#how-it-works) +- [Comparison](#comparison) +- [Security model and limits](#security-model-and-limits) +- [FAQ](#faq) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [License](#license) + +
+ ## Why A coding agent, CI bot or chat-ops tool is about to run a shell command, and you want to stop `git push`. The naive check @@ -30,6 +57,8 @@ mention the words. | `{git,push}` | allowed | `'command word comes from an expansion'` | | `echo origin \| xargs git push` | allowed | `'git push'` | | `g'i't push` | allowed | `'git push'` | +| `trap 'git push' EXIT` | allowed | `'git push'` | +| `python3 -c "import os; os.system('git push')"` | allowed | `'git push'` | | `grep "git push" notes.md` | allowed (`includes` would block it) | `null` — it only mentions it | `shell-command-guard` parses the line instead. Quoted strings, comments and quoted here-document bodies are data; every @@ -287,6 +316,68 @@ rule when a reason such as `'shell runs a script file'` blocks a command you tru Run it in addition to OS-level isolation (containers, separate users, read-only mounts, scoped credentials), not in place of it. +## FAQ + +
+Why is rm -rf $DIR denied? + +`$DIR` is known only when the shell runs, and it could be `/`. `deniedBy` matches a pattern that an expansion could +still complete, so `rm -rf $DIR` returns `'rm -rf /'` under `DEFAULT_DENY`. Spell the path out, or use `runsCommand` +when you only need to know what certainly runs. + +
+ +
+How do I allow a command that a deny reason blocks? + +`deniedBy` returns the reason as a string, so you decide which ones to let through: + +```ts +import { deniedBy, DENY_REASONS } from 'shell-command-guard' + +const hit = deniedBy(cmd, deny) +const trusted = hit === DENY_REASONS.scriptFile && /^bash scripts\/[\w-]+\.sh$/.test(cmd) + +if (hit && !trusted) { + block(hit) +} +``` + +
+ +
+Which shells does it understand? + +It parses bash syntax and also reads each line the way bash 3.2, zsh and sh would, so a command that any of them runs +is caught. Scripts handed to `bash`, `sh`, `zsh`, `dash`, `ksh`, `fish`, `csh` and others with `-c` are read the same +way. + +
+ +
+Does it run anything, or touch the filesystem? + +No. It is a pure function over the command string: no child processes, no file reads, no environment lookups. + +
+ +
+Should I use a deny-list or an allow-list? + +A deny-list blocks the commands you name and lets the rest through, which suits an agent that should mostly work +unattended. When the stakes are high, allow-list instead. `commandSegments` lists every command a line runs, so you can +check each one against the list. + +
+ +## Roadmap + +- [ ] More launchers: `setpriv`, `xcrun`, `direnv exec`, `mise exec`, `op run` +- [ ] Resolve `rm` operands above `~` (`rm -rf ~/..`) against the real home directory +- [ ] A small CLI for shell hooks and CI: `shell-command-guard --deny 'git push' -- "$CMD"` + +Found a bypass or a missing wrapper? [Open an issue](https://github.com/devmaxxx/shell-command-guard/issues). + ## Contributing Issues and pull requests are welcome. A bypass — a line that runs a denied command while `deniedBy` returns `null` — is @@ -298,6 +389,7 @@ pnpm test # vitest pnpm lint # eslint pnpm typecheck # tsc pnpm build # tsdown +pnpm demo # regenerate assets/demo.svg from the real deniedBy output ``` ## License diff --git a/assets/demo.svg b/assets/demo.svg new file mode 100644 index 0000000..2a934c1 --- /dev/null +++ b/assets/demo.svg @@ -0,0 +1,105 @@ + + + + + +shell-command-guard + +// deniedBy(cmd, DEFAULT_DENY) for each line +❯ +grep "git push" notes.md + +✓ allowed +❯ +env FOO=1 git push origin main + +✗ denied "git push" +❯ +bash -c "pnpm test && git push" + +✗ denied "git push" +❯ +echo origin | xargs git push + +✗ denied "git push" +❯ +g'i't push --force + +✗ denied "git push" +❯ +echo 'git push' | sh + +✗ denied "shell reads script from stdin" +❯ +python3 -c "import os; os.system('git push')" + +✗ denied "git push" +❯ +rm -r -f / + +✗ denied "rm -rf /" +❯ +pnpm test && git commit -m "push later" + +✓ allowed + + diff --git a/package.json b/package.json index 5cb99d3..8cda011 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "tsc", + "demo": "unrun scripts/demo.ts", "test": "vitest run", "prepublishOnly": "pnpm build" }, diff --git a/scripts/demo.ts b/scripts/demo.ts new file mode 100644 index 0000000..9b9320b --- /dev/null +++ b/scripts/demo.ts @@ -0,0 +1,132 @@ +// Renders assets/demo.svg: an animated terminal that types each command and prints what `deniedBy` really returns for +// it, so the README demo cannot drift from the library. Run with `pnpm demo`. +import { writeFileSync } from 'node:fs' +import process from 'node:process' +import { DEFAULT_DENY, deniedBy } from '../src' + +const COMMANDS = [ + 'grep "git push" notes.md', + 'env FOO=1 git push origin main', + 'bash -c "pnpm test && git push"', + 'echo origin | xargs git push', + 'g\'i\'t push --force', + 'echo \'git push\' | sh', + 'python3 -c "import os; os.system(\'git push\')"', + 'rm -r -f /', + 'pnpm test && git commit -m "push later"', +] + +const FONT_SIZE = 14 +// Monospace fonts set at 14px advance about 8.4px per character; the cover over a typed line is sized from this, and +// textLength pins the rendered text to the same width so a fallback font cannot peek out past the cover. +const CHAR_WIDTH = 8.4 +const LINE_HEIGHT = 22 +const PAD_X = 20 +const TITLE_BAR = 36 +const WIDTH = 760 +const TYPE_SECONDS_PER_CHAR = 0.035 +const PAUSE_BEFORE_VERDICT = 0.35 +const PAUSE_AFTER_VERDICT = 0.9 +const HOLD_AT_END = 3 + +const COLORS = { + window: '#0d1117', + bar: '#161b22', + border: '#30363d', + text: '#e6edf3', + muted: '#7d8590', + prompt: '#58a6ff', + denied: '#ff7b72', + allowed: '#3fb950', +} + +function escapeXml(s: string): string { + return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') +} + +function pct(seconds: number, total: number): string { + return `${((seconds / total) * 100).toFixed(3)}%` +} + +interface Step { + cmd: string + verdict: string | null + typeStart: number + typeEnd: number + verdictAt: number +} + +function schedule(): { steps: Step[], total: number } { + let t = 0.6 + const steps = COMMANDS.map((cmd) => { + const typeStart = t + const typeEnd = typeStart + cmd.length * TYPE_SECONDS_PER_CHAR + const verdictAt = typeEnd + PAUSE_BEFORE_VERDICT + t = verdictAt + PAUSE_AFTER_VERDICT + return { cmd, verdict: deniedBy(cmd, DEFAULT_DENY), typeStart, typeEnd, verdictAt } + }) + + return { steps, total: t + HOLD_AT_END } +} + +function render(): string { + const { steps, total } = schedule() + const bodyTop = TITLE_BAR + 18 + const height = bodyTop + (steps.length * 2 + 2) * LINE_HEIGHT + const css: string[] = [] + const lines: string[] = [] + + lines.push( + `// deniedBy(cmd, DEFAULT_DENY) for each line`, + ) + + steps.forEach((step, i) => { + const y = bodyTop + (i * 2 + 2) * LINE_HEIGHT - LINE_HEIGHT / 2 + const textX = PAD_X + 2 * CHAR_WIDTH + const coverWidth = step.cmd.length * CHAR_WIDTH + CHAR_WIDTH + const verdict = step.verdict === null + ? `✓ allowed` + : `✗ denied ${escapeXml(JSON.stringify(step.verdict))}` + + css.push( + `.c${i}{animation:c${i} ${total.toFixed(2)}s infinite}`, + `@keyframes c${i}{0%,${pct(step.typeStart, total)}{transform:translateX(0);animation-timing-function:steps(${step.cmd.length},end)}` + + `${pct(step.typeEnd, total)},100%{transform:translateX(${coverWidth.toFixed(1)}px)}}`, + `.v${i},.p${i}{animation:v${i} ${total.toFixed(2)}s infinite;animation-timing-function:step-end}`, + `.p${i}{animation-name:p${i}}`, + `@keyframes v${i}{0%,${pct(step.verdictAt, total)}{opacity:0}${pct(step.verdictAt + 0.001, total)},100%{opacity:1}}`, + `@keyframes p${i}{0%,${pct(step.typeStart, total)}{opacity:0}${pct(step.typeStart + 0.001, total)},100%{opacity:1}}`, + ) + + lines.push( + `❯`, + `${escapeXml(step.cmd)}`, + ``, + `${verdict}`, + ) + }) + + const dots = [['#ff5f57', 20], ['#febc2e', 40], ['#28c840', 60]] as const + + return ` + + + +${dots.map(([fill, cx]) => ``).join('')} +shell-command-guard + +${lines.join('\n')} + + +` +} + +const out = new URL('../assets/demo.svg', import.meta.url) +writeFileSync(out, render()) +process.stdout.write(`wrote ${out.pathname}\n`) diff --git a/tsconfig.json b/tsconfig.json index a38b47c..fc1ae33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "skipDefaultLibCheck": true, "skipLibCheck": true }, - "include": ["src", "test"] + "include": ["src", "test", "scripts"] }