diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..03e6993 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# editorconfig.org +root = true + +[*] +max_line_length = 120 +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.rb] +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5238aa..0fe24f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,43 @@ permissions: contents: read jobs: + ruby-lint: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - uses: useblacksmith/checkout@2d63ce5ba61677748c4e92f6eb578a8694226225 # v1.2.0 + + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + # The Ruby that runs the linter, which is not the Ruby the guard runs + # on -- that is the app's, and .rubocop.yml sets TargetRubyVersion to + # the oldest supported stack so the rules stay honest about it. + ruby-version: "3.4" + bundler-cache: true + + - name: rubocop + run: bundle exec rubocop --format github + + # The lint above loads the guard's files but never runs them, and it runs + # on one Ruby. This is the check that the sources still *parse* the way a + # dyno will parse them: `--disable=gems`, which is how bin/compile checks + # them at build time and how the guard is invoked at run time. + # + # Files are discovered rather than listed, so adding a policy file cannot + # silently drop it from the check. + - name: ruby -c --disable=gems + run: | + files=$(git ls-files '*.rb') + + if [ -z "$files" ]; then + echo "ruby-lint: no ruby files discovered, so this job proves nothing" >&2 + exit 1 + fi + + for f in $files; do + echo "checking $f" + ruby --disable=gems -c "$f" > /dev/null + done + shellcheck: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 22eba6b..1a16715 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,51 @@ jobs: options: --user root steps: - uses: useblacksmith/checkout@2d63ce5ba61677748c4e92f6eb578a8694226225 # v1.2.0 - - name: bash version - run: bash --version + # The guard's policy is Ruby. On a real dyno it runs the app's own + # interpreter, installed by heroku/ruby; the stack images carry none, so + # the suite stands the distribution's in for it. The version spread across + # the matrix is the point -- it is what pins the guard to stdlib only. + - name: Install Ruby + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends ruby + + - name: versions + run: | + bash --version + ruby --version + # Policy. Runs anywhere Ruby does; it is here for the stack, not for the + # Ruby version -- tests-ruby-versions below is what covers those. + - name: Policy tests + run: ruby test/run_ruby_tests.rb + + # Everything the shell does around the policy, which needs a real login + # shell and procfs. - name: End-to-end tests run: ./test/run_tests.sh + + # The guard runs the *app's* interpreter, not the stack's, so the version it + # has to survive is whichever Ruby the app's buildpack installed -- anywhere + # from the 3.0 floor that heroku-22 implies to the current release. + # Every supported version is therefore named here. Only the policy suite runs: + # it is the half that is Ruby, and the other half needs a stack image. + tests-ruby-versions: + runs-on: blacksmith-2vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + ruby: ["3.0", "3.1", "3.2", "3.3", "3.4", "4.0"] + steps: + - uses: useblacksmith/checkout@2d63ce5ba61677748c4e92f6eb578a8694226225 # v1.2.0 + + # No bundler-cache: the guard depends on no gem, and the suite must not + # start depending on one either. Only the linter has a Gemfile. + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: ${{ matrix.ruby }} + + - name: versions + run: ruby --version + + - name: Policy tests + run: ruby test/run_ruby_tests.rb diff --git a/.gitignore b/.gitignore index 07799dd..c2f6bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,14 @@ *.swp *.swo *~ -.vscode/ .idea/ +# .vscode is committed: it carries the shared formatter and extension setup. +# Personal overrides go in the user or workspace settings, not here. +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json + # Claude Code .claude/settings.local.json diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..b2ee0d7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,27 @@ +require: + - standard + +plugins: + - standard-custom + - standard-performance + - rubocop-performance + +inherit_gem: + standard: config/base.yml + standard-performance: config/base.yml + standard-custom: config/base.yml + +# standard rewrites `rescue StandardError` to a bare `rescue`. Kept explicit +# here: every one of these rescues is a deliberate "whatever goes wrong, degrade +# this way" -- unparseable dyno metadata, an unreachable reporting endpoint, an +# unreadable /proc entry -- and the bare form is the least obvious way to say +# that in code whose failure modes are the point. +Style/RescueStandardError: + Enabled: false + +AllCops: + SuggestExtensions: false + NewCops: enable + TargetRubyVersion: 3.0 + Exclude: + - "vendor/**/*" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5eb77f7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "Shopify.ruby-lsp", // Language support for Ruby files (highlighting, go to definition) + "rubocop.vscode-rubocop", // Linting and formatting for Ruby files, using standard's rules + "timonwong.shellcheck", // The same lint CI runs over the two shell files + "esbenp.prettier-vscode" // Formats .json and .yaml files + ], + "unwantedRecommendations": ["rebornix.ruby", "castwide.solargraph"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4ab52b8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + // Applied to files in this project when the folder is opened directly. + "editor.formatOnPaste": false, + "[ruby]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "rubocop.vscode-rubocop" + }, + "[json][jsonc][yaml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // The rubocop extension owns diagnostics and formatting for Ruby, so ruby-lsp + // must not also offer them -- two formatters on save fight each other. + "rubyLsp.enabledFeatures": { + "diagnostics": false, + "formatting": false + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3651b8f --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Development only. Nothing here reaches a dyno: bin/compile installs the files +# under guard/ and nothing else, and the guard runs on stdlib with +# `--disable=gems`, so it cannot load a gem even if one were present. +source "https://rubygems.org" + +gem "rubocop", "~> 1.88.2", require: false # Must satisfy standard's rubocop pin +gem "standard", require: false diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..11588af --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,82 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + json (2.21.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + regexp_parser (2.12.0) + rubocop (1.88.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.56.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.88.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + rubocop (~> 1.88.2) + standard + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + bundler (4.0.14) sha256=d09a0a965cf772266a7e49e83610be7c2f4e49e61134c42a56804bb383cc24b8 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + standard (1.56.0) sha256=ae2af4d9669589162ac69ed5ef59dcf9f346d4afc81f7e62b84339310dfcb787 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + +BUNDLED WITH + 4.0.14 diff --git a/README.md b/README.md index 538bea8..e3e8785 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,18 @@ needs an attributable record of who ran what, and why. ## What it does -Once added to an app, the buildpack installs two things: +Once added to an app, the buildpack installs three things: 1. a `.profile.d` script that runs inside every one-off dyno, before the operator's command 2. a wrapper for `rails`, `rake` and `bundle` on `PATH`, which the profile script makes reachable +3. the policy both of them apply, as Ruby under `.console-guard/lib/console_guard/` Together they: * require the `CONSOLE_USER` and `CONSOLE_REASON` environment variables * reject compound statements and redirections, while allowing the `--exit-code` marker the Heroku CLI appends * permit only unqualified `rails`, `rake` and `bundle exec rails|rake` invocations, minus an - explicit deny list + explicit deny list, and allowlist the options those may be given * warn if [dyno metadata](https://devcenter.heroku.com/articles/dyno-metadata) is not enabled * export `CONSOLE_AUDIT_ENABLED=true` @@ -65,14 +66,65 @@ So the profile script checks only what is sound to check on a raw string: | Compound statements and redirections | Presence of a character in the raw string is exactly the question | | `argv[0]` is literally `rails`, `rake` or `bundle` | Quoting or expanding it makes it stop matching, so it fails closed | -Everything about the **arguments** lives in the command wrapper -(`guard/shim.sh`, installed as `.console-guard/bin/{rails,rake,bundle}`), which runs after the shell has -finished expanding and therefore sees the real `argv`. Because `argv[0]` is guaranteed to be -literally `rails`, `rake` or `bundle`, and because the wrapper directory is prepended to `PATH`, control -always reaches the wrapper. +Everything about the **arguments** lives in the command wrapper (`ConsoleGuard::Command`, reached +through `.console-guard/bin/{rails,rake,bundle}`), which runs after the shell has finished expanding +and therefore sees the real `argv`. Because `argv[0]` is guaranteed to be literally `rails`, `rake` +or `bundle`, and because the wrapper directory is prepended to `PATH`, control always reaches the +wrapper. -**Add argument rules to the wrapper, not to the profile script.** A rule added to the profile script -looks like it works and is bypassable with one quote character. +**Add argument rules to the wrapper, not to the gate.** A rule added to the gate looks like it works +and is bypassable with one quote character. + +## Where the code lives + +The policy is Ruby. The two shell files are what is left over after that move — the things only the +login shell itself can do, because a child process cannot reach into its parent. + +| File | What it is | +|---|---| +| `profile/console_guard.sh` | Sourced into the login shell. Runs the gate, then acts on its exit status: prepend the wrapper to `PATH`, `unset EDITOR VISUAL`, export `CONSOLE_AUDIT_ENABLED`, or `exit 1` | +| `guard/shim.sh` | Installed as `rails`, `rake` and `bundle`. Execs the wrapper, passing the name it was invoked under — `$0` is what distinguishes the three and is lost once Ruby takes over | +| `guard/lib/console_guard/gate.rb` | `ConsoleGuard::Gate` — the profile half's policy | +| `guard/lib/console_guard/command.rb` | `ConsoleGuard::Command` — all argument policy | +| `guard/lib/console_guard/reporter.rb` | The denial record, and where it goes | +| `guard/lib/console_guard/dyno.rb` | Which dyno this is, and what therefore applies to it | +| `guard/libexec/run_gate.rb`, `run_command.rb` | The two entry points the shell files exec | + +The gate reports back to the login shell through its **exit status**, because that is the only +channel a child has to a parent that must then modify its own environment: + +| Status | Meaning | +|---|---| +| `0` | Not a dyno this applies to | +| `10` | Audited, not gated (scheduler, release) | +| `20` | Gated, and permitted | +| `21` | Gated and permitted, and dry-run mode must supply the placeholder operator | +| `1` | Denied. The banner is printed, the record sent and the `--exit-code` marker emitted already | + +### The guard's own interpreter + +Running policy in Ruby adds one attack surface that a shell script does not have: the operator can +set `heroku run -e` variables that choose what the interpreter loads before the guard's first line +runs. All three are closed, and the tests pin each one. + +| Vector | Closed by | +|---|---| +| `PATH` — which `ruby` | Both shell files invoke an **absolute** path, resolved at build time and baked in. Never a `PATH` lookup, and `guard/shim.sh` uses `#!/bin/bash` rather than `/usr/bin/env bash` for the same reason | +| `RUBYOPT` — what it requires first | `--disable=rubyopt` on the interpreter's command line | +| RubyGems, `GEM_HOME`, `GEM_PATH` | `--disable=gems`. The guard uses stdlib only, so nothing is lost | +| `RUBYLIB` — what answers a stdlib `require` | Its entries are removed from `$LOAD_PATH` before the first `require`. The guard's own files are reached with `require_relative`, which never consults `$LOAD_PATH` | + +`bin/compile` resolves the interpreter once and bakes the path into both shell files. `heroku/ruby` +exports its `PATH` for the buildpacks that run after it, so the app's own Ruby is what gets found — +and because the slug is built in `BUILD_DIR` and extracted at `/app`, a vendored interpreter is +re-rooted to the path the *dyno* will see. **The build fails if there is no Ruby at all**, which +means this buildpack was ordered before `heroku/ruby`; a green build with no guard installed is the +worst outcome available. + +Nothing re-resolves it at run time and no environment variable can override it. If the baked path is +gone — the app's buildpacks were reordered, or its Ruby removed, since the last build — the profile +script refuses every dyno that might be a one-off and warns on the rest, rather than taking a web +dyno down over a console control. ## Command policy @@ -116,8 +168,9 @@ duplicates every rule rather than delegating. | `rails credentials:*`, `rails encrypted:*` | Spawns `$EDITOR`, which the operator controls — a shell escape. `EDITOR` and `VISUAL` are also unset | | `rails runner -` (a bare `-` in any argument position) | Reads the program from **stdin**, so the executed code appears neither in the dyno command string nor in an `ARGV` capture inside the app. The session still produces a complete record with a correct user, reason and dyno UUID, while the code that ran is unrecorded | | `rails runner --file `, or any `runner` argument that exists on disk | Same shape — the command string names a file rather than the code that runs | +| Any argument beginning with `-` that is not on the option allowlist | See [Option allowlist](#option-allowlist) below | | `-c` in any argument position | Reaches a shell (`bash -c`). No legitimate `rails`/`rake` invocation uses it. `rails c` — the console shorthand — is unaffected, because that argument is `c`, not `-c` | -| `rails console --sandbox` / `-s` (console only) | The sandbox transaction is rolled back on exit, and a database-backed ActiveJob queue on the primary database puts the audit enqueue inside it — so the rollback discards the audit trail and the session runs entirely unlogged ([console1984#91](https://github.com/basecamp/console1984/issues/91)). Scoped to `console`/`c`, because `-s` is `rake`'s silent flag; `--no-sandbox` is unaffected | +| `rails console --sandbox` / `-s` (console only) | The sandbox transaction is rolled back on exit, and a database-backed ActiveJob queue on the primary database puts the audit enqueue inside it — so the rollback discards the audit trail and the session runs entirely unlogged ([console1984#91](https://github.com/basecamp/console1984/issues/91)). Scoped to `console`/`c`, because `-s` is `rake`'s silent flag. Thor also takes `--sandbox=`, so the `=` forms are refused whatever they carry, `false` included — `--no-sandbox` is the spelling that opts out, and is unaffected. Thor splits a run of short flags into one option per letter, so `rails c -es` is `-e -s`: a bundle containing `s` is refused as `-s` is | Because these are checked after expansion, the quoted, variable and glob spellings of each are blocked too: `rails "dbconsole"`, `rails "credentials:edit"`, `rails runner "-"` and @@ -137,7 +190,58 @@ task-level guard. The `runner` file check tests whether the argument **exists on disk**, which is the same decision Rails itself makes. There is no heuristic on how the argument looks, so -`rails runner 'Model.where(x: 1).rb'` is permitted and `rails runner ~/script` is not. +`rails runner 'Model.where(x: 1).rb'` is permitted and `rails runner ~/script` is not. It is +`File.exist?`, as Rails' own is, rather than a regular-file test: `rails runner /dev/stdin` and +`/dev/fd/0` read the program from stdin just as a bare `-` does, and are refused with it. + +### Option allowlist + +Arguments beginning with `-` are **allowlisted, not screened**. Anything not named below is +refused. + +The reason is `rake -e/-p/-E CODE` (`--execute`, `--execute-print`, `--execute-continue`). Rake +evaluates `CODE` inside its own option parser — before the Rakefile is loaded, without booting +Rails — and then exits. Nothing the code does reaches the console audit hook, which makes it +weaker than the `rails runner 'system("bash")'` case [below](#limitations), where Rails at least +boots and the invocation is recorded. `rails` is affected too: it hands any command it does not +recognise to that same parser with the whole argv, so `rails -e CODE` and `rails db:migrate -e +CODE` reach it. + +A deny list would have to model which of Rake's short options take an argument, in order to know +where a bundle such as `-Ne` or `-se` stops being flags. Get that wrong for one option — in this +version of Rake or a later one — and the bundle hides an `-e`. An allowlist fails the other way: +an unlisted option is refused, so being wrong costs a denial rather than an unlogged shell. It +also refuses things nobody had to think of, such as `-g`/`--system`, which loads tasks from +`$HOME/.rake` — `/app/.rake` on a dyno. + +Every command gets a list; none is exempt. `rails console` and `rails runner` parse their own +options and never reach Rake, so `-e` there is the *environment* — but they get a list of their +own rather than being waved through, because a mistake in a list is a denial while a mistake in +an exemption is a silent bypass. + +| After | Permitted | +|---|---| +| `rails console` / `c` | `-e`/`--environment`, `--no-sandbox`, `-h`/`--help` | +| `rails runner` / `r` | `-e`/`--environment`, `-w`/`--skip-executor`, `-h`/`--help` | +| everything else (Rake's parser) | `-T`/`--tasks`, `-D`/`--describe`, `-W`/`--where`, `-P`/`--prereqs`, `-A`/`--all`, `--comments`, `--rules`, `-t`/`--trace`, `--backtrace`, `--job-stats`, `-s`/`--silent`, `-q`/`--quiet`, `-n`/`--dry-run`, `-v`/`--verbose`, `-V`/`--version`, `-m`/`--multitask`, `-j`/`--jobs`, `-B`/`--build-all`, `-X`/`--no-deprecation-warnings`, `-h`/`-H`/`--help` | + +Task names, task arguments (`some:task[a,b]`) and `VAR=value` assignments are not options and are +not screened, so `rake db:rollback STEP=99` is unaffected. + +Two consequences worth knowing before you hit them: + +- **Short options are matched whole**, so `-sq` is refused where `-s -q` is permitted. This is + what makes `-se CODE` refusable without reasoning about bundling at all. +- **An attached short value is Rake's alone.** `rake -Tdb` and `-j4` are permitted; after + `rails console` or `rails runner` they are not, because Thor has no such form — it reads + `-es` as `-e -s`, and reading it as `-e s` is what would let the sandbox flag through. +- **Abbreviated long forms are refused.** Rake accepts `--task` for `--tasks`; the allowlist does + not. Denials list the permitted set, so this is self-service. + +Deliberately absent from the Rake list: `-e`/`-E`/`-p` (evaluate code), `-f`/`-r`/`-I`/`-R`/`-C` +(name a path — the same shape as a bare `-`), and `-g`/`-G`/`-N` (change which Rakefile is found). +Exploiting the path-naming options needs a file already in the slug, i.e. the same deploy-access +trust boundary as the `BASH_ENV` limitation below. ### Blocked outright (non-Rails commands) @@ -227,6 +331,95 @@ names the first word it rejected. An operator's screenshot is then enough to tel objected to the command that was typed or to something else — a wrapper, a prefix, or a shape the parser does not handle. Long commands are truncated at 300 characters. +### Denials are recorded, not just printed + +That banner reaches only the operator's terminal, over the rendezvous connection. It is not in the +app's log stream and it never reaches Datadog. Left there, the only durable trace of a blocked +command is Heroku's own `api:dyno` record — which shows that *something* was attempted but cannot +distinguish a guard denial from an application error, and forces the audit cross-check to read "no +console record for this dyno" as "blocked, or lost". + +So each denial also POSTs one record, to the same endpoint and with the same credential as the +companion gem — `CONSOLE_LOGGING_DATADOG_PROXY_URL`. `event` is what tells the two apart: + +```json +{ + "event": "command_denied", + "enforced": true, + "rule": "command_not_allowed", + "command": "psql", + "operator": "becky@example.com", + "reason": "checking a migration", + "dyno_id": "b922dfe5-0ede-45c8-a267-78bff7a23481", + "app": "my-app", + "service": "my-service", + "guard_version": "7f1e0d8", + "timestamp": "2026-08-28T06:24:43.000Z" +} +``` + +- **`rule`** is a short stable identifier for the check that refused — group a monitor by this rather + than by the denial text, which gets reworded. Current values: `dyno_name_spoofed`, + `wrapper_missing`, `identity_missing`, `command_unreadable`, `command_not_bash_c`, + `compound_statement`, `command_not_allowed`, `bundle_not_exec`, `bundle_exec_not_allowed`, + `raw_database_session`, `editor_escape`, `stdin_program`, `dash_c_flag`, `sandbox_console`, + `runner_file`, `option_not_allowed`. +- **`enforced`** is `false` in [dry-run mode](#phased-rollout). Phase 1 exists to measure what + enforcement would block, and that is only measurable if the would-be denials are recorded, so they + are sent in both modes. +- **`command`** is what that half of the guard judged: the pre-expansion command string from the + profile script, the post-expansion argv from the command wrapper. The CLI's `--exit-code` marker + is stripped first, so a CI denial records the command the caller wrote. +- **`dyno_id`** comes from the dyno metadata file, not from `HEROKU_DYNO_ID`, which `-e` can set to + anything. It is the join key against the `api:dyno` webhook. +- **`app`** and **`service`** are the attribution fields sent, from `HEROKU_APP_NAME` and + `DD_SERVICE`. The gem sends `service`/`env`/`app`/`version` but stamps them on its **worker**, + because `heroku run -e` can rewrite all four and a record tagged `env:staging` would keep flowing + to Datadog while dropping quietly out of a production-scoped monitor. There is no worker here, so + nothing sent from the dyno carries that guarantee — but omitting these two is worse. The + cross-check queries scope on `@app` to reach both log sources at once, so a denial record without + it is silently skipped by every one of them, and `@app_service` is the same problem one rung down: + the gem stamps it, so a query filtering on it would return the sessions and drop the denials beside + them. Sending `service` makes the attribute mean one thing on both record kinds — absent because + the app sets no `DD_SERVICE`, never because a denial produced the record. + + The tampering argument does not transfer to either field. An operator who wants their denial record + gone can unset the endpoint and delete it outright, so forging them is strictly weaker than what + they can already do, and neither one scopes a monitor. Attribution tampering needs closing where + suppression is impossible, which is the gem's position and not this one. + + `service` is sent under that name even though it reaches Datadog as `@app_service`. `datadog-proxy` + does the renaming, because Datadog's JSON preprocessing would otherwise promote a `service` key + onto the reserved service facet. One sender contract beats two — but it does mean the proxy needs + that rename deployed **before** this buildpack starts sending the field. +- No `env` / `version`. `env` is a reserved facet that scopes monitors, which makes it the one field + where forging buys something suppression does not, so `datadog-proxy` infers it from the delivery + topology instead — nothing in the dyno can reach that. Nothing reads `version`. + +Every string in the record is escaped to **pure ASCII**, and any byte `>= 0x80` is replaced with +U+FFFD. A JSON string has to be valid UTF-8, and both `command` and `reason` are operator-controlled +bytes, so one stray byte would otherwise cost the whole record — `rule`, `operator` and `dyno_id` +along with it — and cost it invisibly, since the only warning goes to the terminal of the operator +who was just blocked. The price is fidelity: `rails runner "puts 'héllo'"` records two replacement +characters where the `é` was. Enough to see that something non-ASCII was there, which is all the +`command` field is for. + +The record is sent with `Net::HTTP` from stdlib. The Basic credential is lifted out of the URL's +userinfo and set as a header, because `Net::HTTP` — unlike `curl`, which this used to shell out to — +does not do that itself, and because `URI.parse` is stricter about what a userinfo may contain than +whatever the proxy issued is guaranteed to be. + +Reporting is **fail-open and best effort**: one attempt, a 4-second ceiling, no retry, and a failure +warns on stderr without holding up the denial. Refusing the command is the control; recording it must +not be able to block that. A failure is reported as a status code, or as the exception's class — +never as its message, which can quote the URL, and never as the URL, which carries the credential. + +It is also **not sufficient on its own.** The URL variable is inherited by the one-off dyno, so an +operator who knows about this can suppress their own denial record with +`heroku run -e CONSOLE_LOGGING_DATADOG_PROXY_URL=`. What survives that is the `api:dyno` webhook and +the exit status. Closing it properly needs the record to originate somewhere the operator cannot +reach, which a buildpack cannot be. + ## Setup Add the buildpack to a Heroku app alongside its existing buildpacks, **pinned to a commit SHA**: @@ -253,6 +446,8 @@ records the installed version: -----> Installing console guard 7f1e0d8 profile script: .profile.d/zzz_console_guard.sh command wrapper: .console-guard/bin/{rails,rake,bundle} + policy: .console-guard/{lib,libexec}/ (10 ruby files) + ruby: /app/.heroku/ruby/bin/ruby dyno metadata file: /etc/heroku/dyno enforcement: blocking unless CONSOLE_BLOCK_ENFORCE=false at run time ``` @@ -285,8 +480,8 @@ records the installed version: ## Companion gem -The buildpack blocks commands and exports `CONSOLE_AUDIT_ENABLED=true`; it does not record anything -itself. Recording console statements is done in-app by +The buildpack blocks commands and exports `CONSOLE_AUDIT_ENABLED=true`; the only thing it records +itself is [its own denials](#denials-are-recorded-not-just-printed). Recording console statements is done in-app by [console1984-datadog](https://github.com/ynab/console1984-datadog), which activates when `CONSOLE_AUDIT_ENABLED` is set. See that repository for what it records and how to configure it. @@ -299,27 +494,27 @@ allowlisted command. Enforcement will break any existing `heroku run` caller that omits the required environment variables or uses a non-permitted command, so the buildpack supports rolling out in two phases. -**Phase 1 — permit but do not block.** Set `CONSOLE_BLOCK_ENFORCE=false` as an app config var. Every +**Phase 1 — dry run: report, but do not block.** Set `CONSOLE_BLOCK_ENFORCE=false` as an app config var. Every check still runs and reports on stderr, but a failure is a warning rather than an exit, and `CONSOLE_AUDIT_ENABLED=true` is still exported so audit records are produced throughout. Use this to find non-permitted commands and missing environment variables, and update the callers. -In permit mode a missing `CONSOLE_USER` is replaced with the literal `[not provided]` before the +In dry-run mode a missing `CONSOLE_USER` is replaced with the literal `[not provided]` before the command runs. This is not cosmetic: console1984 raises `MissingUsername` on an empty operator (`ask_for_username_if_empty` defaults to `false`), so without a value the console dies anyway and -permit mode fails to permit — the one thing it exists to do. The placeholder is deliberately not a +dry-run mode stops being a dry run — the one thing it exists to do. The placeholder is deliberately not a plausible username, so an audit record can never be mistaken for an identified session, and it can never collide with a real `heroku whoami` value. When enforcing, the session is refused instead and no placeholder is set. **Phase 2 — block.** Remove the config var. Enforcement is the **default**, so an app that was never -configured fails closed. Only the exact value `false` opts into permit mode; anything else enforces. +configured fails closed. Only the exact value `false` opts into dry-run mode; anything else enforces. -`CONSOLE_BLOCK_ENFORCE` and permit mode are both **temporary**, and will be removed together once +`CONSOLE_BLOCK_ENFORCE` and dry-run mode are both **temporary**, and will be removed together once enough apps have run in phase 1 to be confident no necessary production use case is blocked. Because of that the variable is not tamper-proof: an operator can set it per session with -`heroku run -e CONSOLE_BLOCK_ENFORCE=false`, but only for as long as permit mode exists at all — -and while permit mode is on, nothing blocks anyway. +`heroku run -e CONSOLE_BLOCK_ENFORCE=false`, but only for as long as dry-run mode exists at all — +and while dry-run mode is on, nothing blocks anyway. Before enabling enforcement anywhere, grep your CI and deploy tooling for existing `heroku run` callers and update them, or they break the moment the requirement is turned on. @@ -353,14 +548,17 @@ Provided per-session via `-e`, and required for every `heroku run`: | Variable | Required | Notes | |---|---|---| -| `CONSOLE_USER` | Yes | Self-reported operator identity; should be the `heroku whoami` value. Whitespace-only counts as missing. Session exits if unset when enforcing; in permit mode it becomes `[not provided]` | +| `CONSOLE_USER` | Yes | Self-reported operator identity; should be the `heroku whoami` value. Whitespace-only counts as missing. Session exits if unset when enforcing; in dry-run mode it becomes `[not provided]` | | `CONSOLE_REASON` | Yes | Free-text justification. Whitespace-only counts as missing. May not contain `;`. Session exits if unset | Set as a config var on the app, and read at **run** time: | Variable | Required | Notes | |---|---|---| -| `CONSOLE_BLOCK_ENFORCE` | No | `false` opts into phase 1 permit mode. Defaults to enforcing, and only the exact value `false` opts out. Temporary: removed at the end of phase 1, and until then not tamper-proof | +| `CONSOLE_BLOCK_ENFORCE` | No | `false` opts into phase 1 dry-run mode. Defaults to enforcing, and only the exact value `false` opts out. Temporary: removed at the end of phase 1, and until then not tamper-proof | +| `CONSOLE_LOGGING_DATADOG_PROXY_URL` | No | Where to POST a [denial record](#denials-are-recorded-not-just-printed). Same variable, endpoint and Basic credential as the companion gem. Unset means denials are not recorded. Read on the one-off dyno, so `-e` can suppress it | +| `HEROKU_APP_NAME` | No | Attribution on a denial record. Set by Heroku's dyno metadata; unset means the record carries no `@app` and the cross-check queries skip it | +| `DD_SERVICE` | No | Attribution on a denial record, forwarded as `@app_service`. The same var the companion gem stamps, so the attribute matches across both record kinds. Unset means the field is omitted | Set as a config var on the app, and read at **build** time: @@ -394,28 +592,62 @@ Populated automatically by Heroku: | `web.N`, `worker.N`, any other process type | Not enforced | Not exported | | Unknown or missing dyno name | Enforced (fails closed) | Exported | -Scheduler and release dynos are one-off dynos, but there is no interactive operator to supply a user -and a reason, and their commands come from app configuration rather than from an ad-hoc invocation. -They are audited but not gated. See [Limitations](#limitations). +Scheduler and release dynos are one-off dynos, but nothing gates them, for two reasons. Gating asks +"who are you and why", and nobody is there to answer — these commands run unattended, so requiring +`CONSOLE_USER` would refuse every scheduled job on the app rather than protect anything. And the +command is not an operator's to choose: it is whatever the app's Scheduler entry or `Procfile` +release line says, which is changed by a deploy or a dashboard edit — a different access path, with +its own controls, and not one a console gate is in front of. + +They are audited anyway, because that access path is the obvious way around this guard. A Scheduler +entry is editable in the Heroku dashboard by anyone with app access, so `rake some:task` scheduled +there reaches the same data a console does with no `heroku run` for the gate to see. Exporting +`CONSOLE_AUDIT_ENABLED` is what makes a record of it exist: `console1984` only hooks the interactive +console, but the companion gem also hooks Rails boot and logs `rake` and `rails runner` as +`noninteractive_command` records. A rake task that does not depend on `:environment` never boots +Rails and so is not logged — see the gem for that gap. Also see [Limitations](#limitations). ## Development ``` -./test/run_tests.sh # end-to-end suite, no dependencies beyond bash + coreutils +ruby test/run_ruby_tests.rb # policy; runs anywhere, macOS included +./test/run_tests.sh # end-to-end; needs Linux (procfs) and a ruby shellcheck -s bash bin/* profile/*.sh guard/*.sh test/*.sh test/lib/*.sh +ruby --disable=gems -c guard/lib/console_guard/*.rb ``` -The suite compiles the buildpack into a temporary build directory and runs payloads through a login -shell arranged to look like a one-off dyno — `$HOME` is the build directory, `$HOME/.profile` sources -`.profile.d/*.sh` the way Heroku's does, and a fake `rails`/`rake`/`bundle` on `PATH` reports the `argv` it -received. A test therefore distinguishes "blocked" from "ran, with exactly these arguments". - -Every bypass fixed in this repo has a regression case, and CI runs the suite inside the -`heroku/heroku:22` and `heroku/heroku:24` stack images as well as on `ubuntu-latest`. - -When adding a rule, put it in `guard/shim.sh` if it is about the command's **arguments** and in -`profile/console_guard.sh` only if it is about the environment or the raw command string. See -[How the two halves fit together](#how-the-two-halves-fit-together). +There are two suites, split by what they can actually observe. Both compile the buildpack into a +temporary build directory first, so they test the rendered files a dyno gets rather than the +templates in `guard/`. + +**`test/run_ruby_tests.rb` — policy.** Which commands, arguments and options are refused, what each +denial records, and which exit status the gate returns. It drives the two entry points as +subprocesses with a fabricated login-shell `argv`, so it needs no procfs and no stack: it runs on a +laptop. Subprocesses rather than in-process calls on purpose — both halves refuse by calling `exit` +and the wrapper ends in `exec`, so testing them in process would mean adding a seam to the guard +that exists only for the tests, and a seam is where a bypass hides. + +**`test/run_tests.sh` — end to end.** Everything the *shell* does, which Ruby cannot stand in for: +`.profile.d` being sourced, the login shell acting on the gate's exit status, the shell expanding +the operator's command before the wrapper sees it, a permitted command reaching the real binary, and +the interpreter hardening above. `$HOME` is the build directory, `$HOME/.profile` sources +`.profile.d/*.sh` the way Heroku's does, and a fake `rails`/`rake`/`bundle` on `PATH` reports the +`argv` it received — so a test distinguishes "blocked" from "ran, with exactly these arguments". + +Every bypass fixed in this repo has a regression case, and CI runs both suites inside every +supported `heroku/heroku` stack image. The stack images carry no Ruby, so the suite installs the +distribution's and the guard runs on that — which is what catches the C-locale encoding traps a +dyno has. + +The interpreter a dyno actually uses is the app's, though, not the stack's, so CI also runs the +policy suite against every Ruby the guard has to survive — 3.0 through 4.0, named explicitly rather +than left to whichever version a stack image happens to ship. That spread is what keeps the policy +honest about using stdlib only. + +When adding a rule, put it in `ConsoleGuard::Command` if it is about the command's **arguments** and +in `ConsoleGuard::Gate` only if it is about the environment or the raw command string. See +[How the two halves fit together](#how-the-two-halves-fit-together) and +[Where the code lives](#where-the-code-lives). ## Limitations @@ -455,6 +687,13 @@ reach models or the database. **`CONSOLE_USER` is self-reported** and is not verified by the buildpack. Heroku's own audit trail (`heroku access -a app_name`) is the authoritative record of who started a session. +**A denial record can be suppressed by the operator it is about.** The endpoint is read from +`CONSOLE_LOGGING_DATADOG_PROXY_URL`, which a one-off dyno inherits, so `-e` on that variable stops +the POST. The gem does not have this problem because its *worker* reads the variable, out of the +operator's reach; nothing running inside the dyno can borrow that defence. Suppression leaves the +`api:dyno` webhook and the exit status, so the attempt is still visible — just not identifiable as a +guard denial. Treat the record as evidence of what was blocked, not as proof that nothing was. + **Statements executed after the audit path is disabled are not recorded.** A statement that disables auditing is itself recorded if the gem logs before execution, but statements after it are not. diff --git a/bin/compile b/bin/compile index 66af94a..717e23c 100755 --- a/bin/compile +++ b/bin/compile @@ -42,18 +42,56 @@ if [[ -z "$CG_VERSION" ]]; then fi [[ -n "$CG_VERSION" ]] || CG_VERSION="unknown" +# ---------- resolve the Ruby interpreter ---------- +# The guard's policy is Ruby, and the dyno must reach it by absolute path: PATH +# is `heroku run -e`-settable, so a lookup there would let an operator hand the +# gate its own interpreter. Resolving it is therefore this script's job, and the +# resolved path is baked into both shell entry points. +# +# heroku/ruby exports its PATH for the buildpacks that run after it, so the +# app's own interpreter is on PATH here. If it is not, this buildpack was +# ordered before heroku/ruby, or the app has no Ruby at all -- either way there +# is nothing to install a guard onto. +CG_RUBY_BUILD="$(command -v ruby 2>/dev/null || true)" + +if [[ -z "$CG_RUBY_BUILD" ]]; then + { + echo "console-guard: no Ruby interpreter found, so the guard cannot be installed." + echo " Add this buildpack after heroku/ruby." + } >&2 + exit 1 +fi + +# The slug is built in BUILD_DIR and extracted at /app, so an interpreter +# vendored into the slug is at a different path in the dyno than it is here. One +# from the stack image is at the same path in both. +case "$CG_RUBY_BUILD" in + "$BUILD_DIR"/*) CG_RUBY="/app${CG_RUBY_BUILD#"$BUILD_DIR"}" ;; + *) CG_RUBY="$CG_RUBY_BUILD" ;; +esac + # ---------- install ---------- render() { local src="$1" dest="$2" test -s "$src" || { echo "console-guard: missing source file $src" >&2; exit 1; } + mkdir -p "$(dirname "$dest")" sed -e "s|@@CG_VERSION@@|${CG_VERSION}|g" \ -e "s|@@CG_DYNO_METADATA_FILE@@|${CG_DYNO_METADATA_FILE}|g" \ + -e "s|@@CG_RUBY@@|${CG_RUBY}|g" \ "$src" > "$dest" test -s "$dest" || { echo "console-guard: failed to write $dest" >&2; exit 1; } # Catch a broken substitution or an editing mistake at build time rather than # in a production dyno. - bash -n "$dest" || { echo "console-guard: $dest is not valid bash" >&2; exit 1; } + case "$dest" in + *.rb) + "$CG_RUBY_BUILD" -c "$dest" > /dev/null || + { echo "console-guard: $dest is not valid ruby" >&2; exit 1; } + ;; + *) + bash -n "$dest" || { echo "console-guard: $dest is not valid bash" >&2; exit 1; } + ;; + esac if grep -q '@@CG_' "$dest"; then echo "console-guard: unsubstituted placeholder left in $dest" >&2 exit 1 @@ -62,33 +100,54 @@ render() { echo "-----> Installing console guard ${CG_VERSION}" +GUARD_DIR="$BUILD_DIR/.console-guard" + +# The policy, and the two entry points that run it. `render` refuses a source +# file that is not there, so a rename fails the build here rather than reaching +# a dyno; a file added and not listed fails the guard's own requires, which the +# test suite runs. +CG_POLICY_FILES=( + lib/console_guard.rb + lib/console_guard/banner.rb + lib/console_guard/command.rb + lib/console_guard/config.rb + lib/console_guard/denials.rb + lib/console_guard/dyno.rb + lib/console_guard/gate.rb + lib/console_guard/reporter.rb + libexec/run_gate.rb + libexec/run_command.rb +) + +for rel in "${CG_POLICY_FILES[@]}"; do + render "$BUILDPACK_DIR/guard/$rel" "$GUARD_DIR/$rel" + chmod 644 "$GUARD_DIR/$rel" +done + # Named zzz_ so it loads last, after every other buildpack's profile script. # CONSOLE_AUDIT_ENABLED must be the final exported value, and the PATH entry that # reaches the command wrapper must not be undone by a later script. -mkdir -p "$BUILD_DIR/.profile.d" render "$BUILDPACK_DIR/profile/console_guard.sh" \ "$BUILD_DIR/.profile.d/zzz_console_guard.sh" chmod 644 "$BUILD_DIR/.profile.d/zzz_console_guard.sh" -# The command wrapper. Installed under all three names; it dispatches on $0. This -# is where all argument policy lives, because it is the first point at which the -# shell has finished expanding the operator's command. +# The command wrapper. Installed under all three names; it dispatches on $0. # # `bundle` is one of them because Heroku's Ruby buildpack rewrites `rake ` # on a one-off dyno to `bundle exec rake `, and `bundle exec` then puts # Bundler's own bin directory ahead of this one on PATH -- so the rails/rake # wrapper is never reached and policy has to be applied by the `bundle` wrapper # itself. -SHIM_DIR="$BUILD_DIR/.console-guard/bin" -mkdir -p "$SHIM_DIR" -render "$BUILDPACK_DIR/guard/shim.sh" "$SHIM_DIR/rails" -cp "$SHIM_DIR/rails" "$SHIM_DIR/rake" -cp "$SHIM_DIR/rails" "$SHIM_DIR/bundle" -chmod 755 "$SHIM_DIR/rails" "$SHIM_DIR/rake" "$SHIM_DIR/bundle" +render "$BUILDPACK_DIR/guard/shim.sh" "$GUARD_DIR/bin/rails" +cp "$GUARD_DIR/bin/rails" "$GUARD_DIR/bin/rake" +cp "$GUARD_DIR/bin/rails" "$GUARD_DIR/bin/bundle" +chmod 755 "$GUARD_DIR/bin/rails" "$GUARD_DIR/bin/rake" "$GUARD_DIR/bin/bundle" { echo "profile script: .profile.d/zzz_console_guard.sh" echo "command wrapper: .console-guard/bin/{rails,rake,bundle}" + echo "policy: .console-guard/{lib,libexec}/ (${#CG_POLICY_FILES[@]} ruby files)" + echo "ruby: ${CG_RUBY}" echo "dyno metadata file: ${CG_DYNO_METADATA_FILE}" echo "enforcement: blocking unless CONSOLE_BLOCK_ENFORCE=false at run time" } | indent diff --git a/guard/lib/console_guard.rb b/guard/lib/console_guard.rb new file mode 100644 index 0000000..21c2014 --- /dev/null +++ b/guard/lib/console_guard.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Console guard, Ruby half. Loaded by both entry points and by nothing else. +# +# The two entry points -- libexec/gate.rb and libexec/command.rb -- are reached +# from thin bash stubs, because a few things can only be done by the login shell +# itself. Everything that is a policy decision is here. +# +# HARDENING THE INTERPRETER +# +# The guard decides whether operator-influenced code may enter a Rails process, +# so nothing the operator controls may reach the guard's own interpreter first. +# `heroku run -e` can set any variable, and three of them are code injection: +# +# RUBYOPT closed by `--disable=rubyopt` on the command line of both stubs +# RubyGems closed by `--disable=gems`, which also closes GEM_HOME/GEM_PATH +# RUBYLIB closed below +# +# RUBYLIB is prepended to $LOAD_PATH, so `require 'json'` could otherwise be +# answered by an operator's file. Its entries are dropped before the first +# require. Our own files are reached with require_relative, which never consults +# $LOAD_PATH at all. +ENV["RUBYLIB"].to_s.split(File::PATH_SEPARATOR).each do |dir| + next if dir.empty? + + $LOAD_PATH.delete(dir) + $LOAD_PATH.delete(File.expand_path(dir)) +end + +require "json" +# rbconfig before net/http, so Ruby 3.4 works with `--disable=gems` +require "rbconfig" +require "net/http" +require "uri" + +require_relative "console_guard/config" +require_relative "console_guard/banner" +require_relative "console_guard/reporter" +require_relative "console_guard/denials" +require_relative "console_guard/dyno" +require_relative "console_guard/gate" +require_relative "console_guard/command" diff --git a/guard/lib/console_guard/banner.rb b/guard/lib/console_guard/banner.rb new file mode 100644 index 0000000..cc9bae6 --- /dev/null +++ b/guard/lib/console_guard/banner.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ConsoleGuard + # The denial banner an operator sees on their terminal. + module Banner + RULE = "=" * 42 + + # Lines may carry the operator's own command, which need not be valid UTF-8, + # so they are written rather than interpolated into one string. + def self.render(lines, enforcing:, io: $stderr) + io.write("\n", RULE, "\n") + lines.each { |line| io.write(" ", line.to_s, "\n") } + unless enforcing + io.write("\n") + io.write(" CONSOLE_BLOCK_ENFORCE=false -- dry-run mode, permitting anyway.\n") + io.write(" This command WILL BE BLOCKED once enforcement is enabled.\n") + end + io.write(" console-guard ", VERSION, "\n") + io.write(RULE, "\n\n") + io.flush + end + + # Denials echo the command back. Without it a denial cannot be diagnosed from + # the operator's side -- "this command is not permitted" says nothing about + # which part of the string the gate objected to, or whether it even parsed + # the string the operator typed. + SHOW_MAX = 300 + + def self.show(text) + bytes = text.to_s.b + return bytes if bytes.bytesize <= SHOW_MAX + + "#{bytes[0, SHOW_MAX]} [truncated]" + end + end +end diff --git a/guard/lib/console_guard/command.rb b/guard/lib/console_guard/command.rb new file mode 100644 index 0000000..1682122 --- /dev/null +++ b/guard/lib/console_guard/command.rb @@ -0,0 +1,452 @@ +# frozen_string_literal: true + +module ConsoleGuard + # The command-wrapper half of the guard. Reached as `rails`, `rake` or + # `bundle` from a directory the profile script prepends to PATH. + # + # WHY THIS HALF EXISTS + # + # The profile half can only see the dyno command as a *string*, before the + # shell has performed quote removal, parameter expansion and pathname + # expansion. Any policy expressed as a string comparison there is therefore + # comparing something other than what `rails` will actually receive. This half + # runs after the shell has finished expanding, so its argv is exactly the argv + # `rails` would have seen, and every deny-list rule lives here rather than + # there. + # + # The division of labour is: + # + # ConsoleGuard::Gate -> is this a gated dyno; is the caller identified; + # is the command free of compound statements and + # redirections; is argv[0] literally `rails`, + # `rake` or `bundle` + # this class -> everything about the arguments + class Command + include Denials + + def initialize(program, argv) + @program = program + # Captured before `bundle exec` rewriting narrows what policy looks at, so + # a denial record shows what was invoked. + @argv = argv.map { |arg| arg.to_s.b } + @dyno = Dyno.resolve + end + + def run + real = resolve_real + refuse_without_real_binary unless real + + program, args = policy_target + apply_policy(program, args) + # apply_policy will exit if the command is denied, so reaching this point + # means the command is allowed. + + # Array form, which always execs directly. Do not simplify it to + # `exec(real, *@argv)`: handed a single string -- which is what a bare + # `rails` with no arguments leaves -- Ruby scans that string for shell + # metacharacters and falls back to `/bin/sh -c`. `real` is assembled from + # PATH, and `heroku run -e` sets PATH, so a directory with a `;` in its + # name would become a shell escape out of the thing whose job is + # preventing them. argv[0] is the resolved path either way. + exec([real, real], *@argv) + end + + # The post-expansion argv, which is what this half actually judged -- the + # profile half's copy is the pre-expansion string, and the two differ in + # exactly the cases this class exists for. + def denial_command + "#{@program} #{@argv.join(" ")}" + end + + def dyno_id + @dyno.id + end + + private + + # PATH still contains this wrapper's directory, so a plain `exec rails` would + # re-enter the wrapper. Walk PATH and take the first match that is not in it. + # + # Directories are compared resolved, so that a duplicate or symlinked PATH + # entry pointing at the wrapper directory cannot send us back here. + def resolve_real + self_dir = realpath(ConsoleGuard.wrapper_dir) + + ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |entry| + entry = "." if entry.empty? + dir = realpath(entry) + next if dir.nil? || dir == self_dir + + candidate = File.join(dir, @program) + return candidate if File.executable?(candidate) && !File.directory?(candidate) + end + + nil + end + + def realpath(path) + File.realpath(path) + rescue StandardError + nil + end + + # Fail closed and loudly: silently doing nothing would look like a broken app + # rather than a guard problem. + def refuse_without_real_binary + warn <<~MESSAGE + + console-guard: could not find the real `#{@program}` on PATH. + PATH=#{ENV["PATH"]} + This is a buildpack bug, not an operator mistake. + + MESSAGE + exit 1 + end + + # Invoked as `bundle`, the interesting command is the one bundler will exec, + # not bundler itself. + # + # Heroku's Ruby buildpack rewrites `rake ` on a one-off dyno to + # `bundle exec rake ` before the login shell runs, so `bundle` has to + # be on the allowlist for any rake task to work at all. It cannot simply be + # waved through: `bundle exec` unshifts Bundler's own bin directory onto + # PATH, so the rails/rake wrapper is NOT reached afterwards and this is the + # only place the argument rules below can be applied. + def policy_target + return [@program, @argv] unless @program == "bundle" + + unless @argv[0] == "exec" + deny "bundle_not_exec", + "`bundle #{@argv[0]}` is not permitted on one-off dynos.", + "", + "Only `bundle exec rails` and `bundle exec rake` are allowed,", + "because those are the forms Heroku's Ruby buildpack produces for", + "a permitted command." + end + + program = @program + # Unqualified, for the same reason the profile half requires it of the + # command itself: a path names a program this wrapper has not vetted. + case @argv[1] + when "rails", "rake" + program = @argv[1] + else + deny "bundle_exec_not_allowed", + "`bundle exec #{@argv[1]}` is not permitted on one-off dynos.", + "", + "Only `rails` and `rake` may be run under `bundle exec`,", + "and the name must be unqualified." + end + + [program, @argv.drop(2)] + end + + def apply_policy(program, args) + subcommand = args[0].to_s + + refuse_raw_database_session(program, subcommand) + refuse_editor_escape(program, subcommand) + refuse_unloggable_arguments(args) + refuse_sandboxed_console(program, subcommand, args) + refuse_runner_file(program, subcommand, args) + check_option_allowlist(program, subcommand, args) + end + + # `rails dbconsole` / `rails db` drop to a raw psql session; no statement is + # ever seen by the console audit hook. + def refuse_raw_database_session(program, subcommand) + return unless ["dbconsole", "db"].include?(subcommand) + + deny "raw_database_session", + "`#{program} #{subcommand}` is not permitted on one-off dynos.", + "", + "It opens a raw database session, so no statement reaches the", + "console audit hook." + end + + # `rails credentials:edit` and `rails encrypted:edit` spawn $EDITOR, which is + # operator-controlled (`heroku run -e EDITOR=bash`) and therefore a shell. + # The profile script also unsets EDITOR and VISUAL; this is the second layer. + def refuse_editor_escape(program, subcommand) + return unless subcommand.start_with?("credentials:", "encrypted:") + + deny "editor_escape", + "`#{program} #{subcommand}` is not permitted on one-off dynos.", + "", + "These commands spawn an editor, which is a shell escape." + end + + def refuse_unloggable_arguments(args) + args.each do |arg| + # A bare `-` makes `rails runner` read the program from stdin, so the + # code that runs appears in no log at all -- not the dyno command string, + # not the api:dyno webhook, not an in-app ARGV capture. + if arg == "-" + deny "stdin_program", + "Reading the program from stdin is not permitted.", + "", + "A bare `-` argument means the executed code never appears in", + "any audit record. Pass the code inline instead." + end + + # `-c` would reach a shell (`bash -c`, `sh -c`). No legitimate rails/rake + # invocation uses it. `rails c` -- the console shorthand -- is + # unaffected, because that argument is `c`, not `-c`. + next unless arg == "-c" + + deny "dash_c_flag", + "The `-c` flag is not permitted on one-off dynos.", + "", + "Use `rails c` for a console." + end + end + + # A run of short flags, which Thor splits into one option per letter. + THOR_SHORT_BUNDLE = /\A-[a-zA-Z]{2,}\z/ + + # `rails console --sandbox` wraps the whole session in a transaction that is + # rolled back on exit. The audit records are enqueued through ActiveJob, and + # a database-backed queue on the primary database (eg Solid Queue) puts that + # enqueue inside the same transaction -- so the rollback discards the audit + # trail along with the operator's changes, leaving an interactive console + # with no record of a single statement. + # + # Scoped to `console`/`c` rather than applied to every argv, because `-s` is + # `rake`'s silent flag and legitimate there. `--no-sandbox` must keep + # working. + # + # Thor parses `--sandbox=true` as well as the bare flag, so the `=` forms are + # denied whatever value they carry. Enumerating Thor's boolean vocabulary + # would be modelling the parser, which is the thing the option allowlist + # below exists to avoid; `--no-sandbox` is the spelling that opts out. + # + # Thor also splits a run of short flags into separate options, so `rails c + # -es` is `-e -s` and the sandbox flag arrives inside a bundle. Any bundle + # containing `s` is refused here; the allowlist refuses bundles too, but + # this rule has to stand on its own for the reason above. + # + # The console_audit gem sets Rails' own `config.disable_sandbox = true` when + # auditing is active, which is a second layer over the same dynos: it holds + # even if the command never reaches this wrapper. + def refuse_sandboxed_console(program, subcommand, args) + return unless program == "rails" && ["console", "c"].include?(subcommand) + + args.drop(1).each do |arg| + next unless sandbox_flag?(arg) + + deny "sandbox_console", + "`rails #{subcommand} #{arg}` is not permitted on one-off dynos.", + "", + "A sandboxed console rolls back its transaction on exit, which", + "discards the queued audit records with it -- the session would", + "run entirely unlogged.", + "", + "Use `rails #{subcommand}` instead. It is audited.", + "`--no-sandbox` is permitted and means the same thing." + end + end + + def sandbox_flag?(arg) + return true if arg == "--sandbox" || arg == "-s" + return true if arg.start_with?("--sandbox=", "-s=") + + THOR_SHORT_BUNDLE.match?(arg) && arg.include?("s") + end + + # `rails runner` reading its program from a file has the same shape as + # reading from stdin: the command string names a path rather than the code + # that runs. + # + # Rails decides file-vs-inline-code by whether the path exists on disk. We + # are past expansion here, so we can apply that same test rather than + # guessing from how the argument looks. + def refuse_runner_file(program, subcommand, args) + return unless program == "rails" && ["runner", "r"].include?(subcommand) + + args.drop(1).each do |arg| + if arg == "--file" || arg.start_with?("--file=") + deny "runner_file", + "`rails runner` may not read its program from a file.", + "", + "Pass the code inline instead." + end + + next unless exists?(arg) + + deny "runner_file", + "`rails runner` may not read its program from a file.", + "", + "`#{arg}` exists on disk, so Rails would execute the", + "file rather than the argument. The command string would then", + "name a path rather than the code that runs, and the executed", + "code would never be audited.", + "", + "Pass the code inline instead." + end + end + + def exists?(path) + File.exist?(path) + rescue StandardError + false + end + + # ---------- option allowlist ---------- + # `rake -e/-p/-E CODE` evaluates CODE inside Rake's own option parser -- + # before the Rakefile is loaded and without booting Rails -- and then exits. + # Nothing the code does reaches the console audit hook, so it is weaker even + # than the `rails runner 'system("bash")'` case the README accepts as best + # effort, where Rails at least boots and the invocation is recorded. + # `-f/-r/-I/-R/-C/-g` name a path rather than the code that runs, which is + # what blocks a bare `-` above. + # + # Rails hands any command it does not recognise to that same parser with the + # whole argv, so `rails -e CODE` and `rails db:migrate -e CODE` reach it too. + # + # Allowlisted rather than screened. A deny list has to model which of Rake's + # short options take an argument, in order to know where a bundle such as + # `-Ne` stops being flags -- get that wrong for one option, in this version + # of Rake or a later one, and the bundle hides an `-e`. An allowlist fails + # the other way: an option nobody listed is refused, so the cost of being + # wrong is a denial rather than an unlogged shell. + # + # Every command gets a list; none is exempt. The two Rails commands below + # parse their own options and never reach Rake, so `-e` there is the + # environment -- but they are given a list of their own rather than being + # waved through, because a mistake in a list is a denial while a mistake in + # an exemption is a bypass, silently and with no failing test. + # + # exact options taking no value: the token must match exactly, so `-se` is + # refused rather than read as a bundle + # value options taking one: exactly, or with the value attached + # (`-T db`, `-Tdb`, `--tasks=db`) + # attached_values + # whether a short option may carry its value in the same token. + # Rake's parser takes `-Tdb`; Thor does not -- it reads a run of + # letters as a bundle, so `-es` is `-e -s` and reading it as `-e s` + # would wave the sandbox flag through behind an allowlisted `-e`. + RAILS_PARSED_WHY = [ + "Options are allowlisted here, so one nobody vetted is refused", + "rather than passed through to Rails." + ].freeze + + CONSOLE_OPTIONS = { + exact: ["--no-sandbox", "-h", "--help"], + value: ["-e", "--environment"], + why: RAILS_PARSED_WHY, + attached_values: false, + extras: "" + }.freeze + + RUNNER_OPTIONS = { + exact: ["-w", "--skip-executor", "-h", "--help"], + value: ["-e", "--environment"], + why: [ + "Options are allowlisted here, so one nobody vetted is refused", + "rather than passed through to Rails. The code to run is not an", + "option and needs no entry." + ].freeze, + attached_values: false, + extras: "" + }.freeze + + # Rake's read-only and output-shaping options. Absent, deliberately: + # -e/-E/-p (evaluate code), -f/-r/-I/-R/-C (name a path), and -g/-G/-N, + # which change which Rakefile is found -- `--system` loads tasks from + # $HOME/.rake, and $HOME is /app on a dyno. + RAKE_OPTIONS = { + exact: [ + "-A", "-B", "-m", "-n", "-P", "-q", "-s", "-t", "-v", "-V", "-X", "-h", "-H", + "--all", "--build-all", "--multitask", "--dry-run", "--prereqs", + "--quiet", "--silent", "--verbose", "--version", "--comments", "--rules", + "--no-deprecation-warnings", "--help" + ].freeze, + value: [ + "-T", "-D", "-W", "-j", + "--tasks", "--describe", "--where", "--jobs", "--trace", "--backtrace", + "--job-stats" + ].freeze, + why: [ + "Options are allowlisted here. Rake evaluates `-e/-p/-E CODE` in", + "its own option parser, before the Rakefile is loaded and without", + "booting Rails, so nothing that code does reaches the console audit", + "hook -- and Rails hands any command it does not recognise to that", + "same parser. Options naming a path are excluded for the reason a", + "bare `-` is." + ].freeze, + attached_values: true, + extras: "task names, VAR=value assignments, and:" + }.freeze + + OPTIONS = { + ["rails", "console"] => CONSOLE_OPTIONS, + ["rails", "c"] => CONSOLE_OPTIONS, + ["rails", "runner"] => RUNNER_OPTIONS, + ["rails", "r"] => RUNNER_OPTIONS + }.freeze + + # The width the permitted set is wrapped to for the denial banner. + WRAP_AT = 58 + + def check_option_allowlist(program, subcommand, args) + allowed = OPTIONS.fetch([program, subcommand], RAKE_OPTIONS) + + args.each do |arg| + # A bare `-` is handled above, with a message about stdin that says more + # than this one would. + next if arg == "-" + next unless arg.start_with?("-") + next if permitted?(arg, allowed) + + # Only when it names a command; for `rake -e 1` the "subcommand" is the + # rejected option itself. + context = program.dup + context << " #{subcommand}" if !subcommand.empty? && !subcommand.start_with?("-") + + extras = allowed[:extras].empty? ? "" : " #{allowed[:extras]}" + + deny "option_not_allowed", + "`#{program} #{arg}` is not permitted on one-off dynos.", + "", + *allowed[:why], + "", + "Permitted after `#{context}`:#{extras}", + *wrap_allowed(allowed), + "", + "Short options are matched whole, so pass them separately rather", + "than bundled into one argument." + end + end + + def permitted?(token, allowed) + return true if allowed[:exact].include?(token) + + allowed[:value].any? do |name| + next true if token == name + next token.start_with?("#{name}=") if name.start_with?("--") + + allowed[:attached_values] && token.start_with?(name) && token.bytesize > name.bytesize + end + end + + # Wrap the permitted set for the denial banner. Derived from the lists above + # rather than written out again, so the two cannot drift apart. + def wrap_allowed(allowed) + lines = [] + line = +"" + + (allowed[:value] + allowed[:exact]).each do |word| + if line.length + word.length + 1 > WRAP_AT + lines << " #{line}" + line = +word + else + line << " " unless line.empty? + line << word + end + end + lines << " #{line}" unless line.empty? + + lines + end + end +end diff --git a/guard/lib/console_guard/config.rb b/guard/lib/console_guard/config.rb new file mode 100644 index 0000000..e4eab07 --- /dev/null +++ b/guard/lib/console_guard/config.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ConsoleGuard + # Substituted by bin/compile at build time. Every build-time value the guard + # needs is here, so there is one file to check for an unsubstituted + # placeholder. + VERSION = "@@CG_VERSION@@" + DYNO_METADATA_FILE = "@@CG_DYNO_METADATA_FILE@@" + + # The command wrapper is installed under all three names. `bundle` is one of + # them because Heroku's Ruby buildpack rewrites `rake ` on a one-off + # dyno to `bundle exec rake `. + WRAPPER_NAMES = ["rails", "rake", "bundle"].freeze + + # Only the exact string opts into dry-run mode, so a typo or an empty value + # fails closed. + def self.enforcing? + ENV["CONSOLE_BLOCK_ENFORCE"] != "false" + end + + def self.home + home = ENV["HOME"].to_s + home.empty? ? "/app" : home + end + + def self.root + File.join(home, ".console-guard") + end + + # Prepended to PATH by the profile script, which is what guarantees a + # permitted command reaches the wrapper. + def self.wrapper_dir + File.join(root, "bin") + end + + # A value that is entirely whitespace is treated the same as an unset one. + # Compared as bytes: CONSOLE_REASON and the dyno command are both + # operator-controlled and need not be valid UTF-8. + def self.blank?(value) + value.to_s.b.strip.empty? + end +end diff --git a/guard/lib/console_guard/denials.rb b/guard/lib/console_guard/denials.rb new file mode 100644 index 0000000..5f50e15 --- /dev/null +++ b/guard/lib/console_guard/denials.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ConsoleGuard + # How both halves of the guard refuse a command. + # + # Including classes supply `denial_command` -- what that half actually judged, + # which differs between them and is the point of the split -- and `dyno_id`. + module Denials + # is a short stable identifier for the check that refused. It is what + # a monitor groups by, because denial messages get reworded and rule names + # do not. + # + # `fatal` refuses in dry-run mode too, for a refusal that is not a + # command-policy decision an operator could usefully be warned about. + # `command` and `dyno_id` override what the record says, for a refusal about + # something other than the command this half judged. + def deny(rule, *lines, fatal: false, command: denial_command, dyno_id: self.dyno_id) + enforced = enforcing? || fatal + + Banner.render(lines, enforcing: enforced) + + # Before the exit, and in dry-run mode too: phase 1 exists to measure what + # enforcement would block, which is only measurable if the would-be + # denials are recorded. + Reporter.report(rule: rule, command: command, enforced: enforced, + dyno_id: dyno_id) + + return unless enforced + + before_exit + exit 1 + end + + def enforcing? + ConsoleGuard.enforcing? + end + + # Hook for anything a half must do on its way out. Only the profile half has + # one; see ConsoleGuard::Gate. + def before_exit + end + end +end diff --git a/guard/lib/console_guard/dyno.rb b/guard/lib/console_guard/dyno.rb new file mode 100644 index 0000000..35ea3bb --- /dev/null +++ b/guard/lib/console_guard/dyno.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module ConsoleGuard + # Which dyno this is, and what the guard therefore applies to it. + # + # $DYNO is an environment variable, and `heroku run -e DYNO=web.1` would + # otherwise let an operator skip the gate entirely. Dyno metadata also writes + # the dyno's name and UUID to a file inside the dyno, which `-e` cannot touch, + # so that is preferred and a mismatch is treated as tampering. + class Dyno + attr_reader :name, :id, :claimed_name, :metadata_name, :metadata_id + + def self.resolve(path = DYNO_METADATA_FILE) + new(**read_metadata(path)) + end + + # Anything unparseable is treated as absent rather than as an error, so a + # change in the file's shape degrades to the $DYNO fallback. + # + # The file carries several objects, each with its own name and id (dyno, app, + # release), so the dyno object is addressed rather than searched: app.name is + # empty on a real dyno, and reading it instead would silently defeat the + # spoof check below. + def self.read_metadata(path) + dyno = JSON.parse(File.read(path))["dyno"] + return {} unless dyno.is_a?(Hash) + + {metadata_name: dyno["name"].to_s, metadata_id: dyno["id"].to_s} + rescue StandardError + {} + end + + def initialize(metadata_name: "", metadata_id: "") + @metadata_name = metadata_name + @metadata_id = metadata_id + @claimed_name = ENV["DYNO"].to_s + + if metadata_seen? + @name = metadata_name + @id = metadata_id.empty? ? ENV["HEROKU_DYNO_ID"].to_s : metadata_id + else + @name = @claimed_name + @id = ENV["HEROKU_DYNO_ID"].to_s + end + end + + def metadata_seen? + !@metadata_name.empty? + end + + def spoofed? + metadata_seen? && !@claimed_name.empty? && @claimed_name != @metadata_name + end + + # The dyno UUID is what correlates a console audit record with Heroku's own + # api:dyno webhook record for the same session, and the metadata file is what + # makes the dyno name un-spoofable. Without dyno metadata neither is + # available. + def correlatable? + metadata_seen? && !@id.empty? + end + + # Which dyno families the guard applies to. See ConsoleGuard::Gate for what + # gated and audited each mean, and why the two are not the same question. + # + # run.N `heroku run` / `heroku run:detached` -- gated and audited + # scheduler.N Heroku Scheduler -- audited only + # release.N release phase -- audited only + # anything else -- neither + def gated? + # No dyno name from either source. Assume a one-off dyno and gate it, + # rather than letting a command through ungated. + @name.start_with?("run.") || @name.empty? + end + + def audited? + gated? || @name.start_with?("scheduler.", "release.") + end + end +end diff --git a/guard/lib/console_guard/gate.rb b/guard/lib/console_guard/gate.rb new file mode 100644 index 0000000..3f35299 --- /dev/null +++ b/guard/lib/console_guard/gate.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +module ConsoleGuard + # The profile half of the guard: everything that can be decided before the + # login shell has expanded the operator's command. + # + # It sees the dyno command only as a *string*, before quote removal, parameter + # expansion and pathname expansion, so a policy expressed as a string + # comparison here is comparing something other than what `rails` will actually + # receive: + # + # rails "dbconsole" -> string has `"dbconsole"`, argv has `dbconsole` + # rails runner "$P" (P=-) -> string has `"$P"`, argv has `-` + # rails runner *.r? -> string has the glob, argv has a filename + # + # So this half checks only what is sound to check on a raw string: which dyno + # this is, whether the caller is identified, whether the command is free of + # compound statements and redirections, and whether argv[0] is literally + # `rails`, `rake` or `bundle`. Everything about the *arguments* is in + # ConsoleGuard::Command, which runs after expansion. + # + # argv[0] is the one thing this half can check soundly, because quoting or + # expanding it makes it stop matching the allowlist and so fails closed. That + # is what guarantees control reaches the command wrapper. + class Gate + include Denials + + # The contract with profile/console_guard.sh. It can only act on an exit + # status, so each distinct thing the login shell has to do to its own + # environment gets a code of its own. + # + # Two independent things can apply to a dyno, and the code says which: + # + # gated the guard vets the command before it runs. This is what + # requires CONSOLE_USER and CONSOLE_REASON, applies the command + # and argument policy, and refuses the session. + # audited CONSOLE_AUDIT_ENABLED is exported, so the console_audit gem + # inside the app records what the session does. It refuses + # nothing: it is the record, not the control. + # + # Gating implies auditing -- a vetted session is worth a record. Auditing + # does not imply gating, and AUDITED below is that case. + + # Not a console at all: a long-running dyno (web, worker, or any other + # app-defined process type). Nothing applies. + NOT_APPLICABLE = 0 + + # A one-off dyno whose command the app configured rather than an operator + # typed: Heroku Scheduler and release phase. + # + # Not gated, for two reasons. Gating asks "who are you and why", and there + # is nobody there to answer -- these run unattended, so requiring + # CONSOLE_USER would refuse every scheduled job on the app rather than + # protect anything. And the command is not an operator's to choose: it is + # whatever the Scheduler entry or Procfile release line says, which changes + # by a deploy or a dashboard edit -- a different access path, with its own + # controls, and not one a console gate is in front of. + # + # Audited anyway, because that access path is the obvious way around this + # guard. A Scheduler entry is editable in the Heroku dashboard by anyone + # with app access, so `rake some:task` scheduled there reaches the same data + # a console does, with no `heroku run` for the gate to see. Setting the flag + # is what makes a record of it exist: console1984 only hooks the interactive + # console, but the companion gem also hooks Rails boot and logs rake and + # `rails runner` as `noninteractive_command`. + AUDITED = 10 + + # An operator's `heroku run`, vetted and permitted. + GATED = 20 + + # As GATED, and the login shell must also supply a placeholder CONSOLE_USER. + # Only reachable in dry-run mode; see require_identity for why it is needed + # and why it is a status rather than something this process can do. + GATED_ANONYMOUS = 21 + + # Refused. The banner is printed, the record sent, and the CLI's + # exit-status marker emitted, before this is returned. + DENIED = 1 + + # `heroku run --exit-code` appends + # + # ; echo " heroku-command-exit-status: $?" + # + # to the dyno command and reads the resulting line off stdout to decide what + # to exit with. It is the only way `heroku run` reports failure, so every CI + # caller that can tell a broken migration from a good one uses it. + # + # That matters here twice over. The appended text makes the command a + # compound statement, which the check below would otherwise reject; and a + # denial exits during .profile.d, so the appended `echo` never runs, no + # marker reaches stdout, and the CLI reports success for a command it never + # ran. + EXIT_SENTINEL = "\uFFFF" + EXIT_MARKER = %(; echo "\uFFFF heroku-command-exit-status: $?").b + + SHELLS = ["bash", "sh", "zsh", "dash"].freeze + # `-c`, and the combined short forms such as `-lc` that mean the same thing. + COMBINED_DASH_C = /\A-[^-].*c\z/ + + TRAILING_SPACE = /[ \t\n\r\f\v]+\z/n + + USAGE = 'heroku run -e "CONSOLE_USER=$(heroku whoami);CONSOLE_REASON=test" rails c -a app_name' + + def initialize(cmdline_path: ENV["CONSOLE_GUARD_CMDLINE"].to_s) + @dyno = Dyno.resolve + # Read up front: both the marker check and the command parsing need it, and + # a denial needs the marker answer from the very first call site -- the + # identity gate is the denial CI is most likely to hit. + @argv = read_cmdline(cmdline_path) + # True only when the caller passed --exit-code, so a plain `heroku run` is + # not given a stray marker line it never asked for. + @exit_marker_seen = @argv.any? { |arg| arg.end_with?(EXIT_MARKER) } + @command, @command_read = extract_command + @anonymous = false + end + + def run + refuse_spoofed_dyno if @dyno.spoofed? + return NOT_APPLICABLE unless @dyno.audited? + return AUDITED unless @dyno.gated? + + require_wrapper + require_identity + require_readable_command + refuse_compound_statement + check_command_allowlist + warn_without_metadata + + @anonymous ? GATED_ANONYMOUS : GATED + end + + # What this half judged: the pre-expansion command string, falling back to + # the login shell's whole argv when there was no command to extract. + def denial_command + @command.empty? ? @argv.join(" ") : @command + end + + def dyno_id + @dyno.id + end + + # Stand in for the `echo` the CLI appended, which exiting skips. On stdout, + # because that is the stream the CLI parses -- the banner goes to stderr and + # is invisible to it. Without this a denied CI job exits 0 and the pipeline + # goes green. + def before_exit + return unless @exit_marker_seen + + $stdout.write("#{EXIT_SENTINEL} heroku-command-exit-status: 1\n") + $stdout.flush + end + + private + + # Heroku executes the one-off command through a login shell, which is the + # process that sources the profile script, so its argv is + # `bash -c `. + def read_cmdline(path) + return [] if path.empty? + + raw = File.binread(path) + parts = raw.split("\0".b, -1) + parts.pop if raw.end_with?("\0".b) + parts + rescue StandardError + [] + end + + def extract_command + return ["".b, false] if @argv.empty? + return ["".b, false] unless SHELLS.include?(File.basename(@argv[0])) + + (1...@argv.length).each do |index| + arg = @argv[index] + next unless arg == "-c" || COMBINED_DASH_C.match?(arg) + + return [strip_exit_marker(@argv[index + 1].to_s.b), true] + end + + ["".b, false] + end + + # Removed before anything vets or reports the command, so `heroku run + # --exit-code rake foo` is judged -- and recorded -- as `rake foo` rather + # than as the compound the CLI made of it. + # + # Exact literal, anchored to the end, removed at most once. A looser pattern + # is a shell escape: `rails c ; bash # heroku-command-exit-status` would be + # stripped back to `rails c` and permitted. Two markers leave one behind, + # which the compound check then rejects. + # + # If Heroku changes the marker this stops matching and CI is denied again -- + # noisy, but the safe direction to fail in. + def strip_exit_marker(command) + candidate = command.sub(TRAILING_SPACE, "") + return command unless candidate.end_with?(EXIT_MARKER) + + candidate[0, candidate.bytesize - EXIT_MARKER.bytesize].sub(TRAILING_SPACE, "") + end + + # Fatal in both enforcement modes. This is not a command-policy decision an + # operator can be warned about; it is an attempt to change which dyno the + # guard believes it is running on. + # + # Recorded with the metadata's dyno id, not $DYNO's, so the record files + # under the dyno this actually is. + def refuse_spoofed_dyno + deny "dyno_name_spoofed", + "$DYNO (#{@dyno.claimed_name}) does not match this dyno's metadata", + "(#{@dyno.metadata_name}). Refusing to run.", + fatal: true, + command: "$DYNO=#{@dyno.claimed_name}", + dyno_id: @dyno.metadata_id + end + + # All argument policy lives in the wrapper. If it is missing this half cannot + # enforce anything meaningful, so refuse rather than run half a gate. + def require_wrapper + return if WRAPPER_NAMES.all? { |name| File.executable?(File.join(ConsoleGuard.wrapper_dir, name)) } + + deny "wrapper_missing", + "The console guard command wrapper is missing from this dyno.", + "", + "Expected: #{ConsoleGuard.wrapper_dir}/{rails,rake,bundle}", + "", + "This is a build problem, not an operator mistake. Redeploy the", + "app; if it persists the buildpack is misconfigured." + end + + def require_identity + # Name the one that is missing. "both are required" sends an operator + # checking the variable that was already fine, and the usual cause -- a + # failed `heroku whoami` substituting an empty string -- looks like neither + # was set. + missing = [] + missing << "CONSOLE_USER" if ConsoleGuard.blank?(ENV["CONSOLE_USER"]) + missing << "CONSOLE_REASON" if ConsoleGuard.blank?(ENV["CONSOLE_REASON"]) + + unless missing.empty? + described = if missing.length == 2 + "CONSOLE_USER and CONSOLE_REASON are" + else + "#{missing.first} is" + end + + deny "identity_missing", + "#{described} not set.", + "", + "Both are required on one-off dynos. CONSOLE_USER must be your", + "`heroku whoami` value, so that console records can be compared", + "against Heroku's own audit trail.", + "", + "If you built CONSOLE_USER from `heroku whoami`, check that it", + "succeeded -- an expired login makes it print an error and return", + "an empty string, which arrives here as unset.", + "", + "Usage:", + " #{USAGE}" + end + + # console1984 raises MissingUsername on an empty CONSOLE_USER + # (ask_for_username_if_empty defaults to false), so leaving it empty kills + # the console even in dry-run mode -- which is exactly the breakage permit + # mode exists to avoid during phase 1. The profile script supplies a + # placeholder instead; see GATED_ANONYMOUS. + # + # Only in dry-run mode. When enforcing, the denial above has already exited. + @anonymous = true if !enforcing? && ConsoleGuard.blank?(ENV["CONSOLE_USER"]) + end + + # Positioned after the identity gate, so that a CI caller who is missing a + # reason is told that rather than told the gate could not parse its command. + def require_readable_command + if @argv.empty? + # Fail closed: if we cannot read the command, we cannot vet it. + deny "command_unreadable", + "Could not read the dyno command.", + "", + "/proc/$$/cmdline is empty or unreadable, and the console gate", + "cannot vet a command it cannot see, so the session is refused.", + "", + "This is a platform or build problem, not an operator mistake." + elsif !@command_read || ConsoleGuard.blank?(@command) + # No `-c` payload means this is not the `bash -c ` shape the + # gate is built on: the login shell was invoked some other way, or the + # command arrives on stdin. There is no command string to vet, so refuse + # -- and say so. + @command_read = false + deny "command_not_bash_c", + "Could not determine the dyno command.", + "", + "The gate expects this session's login shell to have been invoked", + "as `bash -c `. It was not, so there is no command", + "string to vet and the session is refused.", + "", + "Login shell argv:", + " #{Banner.show(@argv.join(" "))}", + "", + "This is a platform or build problem, not an operator mistake." + end + end + + # The allowlist below matches argv[0] only, so without this an operator could + # append a second command -- eg `rails runner "1"; bash` -- and reach a + # shell. + # + # Redirections are rejected for the same reason the wrapper rejects a bare + # `-`: `rails c < /app/payload.rb` feeds a program in through stdin, so the + # command string names a file rather than the code that runs. + # + # Best effort: `rails runner 'system("bash")'` contains none of these and + # still shells out. + COMPOUND = /[;&|`<>\n]|\$\(/n + + def refuse_compound_statement + return unless @command_read + return unless COMPOUND.match?(@command) + + deny "compound_statement", + "Compound statements and redirections are not permitted on one-off", + "dynos.", + "", + "The command may not contain any of: ; & | ` $( < > newline", + "", + "Command:", + " #{Banner.show(@command)}", + "", + "Run each command as its own `heroku run`." + end + + # Only `rails`, `rake` and `bundle` are permitted, because those are the only + # paths that enter a Rails process where the console audit hook can observe + # what runs. Everything else -- bash, sh, zsh, irb, ruby, node, python, psql, + # pg_dump, pg_restore, pgcli, curl, wget, nc, ssh, scp, env, printenv, cat -- + # is blocked by falling through this allowlist. + # + # `bundle` is here because Heroku's Ruby buildpack rewrites `rake ` on + # a one-off dyno to `bundle exec rake ` before this runs, so without it + # no rake task works at all. It is admitted only as far as the wrapper: the + # `bundle` wrapper permits `bundle exec rails|rake` and nothing else, so + # `bundle exec bash` still dies -- on `bash`, one layer later. + # + # The name must be unqualified. `bin/rails` and `/app/bin/rails` are rejected + # even though they are the same program, because naming a path bypasses the + # PATH lookup that reaches the command wrapper, and the wrapper is where + # argument policy is enforced. A leading `VAR=value` assignment is rejected + # for the same reason: `PATH=/app/bin rails c` would take the wrapper out of + # the picture. + def check_command_allowlist + return unless @command_read + + # Split on whitespace only. Nothing here is glob-expanded or re-quoted: + # the first word is the whole question. + first_word = @command.split(/[ \t\n]+/n).reject(&:empty?).first.to_s + return if WRAPPER_NAMES.include?(first_word) + + deny "command_not_allowed", + "This command is not permitted on one-off dynos.", + "", + "Command:", + " #{Banner.show(@command)}", + "Rejected because its first word is:", + " #{Banner.show(first_word)}", + "", + "Allowed:", + " rails ", + " rake ", + " bundle exec rails|rake ", + "", + "The name must be unqualified -- `rails`, not `bin/rails` --", + "and may not be preceded by a VAR=value assignment.", + "", + "Example:", + " #{USAGE}" + end + + # A configuration error on the app, not an operator mistake, so it warns + # rather than blocks. + def warn_without_metadata + return if @dyno.correlatable? + + app = ENV["HEROKU_APP_NAME"].to_s + app = "app_name" if app.empty? + + warn <<~WARNING + + WARNING: dyno metadata is not enabled on this app, so this session + cannot be correlated with Heroku's audit trail, and the gate + is relying on $DYNO, which an operator can set. + Enable it: + heroku labs:enable runtime-dyno-metadata -a #{app} + + WARNING + end + end +end diff --git a/guard/lib/console_guard/reporter.rb b/guard/lib/console_guard/reporter.rb new file mode 100644 index 0000000..5ce9ec3 --- /dev/null +++ b/guard/lib/console_guard/reporter.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module ConsoleGuard + # Durable record of a console-guard denial. + # + # WHY THIS FILE EXISTS + # + # The denial banner is written to the operator's terminal over the rendezvous + # connection. It is not in the app's log stream, and it never reaches Datadog. + # So a blocked command leaves behind only Heroku's own `api:dyno` record, which + # shows that a command was attempted but cannot distinguish a guard denial from + # an application error -- and the cross-check queries have to read "no console + # record for this dyno" as "blocked, or lost", which is not an audit trail. + # + # One record per denial closes that. The queries then read a missing console + # record as lost, full stop. + # + # WHERE IT GOES + # + # The same endpoint and the same credential as the console_audit gem: + # CONSOLE_LOGGING_DATADOG_PROXY_URL, carrying HTTP Basic userinfo. One endpoint, + # one credential to issue and rotate, one Datadog source, and the join keys the + # proxy already derives (`dyno_id`, and `console_identity` from `operator`) + # apply to these records unchanged. `event` is what tells them apart. + # + # ATTRIBUTION: `app` AND `service` + # + # The gem sends `service` / `env` / `app` / `version` and stamps them on the + # *worker*, because `heroku run -e` can rewrite every one of them and a record + # tagged `env:staging` would keep flowing to Datadog while dropping quietly out + # of a production-scoped monitor. There is no worker here, so nothing sent from + # this side can carry that guarantee. + # + # `app` and `service` are sent anyway, because the tampering argument does not + # transfer to them. An operator who wants their denial record gone can unset the + # endpoint above and delete it outright, so forging either field is strictly + # weaker than what they can already do, and neither one scopes a monitor. What + # tampering would actually buy is closed where suppression is impossible, which + # is the gem's position and not this one. + # + # Sending them is what keeps a denial record reachable. The cross-check queries + # scope on `@app` to span both log sources at once, so without it they skip every + # denial silently. `@service` is the same problem one rung down: the gem stamps + # it, so a query that filters on it would return sessions and drop the denials + # beside them. Send it when the app sets DD_SERVICE, and the attribute means one + # thing on both record kinds -- absent because the app has no DD_SERVICE, never + # because of which half of the audit trail produced the record. + # + # `service` goes under that name, not the `app_service` it lands in Datadog as. + # datadog-proxy does the renaming (Datadog's JSON preprocessing would otherwise + # promote a `service` key onto the reserved facet), and one sender contract beats + # two. It follows that the proxy must have that rename deployed before this does. + # + # `env` stays unsent, and the asymmetry is deliberate: it is a reserved facet that + # scopes monitors, so forging it is the one case where tampering buys something + # suppression does not. datadog-proxy infers it from the delivery topology, which + # nothing in this dyno can reach. `version` stays unsent because nothing reads it. + # + # LIMITATION + # + # Fail-open, and not sufficient on its own. The URL variable is inherited by the + # one-off dyno, so an operator who knows about this can suppress their own + # denial record with `heroku run -e CONSOLE_LOGGING_DATADOG_PROXY_URL=`. What + # survives that is the `api:dyno` webhook and the exit status. See the README. + module Reporter + URL_VAR = "CONSOLE_LOGGING_DATADOG_PROXY_URL" + EVENT = "command_denied" + OPEN_TIMEOUT = 2 + MAX_TIME = 4 + # Matches the denial banner, so the record and the banner agree on what the + # guard was judging. + COMMAND_MAX = 300 + + # rule short stable identifier for the check that refused -- the field to + # group a monitor by, because denial *messages* get reworded + # command what the guard was judging, as the banner shows it + # enforced Sent in dry-run mode as well: phase 1 exists to measure what + # enforcement would block, which is only measurable if the would-be + # denials are recorded. + # dyno_id the join key against the api:dyno webhook + def self.report(rule:, command:, enforced:, dyno_id:) + url = ENV[URL_VAR].to_s + # Nothing configured: an app that has not been given the endpoint is not + # one this can report for. Silent, because it is also the state of every + # app before rollout reaches it. + return if url.empty? + + post(url, body(rule: rule, command: command, enforced: enforced, dyno_id: dyno_id)) + end + + def self.body(rule:, command:, enforced:, dyno_id:) + fields = { + "event" => EVENT, + "enforced" => enforced, + "rule" => ascii(rule.to_s.empty? ? "unknown" : rule), + "command" => ascii(truncate(command)), + "operator" => ascii(ENV["CONSOLE_USER"]), + "reason" => ascii(ENV["CONSOLE_REASON"]), + # Resolved from the dyno metadata file, which the profile script refuses + # a session for when $DYNO disagrees with it. HEROKU_DYNO_ID is the + # fallback and is `-e`-settable, so it is only as good as the app's + # metadata being enabled. + "dyno_id" => ascii(dyno_id), + # Attribution, so `@app` and `@app_service` reach this record too. + "app" => ascii(ENV["HEROKU_APP_NAME"]), + "service" => ascii(ENV["DD_SERVICE"]), + "guard_version" => ascii(VERSION), + # datadog-proxy claims `timestamp` as the log's official date, exactly as + # it does for the gem's records, so a denial is filed at the moment it + # happened. + "timestamp" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.000Z") + } + # Omitted rather than null so that a missing operator reads as absent in + # Datadog rather than as the string "null". + fields.reject! { |_, value| value.is_a?(String) && value.empty? } + + # ascii_only, so every string in the record is pure ASCII and a byte + # scrubbed to U+FFFD below is written as an escape rather than as raw bytes. + JSON.generate(fields, ascii_only: true) + end + + def self.truncate(command) + bytes = command.to_s.b + return bytes if bytes.bytesize <= COMMAND_MAX + + "#{bytes[0, COMMAND_MAX]} [truncated]" + end + + # Replace every byte >= 0x80 with U+FFFD. + # + # A JSON string has to be valid UTF-8, and both the command and the reason + # are operator-controlled bytes. One stray byte would cost the entire record + # -- rule, operator and dyno_id with it -- and it would be lost where nobody + # auditing can see it, because the only warning goes to the terminal of the + # operator who was just blocked. + # + # The cost is fidelity: `puts 'héllo'` is recorded with two replacement + # characters, and the leftover exit-status sentinel in the two-marker case + # reads as three. Enough to see that something non-ASCII was there. + def self.ascii(value) + value.to_s.b.gsub(/[^\x00-\x7f]/n) { "\xEF\xBF\xBD".b }.force_encoding(Encoding::UTF_8) + end + + # One attempt, short timeouts, no retry: the dyno is about to exit, and the + # operator should not wait on the audit pipeline to be told they were denied. + def self.post(url, json) + endpoint, user, password = split_userinfo(url) + uri = URI.parse(endpoint) + + request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json") + # curl takes Basic credentials from the URL's userinfo; Net::HTTP does not. + request.basic_auth(user, password.to_s) if user + request.body = json + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = OPEN_TIMEOUT + http.read_timeout = MAX_TIME + http.write_timeout = MAX_TIME + + response = http.start { |connection| connection.request(request) } + return if response.is_a?(Net::HTTPSuccess) + + failed(response.code) + rescue StandardError => e + # The class, never the message: an exception from here can quote the URL, + # which carries the Basic credential. + failed("no response (#{e.class})") + end + + # Loud, because a denial that was not recorded is the gap this file exists to + # close. Never fatal: refusing the command is the control, and recording it + # must not be able to hold that up. + def self.failed(detail) + warn "console-guard: denial not recorded (#{URL_VAR} returned #{detail})" + end + + # `URI.parse` is strict about what a userinfo may contain and the credential + # here is whatever the proxy issued, so it is lifted out before parsing + # rather than parsed and read back off. + USERINFO = %r{\A(?[a-zA-Z][a-zA-Z0-9+.-]*://)(?[^/@]*)@(?.*)\z}m + + def self.split_userinfo(url) + match = USERINFO.match(url) + return [url, nil, nil] unless match + + user, _, password = match[:userinfo].partition(":") + ["#{match[:scheme]}#{match[:rest]}", unescape(user), unescape(password)] + end + + def self.unescape(value) + value.gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr } + end + end +end diff --git a/guard/libexec/run_command.rb b/guard/libexec/run_command.rb new file mode 100644 index 0000000..38fe005 --- /dev/null +++ b/guard/libexec/run_command.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Entry point for the command-wrapper half of the guard. Judges the *expanded* +# argv and then, if it passes, `exec`s the real rails/rake/bundle -- so this +# process goes on to be the operator's command. It does not hand a verdict back +# to anyone: a denial exits non-zero from here, which is the command failing. +# +# The other half, run_gate.rb, decides and returns rather than executing. +# +# Run by .console-guard/bin/{rails,rake,bundle}, which passes the name it was +# invoked under as the first argument -- that is what distinguishes the three, +# and $0 stops carrying it once the interpreter takes over. +# +# No shebang, for the reason given in run_gate.rb. + +require_relative "../lib/console_guard" + +ConsoleGuard::Command.new(ARGV.shift.to_s, ARGV).run diff --git a/guard/libexec/run_gate.rb b/guard/libexec/run_gate.rb new file mode 100644 index 0000000..20209a1 --- /dev/null +++ b/guard/libexec/run_gate.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Entry point for the profile half of the guard. Judges the dyno command +# *before* the shell has expanded it, and runs nothing: it returns a verdict as +# an exit status, and .profile.d/zzz_console_guard.sh acts on it. See +# ConsoleGuard::Gate for what each code means. +# +# The other half, run_command.rb, does execute the command. +# +# No shebang: it is always invoked as ` --disable=gems,rubyopt ` +# so that the interpreter and its flags are chosen by the caller rather than by +# a PATH lookup an operator can influence. + +require_relative "../lib/console_guard" + +exit ConsoleGuard::Gate.new.run diff --git a/guard/shim.sh b/guard/shim.sh index 7c91f0a..fe0005a 100755 --- a/guard/shim.sh +++ b/guard/shim.sh @@ -1,243 +1,43 @@ -#!/usr/bin/env bash +#!/bin/bash # Console guard command wrapper. Installed by heroku-buildpack-console-guard as -# both `rails` and `rake` in a directory prepended to PATH on one-off dynos. +# `rails`, `rake` and `bundle` in a directory prepended to PATH on one-off dynos. # -# WHY THIS FILE EXISTS +# All argument policy is in Ruby -- see ConsoleGuard::Command. This file exists +# because the wrapper has to be an executable the shell finds on PATH, and +# because $0 is what distinguishes `rails` from `rake` from `bundle` and is lost +# once the interpreter takes over. # -# The profile script can only see the dyno command as a *string*, before the -# shell has performed quote removal, parameter expansion and pathname -# expansion. Any policy expressed as a string comparison there is therefore -# comparing something other than what `rails` will actually receive: -# -# rails "dbconsole" -> string has `"dbconsole"`, argv has `dbconsole` -# rails runner "$P" (P=-) -> string has `"$P"`, argv has `-` -# rails runner *.r? -> string has the glob, argv has a filename -# -# This wrapper runs after the shell has finished expanding, so `"$@"` here is -# exactly the argv `rails` would have seen. Every deny-list rule therefore lives -# in this file and not in the profile script. -# -# The division of labour is: -# -# profile script -> is this a gated dyno; is the caller identified; is the -# command free of compound statements and redirections; -# is argv[0] literally `rails` or `rake` -# this wrapper -> everything about the arguments -# -# argv[0] is the one thing the profile script can check soundly, because -# quoting or expanding it makes it stop matching the allowlist and so fails -# closed. That is what guarantees control reaches this wrapper. +# `#!/bin/bash`, not `/usr/bin/env bash`: PATH is `heroku run -e`-settable, so an +# env lookup would let an operator choose the interpreter for the thing vetting +# their command. set -uo pipefail -_cg_version="@@CG_VERSION@@" - -# Phase 1 permit mode. Read at runtime, and deliberately not tamper-proof: this -# variable and permit mode are both temporary and go away together at the end of -# phase 1. See profile/console_guard.sh for the full note. -_cg_enforcing=true -if [[ "${CONSOLE_BLOCK_ENFORCE:-true}" == "false" ]]; then - _cg_enforcing=false -fi - +# Substituted by bin/compile, as it is in the profile script, so both halves run +# on the interpreter the build resolved. Not read from the environment: this +# wrapper vets an operator's command, and letting them name its interpreter +# would be handing them the thing doing the vetting. +_cg_ruby="@@CG_RUBY@@" +_cg_root="${HOME:-/app}/.console-guard" + +# The name this was invoked as, with any directory stripped: one of `rails`, +# `rake` or `bundle`, all three of which are this same file. It decides which +# policy applies, so it is passed to the wrapper explicitly -- $0 becomes the +# Ruby entry point's own path the moment the interpreter takes over. _cg_prog="${0##*/}" -_cg_deny() { - local line - { - echo "" - echo "==========================================" - for line in "$@"; do - echo " ${line}" - done - if [[ "$_cg_enforcing" != "true" ]]; then - echo "" - echo " CONSOLE_BLOCK_ENFORCE=false -- permitting anyway (phase 1)." - echo " This command WILL BE BLOCKED once enforcement is enabled." - fi - echo " console-guard ${_cg_version}" - echo "==========================================" - echo "" - } >&2 - - if [[ "$_cg_enforcing" == "true" ]]; then - exit 1 - fi -} - -# ---------- resolve the real rails/rake ---------- -# PATH still contains this wrapper's directory, so a plain `exec rails` would -# re-enter this script. Walk PATH and take the first match that is not this -# file. -_cg_self_dir="$(cd "$(dirname "$0")" && pwd)" -_cg_real="" -IFS=':' read -ra _cg_path_entries <<< "${PATH:-}" -for _cg_entry in "${_cg_path_entries[@]}"; do - [[ -n "$_cg_entry" ]] || _cg_entry="." - # Compare resolved directories so that a duplicate or symlinked PATH entry - # pointing at the wrapper directory cannot send us back here. - _cg_entry_dir="$(cd "$_cg_entry" 2>/dev/null && pwd)" || continue - [[ "$_cg_entry_dir" == "$_cg_self_dir" ]] && continue - if [[ -x "$_cg_entry_dir/$_cg_prog" && ! -d "$_cg_entry_dir/$_cg_prog" ]]; then - _cg_real="$_cg_entry_dir/$_cg_prog" - break - fi -done - -if [[ -z "$_cg_real" ]]; then +if [[ ! -x "$_cg_ruby" ]]; then # Fail closed and loudly: silently doing nothing would look like a broken app # rather than a guard problem. { echo "" - echo "console-guard: could not find the real \`${_cg_prog}\` on PATH." - echo " PATH=${PATH:-}" + echo "console-guard: no Ruby interpreter at \`${_cg_ruby}\`, so \`${_cg_prog}\`" + echo " cannot be vetted." echo " This is a buildpack bug, not an operator mistake." echo "" } >&2 exit 1 fi -# ---------- what policy applies to ---------- -# Invoked as `bundle`, the interesting command is the one bundler will exec, not -# bundler itself. -# -# Heroku's Ruby buildpack rewrites `rake ` on a one-off dyno to -# `bundle exec rake ` before the login shell runs, so `bundle` has to be on -# the allowlist for any rake task to work at all. It cannot simply be waved -# through: `bundle exec` unshifts Bundler's own bin directory onto PATH, so the -# rails/rake wrapper is NOT reached afterwards and this is the only place the -# argument rules below can be applied. -_cg_policy_prog="$_cg_prog" -_cg_policy_args=("$@") - -if [[ "$_cg_prog" == "bundle" ]]; then - if [[ "${1:-}" != "exec" ]]; then - _cg_deny "\`bundle ${1:-}\` is not permitted on one-off dynos." \ - "" \ - "Only \`bundle exec rails\` and \`bundle exec rake\` are allowed," \ - "because those are the forms Heroku's Ruby buildpack produces for" \ - "a permitted command." - fi - - # Unqualified, for the same reason the profile script requires it of the - # command itself: a path names a program this wrapper has not vetted. - case "${2:-}" in - rails|rake) - _cg_policy_prog="$2" - ;; - *) - _cg_deny "\`bundle exec ${2:-}\` is not permitted on one-off dynos." \ - "" \ - "Only \`rails\` and \`rake\` may be run under \`bundle exec\`," \ - "and the name must be unqualified." - ;; - esac - - _cg_policy_args=("${@:3}") -fi - -# ---------- policy ---------- - -_cg_sub="${_cg_policy_args[0]:-}" - -# `rails dbconsole` / `rails db` drop to a raw psql session; no statement is -# ever seen by the console audit hook. -case "$_cg_sub" in - dbconsole|db) - _cg_deny "\`${_cg_policy_prog} ${_cg_sub}\` is not permitted on one-off dynos." \ - "" \ - "It opens a raw database session, so no statement reaches the" \ - "console audit hook." - ;; -esac - -# `rails credentials:edit` and `rails encrypted:edit` spawn $EDITOR, which is -# operator-controlled (`heroku run -e EDITOR=bash`) and therefore a shell. The -# profile script also unsets EDITOR and VISUAL; this is the second layer. -case "$_cg_sub" in - credentials:*|encrypted:*) - _cg_deny "\`${_cg_policy_prog} ${_cg_sub}\` is not permitted on one-off dynos." \ - "" \ - "These commands spawn an editor, which is a shell escape." - ;; -esac - -for _cg_arg in ${_cg_policy_args[@]+"${_cg_policy_args[@]}"}; do - # A bare `-` makes `rails runner` read the program from stdin, so the code - # that runs appears in no log at all -- not the dyno command string, not the - # api:dyno webhook, not an in-app ARGV capture. - if [[ "$_cg_arg" == "-" ]]; then - _cg_deny "Reading the program from stdin is not permitted." \ - "" \ - "A bare \`-\` argument means the executed code never appears in" \ - "any audit record. Pass the code inline instead." - fi - - # `-c` would reach a shell (`bash -c`, `sh -c`). No legitimate rails/rake - # invocation uses it. `rails c` -- the console shorthand -- is unaffected, - # because that argument is `c`, not `-c`. - if [[ "$_cg_arg" == "-c" ]]; then - _cg_deny "The \`-c\` flag is not permitted on one-off dynos." \ - "" \ - "Use \`rails c\` for a console." - fi -done - -# `rails console --sandbox` wraps the whole session in a transaction that is -# rolled back on exit. The audit records are enqueued through ActiveJob, and a -# database-backed queue on the primary database (eg Solid Queue) puts that -# enqueue inside the same transaction -- so the rollback discards -# the audit trail along with the operator's changes, leaving an interactive -# console with no record of a single statement. -# -# Scoped to `console`/`c` rather than applied to every argv, because `-s` is -# `rake`'s silent flag and legitimate there. `--no-sandbox` must keep working. -# -# The console_audit gem sets Rails' own `config.disable_sandbox = true` when -# auditing is active, which is a second layer over the same dynos: it holds even -# if the command never reaches this wrapper. -if [[ "$_cg_policy_prog" == "rails" && ( "$_cg_sub" == "console" || "$_cg_sub" == "c" ) ]]; then - for _cg_arg in "${_cg_policy_args[@]:1}"; do - case "$_cg_arg" in - --sandbox|-s) - _cg_deny "\`rails ${_cg_sub} ${_cg_arg}\` is not permitted on one-off dynos." \ - "" \ - "A sandboxed console rolls back its transaction on exit, which" \ - "discards the queued audit records with it -- the session would" \ - "run entirely unlogged." \ - "" \ - "Use \`rails ${_cg_sub}\` instead. It is audited." - ;; - esac - done -fi - -# `rails runner` reading its program from a file has the same shape as reading -# from stdin: the command string names a path rather than the code that runs. -# -# Rails decides file-vs-inline-code by whether the path exists on disk. We are -# past expansion here, so we can apply that same test rather than guessing from -# how the argument looks. -if [[ "$_cg_policy_prog" == "rails" && ( "$_cg_sub" == "runner" || "$_cg_sub" == "r" ) ]]; then - for _cg_arg in "${_cg_policy_args[@]:1}"; do - case "$_cg_arg" in - --file|--file=*) - _cg_deny "\`rails runner\` may not read its program from a file." \ - "" \ - "Pass the code inline instead." - ;; - esac - if [[ -f "$_cg_arg" ]]; then - _cg_deny "\`rails runner\` may not read its program from a file." \ - "" \ - "\`${_cg_arg}\` exists on disk, so Rails would execute the" \ - "file rather than the argument. The command string would then" \ - "name a path rather than the code that runs, and the executed" \ - "code would never be audited." \ - "" \ - "Pass the code inline instead." - fi - done -fi - -exec "$_cg_real" "$@" +exec "$_cg_ruby" --disable=gems,rubyopt \ + "$_cg_root/libexec/run_command.rb" "$_cg_prog" "$@" diff --git a/profile/console_guard.sh b/profile/console_guard.sh index a38a8a7..0e64f62 100644 --- a/profile/console_guard.sh +++ b/profile/console_guard.sh @@ -1,473 +1,133 @@ # shellcheck shell=bash -# shellcheck disable=SC2016 # `$(` and friends appear here as literals to match on # Console gate for one-off Heroku dynos. # Installed by heroku-buildpack-console-guard into .profile.d/ # # This file is SOURCED by the login shell that runs the dyno command. It has no -# shebang on purpose: it is not executable, and `return` below would be a syntax -# error if it were run as a script. -# -# It is one of two halves. This half can only see the dyno command as a string, -# before the shell expands it, so it checks only what is sound to check on a raw -# string: -# -# - is this a gated dyno -# - is the caller identified (CONSOLE_USER / CONSOLE_REASON) -# - is the command free of compound statements and redirections -# - is argv[0] literally `rails` or `rake` -# -# Everything about the *arguments* lives in the command wrapper installed on -# PATH (see guard/shim.sh), which runs after expansion and can therefore see the -# real argv. Quoting or expanding argv[0] makes it stop matching the allowlist -# here, so it fails closed -- that is what guarantees the wrapper is reached. -# -# - Requires CONSOLE_USER and CONSOLE_REASON for all one-off dynos -# - Blocks compound statements, redirections and command substitution -# - Only permits `rails` and `rake` (unqualified, so the wrapper applies) -# - Warns when runtime-dyno-metadata is disabled (the dyno UUID correlates a -# session with Heroku's own api:dyno record) -# - Exports CONSOLE_AUDIT_ENABLED=true, which activates the in-app console -# audit hook -# - -# Values substituted by bin/compile at build time. -_CG_VERSION="@@CG_VERSION@@" -_cg_metadata_file="@@CG_DYNO_METADATA_FILE@@" -_cg_shim_dir="${HOME:-/app}/.console-guard/bin" - -# ---------- enforcement mode (phase 1 rollout) ---------- -# Phase 1 permits but does not block: every check still runs and reports, but a -# failure is a warning rather than an exit. Set CONSOLE_BLOCK_ENFORCE=false as an -# app config var to opt into permit mode. Defaults to enforcing, so an app that -# was never configured fails closed. -# -# Deliberately not tamper-proof. This variable and permit mode are both temporary -# and go away together at the end of phase 1; while permit mode is on nothing -# blocks anyway, so overriding it per session gains an operator nothing. -_cg_enforcing=true -if [[ "${CONSOLE_BLOCK_ENFORCE:-true}" == "false" ]]; then - _cg_enforcing=false -fi - -# ---------- determine the dyno name ---------- -# $DYNO is an environment variable, and `heroku run -e DYNO=web.1` would -# otherwise let an operator skip the gate entirely. Dyno metadata also writes the -# dyno's name and UUID to a file inside the dyno, which `-e` cannot touch, so -# prefer that and treat a mismatch as tampering. -_cg_dyno_name="${DYNO:-}" -_cg_dyno_id="${HEROKU_DYNO_ID:-}" -_cg_metadata_seen=false - -if [[ -r "$_cg_metadata_file" ]]; then - _cg_meta_raw="$(tr -d '\n' < "$_cg_metadata_file" 2>/dev/null)" - # Minimal, dependency-free extraction of "name" and "id" from the metadata - # JSON. Anything unparseable is treated as absent rather than as an error, so a - # change in the file's shape degrades to the $DYNO fallback below. +# shebang on purpose: it is not executable. +# +# All policy is in Ruby -- see ConsoleGuard::Gate. What is left here is the set +# of things only the login shell itself can do, because it is the process that +# will run the operator's command and a child cannot reach into its parent: +# +# - name /proc/$$/cmdline. The gate does the reading, but `$$` has to be +# expanded here: this shell's argv is `bash -c `, and a +# child looking up its own PID would find the gate's argv instead +# - exit, which is how a denial refuses the session +# - prepend the command wrapper to PATH, so `rails` and `rake` reach it +# - unset EDITOR/VISUAL, and export CONSOLE_AUDIT_ENABLED and CONSOLE_USER +# +# Adding a rule means editing the Ruby, not this file. +# +# WHAT THE GATE'S EXIT STATUS MEANS +# +# Two independent things can apply to a dyno, and the status says which: +# +# gated the guard vets the command before it runs -- the caller must +# identify themselves, and the command and its arguments must pass +# policy. This is what refuses a session. +# audited CONSOLE_AUDIT_ENABLED is exported, so the console_audit gem inside +# the app records what the session does. This refuses nothing; it is +# the record, not the control. +# +# Gated implies audited. The reverse does not hold, and status 10 is that case. +# +# 0 neither. A long-running dyno (web, worker, ...), which is not a console. +# 1 denied. The gate has already told the operator and recorded it. +# 10 audited, not gated. See ConsoleGuard::Gate for why the distinction +# exists and which dynos land here. +# 20 gated and audited. An operator's `heroku run`, and it passed. +# 21 as 20, and dry-run mode needs CONSOLE_USER supplied. This shell script +# will set a placeholder value. +# +# Any other status is a gate that died rather than decided, and is treated as a +# denial. The case where the gate cannot be started at all is handled before it +# is reached, and does not borrow this channel. + +# CG_RUBY is defined by bin/compile at build time, and is a fixed absolute path. +_cg_ruby="@@CG_RUBY@@" +_cg_root="${HOME:-/app}/.console-guard" + +if [[ ! -x "$_cg_ruby" ]]; then + # The interpreter bin/compile resolved is not there any more -- the app's + # buildpacks were reordered, or its Ruby removed, since the last build. Not + # something an operator can arrange: the path above is absolute and does not + # depend on anything `-e` can set. # - # The file carries several objects, each with its own "name"/"id" (dyno, app, - # release). Narrow to the "dyno" object first: a greedy match over the whole - # file picks the LAST "name", which is app.name (empty), silently defeating the - # spoof check. The dyno object has no nested braces, so [^}] delimits it. - _cg_meta_dyno="$(printf '%s' "$_cg_meta_raw" | - sed -n 's/.*"dyno"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p')" - _cg_meta_name="$(printf '%s' "$_cg_meta_dyno" | - sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" - _cg_meta_id="$(printf '%s' "$_cg_meta_dyno" | - sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" - - if [[ -n "$_cg_meta_name" ]]; then - _cg_metadata_seen=true - if [[ -n "${DYNO:-}" && "${DYNO}" != "$_cg_meta_name" ]]; then + # $DYNO is spoofable, and is used anyway, because the thing that would read + # the un-spoofable metadata file is the gate this branch cannot run. Refuse + # anything that might be a one-off dyno; leave long-running dynos running + # rather than taking the whole app down over a console control. + case "${DYNO:-}" in + run.* | scheduler.* | release.* | "") { echo "" echo "==========================================" - echo " \$DYNO (${DYNO}) does not match this dyno's metadata" - echo " (${_cg_meta_name}). Refusing to run." - echo " console-guard ${_CG_VERSION}" + echo " No Ruby interpreter for the console guard, so this session" + echo " cannot be vetted. Refusing to run." + echo " console-guard @@CG_VERSION@@" echo "==========================================" echo "" } >&2 - # Fatal in both enforcement modes. This is not a command-policy decision an - # operator can be warned about; it is an attempt to change which dyno the - # guard believes it is running on. + unset _cg_ruby _cg_root exit 1 - fi - _cg_dyno_name="$_cg_meta_name" - [[ -n "$_cg_meta_id" ]] && _cg_dyno_id="$_cg_meta_id" - fi - unset _cg_meta_raw _cg_meta_dyno _cg_meta_name _cg_meta_id -fi - -# ---------- decide what applies to this dyno ---------- -# One-off dyno families: -# run.N `heroku run` / `heroku run:detached` -- fully gated -# scheduler.N Heroku Scheduler -- audited but not gated: there is no -# interactive operator to supply a user or a reason, and the -# command comes from app configuration -# release.N release phase -- same -# Long-running dynos (web.N, worker.N, and any other app-defined process type) -# get neither: the audit hook is a console concern. -_cg_gated=false -_cg_audited=false -case "$_cg_dyno_name" in - run.*) _cg_gated=true; _cg_audited=true ;; - scheduler.*|release.*) _cg_audited=true ;; - "") - # No dyno name from either source. Assume a one-off dyno and gate it, rather - # than letting a command through ungated. - _cg_gated=true; _cg_audited=true - ;; -esac - -if [[ "$_cg_gated" != "true" ]]; then - if [[ "$_cg_audited" == "true" ]]; then - export CONSOLE_AUDIT_ENABLED=true - fi - unset _cg_enforcing _CG_VERSION _cg_metadata_file _cg_shim_dir \ - _cg_dyno_name _cg_dyno_id _cg_metadata_seen _cg_gated _cg_audited - return 0 -fi - -# ---------- the CLI's exit-status marker ---------- -# `heroku run --exit-code` appends -# -# ; echo " heroku-command-exit-status: $?" -# -# to the dyno command and reads the resulting line off stdout to decide what to -# exit with. It is the only way `heroku run` reports failure, so every CI caller -# that can tell a broken migration from a good one uses it. -# -# That matters here twice over. The appended text makes the command a compound -# statement, which the gate below would otherwise reject; and a denial exits -# during .profile.d, so the appended `echo` never runs, no marker reaches stdout, -# and the CLI reports success for a command it never ran. -# U+FFFF as explicit UTF-8 bytes, not the \u escape. A one-off dyno runs in the C -# locale (`locale charmap` is ANSI_X3.4-1968), and there bash cannot represent the -# codepoint, so the \u form silently yields the six-character string \uFFFF. The -# strip would then never match and the denial marker would be unrecognisable. -_CG_EXIT_SENTINEL=$'\xef\xbf\xbf' -_CG_EXIT_MARKER="; echo \"${_CG_EXIT_SENTINEL} heroku-command-exit-status: \$?\"" - -# Read the login shell's argv up front: both the marker check and the command -# parsing below need it, and _cg_deny needs the marker answer from its very first -# call site -- the identity gate is the denial CI is most likely to hit. -_cg_argv=() -if [[ -r /proc/$$/cmdline ]]; then - while IFS= read -r -d '' _cg_arg; do - _cg_argv+=("$_cg_arg") - done < /proc/$$/cmdline -fi - -# True only when the caller passed --exit-code, so a plain `heroku run` is not -# given a stray marker line it never asked for. -_cg_exit_marker_seen=false -for _cg_arg in "${_cg_argv[@]}"; do - if [[ "$_cg_arg" == *"$_CG_EXIT_MARKER" ]]; then - _cg_exit_marker_seen=true - break - fi -done - -# Print a denial. Exits the dyno when enforcing; warns and continues otherwise. -_cg_deny() { - local _cg_line - { - echo "" - echo "==========================================" - for _cg_line in "$@"; do - echo " ${_cg_line}" - done - if [[ "$_cg_enforcing" != "true" ]]; then - echo "" - echo " CONSOLE_BLOCK_ENFORCE=false -- permitting anyway (phase 1)." - echo " This command WILL BE BLOCKED once enforcement is enabled." - fi - echo " console-guard ${_CG_VERSION}" - echo "==========================================" - echo "" - } >&2 - - if [[ "$_cg_enforcing" == "true" ]]; then - # Stand in for the `echo` the CLI appended, which exiting here skips. On - # stdout, because that is the stream the CLI parses -- the banner above goes - # to stderr and is invisible to it. Without this a denied CI job exits 0 and - # the pipeline goes green. - if [[ "$_cg_exit_marker_seen" == "true" ]]; then - printf '%s heroku-command-exit-status: 1\n' "$_CG_EXIT_SENTINEL" - fi - exit 1 - fi -} - -_CG_USAGE='heroku run -e "CONSOLE_USER=$(heroku whoami);CONSOLE_REASON=test" rails c -a app_name' - -# ---------- the command wrapper must be installed ---------- -# All argument policy lives in the wrapper. If it is missing this script cannot -# enforce anything meaningful, so refuse rather than run half a gate. -if [[ ! -x "$_cg_shim_dir/rails" || ! -x "$_cg_shim_dir/rake" || - ! -x "$_cg_shim_dir/bundle" ]]; then - _cg_deny "The console guard command wrapper is missing from this dyno." \ - "" \ - "Expected: ${_cg_shim_dir}/{rails,rake,bundle}" \ - "" \ - "This is a build problem, not an operator mistake. Redeploy the" \ - "app; if it persists the buildpack is misconfigured." -fi - -# ---------- require CONSOLE_USER and CONSOLE_REASON ---------- -# A value that is entirely whitespace is treated the same as an unset one. -_cg_user_check="${CONSOLE_USER:-}" -_cg_reason_check="${CONSOLE_REASON:-}" - -# Name the one that is missing. "both are required" sends an operator checking -# the variable that was already fine, and the usual cause -- a failed -# `heroku whoami` substituting an empty string -- looks like neither was set. -_cg_missing=() -[[ -z "${_cg_user_check//[[:space:]]/}" ]] && _cg_missing+=("CONSOLE_USER") -[[ -z "${_cg_reason_check//[[:space:]]/}" ]] && _cg_missing+=("CONSOLE_REASON") - -if (( ${#_cg_missing[@]} > 0 )); then - if (( ${#_cg_missing[@]} == 2 )); then - _cg_missing_desc="CONSOLE_USER and CONSOLE_REASON are" - else - _cg_missing_desc="${_cg_missing[0]} is" - fi - _cg_deny "${_cg_missing_desc} not set." \ - "" \ - "Both are required on one-off dynos. CONSOLE_USER must be your" \ - "\`heroku whoami\` value, so that console records can be compared" \ - "against Heroku's own audit trail." \ - "" \ - "If you built CONSOLE_USER from \`heroku whoami\`, check that it" \ - "succeeded -- an expired login makes it print an error and return" \ - "an empty string, which arrives here as unset." \ - "" \ - "Usage:" \ - " ${_CG_USAGE}" -fi - -# ---------- permit mode still has to hand the app an operator ---------- -# console1984 raises MissingUsername on an empty CONSOLE_USER -# (ask_for_username_if_empty defaults to false), so leaving it empty kills the -# console even in permit mode -- which is exactly the breakage permit mode -# exists to avoid during phase 1. Supply a placeholder instead. -# -# Deliberately not a plausible username: it has to be obvious in an audit record -# that nobody identified themselves, and it must never collide with a real -# `heroku whoami` value. -# -# Only in permit mode. When enforcing, _cg_deny above has already exited. -if [[ "$_cg_enforcing" != "true" && -z "${_cg_user_check//[[:space:]]/}" ]]; then - export CONSOLE_USER="[not provided]" -fi - -# ---------- determine the dyno command ---------- -# Heroku executes the one-off command through a login shell, which is the process -# that sources this script. Its argv is therefore `bash -c `; -# we want the payload, not the wrapper. `_cg_argv` was read above, where the -# exit-status marker check needed it. - -# Denials echo the command back. Without it a denial cannot be diagnosed from the -# operator's side -- "this command is not permitted" says nothing about which -# part of the string the gate objected to, or whether it even parsed the string -# the operator typed. -_cg_show() { - local _cg_text="$1" - if (( ${#_cg_text} > 300 )); then - printf '%s [truncated]' "${_cg_text:0:300}" - else - printf '%s' "$_cg_text" - fi -} - -_CG_DYNO_CMD="" -_cg_cmd_read=false - -if (( ${#_cg_argv[@]} == 0 )); then - # Fail closed: if we cannot read the command, we cannot vet it. - _cg_deny "Could not read the dyno command." \ - "" \ - "/proc/\$\$/cmdline is empty or unreadable, and the console gate" \ - "cannot vet a command it cannot see, so the session is refused." \ - "" \ - "This is a platform or build problem, not an operator mistake." -else - case "${_cg_argv[0]##*/}" in - bash|sh|zsh|dash) - _cg_i=1 - while (( _cg_i < ${#_cg_argv[@]} )); do - # Match `-c` and also combined short forms such as `-lc`, which mean the - # same thing to the shell. - case "${_cg_argv[_cg_i]}" in - -c|-[!-]*c) - _CG_DYNO_CMD="${_cg_argv[_cg_i + 1]:-}" - _cg_cmd_read=true - break - ;; - esac - (( _cg_i++ )) - done ;; - esac - - # No `-c` payload means this is not the `bash -c ` shape the gate is - # built on: the login shell was invoked some other way, or the command arrives - # on stdin. There is no command string to vet, so refuse -- and say so. - if [[ "$_cg_cmd_read" != "true" || -z "${_CG_DYNO_CMD//[[:space:]]/}" ]]; then - _cg_cmd_read=false - _cg_deny "Could not determine the dyno command." \ - "" \ - "The gate expects this session's login shell to have been invoked" \ - "as \`bash -c \`. It was not, so there is no command" \ - "string to vet and the session is refused." \ - "" \ - "Login shell argv:" \ - " $(_cg_show "${_cg_argv[*]}")" \ - "" \ - "This is a platform or build problem, not an operator mistake." - fi -fi - -# ---------- strip the CLI's exit-status marker ---------- -# Removed before vetting, so `heroku run --exit-code rake foo` is judged on -# `rake foo` rather than on the compound the CLI made of it. See the marker -# definition near the top for why dropping --exit-code is not an option. -# -# Exact literal, anchored to the end, removed at most once. A looser pattern is a -# shell escape: `rails c ; bash # heroku-command-exit-status` would be stripped -# back to `rails c` and permitted. Two markers leave one behind, which the -# compound check then rejects. -# -# If Heroku changes the marker this stops matching and CI is denied again -- -# noisy, but the safe direction to fail in. -if [[ "$_cg_cmd_read" == "true" ]]; then - _cg_candidate="${_CG_DYNO_CMD%"${_CG_DYNO_CMD##*[![:space:]]}"}" - if [[ "$_cg_candidate" == *"$_CG_EXIT_MARKER" ]]; then - _cg_candidate="${_cg_candidate%"$_CG_EXIT_MARKER"}" - _CG_DYNO_CMD="${_cg_candidate%"${_cg_candidate##*[![:space:]]}"}" - fi -fi - -# ---------- block compound statements and redirections ---------- -# The allowlist below matches argv[0] only, so without this an operator could -# append a second command -- eg `rails runner "1"; bash` -- and reach a shell. -# -# Redirections are rejected for the same reason the wrapper rejects a bare `-`: -# `rails c < /app/payload.rb` feeds a program in through stdin, so the command -# string names a file rather than the code that runs. -# -# Best effort: `rails runner 'system("bash")'` contains none of these and still -# shells out. -if [[ "$_cg_cmd_read" == "true" ]] && - [[ "$_CG_DYNO_CMD" == *';'* || - "$_CG_DYNO_CMD" == *'&'* || - "$_CG_DYNO_CMD" == *'|'* || - "$_CG_DYNO_CMD" == *'`'* || - "$_CG_DYNO_CMD" == *'$('* || - "$_CG_DYNO_CMD" == *'<'* || - "$_CG_DYNO_CMD" == *'>'* || - "$_CG_DYNO_CMD" == *$'\n'* ]]; then - _cg_deny "Compound statements and redirections are not permitted on one-off" \ - "dynos." \ - "" \ - "The command may not contain any of: ; & | \` \$( < > newline" \ - "" \ - "Command:" \ - " $(_cg_show "$_CG_DYNO_CMD")" \ - "" \ - "Run each command as its own \`heroku run\`." -fi - -# Take argv[0] only; the wrapper handles the rest. `read -ra` splits on IFS -# without performing pathname expansion, so nothing can be glob-expanded here. -# IFS is set explicitly because an earlier .profile.d script could have changed -# it. -IFS=$' \t\n' read -ra _cg_tokens <<< "$_CG_DYNO_CMD" -_cg_bin="${_cg_tokens[0]:-}" - -# ---------- command allowlist ---------- -# Only `rails`, `rake` and `bundle` are permitted, because those are the only -# paths that enter a Rails process where the console audit hook can observe what -# runs. Everything else -- -# bash, sh, zsh, irb, ruby, node, python, psql, pg_dump, pg_restore, pgcli, -# curl, wget, nc, ssh, scp, env, printenv, cat -- is blocked by falling through -# this allowlist. -# -# `bundle` is here because Heroku's Ruby buildpack rewrites `rake ` on a -# one-off dyno to `bundle exec rake ` before this script runs, so without -# it no rake task works at all. It is admitted only as far as the wrapper: the -# `bundle` wrapper permits `bundle exec rails|rake` and nothing else, so -# `bundle exec bash` still dies -- on `bash`, one layer later. -# -# The name must be unqualified. `bin/rails` and `/app/bin/rails` are rejected -# even though they are the same program, because naming a path bypasses the PATH -# lookup that reaches the command wrapper, and the wrapper is where argument -# policy is enforced. A leading `VAR=value` assignment is rejected for the same -# reason: `PATH=/app/bin rails c` would take the wrapper out of the picture. -if [[ "$_cg_cmd_read" == "true" ]]; then - case "$_cg_bin" in - rails|rake|bundle) : ;; *) - _cg_deny "This command is not permitted on one-off dynos." \ - "" \ - "Command:" \ - " $(_cg_show "$_CG_DYNO_CMD")" \ - "Rejected because its first word is:" \ - " $(_cg_show "$_cg_bin")" \ - "" \ - "Allowed:" \ - " rails " \ - " rake " \ - " bundle exec rails|rake " \ - "" \ - "The name must be unqualified -- \`rails\`, not \`bin/rails\` --" \ - "and may not be preceded by a VAR=value assignment." \ - "" \ - "Example:" \ - " ${_CG_USAGE}" + echo "console-guard: no Ruby interpreter found; the audit hook is not active" >&2 ;; esac -fi -# ---------- reach the command wrapper ---------- -# Prepended, so `rails` and `rake` resolve to the wrapper, which re-resolves the -# real binary from the rest of PATH. -export PATH="$_cg_shim_dir:$PATH" + unset _cg_ruby _cg_root + return 0 +fi -# `rails credentials:edit` and `rails encrypted:edit` spawn $EDITOR, which an -# operator can set with `-e`. The wrapper blocks those subcommands; this removes -# the mechanism as well. -unset EDITOR VISUAL +# `--disable=gems,rubyopt` closes RUBYOPT and RubyGems, both of which an +# operator can point at their own code. RUBYLIB is closed inside the gate, +# before it requires anything. +CONSOLE_GUARD_CMDLINE="/proc/$$/cmdline" \ + "$_cg_ruby" --disable=gems,rubyopt "$_cg_root/libexec/run_gate.rb" +_cg_status=$? -# ---------- dyno metadata check ---------- -# The dyno UUID is what correlates a console audit record with Heroku's own -# api:dyno webhook record for the same session, and the metadata file is what -# makes the dyno name above un-spoofable. Without dyno metadata neither is -# available. This is a configuration error on the app, not an operator mistake, -# so it warns rather than blocks. -if [[ "$_cg_metadata_seen" != "true" || -z "$_cg_dyno_id" ]]; then - { - echo "" - echo "WARNING: dyno metadata is not enabled on this app, so this session" - echo " cannot be correlated with Heroku's audit trail, and the gate" - echo " is relying on \$DYNO, which an operator can set." - echo " Enable it:" - echo " heroku labs:enable runtime-dyno-metadata -a ${HEROKU_APP_NAME:-app_name}" - echo "" - } >&2 -fi +case "$_cg_status" in + 0) + # Neither gated nor audited. + ;; + 10) + # Audited, not gated: record what the session does, but vet nothing and + # refuse nothing. + export CONSOLE_AUDIT_ENABLED=true + ;; + 20 | 21) + # Prepended, so `rails` and `rake` resolve to the wrapper, which re-resolves + # the real binary from the rest of PATH. + export PATH="$_cg_root/bin:$PATH" + + # `rails credentials:edit` and `rails encrypted:edit` spawn $EDITOR, which an + # operator can set with `-e`. The wrapper blocks those subcommands; this + # removes the mechanism as well. + unset EDITOR VISUAL + + # Exported after the gate: .profile.d runs after config vars and + # `heroku run -e` vars are applied, so this overrides any operator-supplied + # value. It is exported in dry-run mode too, so that phase 1 still produces + # console audit records. + export CONSOLE_AUDIT_ENABLED=true -# ---------- activate the console audit hook ---------- -# Exported after the gates above: .profile.d runs after config vars and -# `heroku run -e` vars are applied, so this overrides any operator-supplied -# value. It is also exported in permit mode, so that phase 1 still produces -# console audit records. -export CONSOLE_AUDIT_ENABLED=true + if [[ "$_cg_status" == 21 ]]; then + # Dry-run mode with no CONSOLE_USER. console1984 raises MissingUsername on + # an empty one, so the console would die anyway and dry-run mode would stop + # being a dry run. Deliberately not a plausible username: it has to be + # obvious in an audit record that nobody identified themselves. + export CONSOLE_USER="[not provided]" + fi + ;; + *) + # A denial. The gate has already printed the banner, recorded the denial and + # emitted the CLI's exit-status marker if one was called for. + unset _cg_ruby _cg_root _cg_status + exit 1 + ;; +esac # This script is sourced, so clean up after ourselves rather than leaking state # into the console session. -unset -f _cg_deny _cg_show -unset _cg_enforcing _CG_VERSION _cg_metadata_file _cg_shim_dir _cg_dyno_name \ - _cg_dyno_id _cg_metadata_seen _cg_gated _cg_audited _cg_user_check \ - _cg_reason_check _cg_missing _cg_missing_desc _cg_argv _cg_arg _cg_i \ - _cg_tokens _cg_bin _cg_cmd_read _CG_DYNO_CMD _CG_USAGE +unset _cg_ruby _cg_root _cg_status diff --git a/test/command_test.rb b/test/command_test.rb new file mode 100644 index 0000000..861cc11 --- /dev/null +++ b/test/command_test.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require_relative "helper" + +# ConsoleGuard::Command -- all argument policy. +# +# These drive the wrapper with an argv directly, which is what it gets from the +# shell after expansion. That the *shell* delivers the expanded argv here -- +# rather than the quoted, globbed or interpolated spelling the operator typed -- +# is the property test/run_tests.sh exists to pin, and is not re-checked in +# every case below. +class CommandTest < GuardTest + def test_permitted_commands_run + assert_ran wrapper("rails", "c"), "rails c" + assert_ran wrapper("rails", "console"), "rails console" + assert_ran wrapper("rake", "some_task:some_action"), "rake some_task:some_action" + assert_ran wrapper("rails", "runner", "Model.some_method"), "rails runner Model.some_method" + assert_ran wrapper("rails", "db:migrate"), "rails db:migrate" + assert_ran wrapper("rake", "db:migrate"), "rake db:migrate" + end + + def test_quoted_inline_code_survives + # The wrapper exists so that policy can be enforced without banning quotes: + # by here the shell has already removed them. + assert_ran wrapper("rails", "runner", "Model.some_method(1, 2)"), + "rails runner Model.some_method(1, 2)" + end + + def test_destructive_db_tasks_are_not_gated_here + # This guard is about making sure what runs is logged, not about preventing + # damage, and blocking these impedes on-call. They still reach an api:dyno + # webhook, and the app is the right place for a task-level guard. + assert_ran wrapper("rake", "db:drop"), "rake db:drop" + assert_ran wrapper("rails", "db:migrate:reset"), "rails db:migrate:reset" + assert_ran wrapper("rake", "db:rollback", "STEP=99"), "rake db:rollback STEP=99" + assert_ran wrapper("rake", "db:seed") + end + + # ---------------------------------------------------------------- bundle exec + + # Heroku rewrites `rake ` on a one-off dyno to `bundle exec rake ` + # before the login shell runs, so `bundle` must be permitted or no rake task + # works at all. `bundle exec` also unshifts Bundler's bin directory onto PATH, + # which puts the real rails/rake ahead of the wrapper -- so the `bundle` + # wrapper is the only place the argument rules can be applied. + def test_bundle_exec_is_permitted + assert_ran wrapper("bundle", "exec", "rake", "db:migrate"), "bundle exec rake db:migrate" + assert_ran wrapper("bundle", "exec", "rails", "c"), "bundle exec rails c" + assert_ran wrapper("bundle", "exec", "rails", "runner", "Model.some_method") + end + + def test_bundle_does_not_admit_what_it_can_wrap + assert_denied wrapper("bundle", "exec", "bash"), "not permitted" + assert_denied wrapper("bundle", "exec", "sh", "-c", "id"), "not permitted" + assert_denied wrapper("bundle", "exec", "irb"), "not permitted" + assert_denied wrapper("bundle", "exec", "bin/rails", "c"), "unqualified" + end + + def test_bundle_without_exec_is_refused + assert_denied wrapper("bundle", "install"), "Ruby buildpack produces" + assert_denied wrapper("bundle"), "Ruby buildpack produces" + assert_denied wrapper("bundle", "exec"), "may be run under" + end + + def test_every_rule_applies_through_bundle_too + # This is now the only wrapper the command reaches, so a rule that is not + # duplicated here is not enforced at all. + assert_denied wrapper("bundle", "exec", "rails", "dbconsole"), "raw database session" + assert_denied wrapper("bundle", "exec", "rails", "credentials:edit"), "editor" + assert_denied wrapper("bundle", "exec", "rails", "runner", "-"), "stdin" + assert_denied wrapper("bundle", "exec", "rails", "runner", "script.rb"), "exists on disk" + assert_denied wrapper("bundle", "exec", "rake", "-c"), "flag is not permitted" + assert_denied wrapper("bundle", "exec", "rake", "-e", "1"), "allowlisted" + assert_denied wrapper("bundle", "exec", "rails", "c", "--sandbox"), "unlogged" + end + + # ---------------------------------------------------------------- deny list + + def test_raw_database_sessions_are_refused + # Drops to a raw psql session; no statement is seen by the Rails console hook. + assert_denied wrapper("rails", "dbconsole"), "raw database session" + assert_denied wrapper("rails", "db"), "raw database session" + end + + def test_editor_escapes_are_refused + # $EDITOR is operator-controlled, so these are a shell escape. + assert_denied wrapper("rails", "credentials:edit"), "editor" + assert_denied wrapper("rails", "credentials:show"), "editor" + assert_denied wrapper("rails", "encrypted:edit", "config/x"), "editor" + end + + def test_a_bare_dash_is_refused_in_any_position + # `rails runner -` reads the program from stdin, so the executed code + # appears neither in the dyno command string nor in an ARGV capture. + assert_denied wrapper("rails", "runner", "-"), "stdin" + assert_denied wrapper("rake", "some:task", "-"), "stdin" + end + + def test_dash_c_is_refused_in_any_position + # `-c` reaches a shell. `rails c` is unaffected: that argument is `c`. + assert_denied wrapper("rails", "-c", "foo"), "flag is not permitted" + assert_denied wrapper("rake", "some:task", "-c"), "flag is not permitted" + assert_ran wrapper("rails", "c") + end + + # ---------------------------------------------------------------- sandbox + + # A sandboxed console rolls back its transaction on exit, and a + # database-backed ActiveJob queue on the primary database puts the audit + # enqueue inside it -- so the rollback discards the audit trail and the + # session runs entirely unlogged. + def test_sandboxed_consoles_are_refused + ["--sandbox", "-s"].each do |flag| + ["console", "c"].each do |subcommand| + assert_denied wrapper("rails", subcommand, flag), "unlogged" + end + end + end + + def test_the_equals_spellings_are_refused_whatever_the_value + # Thor takes `--flag=value` for a boolean, so these have to be denied by this + # rule and not merely by the option allowlist -- otherwise adding a + # sandbox-ish entry to the console's allowlist reopens the bypass with the + # whole suite green. Deciding which values Thor reads as true is modelling + # the parser, so `false` is denied too. + ["--sandbox=true", "--sandbox=1", "--sandbox=false", "-s=true"].each do |flag| + assert_denied wrapper("rails", "c", flag), "unlogged" + end + end + + def test_the_sandbox_flag_is_refused_inside_a_bundle + # Thor splits a run of short flags into one option per letter, so `rails c + # -es` is `-e -s` and the sandbox flag rides in behind an allowlisted `-e`. + # console1984 still refuses the session; the buildpack has to as well, or + # the operator gets a Rails error instead of the guard's banner. + ["-es", "-se", "-esw"].each do |flag| + assert_denied wrapper("rails", "c", flag), "unlogged" + end + assert_denied wrapper("bundle", "exec", "rails", "console", "-es"), "unlogged" + end + + def test_the_sandbox_denial_names_the_spelling_that_works + assert_denied wrapper("rails", "c", "--sandbox=true"), "`--no-sandbox` is permitted" + end + + def test_the_sandbox_rule_is_scoped_to_the_console + # -s is rake's silent flag, and --no-sandbox is the safe direction. Neither + # is collateral damage. + assert_ran wrapper("rake", "-s", "some:task") + assert_ran wrapper("rails", "c", "--no-sandbox") + end + + # ---------------------------------------------------------------- runner file + + def test_runner_may_not_read_its_program_from_a_file + assert_denied wrapper("rails", "runner", "--file=/app/script.rb"), "from a file" + assert_denied wrapper("rails", "runner", "--file", "script.rb"), "from a file" + end + + def test_a_runner_argument_that_exists_on_disk_is_refused + # The same decision Rails itself makes, so there is no heuristic on how the + # argument looks. + assert_denied wrapper("rails", "runner", "script.rb"), "exists on disk" + assert_denied wrapper("rails", "runner", "./script.rb"), "exists on disk" + assert_denied wrapper("rails", "r", "payload.rb"), "exists on disk" + end + + def test_a_device_or_fd_path_is_refused_too + # Rails checks File.exist? and then Kernel.load, and loading /dev/stdin + # reads the program from stdin -- the same gap the bare `-` rule closes. + # Neither of these is a regular file, so File.file? would let them through. + assert_denied wrapper("rails", "runner", "/dev/stdin"), "exists on disk" + assert_denied wrapper("rails", "runner", "/dev/fd/0"), "exists on disk" + end + + def test_inline_code_that_merely_looks_like_a_path_is_permitted + assert_ran wrapper("rails", "runner", "Model.where(x: 1).rb") + assert_ran wrapper("rails", "runner", "no_such_file.rb") + end + + # ---------------------------------------------------------------- options + + # `rake -e/-p/-E CODE` evaluates CODE inside Rake's own option parser, before + # the Rakefile is loaded and without booting Rails, and then exits. Nothing it + # does reaches the console audit hook. + def test_rake_code_evaluating_options_are_refused + [["-e", "1"], ["--execute", "1"], ["-p", "1+1"], ["-E", "1"], + ["--execute-print", "1"], ["--execute-continue", "1"], ["--execute=1"]].each do |args| + assert_denied wrapper("rake", *args), "allowlisted" + end + end + + def test_short_options_are_matched_whole + # Rake bundles short options, so a deny list would have to model which of + # them take an argument in order to know where `-Ne` stops being flags. The + # allowlist matches whole tokens instead, which refuses `-se` without + # reasoning about bundling at all. + assert_denied wrapper("rake", "-Ne", "1"), "allowlisted" + assert_denied wrapper("rake", "-se", "1"), "allowlisted" + assert_denied wrapper("rake", "-qsNe", "1"), "allowlisted" + # ...which also means a bundle of two permitted flags is refused. Cheap. + assert_denied wrapper("rake", "-sq", "some:task"), "matched whole" + end + + def test_abbreviated_long_forms_are_refused + # Rake accepts these; the allowlist does not. + assert_denied wrapper("rake", "--exec", "1"), "allowlisted" + assert_denied wrapper("rake", "--ex", "1"), "allowlisted" + assert_denied wrapper("rake", "--task"), "allowlisted" + end + + def test_options_that_name_a_path_are_refused + [["-f", "Rakefile", "some:task"], ["-r", "./payload", "some:task"], ["-I", "/app", "some:task"], + ["-R", "/app", "some:task"], ["-C", "/app", "some:task"], ["--require", "./payload"], + ["--rakefile", "Rakefile"]].each do |args| + assert_denied wrapper("rake", *args), "allowlisted" + end + end + + def test_options_that_change_which_rakefile_is_found_are_refused + # `--system` loads tasks from $HOME/.rake, and $HOME is /app on a dyno. The + # allowlist refuses these without anyone having had to think of them. + [["-g", "some:task"], ["-G", "some:task"], ["-N", "some:task"], ["--system", "some:task"], + ["--suppress-backtrace", "x"], ["--no-such-option"]].each do |args| + assert_denied wrapper("rake", *args), "allowlisted" + end + end + + def test_rails_reaches_rakes_parser_too + # Rails hands a command it does not recognise to that same parser, argv and + # all, so the same options arrive by way of `rails`. + assert_denied wrapper("rails", "-e", "1"), "allowlisted" + assert_denied wrapper("rails", "db:migrate", "-e", "1"), "allowlisted" + end + + def test_the_option_denial_is_self_service + assert_denied wrapper("rake", "--no-such-option"), "--tasks" + assert_denied wrapper("rake", "--no-such-option"), "`rake --no-such-option` is not permitted" + assert_denied wrapper("rails", "db:migrate", "--no-such-option"), + "Permitted after `rails db:migrate`" + end + + def test_the_permitted_rake_options_still_work + [["-T"], ["-T", "db"], ["-Tdb"], ["--tasks=db"], ["-D", "db"], ["-W", "some:task"], ["-P"], + ["-s", "some:task"], ["-q", "some:task"], ["-n", "some:task"], ["-t", "some:task"], + ["-v", "some:task"], ["-V"], ["-A", "-T"], ["-B", "some:task"], ["-m", "some:task"], + ["-j", "4", "some:task"], ["-j4", "some:task"], ["-X", "some:task"], ["--trace", "some:task"], + ["--trace=stderr", "some:task"], ["--backtrace", "some:task"], ["--dry-run", "some:task"], + ["--all", "--tasks"], ["--comments", "--tasks"], ["--rules"], ["--job-stats", "some:task"], + ["--silent", "some:task"], ["--version"]].each do |args| + assert_ran wrapper("rake", *args) + end + end + + def test_task_names_and_assignments_are_not_options + assert_ran wrapper("rake", "db:rollback", "STEP=99"), "rake db:rollback STEP=99" + assert_ran wrapper("rake", "some:task[a,b]") + assert_ran wrapper("rake", "-s", "db:migrate", "STEP=1") + end + + # The two Rails commands parse their own options and never reach Rake, so `-e` + # there is the environment -- but they get a list of their own rather than + # being waved through, because a mistake in a list is a denial while a mistake + # in an exemption is a silent bypass. + def test_the_commands_rails_parses_itself_get_their_own_list + assert_ran wrapper("rails", "runner", "-e", "production", "Model.foo") + assert_ran wrapper("rails", "runner", "--environment", "production", "Model.foo") + assert_ran wrapper("rails", "runner", "-w", "Model.foo") + assert_ran wrapper("rails", "c", "-e", "production") + assert_ran wrapper("rails", "console", "--environment", "production") + assert_ran wrapper("bundle", "exec", "rails", "c", "-e", "production") + end + + def test_the_rails_commands_take_no_attached_short_value + # Thor has no attached short values: `-eproduction` is a bundle of letters, + # not `-e production`. Reading one as a value is what let `-es` through. + assert_denied wrapper("rails", "c", "-eproduction"), "matched whole" + assert_denied wrapper("rails", "runner", "-ew", "Model.foo"), "matched whole" + # Rake's parser does take them, and that is not collateral damage: `-Tdb` + # and `-j4` are pinned above. + end + + def test_options_neither_rails_command_takes_are_refused + assert_denied wrapper("rails", "c", "--no-such-option"), "allowlisted" + assert_denied wrapper("rails", "runner", "-f", "Model.foo"), "allowlisted" + assert_denied wrapper("rails", "c", "-w"), "allowlisted" + # `-s` reaches neither parser as anything useful. The rule that matters -- + # the sandbox denial is scoped to the console -- is pinned above. + assert_denied wrapper("rails", "runner", "-s", "Model.foo"), "allowlisted" + end + + # ---------------------------------------------------------------- enforcement + + def test_enforcement_defaults_to_blocking + # Only the exact string `false` opts into dry-run mode, so a typo or an empty + # value fails closed. + ["0", "", "False", "true"].each do |value| + assert_denied wrapper("rails", "dbconsole", env: {"CONSOLE_BLOCK_ENFORCE" => value}) + end + end + + def test_permit_mode_warns_and_runs + result = wrapper("rails", "dbconsole", env: {"CONSOLE_BLOCK_ENFORCE" => "false"}) + assert_ran result + assert_includes result.output, "WILL BE BLOCKED" + end + + def test_the_resolved_path_is_never_handed_to_a_shell + # Ruby's `exec` picks its own dispatch: a single string is scanned for shell + # metacharacters and falls back to `/bin/sh -c`. A permitted command with no + # arguments is exactly that single-string case, and the path is assembled + # from the operator-settable PATH -- so the array form in Command#run is + # what stops a directory name from becoming a shell escape. + result = wrapper("rails", env: {"PATH" => ConsoleGuardTest::HOSTILE_BIN}) + + assert_ran result, "rails" + refute_includes result.output, "PWNED" + end + + def test_denials_name_the_guard_version + # So an operator's report identifies the deployed guard exactly. + assert_denied wrapper("rails", "dbconsole"), "console-guard " + end +end diff --git a/test/gate_test.rb b/test/gate_test.rb new file mode 100644 index 0000000..f6451ca --- /dev/null +++ b/test/gate_test.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require_relative "helper" + +# ConsoleGuard::Gate -- the profile half. +# +# The gate reports back through its exit status, because that is the only +# channel a child process has to a parent that must then modify its own +# environment. What the login shell *does* with each status is +# test/run_tests.sh's subject; what produces each status is this one's. +class GateTest < GuardTest + NOT_APPLICABLE = 0 + DENIED = 1 + AUDITED = 10 + GATED = 20 + GATED_ANONYMOUS = 21 + + def test_a_permitted_command_gates_the_dyno + assert_equal GATED, gate("rails c").status + assert_equal GATED, gate("rake db:migrate").status + assert_equal GATED, gate("bundle exec rake db:migrate").status + end + + # ---------------------------------------------------------------- identity + + def test_both_variables_are_required + assert_denied gate("rails c", env: {"CONSOLE_USER" => ""}), "CONSOLE_USER is not set" + assert_denied gate("rails c", env: {"CONSOLE_REASON" => ""}), "CONSOLE_REASON is not set" + end + + def test_a_whitespace_only_value_counts_as_unset + assert_denied gate("rails c", env: {"CONSOLE_USER" => " "}), "CONSOLE_USER is not set" + assert_denied gate("rails c", env: {"CONSOLE_REASON" => "\t "}), "CONSOLE_REASON is not set" + end + + def test_the_denial_names_only_the_missing_variable + # "both are required" sends an operator checking the variable that was + # already fine, and the usual cause -- a failed `heroku whoami` substituting + # an empty string -- looks like neither was set. + result = gate("rails c", env: {"CONSOLE_USER" => ""}) + refute_includes result.output, "CONSOLE_REASON is not set" + + both = gate("rails c", env: {"CONSOLE_USER" => "", "CONSOLE_REASON" => ""}) + assert_includes both.output, "CONSOLE_USER and CONSOLE_REASON are not set" + end + + # ---------------------------------------------------------------- allowlist + + def test_only_rails_rake_and_bundle_are_permitted + ["bash", "sh", "irb", "psql", "printenv"].each do |command| + assert_denied gate(command), "not permitted" + end + assert_denied gate("ruby -e 1"), "not permitted" + assert_denied gate("curl https://example.com"), "not permitted" + end + + def test_the_name_must_be_unqualified + # Naming a path skips the PATH lookup that reaches the command wrapper, and + # the wrapper is where argument policy is enforced. + ["bin/rails c", "./bin/rails c", "/app/bin/rails c"].each do |command| + assert_denied gate(command), "must be unqualified" + end + end + + def test_a_leading_assignment_is_refused + # `PATH=/app/bin rails c` would take the wrapper out of the picture. + assert_denied gate("FOO=1 rails c"), "must be unqualified" + assert_denied gate("PATH=/usr/bin rails c"), "must be unqualified" + end + + def test_the_denial_echoes_what_was_parsed + # An operator's screenshot is then enough to tell whether the gate objected + # to the command that was typed or to something else. + assert_denied gate("/app/bin/rails c"), "/app/bin/rails c" + assert_denied gate("bundle exec rails c; bash"), "bundle exec rails c; bash" + end + + # ---------------------------------------------------------------- compounds + + def test_compound_statements_and_redirections_are_refused + ['rails runner "1"; bash', "rails c && bash", "rails c | tee /tmp/x", + "rails runner `whoami`", "rails runner $(whoami)", + "rails runner Model.foo\nbash"].each do |command| + assert_denied gate(command), "Compound" + end + end + + def test_redirections_are_refused + # Same reason the wrapper rejects a bare `-`: the command string names a + # file rather than the code that runs. + ["rails c < /app/script.rb", 'rake some:task <<< "x"', + "rails runner Model.foo > /tmp/o", "rails runner Model.foo 2>/tmp/o"].each do |command| + assert_denied gate(command), "redirections" + end + end + + # ---------------------------------------------------------------- exit marker + + # `heroku run --exit-code` appends this and reads the line it produces off + # stdout. Without special handling every CI caller is denied as a compound + # statement, and every denial exits 0 because the appended echo never runs. + SENTINEL = "\uFFFF" + MARKER = %(; echo "#{SENTINEL} heroku-command-exit-status: $?") + + def test_the_marker_is_stripped_before_the_command_is_vetted + assert_equal GATED, gate("rake db:version#{MARKER}").status + assert_equal GATED, gate("rails runner 1#{MARKER}").status + assert_equal GATED, gate("bundle exec rake db:version#{MARKER}").status + end + + def test_what_precedes_the_marker_is_still_vetted_in_full + assert_denied gate("psql#{MARKER}"), "not permitted" + assert_denied gate("rails c ; bash#{MARKER}"), "Compound" + end + + def test_the_marker_is_stripped_at_most_once + assert_denied gate("rails c#{MARKER}#{MARKER}"), "Compound" + end + + def test_the_match_is_an_exact_literal + # A loose rule such as s/;.*exit-status.*$// strips these back to `rails c` + # and lets a shell out. + assert_denied gate("rails c ; bash # heroku-command-exit-status"), "Compound" + assert_denied gate('rails c ; bash ; echo "heroku-command-exit-status: $?"'), "Compound" + end + + def test_a_denial_emits_a_failing_marker_when_exit_code_was_used + # On stdout, because that is the stream the CLI parses. Without this a + # denied CI job exits 0 and the pipeline goes green. + result = gate("psql#{MARKER}") + assert_includes result.stdout, "#{SENTINEL} heroku-command-exit-status: 1".b + end + + def test_the_identity_gate_emits_it_too + # The denial CI is likeliest to hit. + result = gate("rake db:version#{MARKER}", env: {"CONSOLE_USER" => ""}) + assert_includes result.stdout, "#{SENTINEL} heroku-command-exit-status: 1".b + end + + def test_a_spoofed_dyno_name_emits_it_too + # It refuses like any other denial, so it has to report like one: a refusal + # that skips the marker exits 0 and the pipeline goes green. + result = gate("rake db:version#{MARKER}", env: {"DYNO" => "web.1"}) + assert_includes result.stdout, "#{SENTINEL} heroku-command-exit-status: 1".b + end + + def test_no_marker_is_invented_for_a_caller_that_never_asked + refute_includes gate("psql").stdout, "heroku-command-exit-status" + end + + # ---------------------------------------------------------------- unreadable + + def test_a_login_shell_with_no_dash_c_payload_is_refused + # Not the allowlist denial: there is no command string here, and reporting + # one invented from the whole argv sends the operator hunting for a command + # they never typed. + result = gate(argv: ["bash", "-l"]) + assert_denied result, "Could not determine the dyno command" + assert_includes result.output, "Login shell argv:" + refute_includes result.output, "not permitted on one-off dynos" + end + + def test_an_empty_argv_is_refused + assert_denied gate(argv: []), "Could not read the dyno command" + end + + def test_combined_short_forms_are_understood + # `bash -lc ` means the same thing to the shell as `bash -c`. + assert_equal GATED, gate(argv: ["bash", "-lc", "rails c"]).status + assert_denied gate(argv: ["bash", "-lc", "psql"]), "not permitted" + end + + # ---------------------------------------------------------------- wrapper + + def test_a_missing_wrapper_refuses_the_session + # All argument policy lives in the wrapper. Without it the gate cannot + # enforce anything meaningful, so it refuses rather than running half a gate. + ["rails", "bundle"].each do |name| + path = File.join(ConsoleGuardTest::APP, ".console-guard/bin", name) + File.rename(path, "#{path}.bak") + begin + assert_denied gate("rails c"), "command wrapper is missing" + ensure + File.rename("#{path}.bak", path) + end + end + + assert_equal GATED, gate("rails c").status + end + + # ---------------------------------------------------------------- dyno family + + def test_scheduler_and_release_dynos_are_audited_but_not_gated + # There is no interactive operator to supply a user or a reason, and the + # command comes from app configuration. + write_metadata("dyno" => {"id" => "x", "name" => "scheduler.9"}) + assert_equal AUDITED, gate("psql", env: {"DYNO" => "scheduler.9"}).status + + write_metadata("dyno" => {"id" => "x", "name" => "release.9"}) + assert_equal AUDITED, gate("psql", env: {"DYNO" => "release.9"}).status + end + + def test_long_running_dynos_are_left_alone + ["web.1", "worker.1", "clock.1"].each do |name| + write_metadata("dyno" => {"id" => "x", "name" => name}) + assert_equal NOT_APPLICABLE, gate("psql", env: {"DYNO" => name}).status + end + end + + def test_an_absent_dyno_name_is_treated_as_a_one_off_dyno + # Rather than letting a command through ungated. + write_metadata("not json at all") + assert_denied gate("bash", env: {"DYNO" => ""}), "not permitted" + end + + # ---------------------------------------------------------------- metadata + + def test_dyno_metadata_beats_a_spoofed_dyno_variable + # `heroku run -e DYNO=web.1` would otherwise let an operator skip the gate. + # The metadata file is written inside the dyno and `-e` cannot touch it. + assert_denied gate("bash", env: {"DYNO" => "web.1"}), "does not match this dyno" + assert_denied gate("rails c", env: {"DYNO" => "web.1"}), "does not match this dyno" + assert_denied gate("bash", env: {"DYNO" => "run.9999"}), "does not match this dyno" + end + + def test_a_spoofed_dyno_name_is_fatal_in_permit_mode_too + # Not a command-policy decision an operator can be warned about; it is an + # attempt to change which dyno the guard believes it is running on. + assert_denied gate("bash", env: {"DYNO" => "web.1", "CONSOLE_BLOCK_ENFORCE" => "false"}), + "does not match this dyno" + end + + def test_the_dyno_object_is_addressed_rather_than_searched + # The real file carries three objects, each with its own name and id, and + # app.name is empty. A greedy parser reads that instead of dyno.name and + # silently falls back to trusting $DYNO. + write_metadata('{"dyno":{"id":"de7c25da-uuid","name":"run.1234"},' \ + '"app":{"id":"0d276459-uuid","name":""},' \ + '"release":{"id":117,"commit":"9eb6f0d7"}}') + assert_equal GATED, gate("rails c").status + assert_denied gate("rails c", env: {"DYNO" => "web.1"}), "does not match this dyno" + end + + def test_unparseable_metadata_degrades_to_the_dyno_fallback + write_metadata("not json at all") + assert_equal GATED, gate("rails c").status + assert_includes gate("rails c").output, "dyno metadata is not enabled" + end + + def test_no_warning_when_metadata_is_present + refute_includes gate("rails c").output, "dyno metadata is not enabled" + end + + # ---------------------------------------------------------------- dry-run mode + + def test_permit_mode_warns_and_permits + result = gate("bash", env: {"CONSOLE_BLOCK_ENFORCE" => "false"}) + assert_equal GATED, result.status + assert_includes result.output, "WILL BE BLOCKED" + end + + def test_permit_mode_asks_for_a_placeholder_operator + # console1984 raises MissingUsername on an empty CONSOLE_USER, so leaving it + # empty kills the console even in dry-run mode -- exactly the breakage permit + # mode exists to avoid. The distinct status is how the login shell is told. + result = gate("rails c", env: {"CONSOLE_USER" => "", "CONSOLE_BLOCK_ENFORCE" => "false"}) + assert_equal GATED_ANONYMOUS, result.status + + whitespace = gate("rails c", env: {"CONSOLE_USER" => " ", "CONSOLE_BLOCK_ENFORCE" => "false"}) + assert_equal GATED_ANONYMOUS, whitespace.status + end + + def test_a_supplied_identity_needs_no_placeholder + assert_equal GATED, gate("rails c", env: {"CONSOLE_BLOCK_ENFORCE" => "false"}).status + end + + def test_enforcement_defaults_to_blocking + ["0", "", "False", "true"].each do |value| + assert_denied gate("bash", env: {"CONSOLE_BLOCK_ENFORCE" => value}) + end + end +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..87a3053 --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +# Shared setup for the guard's Ruby test suites. +# +# The policy is compiled once, by bin/compile, into a temporary slug -- so these +# suites test the same rendered files a dyno gets, placeholders substituted, and +# not the templates in guard/. +# +# The two entry points are then driven as subprocesses. That is deliberate: both +# halves refuse by calling `exit`, and the wrapper ends in `exec`, so testing +# them in process would mean adding a seam to the guard that exists only for the +# tests -- and a seam is exactly where a bypass hides. A subprocess is what a +# dyno runs. +# +# What the shell does around them -- sourcing .profile.d, expanding the command, +# prepending PATH, finding the wrapper -- is not covered here. That is +# test/run_tests.sh, which needs a Linux login shell for it. + +require "minitest/autorun" +require "English" +require "fileutils" +require "json" +require "rbconfig" +require "tmpdir" + +module ConsoleGuardTest + ROOT = File.expand_path("..", __dir__) + TMP = Dir.mktmpdir("console-guard-test") + + APP = File.join(TMP, "app") + METADATA = File.join(TMP, "dyno-metadata.json") + FAKE_BIN = File.join(TMP, "fakebin") + # The same fakes in a directory whose name is a shell metacharacter, for the + # case where the resolved binary's path must not reach a shell. + HOSTILE_BIN = File.join(TMP, "fake;echo PWNED") + # Two files a test can name where one has to exist on disk, for the `rails + # runner ` rule -- which turns on exactly that, as Rails' own does. + ON_DISK = ["script.rb", "payload.rb"].freeze + + RUBY = RbConfig.ruby + GATE = File.join(APP, ".console-guard/libexec/run_gate.rb") + WRAPPER = File.join(APP, ".console-guard/libexec/run_command.rb") + + RECORD_LOG = File.join(TMP, "records.log") + RECORD_PORT_FILE = File.join(TMP, "recorder.port") + RECORD_PATH = "/webhooks/console_audit" + # In the URL as it is in the real config var, so a test can prove it never + # reaches the operator. + RECORD_CRED = "s3cr3t-not-for-operators" + + # The environment a gated `heroku run` arrives with. Individual tests override + # what they are about and inherit the rest, so a case reads as its own subject. + BASE_ENV = { + "HOME" => APP, + "DYNO" => "run.1234", + "CONSOLE_USER" => "becky", + "CONSOLE_REASON" => "testing", + "PATH" => "#{FAKE_BIN}:/usr/local/bin:/usr/bin:/bin" + }.freeze + + Result = Struct.new(:status, :stdout, :stderr) do + def output + "#{stdout}#{stderr}" + end + + def denied? + status == 1 + end + + def ran? + stdout.include?("RAN ") + end + end + + class << self + def setup! + build! + fake_binaries! + start_recorder! + end + + def build! + env_dir = File.join(TMP, "env") + FileUtils.mkdir_p([APP, env_dir, File.join(TMP, "cache")]) + File.write(File.join(env_dir, "CONSOLE_GUARD_DYNO_METADATA_FILE"), METADATA) + + log = IO.popen([File.join(ROOT, "bin/compile"), APP, File.join(TMP, "cache"), env_dir], + err: [:child, :out], &:read) + raise "bin/compile failed:\n#{log}" unless $CHILD_STATUS.success? + + ON_DISK.each { |name| FileUtils.touch(File.join(APP, name)) } + end + + # Stand in for the real rails/rake/bundle the wrapper execs into, so a test + # can tell "blocked" from "ran, with exactly these arguments". + def fake_binaries! + [FAKE_BIN, HOSTILE_BIN].each do |dir| + FileUtils.mkdir_p(dir) + ["rails", "rake", "bundle"].each do |name| + path = File.join(dir, name) + File.write(path, "#!/bin/sh\necho \"RAN #{name} $*\"\n") + File.chmod(0o755, path) + end + end + end + + def start_recorder! + File.write(RECORD_LOG, "") + @recorder = spawn(RUBY, File.join(ROOT, "test/lib/recorder.rb"), + RECORD_LOG, RECORD_PORT_FILE) + + 40.times do + break if File.size?(RECORD_PORT_FILE) + + sleep 0.05 + end + raise "the denial recorder did not start" unless File.size?(RECORD_PORT_FILE) + + @port = File.read(RECORD_PORT_FILE).strip + end + + attr_reader :port + + def report_url(path = RECORD_PATH) + "http://reporter:#{RECORD_CRED}@127.0.0.1:#{@port}#{path}" + end + + def teardown! + Process.kill("TERM", @recorder) if @recorder + FileUtils.remove_entry(TMP) + rescue StandardError + nil + end + end +end + +ConsoleGuardTest.setup! +Minitest.after_run { ConsoleGuardTest.teardown! } + +# Base class for both entry-point suites. +class GuardTest < Minitest::Test + include ConsoleGuardTest + + def setup + # Present unless a test says otherwise, so the metadata-absent path is never + # reached by accident and read as a pass. + write_metadata("dyno" => {"id" => "dyno-uuid-1", "name" => "run.1234"}, + "app" => {"id" => "aid", "name" => ""}, + "release" => {"id" => 117}) + File.write(ConsoleGuardTest::RECORD_LOG, "") + end + + def write_metadata(value) + File.write(ConsoleGuardTest::METADATA, value.is_a?(String) ? value : JSON.generate(value)) + end + + # Run the profile half against a fabricated login-shell argv. + # + # `command` is the dyno command as Heroku would pass it, i.e. the payload of + # `bash -c`. Pass `argv:` instead to control the whole login-shell argv, which + # is what the "the gate cannot read this" cases are about. + def gate(command = nil, env: {}, argv: nil) + argv ||= ["bash", "-c", command] + cmdline = File.join(ConsoleGuardTest::TMP, "cmdline.bin") + File.binwrite(cmdline, argv.map { |a| "#{a}\0" }.join) + + run_guard(ConsoleGuardTest::GATE, [], env.merge("CONSOLE_GUARD_CMDLINE" => cmdline)) + end + + # Run the command-wrapper half. `program` is the name it was invoked under, + # which is what distinguishes rails from rake from bundle. + def wrapper(program, *args, env: {}) + run_guard(ConsoleGuardTest::WRAPPER, [program, *args], env) + end + + def run_guard(entry_point, args, env) + out_path = File.join(ConsoleGuardTest::TMP, "stdout") + err_path = File.join(ConsoleGuardTest::TMP, "stderr") + + child_env = ConsoleGuardTest::BASE_ENV.merge(env) + # nil means "unset this", which is a different thing from empty and is the + # difference several cases turn on. + child_env.each_key { |key| child_env[key] = nil if child_env[key].nil? } + + pid = spawn(child_env, + # The flags the shell stubs pass, so the tests run the + # interpreter the way a dyno does. + ConsoleGuardTest::RUBY, "--disable=gems,rubyopt", entry_point, *args, + unsetenv_others: true, chdir: ConsoleGuardTest::APP, + out: out_path, err: err_path) + Process.wait(pid) + + # Read as bytes. A dyno runs in the C locale, so Encoding.default_external + # is US-ASCII there and File.read would hand back an invalidly-tagged string + # the moment the guard writes the exit-status sentinel or scrubs a byte. + Result.new($CHILD_STATUS.exitstatus, File.binread(out_path), File.binread(err_path)) + end + + # ---------------------------------------------------------------- assertions + + def assert_denied(result, expected = nil, message = nil) + refute result.ran?, "expected a denial, but the command ran:\n#{result.output}" + assert_equal 1, result.status, + "expected exit 1, got #{result.status}:\n#{result.output}" + return unless expected + + assert_includes result.output, expected.b, message + end + + def assert_permitted(result) + refute_equal 1, result.status, "expected the command to be permitted:\n#{result.output}" + end + + # The wrapper ends in `exec`, so "permitted" is observable as the fake binary + # reporting the argv it received. + def assert_ran(result, expected_argv = nil) + assert result.ran?, "expected the command to run:\n#{result.output}" + return unless expected_argv + + assert_includes result.stdout, "RAN #{expected_argv}".b + end + + # ---------------------------------------------------------------- records + + def reporting_env(path = ConsoleGuardTest::RECORD_PATH) + {"CONSOLE_LOGGING_DATADOG_PROXY_URL" => ConsoleGuardTest.report_url(path)} + end + + # The denial records POSTed since this test started, as parsed JSON. + def records + File.binread(ConsoleGuardTest::RECORD_LOG) + .lines + .filter_map { |line| JSON.parse(line.delete_prefix("BODY ")) if line.start_with?("BODY ") } + end + + def raw_records + File.binread(ConsoleGuardTest::RECORD_LOG) + end + + def assert_one_record + found = records + assert_equal 1, found.length, "expected exactly one denial record, got: #{found.inspect}" + found.first + end +end diff --git a/test/lib/harness.sh b/test/lib/harness.sh index a42b478..a1a7c67 100644 --- a/test/lib/harness.sh +++ b/test/lib/harness.sh @@ -11,9 +11,11 @@ # the profile script parses # * a fake `rails` and `rake` sit on PATH and report what argv they received, # so a test can tell "blocked" from "ran, with these arguments" +# * a recorder listening on loopback stands in for datadog-proxy, so denial +# records can be asserted on without a network # # Everything the guard decides is therefore exercised end to end: bin/compile, -# the profile script, and the command wrapper. +# the profile script, the command wrapper and the Ruby policy behind both. set -uo pipefail @@ -28,15 +30,47 @@ if [[ ! -r /proc/$$/cmdline ]]; then exit 1 fi +# The guard's policy is Ruby, and bin/compile refuses to install without an +# interpreter. Fail here rather than as a hundred build failures. +if ! command -v ruby > /dev/null 2>&1; then + echo "FATAL: this suite needs a ruby on PATH (the guard's policy is Ruby)." >&2 + exit 1 +fi + CG_TMP_ROOT="$(mktemp -d)" CG_TESTS_RUN=0 CG_TESTS_FAILED=0 CG_CURRENT_ENV="" CG_STICKY_ENV="" -cg_cleanup() { rm -rf "$CG_TMP_ROOT"; } +cg_cleanup() { + [[ -n "${CG_RECORDER_PID:-}" ]] && kill "$CG_RECORDER_PID" 2> /dev/null + rm -rf "$CG_TMP_ROOT" +} trap cg_cleanup EXIT +# ---------------------------------------------------------------- recorder + +# Stands in for datadog-proxy. Started once for the suite and shared by every +# build, because the guard reaches it over loopback rather than through a fake +# binary on PATH -- the reporter is Net::HTTP now, not curl. +CG_RECORD_LOG="$CG_TMP_ROOT/records.log" +: > "$CG_RECORD_LOG" +ruby "$CG_ROOT/test/lib/recorder.rb" "$CG_RECORD_LOG" "$CG_TMP_ROOT/recorder.port" & +CG_RECORDER_PID=$! + +for _cg_wait in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + [[ -s "$CG_TMP_ROOT/recorder.port" ]] && break + sleep 0.1 +done +unset _cg_wait + +if [[ ! -s "$CG_TMP_ROOT/recorder.port" ]]; then + echo "FATAL: the denial recorder did not start, so no record could be asserted." >&2 + exit 1 +fi +CG_RECORDER_PORT="$(cat "$CG_TMP_ROOT/recorder.port")" + # ---------------------------------------------------------------- build # cg_build [CONFIG_VAR=value ...] @@ -246,6 +280,43 @@ assert_no_output() { fi } +# ------------------------------------------------- denial records + +# The URL the guard is given for these tests. The credential is in it, as it is +# in the real config var, so that a test can prove it never reaches the operator. +# shellcheck disable=SC2034 # read by run_tests.sh +CG_REPORT_CRED="s3cr3t-not-for-operators" +CG_REPORT_PATH="/webhooks/console_audit" +CG_REPORT_URL="http://reporter:${CG_REPORT_CRED}@127.0.0.1:${CG_RECORDER_PORT}${CG_REPORT_PATH}" + +# cg_reported_bodies -- the request bodies seen since the last cg_run. +cg_reported_bodies() { sed -n 's/^BODY //p' "$CG_RECORD_LOG"; } + +# cg_run_reporting -- as cg_run, with the endpoint configured and the +# request log cleared first. +cg_run_reporting() { + : > "$CG_RECORD_LOG" + # shellcheck disable=SC2086 # deliberate word splitting: keep any cg_env values + cg_env "CONSOLE_LOGGING_DATADOG_PROXY_URL=$CG_REPORT_URL" $CG_CURRENT_ENV + cg_run "$1" +} + +# assert_reported