From 6101d8f348f2527ebc34669a41a05feb151a3c39 Mon Sep 17 00:00:00 2001 From: Rome-1 Date: Wed, 16 Sep 2026 22:40:43 -0700 Subject: [PATCH] fix(action): a fork's branch name must not reach the shell holding the API key (rf-7xv0, rf-v2mj) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0, authorized by Rome 2026-09-13. Both defects are live AT THE v1 TAG, which is what consumers pin. MERGING THIS WITHOUT MOVING v1 CHANGES NOTHING FOR ANY CONSUMER — v1 is 34850d46, 115 commits and five months behind main, so no fix that has ever landed on main has reached them. rf-7xv0 — KEY EXFILTRATION, github-action/action.yml. "branch_name": "${{ github.head_ref || github.ref_name }}", sat inside a `run:` block whose env carries RAFTER_API_KEY. The runner expands `${{ }}` into the script TEXT before bash sees it, so that value was not data — it was source code, chosen by whoever opened the pull request, and anyone can open one from a fork. Measured, not argued: with a branch name of x"; printf %s "$RAFTER_API_KEY" > "$CANARY_PATH"; echo " the canary file came back containing the key. `$( )` works too and needs no quote-breaking at all. Fixed the way GitHub documents: the values reach the script through `env:` (GH_REPOSITORY, GH_BRANCH) and never through `${{ }}` in `run:`. The body is then built with `jq -nc --arg` rather than pasted into a hand-quoted JSON string, which fixes a second bug in the same line — a branch name containing a quote or backslash produced MALFORMED JSON even with no attacker involved. Hand-quoting would have to get shell and JSON escaping both right; jq --arg gets both right by construction. rf-v2mj — AN UNREADABLE REPORT RENDERED AS A CLEAN SCAN, root action.yml. `COUNT=$(... | jq ... || echo "0")` made "no findings" and "I could not read the output" the same value, so the gate passed precisely when it could not see its input. Now the count is written only when a parse actually succeeded; otherwise the step fails and finding-count is left EMPTY rather than a fabricated 0. The text branch distinguishes grep's exit 1 (no matches — clean) from grep failing (>1). Note which file that is: the same defect class in github-action/action.yml was fixed under sable-fgk7 and this ROOT file was never touched by it. Two action.yml files; only one had been fixed. That is the measurement trap the bead warned about, and it is why the corpus is stated below. PROBES — each FAILS on the pre-fix file and passes after, each with a control so it cannot pass by doing nothing: test-trigger-injection.sh unfixed 3 failures -> fixed 0 quote-breaking injection reads the key; $( ) injection reads the key; and a FIDELITY control that a legal branch name containing a quote and a backslash still arrives intact as valid JSON, checked against a real local listener that records the body actually sent. test-root-action-counts.sh unfixed 2 failures -> fixed 0 unparseable report and truncated JSON must fail the step; CONTROLS that a genuinely clean report still passes with count 0 and that real findings are still counted as 2. Without those controls a "fail on everything" change would have passed. Writing the fidelity control honestly cost two rewrites: the first scraped the script text for the old inline-JSON shape and silently matched nothing once the fix removed that shape, and the probe did not model the step's `env:` block, so after the fix the payload was not arriving anywhere and the injection tests were passing vacuously. Both are why the probe now renders the env mapping the way the runner does. CI: both probes wired into test-github-action.yml. The workflow's path filter watched only `github-action/**`, so a change to the ROOT action.yml would not have run the probe that guards it; `action.yml` added to both the pull_request and push filters. CORPUS SEARCHED, as the bead requires. Every action file at both refs: origin/main action.yml head_ref/ref_name: 0 origin/main github-action/action.yml head_ref/ref_name: 1 v1 action.yml head_ref/ref_name: 0 v1 github-action/action.yml head_ref/ref_name: 1 plus .github/workflows/test-action.yml and test-github-action.yml, which are workflows rather than actions and carry no such interpolation. NOT FIXED HERE, reported rather than silently left. A sweep of every `${{ }}` inside a `run:` block found more of the same CLASS, none of them attacker-controlled from a fork: root action.yml interpolates inputs.scan-path, inputs.args, inputs.format and inputs.version directly into run blocks, so a consumer whose workflow passes untrusted text into those inputs has the same shape of problem one level out. github-action/action.yml also interpolates github.repository and github.event.pull_request.number, both runner-supplied and narrowly typed. Worth its own pass; not smuggled into a P0. --- .github/workflows/test-github-action.yml | 28 +++ action.yml | 24 ++- github-action/action.yml | 25 ++- .../tests/test-root-action-counts.sh | 108 +++++++++++ github-action/tests/test-trigger-injection.sh | 171 ++++++++++++++++++ 5 files changed, 349 insertions(+), 7 deletions(-) create mode 100755 github-action/tests/test-root-action-counts.sh create mode 100755 github-action/tests/test-trigger-injection.sh diff --git a/.github/workflows/test-github-action.yml b/.github/workflows/test-github-action.yml index 7a7665d8..5d524375 100644 --- a/.github/workflows/test-github-action.yml +++ b/.github/workflows/test-github-action.yml @@ -14,10 +14,15 @@ on: - main paths: - 'github-action/**' + - 'action.yml' - '.github/workflows/test-github-action.yml' pull_request: paths: - 'github-action/**' + # The ROOT action.yml is a SECOND action file, and rf-v2mj lived in it. + # Without this line the probe that guards it would never fire on a change + # to it — a check watching a path its subject is not on. + - 'action.yml' - '.github/workflows/test-github-action.yml' workflow_dispatch: @@ -404,6 +409,29 @@ jobs: [ "$FAIL" -eq 0 ] && echo "PASS: counts reached the gate and the gate failed the build." exit $FAIL + # sable-oubg / rf-7xv0 — a fork's branch name reached a shell holding + # RAFTER_API_KEY. Both probes FAIL on the pre-fix file and pass after, and + # both carry controls, so neither can pass by doing nothing. + test-trigger-injection: + name: "Trigger: a branch name cannot reach the key's shell" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run the injection probe + run: bash github-action/tests/test-trigger-injection.sh + + # sable-oubg / rf-v2mj — the ROOT action.yml derived its finding count with + # `|| echo "0"`, so an unreadable report rendered as a clean scan. Note this + # is the OTHER action.yml: the same defect class in github-action/action.yml + # was fixed under sable-fgk7 and this file was never touched by it. + test-root-action-counts: + name: "Root action: an unreadable report is not a clean scan" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run the count-derivation probe + run: bash github-action/tests/test-root-action-counts.sh + test-yaml-validity: name: action.yml is valid YAML runs-on: ubuntu-latest diff --git a/action.yml b/action.yml index f270f51f..9eb9628f 100644 --- a/action.yml +++ b/action.yml @@ -98,10 +98,30 @@ runs: # ({_note, scan_mode, triage_applied, results: [...]}); older versions # emitted a bare array. Handle both so users pinning `version:` to an # older release don't break. + # rf-v2mj — `|| echo "0"` here was the whole bug: jq fails on a truncated + # report, an HTML error page or an auth failure, and the fallback turned + # every one of those into finding-count=0. Zero findings and "I could not + # read the output" became the same value, so the gate passed PRECISELY + # when it could not see its input. An unreadable report is not a clean + # scan; the count is emitted only when a parse actually succeeded. if [ "${{ inputs.format }}" = "json" ]; then - COUNT=$(echo "${OUTPUT}" | jq '[(if type == "array" then . else .results end) | .[]?.matches[]?] | length' 2>/dev/null || echo "0") + if ! COUNT=$(printf '%s' "${OUTPUT}" | jq -e '[(if type == "array" then . else .results end) | .[]?.matches[]?] | length' 2>/dev/null); then + echo "::error::Rafter's output could not be parsed as JSON, so the finding count is unknown." + echo "::error::A report this action cannot read is not a clean scan. Re-run, or check the scanner's stderr above." + # Deliberately NOT written as 0: a consumer reading finding-count + # gets an empty string, never a fabricated zero. + echo "finding-count=" >> "$GITHUB_OUTPUT" + exit 1 + fi else - COUNT=$(echo "${OUTPUT}" | grep -c 'Secret:' 2>/dev/null || echo "0") + # grep exits 1 for "no matches", which is a CLEAN result and must not + # be confused with grep failing (exit >1). + COUNT=$(printf '%s' "${OUTPUT}" | grep -c 'Secret:'); GREP_STATUS=$? + if [ "$GREP_STATUS" -gt 1 ]; then + echo "::error::Could not scan Rafter's output for findings (grep exited ${GREP_STATUS})." + echo "finding-count=" >> "$GITHUB_OUTPUT" + exit 1 + fi fi echo "finding-count=${COUNT}" >> "$GITHUB_OUTPUT" diff --git a/github-action/action.yml b/github-action/action.yml index 8fe5ee3c..9552d0ff 100644 --- a/github-action/action.yml +++ b/github-action/action.yml @@ -68,20 +68,35 @@ runs: RAFTER_API_KEY: ${{ inputs.api-key }} RAFTER_URL: ${{ inputs.rafter-url }} SCAN_MODE: ${{ inputs.scan-mode }} + # rf-7xv0 — these two reach the script through the ENVIRONMENT, never + # through `${{ }}` inside `run:`. The runner expands `${{ }}` into the + # script TEXT before bash sees it, so an interpolated value is not data, + # it is source code — and `github.head_ref` is the branch name chosen by + # whoever opened the pull request. Any stranger can open one from a fork. + # This step's env carries RAFTER_API_KEY, so the branch name was running + # in a shell that could read the key. + GH_REPOSITORY: ${{ github.repository }} + GH_BRANCH: ${{ github.head_ref || github.ref_name }} run: | # --fail-with-body: non-2xx → body printed to stdout AND exit nonzero. # We capture body+status separately so future failures self-explain # (instead of just "curl exit 22"). API key never echoed. BODY_FILE="$(mktemp)" + # Built by jq from environment variables, not by pasting values into a + # quoted string. That fixes two things at once: the shell never sees the + # branch name as code, and a branch name containing a quote or backslash + # produces VALID JSON instead of a malformed body. Hand-quoting would + # have to get both right; jq --arg gets both right by construction. + PAYLOAD=$(jq -nc \ + --arg repository_name "$GH_REPOSITORY" \ + --arg branch_name "$GH_BRANCH" \ + --arg scan_mode "$SCAN_MODE" \ + '{repository_name: $repository_name, branch_name: $branch_name, scan_mode: $scan_mode}') HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 60 \ -o "$BODY_FILE" -w "%{http_code}" -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: ${RAFTER_API_KEY}" \ - -d "{ - \"repository_name\": \"${{ github.repository }}\", - \"branch_name\": \"${{ github.head_ref || github.ref_name }}\", - \"scan_mode\": \"${SCAN_MODE}\" - }" \ + -d "$PAYLOAD" \ "${RAFTER_URL}/api/static/scan") || { echo "::error::curl transport error contacting ${RAFTER_URL}/api/static/scan" cat "$BODY_FILE" || true diff --git a/github-action/tests/test-root-action-counts.sh b/github-action/tests/test-root-action-counts.sh new file mode 100755 index 00000000..9f15ae4a --- /dev/null +++ b/github-action/tests/test-root-action-counts.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# rf-v2mj — an unreadable report must not render as a clean scan. +# +# The root action derives `finding-count` from the scanner's stdout. It used to +# end that derivation with `|| echo "0"`, so a truncated report, an HTML error +# page or an auth failure all produced finding-count=0: the value that means +# CLEAN. A consumer gating on it was told "no findings" precisely when the +# action could not see any output at all. +# +# Corpus: the ROOT action.yml. The separate github-action/action.yml had the +# same class of defect in its five severity counts and was fixed under +# sable-fgk7; this file was never touched by that work. Two action.yml files, +# and only one of them had been fixed. +# +# The control is the point: a fix that fails on everything would pass the first +# case and be useless. A genuinely clean report must still succeed with count 0. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)/action.yml" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +failures=0 + +# The scan step, with the runner's `${{ inputs.* }}` expansion applied. +render() { + python3 - "$ROOT" "$1" <<'PY' +import sys, yaml, re +d = yaml.safe_load(open(sys.argv[1])); fmt = sys.argv[2] +for s in d["runs"]["steps"]: + if s.get("id") == "scan": + r = s["run"] + r = re.sub(r"\$\{\{\s*inputs\.format\s*\}\}", fmt, r) + r = re.sub(r"\$\{\{\s*inputs\.(scan-path|args)\s*\}\}", "", r) + r = re.sub(r"\$\{\{.*?\}\}", "", r) + print(r); break +else: + sys.exit("no step id 'scan'") +PY +} + +# A stub `rafter` that emits exactly what we want to test, with a chosen exit code. +stub() { + mkdir -p "$TMP/bin" + { echo '#!/usr/bin/env bash'; echo "cat <<'RAFTER_STUB_EOF'"; printf '%s\n' "$1"; echo 'RAFTER_STUB_EOF'; echo "exit ${2}"; } > "$TMP/bin/rafter" + chmod +x "$TMP/bin/rafter" +} + +run_scan() { + render "$1" > "$TMP/scan.sh" + : > "$TMP/gh_output" + ( cd "$TMP" && PATH="$TMP/bin:$PATH" GITHUB_OUTPUT="$TMP/gh_output" \ + timeout 30 bash "$TMP/scan.sh" >"$TMP/out" 2>&1 ) + echo $? +} +count_written() { sed -n 's/^finding-count=\(.*\)$/\1/p' "$TMP/gh_output" | tail -1; } + +echo "== rf-v2mj: an unreadable report is not a clean scan ==" + +# 1. THE BUG. Unparseable stdout with a success exit code — a truncated report, +# an error page, anything jq cannot read. +stub '502 Bad Gateway' 0 +rc=$(run_scan json); c=$(count_written) +if [ "$rc" -eq 0 ] && [ "$c" = "0" ]; then + echo "FAIL: an unparseable report produced finding-count=0 and exit 0 — a clean scan" + failures=$((failures+1)) +elif [ "$rc" -eq 0 ]; then + echo "FAIL: an unparseable report exited 0 (count written: '${c}')" + failures=$((failures+1)) +else + echo "PASS: an unparseable report fails the step (exit ${rc}, count '${c}')" +fi + +# 2. Truncated JSON — the likeliest real shape, and still not a clean scan. +stub '{"results": [{"matches": [{"rule":' 0 +rc=$(run_scan json); c=$(count_written) +if [ "$rc" -eq 0 ]; then + echo "FAIL: truncated JSON exited 0 (count written: '${c}')" + failures=$((failures+1)) +else + echo "PASS: truncated JSON fails the step (exit ${rc})" +fi + +# 3. THE CONTROL. A genuinely clean report must still succeed, with count 0. +# Without this, "fail on everything" would pass the two cases above. +stub '{"_note":"x","scan_mode":"fast","triage_applied":false,"results":[]}' 0 +rc=$(run_scan json); c=$(count_written) +if [ "$rc" -eq 0 ] && [ "$c" = "0" ]; then + echo "PASS: a genuinely clean report still passes, count 0" +else + echo "FAIL: CONTROL — a clean report no longer passes (exit ${rc}, count '${c}')" + failures=$((failures+1)) +fi + +# 4. Second control: real findings must still be counted, not just tolerated. +stub '{"results":[{"matches":[{"rule":"aws"},{"rule":"gh"}]}]}' 1 +rc=$(run_scan json); c=$(count_written) +if [ "$c" = "2" ]; then + echo "PASS: real findings are counted (2)" +else + echo "FAIL: CONTROL — findings miscounted: got '${c}', expected 2" + failures=$((failures+1)) +fi + +echo "" +echo "── results ──────────────────────────────────────────────" +echo "Failures: $failures" +[ "$failures" -eq 0 ] || exit 1 +echo "OK: the count is emitted only when the report was actually read" diff --git a/github-action/tests/test-trigger-injection.sh b/github-action/tests/test-trigger-injection.sh new file mode 100755 index 00000000..e589a90c --- /dev/null +++ b/github-action/tests/test-trigger-injection.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# +# rf-7xv0 — a fork's BRANCH NAME must not reach a shell that holds the API key. +# +# `${{ … }}` is expanded by the runner into the script TEXT before bash ever +# sees it, so a value interpolated inside a `run:` block is not data — it is +# source code. The trigger step's env carries RAFTER_API_KEY, and +# `github.head_ref` is chosen by whoever opened the pull request. Anyone can +# open one from a fork. +# +# This probe reproduces the runner's expansion exactly — textual substitution +# into the script, then execute — with a branch name that tries to read the key. +# It asserts two different things, because either alone can pass while the bug +# is live: +# +# 1. INJECTION: the payload must not execute. Canary file must not appear. +# 2. FIDELITY: a hostile-but-legal branch name must still arrive intact in +# the request body. A fix that mangles or drops branch names is +# not a fix, it is a different bug. +# +# Corpus: github-action/action.yml. The ROOT action.yml has no head_ref +# interpolation (checked on main and at v1); rf-v2mj is the defect there. +set -uo pipefail + +ACTION="$(cd "$(dirname "$0")/.." && pwd)/action.yml" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +failures=0 + +# The run: block of the trigger step, as the runner would hand it to bash. +extract_trigger() { + python3 - "$ACTION" <<'PY' +import sys, yaml +d = yaml.safe_load(open(sys.argv[1])) +for s in d["runs"]["steps"]: + if s.get("id") == "scan": + print(s["run"]); break +else: + sys.exit("no step with id 'scan'") +PY +} + +# The runner also expands `${{ }}` inside the step's env: VALUES and exports +# them. Modelling only the script text would make this probe vacuous after the +# fix — the payload would simply never arrive anywhere, and the test would pass +# because nothing was tested. Emits `export` lines for the step's env. +step_env() { + python3 - "$ACTION" "$1" <<'PY' +import sys, yaml, re, shlex +d = yaml.safe_load(open(sys.argv[1])); branch = sys.argv[2] +for s in d["runs"]["steps"]: + if s.get("id") == "scan": + for k, v in (s.get("env") or {}).items(): + v = str(v) + v = re.sub(r"\$\{\{\s*github\.head_ref\s*\|\|\s*github\.ref_name\s*\}\}", lambda _: branch, v) + v = re.sub(r"\$\{\{\s*github\.repository\s*\}\}", "owner/repo", v) + v = re.sub(r"\$\{\{\s*inputs\.api-key\s*\}\}", "CANARY-KEY-DO-NOT-LEAK", v) + v = re.sub(r"\$\{\{\s*inputs\.scan-mode\s*\}\}", "fast", v) + v = re.sub(r"\$\{\{\s*inputs\.rafter-url\s*\}\}", "__URL__", v) + v = re.sub(r"\$\{\{.*?\}\}", "", v) + print(f"export {k}={shlex.quote(v)}") + break +PY +} + +# What the runner does: substitute the expression's VALUE into the script text. +expand() { + python3 - "$1" "$2" <<'PY' +import sys, re +script, branch = open(sys.argv[1]).read(), sys.argv[2] +script = re.sub(r"\$\{\{\s*github\.head_ref\s*\|\|\s*github\.ref_name\s*\}\}", + lambda _: branch, script) +script = re.sub(r"\$\{\{\s*github\.repository\s*\}\}", "owner/repo", script) +# Any OTHER ${{ }} left in the script would also be runner-expanded; leaving +# them makes bash choke, which would mask the result rather than test it. +script = re.sub(r"\$\{\{.*?\}\}", "", script) +sys.stdout.write(script) +PY +} + +run_case() { + local label="$1" branch="$2" + extract_trigger > "$TMP/raw.sh" || { echo "FAIL: could not extract the trigger step"; return 1; } + expand "$TMP/raw.sh" "$branch" > "$TMP/run.sh" + rm -f "$TMP/CANARY" "$TMP/body.json" + # No network: point the API at a closed port so curl fails fast. The question + # is never whether the request succeeds — it is whether the payload RAN. + step_env "$branch" | sed "s#__URL__#http://127.0.0.1:9#" > "$TMP/env.sh" + ( cd "$TMP" && \ + RAFTER_API_KEY="CANARY-KEY-DO-NOT-LEAK" \ + RAFTER_URL="http://127.0.0.1:9" \ + SCAN_MODE="fast" \ + GITHUB_OUTPUT="$TMP/gh_output" \ + CANARY_PATH="$TMP/CANARY" \ + timeout 30 bash -c 'set -a; . "$1/env.sh"; set +a; exec bash "$1/run.sh"' _ "$TMP" >"$TMP/out" 2>&1 ) + return 0 +} + +echo "== rf-7xv0: branch name must not execute in the key's shell ==" + +# The payload closes the JSON string and the shell's double quote, then writes +# the key to a file. If the file appears, a stranger could have sent it away. +PAYLOAD='x"; printf %s "$RAFTER_API_KEY" > "$CANARY_PATH"; echo "' +run_case "injection" "$PAYLOAD" +if [ -f "$TMP/CANARY" ]; then + echo "FAIL: INJECTION — a branch name executed and read the API key:" + echo " canary contains: $(cat "$TMP/CANARY")" + failures=$((failures+1)) +else + echo "PASS: a hostile branch name did not execute" +fi + +# Command substitution is the other half: it needs no quote-breaking at all. +run_case "subst" 'x$(printf %s "$RAFTER_API_KEY" > "$CANARY_PATH")' +if [ -f "$TMP/CANARY" ]; then + echo "FAIL: INJECTION via \$( ) — the key was read" + failures=$((failures+1)) +else + echo "PASS: command substitution in a branch name did not execute" +fi + +# Fidelity: a legal branch name with JSON-significant characters must arrive +# INTACT in the request body. Asserted against a real local listener that +# records what was actually sent — an earlier version of this check scraped the +# script text for the old inline-JSON shape, and once the fix replaced that +# shape it silently matched nothing and asserted nothing. +ODD='feature/"quote-and\backslash' +python3 - "$TMP" <<'PY' & +import sys, json +from http.server import BaseHTTPRequestHandler, HTTPServer +tmp = sys.argv[1] +class H(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) + open(tmp + "/sent.json", "wb").write(body) + self.send_response(200); self.send_header("Content-Length", "22"); self.end_headers() + self.wfile.write(b'{"scan_id":"probe-001"}') + def log_message(self, *a): pass +HTTPServer(("127.0.0.1", 8799), H).serve_forever() +PY +LISTENER=$! +for _ in $(seq 1 40); do curl -s -o /dev/null -X POST -d '{}' http://127.0.0.1:8799/ 2>/dev/null && break; sleep 0.1; done +rm -f "$TMP/sent.json" +extract_trigger > "$TMP/raw.sh" +expand "$TMP/raw.sh" "$ODD" > "$TMP/run.sh" +step_env "$ODD" | sed "s#__URL__#http://127.0.0.1:8799#" > "$TMP/env.sh" +( cd "$TMP" && RAFTER_API_KEY="CANARY-KEY-DO-NOT-LEAK" RAFTER_URL="http://127.0.0.1:8799" \ + SCAN_MODE="fast" GITHUB_OUTPUT="$TMP/gh_output" CANARY_PATH="$TMP/CANARY" \ + timeout 30 bash -c 'set -a; . "$1/env.sh"; set +a; exec bash "$1/run.sh"' _ "$TMP" >"$TMP/out2" 2>&1 ) +kill $LISTENER 2>/dev/null; wait $LISTENER 2>/dev/null + +if [ ! -s "$TMP/sent.json" ]; then + echo "FAIL: FIDELITY — no request body was captured, so this asserted nothing" + failures=$((failures+1)) +else + GOT=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get('branch_name','(missing)'))" "$TMP/sent.json" 2>/dev/null || echo "(unparseable JSON)") + if [ "$GOT" = "$ODD" ]; then + echo "PASS: a branch name with a quote and a backslash arrived intact, as valid JSON" + else + echo "FAIL: FIDELITY — branch name was mangled." + echo " sent: $GOT" + echo " expected: $ODD" + failures=$((failures+1)) + fi +fi + +echo "" +echo "── results ──────────────────────────────────────────────" +echo "Failures: $failures" +[ "$failures" -eq 0 ] || exit 1 +echo "OK: a branch name cannot reach the shell that holds the key"