diff --git a/README.md b/README.md index 62a0767..153e748 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,90 @@ 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 [permit 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. + +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 — 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**: @@ -300,6 +384,7 @@ records the installed version: -----> Installing console guard 7f1e0d8 profile script: .profile.d/zzz_console_guard.sh command wrapper: .console-guard/bin/{rails,rake,bundle} + denial reporter: .console-guard/lib/denial_report.sh dyno metadata file: /etc/heroku/dyno enforcement: blocking unless CONSOLE_BLOCK_ENFORCE=false at run time ``` @@ -332,8 +417,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. @@ -408,6 +493,9 @@ 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_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: @@ -421,6 +509,7 @@ Set by the buildpack itself: | Variable | Value | Notes | |---|---|---| | `CONSOLE_AUDIT_ENABLED` | `true` | Exported on `run`, `scheduler` and `release` dynos, in both enforcement modes. Activates the audit hook in the companion gem. Because `.profile.d` scripts run *after* config vars and `-e` vars are applied, an operator cannot disable it via `-e`. In local and development environments, where this buildpack does not run, set it manually to opt in | +| `CONSOLE_GUARD_DYNO_ID` | dyno UUID | Exported on gated dynos only, from the dyno metadata file, so the command wrapper's denial records carry a join key `-e` cannot forge. Empty when metadata is disabled | | `PATH` | prepended | With `.console-guard/bin`, so `rails`, `rake` and `bundle` resolve to the command wrapper | | `EDITOR`, `VISUAL` | unset | They are a shell escape via `rails credentials:edit` | @@ -502,6 +591,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..6d26150 100755 --- a/bin/compile +++ b/bin/compile @@ -62,6 +62,13 @@ render() { echo "-----> Installing console guard ${CG_VERSION}" +# Sourced by both halves of the guard, so it belongs beside them rather than in +# either one. Installed first, before the two scripts that look for it. +LIB_DIR="$BUILD_DIR/.console-guard/lib" +mkdir -p "$LIB_DIR" +render "$BUILDPACK_DIR/guard/denial_report.sh" "$LIB_DIR/denial_report.sh" +chmod 644 "$LIB_DIR/denial_report.sh" + # 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. @@ -89,6 +96,7 @@ chmod 755 "$SHIM_DIR/rails" "$SHIM_DIR/rake" "$SHIM_DIR/bundle" { echo "profile script: .profile.d/zzz_console_guard.sh" echo "command wrapper: .console-guard/bin/{rails,rake,bundle}" + echo "denial reporter: .console-guard/lib/denial_report.sh" echo "dyno metadata file: ${CG_DYNO_METADATA_FILE}" echo "enforcement: blocking unless CONSOLE_BLOCK_ENFORCE=false at run time" } | indent diff --git a/guard/denial_report.sh b/guard/denial_report.sh new file mode 100644 index 0000000..a8f818a --- /dev/null +++ b/guard/denial_report.sh @@ -0,0 +1,191 @@ +# shellcheck shell=bash +# Durable record of a console-guard denial. Sourced by both halves of the guard +# (profile/console_guard.sh and guard/shim.sh); provides _cg_report_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. + +_CG_REPORT_VERSION="@@CG_VERSION@@" +_CG_REPORT_URL_VAR="CONSOLE_LOGGING_DATADOG_PROXY_URL" +_CG_REPORT_EVENT="command_denied" +_CG_REPORT_CONNECT_TIMEOUT=2 +_CG_REPORT_MAX_TIME=4 +# Matches the profile script's denial banner, so the record and the banner agree +# on what the guard was judging. +_CG_REPORT_CMD_MAX=300 + +# JSON-escape a string onto stdout, without the surrounding quotes. +# +# Backslash first, or it would double the ones the later rules introduce. +# +# The loop terminates: each pass replaces every occurrence of one control byte +# with a \u escape containing none, so the set of remaining ones strictly +# shrinks. It runs at most 33 times, and for a real command, zero. +# +# The output is pure ASCII, which is the point: 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: every byte >= 0x80 reads as U+FFFD, so `puts 'héllo'` is +# recorded with two of them, and the leftover sentinel in the two-marker case +# reads as three. Enough to see that something non-ASCII was there. +_cg_json_escape() { + local _s="$1" _c + _s="${_s//\\/\\\\}" + _s="${_s//\"/\\\"}" + _s="${_s//$'\n'/\\n}" + _s="${_s//$'\r'/\\r}" + _s="${_s//$'\t'/\\t}" + # Before the loop below, so [[:cntrl:]] cannot reach a high byte in a locale + # that counts 0x80-0x9f as controls. Written as an escape, not a literal, to + # keep the body ASCII. + _s="${_s//[$'\x80'-$'\xff']/\\ufffd}" + # The remaining C0 controls have no short form and are illegal raw in a JSON + # string, so they would make the whole record unparseable. + while [[ "$_s" =~ [[:cntrl:]] ]]; do + _c="${BASH_REMATCH[0]}" + _s="${_s//"$_c"/$(printf '\\u%04x' "'$_c")}" + done + printf '%s' "$_s" +} + +# _cg_json_field -- emits `,"name":"value"`, or nothing when the +# value is empty. Omitted rather than null so that a missing operator reads as +# absent in Datadog rather than as the string "null". +_cg_json_field() { + [[ -n "${2:-}" ]] || return 0 + printf ',"%s":"%s"' "$1" "$(_cg_json_escape "$2")" +} + +_cg_report_truncate() { + if (( ${#1} > _CG_REPORT_CMD_MAX )); then + printf '%s [truncated]' "${1:0:_CG_REPORT_CMD_MAX}" + else + printf '%s' "$1" + fi +} + +# _cg_report_denial +# +# 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 true|false. Sent in permit mode as well: phase 1 exists to measure +# what enforcement would block, which is only measurable if the +# would-be denials are recorded. +_cg_report_denial() { + local _cg_rule="${1:-unknown}" _cg_cmd="${2:-}" _cg_enforced="${3:-true}" + local _cg_url="${!_CG_REPORT_URL_VAR:-}" + + # 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. + [[ -n "$_cg_url" ]] || return 0 + + if ! command -v curl > /dev/null 2>&1; then + echo "console-guard: curl is unavailable, denial not recorded" >&2 + return 0 + fi + + local _cg_body + _cg_body="{\"event\":\"${_CG_REPORT_EVENT}\",\"enforced\":${_cg_enforced}" + _cg_body+="$(_cg_json_field rule "$_cg_rule")" + _cg_body+="$(_cg_json_field command "$(_cg_report_truncate "$_cg_cmd")")" + _cg_body+="$(_cg_json_field operator "${CONSOLE_USER:-}")" + _cg_body+="$(_cg_json_field reason "${CONSOLE_REASON:-}")" + # Resolved from the dyno metadata file by the profile script, which refuses a + # session whose $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. + _cg_body+="$(_cg_json_field dyno_id "${CONSOLE_GUARD_DYNO_ID:-${HEROKU_DYNO_ID:-}}")" + # Attribution, so `@app` and `@app_service` reach this record too. See the header. + _cg_body+="$(_cg_json_field app "${HEROKU_APP_NAME:-}")" + _cg_body+="$(_cg_json_field service "${DD_SERVICE:-}")" + _cg_body+="$(_cg_json_field guard_version "$_CG_REPORT_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. + _cg_body+="$(_cg_json_field timestamp "$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')")" + _cg_body+="}" + + # 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. + # + # stderr is discarded because curl reports failures by quoting the URL, which + # carries the Basic credential. Report the status code instead, never the URL. + local _cg_code + _cg_code="$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --connect-timeout "$_CG_REPORT_CONNECT_TIMEOUT" \ + --max-time "$_CG_REPORT_MAX_TIME" \ + --header 'Content-Type: application/json' \ + --data-binary "$_cg_body" \ + "$_cg_url" 2>/dev/null)" + + case "$_cg_code" in + 2*) return 0 ;; + *) + # 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. + echo "console-guard: denial not recorded (${_CG_REPORT_URL_VAR} returned ${_cg_code:-no response})" >&2 + return 0 + ;; + esac +} diff --git a/guard/shim.sh b/guard/shim.sh index b98b430..ec9dfea 100755 --- a/guard/shim.sh +++ b/guard/shim.sh @@ -42,7 +42,24 @@ fi _cg_prog="${0##*/}" +# ---------- denial reporting ---------- +# Half the guard's denials happen here rather than in the profile script, and an +# audit trail that records only the other half is worse than none. A missing +# library degrades to a no-op: the record is observability, and losing it must +# not change what the wrapper permits. +_cg_lib="${HOME:-/app}/.console-guard/lib/denial_report.sh" +if [[ -r "$_cg_lib" ]]; then + # shellcheck source=guard/denial_report.sh + . "$_cg_lib" +else + echo "console-guard: denial reporter is missing, denials will not be recorded" >&2 + _cg_report_denial() { :; } +fi + +# 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. _cg_deny() { + local _cg_rule="$1"; shift local line { echo "" @@ -60,11 +77,22 @@ _cg_deny() { echo "" } >&2 + # The post-expansion argv, which is what this half actually judged -- the + # profile script's copy is the pre-expansion string, and the two differ in + # exactly the cases this wrapper exists for. + local _cg_enforced=false + [[ "$_cg_enforcing" == "true" ]] && _cg_enforced=true + _cg_report_denial "$_cg_rule" "$_cg_prog ${_cg_argv_seen[*]:-}" "$_cg_enforced" + if [[ "$_cg_enforcing" == "true" ]]; then exit 1 fi } +# Captured before any policy runs, and before `bundle exec` rewriting narrows +# what policy looks at, so a denial record shows what was invoked. +_cg_argv_seen=("$@") + # ---------- 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 @@ -112,7 +140,8 @@ _cg_policy_args=("$@") if [[ "$_cg_prog" == "bundle" ]]; then if [[ "${1:-}" != "exec" ]]; then - _cg_deny "\`bundle ${1:-}\` is not permitted on one-off dynos." \ + _cg_deny bundle_not_exec \ + "\`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" \ @@ -126,7 +155,8 @@ if [[ "$_cg_prog" == "bundle" ]]; then _cg_policy_prog="$2" ;; *) - _cg_deny "\`bundle exec ${2:-}\` is not permitted on one-off dynos." \ + _cg_deny bundle_exec_not_allowed \ + "\`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." @@ -144,7 +174,8 @@ _cg_sub="${_cg_policy_args[0]:-}" # 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." \ + _cg_deny raw_database_session \ + "\`${_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." @@ -156,7 +187,8 @@ esac # 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." \ + _cg_deny editor_escape \ + "\`${_cg_policy_prog} ${_cg_sub}\` is not permitted on one-off dynos." \ "" \ "These commands spawn an editor, which is a shell escape." ;; @@ -167,7 +199,8 @@ for _cg_arg in ${_cg_policy_args[@]+"${_cg_policy_args[@]}"}; do # 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." \ + _cg_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." @@ -177,7 +210,8 @@ for _cg_arg in ${_cg_policy_args[@]+"${_cg_policy_args[@]}"}; do # 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." \ + _cg_deny dash_c_flag \ + "The \`-c\` flag is not permitted on one-off dynos." \ "" \ "Use \`rails c\` for a console." fi @@ -205,7 +239,8 @@ if [[ "$_cg_policy_prog" == "rails" && ( "$_cg_sub" == "console" || "$_cg_sub" = for _cg_arg in "${_cg_policy_args[@]:1}"; do case "$_cg_arg" in --sandbox|--sandbox=*|-s|-s=*) - _cg_deny "\`rails ${_cg_sub} ${_cg_arg}\` is not permitted on one-off dynos." \ + _cg_deny sandbox_console \ + "\`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" \ @@ -228,13 +263,15 @@ if [[ "$_cg_policy_prog" == "rails" && ( "$_cg_sub" == "runner" || "$_cg_sub" == 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." \ + _cg_deny runner_file \ + "\`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_deny runner_file \ + "\`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" \ @@ -368,14 +405,14 @@ for _cg_arg in ${_cg_policy_args[@]+"${_cg_policy_args[@]}"}; do _cg_allowed_lines+=("$_cg_line") done < <(_cg_wrap_allowed) - _cg_deny "\`${_cg_policy_prog} ${_cg_arg}\` is not permitted on one-off dynos." \ + _cg_deny option_not_allowed \ + "\`${_cg_policy_prog} ${_cg_arg}\` is not permitted on one-off dynos." \ "" \ "${_cg_allow_why[@]}" \ "" \ "Permitted after \`${_cg_context}\`:${_cg_allow_extras:+ $_cg_allow_extras}" \ "${_cg_allowed_lines[@]}" \ "" \ - "" \ "Short options are matched whole, so pass them separately rather" \ "than bundled into one argument." done diff --git a/profile/console_guard.sh b/profile/console_guard.sh index a38a8a7..d903c67 100644 --- a/profile/console_guard.sh +++ b/profile/console_guard.sh @@ -34,6 +34,19 @@ _CG_VERSION="@@CG_VERSION@@" _cg_metadata_file="@@CG_DYNO_METADATA_FILE@@" _cg_shim_dir="${HOME:-/app}/.console-guard/bin" +_cg_lib_dir="${HOME:-/app}/.console-guard/lib" + +# ---------- denial reporting ---------- +# Sourced before the first check, because the $DYNO-spoof refusal below is one of +# the denials worth recording. A missing library degrades to a no-op: the record +# is observability, and losing it must not change what the guard permits. +if [[ -r "$_cg_lib_dir/denial_report.sh" ]]; then + # shellcheck source=guard/denial_report.sh + . "$_cg_lib_dir/denial_report.sh" +else + echo "console-guard: denial reporter is missing, denials will not be recorded" >&2 + _cg_report_denial() { :; } +fi # ---------- enforcement mode (phase 1 rollout) ---------- # Phase 1 permits but does not block: every check still runs and reports, but a @@ -87,6 +100,11 @@ if [[ -r "$_cg_metadata_file" ]]; then echo "==========================================" echo "" } >&2 + # Recorded with the metadata's dyno id, not $DYNO's, so the record files + # under the dyno this actually is. + CONSOLE_GUARD_DYNO_ID="${_cg_meta_id:-}" \ + _cg_report_denial dyno_name_spoofed "\$DYNO=${DYNO}" true + # 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. @@ -123,11 +141,23 @@ 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 + unset -f _cg_report_denial _cg_json_escape _cg_json_field \ + _cg_report_truncate 2>/dev/null + unset _cg_enforcing _CG_VERSION _cg_metadata_file _cg_shim_dir _cg_lib_dir \ + _cg_dyno_name _cg_dyno_id _cg_metadata_seen _cg_gated _cg_audited \ + _CG_REPORT_VERSION _CG_REPORT_URL_VAR _CG_REPORT_EVENT \ + _CG_REPORT_CONNECT_TIMEOUT _CG_REPORT_MAX_TIME _CG_REPORT_CMD_MAX return 0 fi +# The trusted dyno id, for the command wrapper's own denial records: it is +# resolved from the metadata file above, which `heroku run -e` cannot reach, +# whereas HEROKU_DYNO_ID can be set to anything. Exported here rather than +# earlier so it appears only on the dynos the wrapper is installed for. +if [[ -n "$_cg_dyno_id" ]]; then + export CONSOLE_GUARD_DYNO_ID="$_cg_dyno_id" +fi + # ---------- the CLI's exit-status marker ---------- # `heroku run --exit-code` appends # @@ -168,8 +198,66 @@ for _cg_arg in "${_cg_argv[@]}"; do fi done +# ---------- extract the dyno command ---------- +# Heroku executes the one-off command through a login shell, which is the process +# that sources this script, so its argv is `bash -c ` and we +# want the payload. +# +# Extracted here, before the first check, so that every denial record carries the +# command -- including the identity denial, which is the one CI hits and the one +# where "what did they try to run" matters most. The refusals for an argv this +# cannot read stay further down, where they were, so denial precedence is +# unchanged. +_CG_DYNO_CMD="" +_cg_cmd_read=false + +if (( ${#_cg_argv[@]} > 0 )); then + 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 +fi + +# ---------- strip the CLI's exit-status marker ---------- +# 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. See the marker definition above 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 + # Print a denial. Exits the dyno when enforcing; warns and continues otherwise. +# +# 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. _cg_deny() { + local _cg_rule="$1"; shift local _cg_line { echo "" @@ -187,6 +275,13 @@ _cg_deny() { echo "" } >&2 + # Before the exit, and in permit mode too: phase 1 exists to measure what + # enforcement would block, which is only measurable if the would-be denials + # are recorded. + local _cg_enforced=false + [[ "$_cg_enforcing" == "true" ]] && _cg_enforced=true + _cg_report_denial "$_cg_rule" "${_CG_DYNO_CMD:-${_cg_argv[*]:-}}" "$_cg_enforced" + 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 @@ -206,7 +301,8 @@ _CG_USAGE='heroku run -e "CONSOLE_USER=$(heroku whoami);CONSOLE_REASON=test" rai # 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." \ + _cg_deny wrapper_missing \ + "The console guard command wrapper is missing from this dyno." \ "" \ "Expected: ${_cg_shim_dir}/{rails,rake,bundle}" \ "" \ @@ -232,7 +328,8 @@ if (( ${#_cg_missing[@]} > 0 )); then else _cg_missing_desc="${_cg_missing[0]} is" fi - _cg_deny "${_cg_missing_desc} not set." \ + _cg_deny identity_missing \ + "${_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" \ @@ -261,11 +358,10 @@ if [[ "$_cg_enforcing" != "true" && -z "${_cg_user_check//[[:space:]]/}" ]]; the 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. +# ---------- refuse an argv the gate cannot read ---------- +# The extraction itself happened above, before the first check. What is left here +# is the pair of refusals for the shapes it could not handle, kept in this +# position so that the identity gate above still takes precedence. # 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 @@ -280,42 +376,23 @@ _cg_show() { 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." \ + _cg_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." 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." \ + _cg_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" \ @@ -328,26 +405,6 @@ else 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. @@ -367,7 +424,8 @@ if [[ "$_cg_cmd_read" == "true" ]] && "$_CG_DYNO_CMD" == *'<'* || "$_CG_DYNO_CMD" == *'>'* || "$_CG_DYNO_CMD" == *$'\n'* ]]; then - _cg_deny "Compound statements and redirections are not permitted on one-off" \ + _cg_deny compound_statement \ + "Compound statements and redirections are not permitted on one-off" \ "dynos." \ "" \ "The command may not contain any of: ; & | \` \$( < > newline" \ @@ -408,7 +466,8 @@ if [[ "$_cg_cmd_read" == "true" ]]; then case "$_cg_bin" in rails|rake|bundle) : ;; *) - _cg_deny "This command is not permitted on one-off dynos." \ + _cg_deny command_not_allowed \ + "This command is not permitted on one-off dynos." \ "" \ "Command:" \ " $(_cg_show "$_CG_DYNO_CMD")" \ @@ -466,8 +525,14 @@ export CONSOLE_AUDIT_ENABLED=true # 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 +# +# The command wrapper sources the denial reporter itself, so nothing here needs +# to survive for it -- and CONSOLE_GUARD_DYNO_ID, which does, is exported. +unset -f _cg_deny _cg_show _cg_report_denial _cg_json_escape _cg_json_field \ + _cg_report_truncate +unset _cg_enforcing _CG_VERSION _cg_metadata_file _cg_shim_dir _cg_lib_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 \ + _CG_REPORT_VERSION _CG_REPORT_URL_VAR _CG_REPORT_EVENT \ + _CG_REPORT_CONNECT_TIMEOUT _CG_REPORT_MAX_TIME _CG_REPORT_CMD_MAX diff --git a/test/lib/harness.sh b/test/lib/harness.sh index a42b478..99e68ba 100644 --- a/test/lib/harness.sh +++ b/test/lib/harness.sh @@ -80,6 +80,29 @@ EOF chmod 755 "$base/fakebin/$fake" done + # A fake `curl`, so a test can see the denial record the guard POSTs without a + # network. Records the request body and the URL it was given. Answers 202 -- + # the real endpoint's success code -- unless the URL says to fail, which is how + # the "a failed report never leaks the credential" test is arranged. + CG_CURL_LOG="$base/curl.log" + : > "$CG_CURL_LOG" + cat > "$base/fakebin/curl" <> "$CG_CURL_LOG" ;; + esac + _prev="\$_a" +done +printf 'ARGV %s\n' "\$*" >> "$CG_CURL_LOG" +case "\$*" in + *fail-me*) exit 7 ;; +esac +printf '202' +EOF + chmod 755 "$base/fakebin/curl" + # Stand in for Heroku's own .profile. cat > "$CG_APP/.profile" < -- as cg_run, with the endpoint configured and the +# request log cleared first. +cg_run_reporting() { + : > "$CG_CURL_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