diff --git a/actions/runner-isolated/action.yml b/actions/runner-isolated/action.yml new file mode 100644 index 0000000..24a51f6 --- /dev/null +++ b/actions/runner-isolated/action.yml @@ -0,0 +1,32 @@ +name: Runner isolated +description: >- + Assert that no `pull_request`- (or `pull_request_target`-) triggered + workflow names a self-hosted runner (#58). A pull_request workflow runs + the PR branch's code — from a fork, unreviewed code — and a self-hosted + runner executes it on our own hardware, inside our own network; the + fork-PR write-token and secrets toggles protect credentials, not the + runner. The rule is file-level, deliberately: a file whose trigger block + names pull_request and which names self-hosted anywhere fails, even + across jobs — the fix is to split the workflow. Known gaps, so silence + is never read as coverage: workflow_call reachability is not followed + (a pull_request caller plus a self-hosted callee goes unseen), and + indirection is not resolved (runner groups, matrix or expression values + for runs-on). A missing workflows directory is a pass. The caller must + have checked out its own repository first: the guard reads the + consumer's tree at the workspace. +inputs: + workflows-dir: + description: >- + Directory scanned for `*.yml`/`*.yaml` workflow files. A missing + directory passes — a guard that fails on absence is a guard nobody + adopts. + required: false + default: .github/workflows +runs: + using: composite + steps: + - name: runner isolated + shell: bash + env: + WORKFLOWS_DIR: ${{ inputs.workflows-dir }} + run: bash "$GITHUB_ACTION_PATH/runner-isolated.sh" diff --git a/actions/runner-isolated/runner-isolated.sh b/actions/runner-isolated/runner-isolated.sh new file mode 100644 index 0000000..d6a119f --- /dev/null +++ b/actions/runner-isolated/runner-isolated.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +# runner-isolated.sh [] — assert that no `pull_request`- +# triggered workflow in the tree names a self-hosted runner (#58; epic #56 +# decision D5). +# +# The threat, stated once: a `pull_request` workflow runs code from the +# PR's branch. When that branch comes from a fork and the repo's fork-PR +# settings do not require approval, that code is UNREVIEWED. Point such a +# job at a self-hosted runner and unreviewed code executes on our own +# hardware, inside our own network. Nothing else in the fleet's setup +# gates that path — the write-token and secrets toggles (correctly off, +# #16's ruling) protect credentials, not the runner. +# +# Nothing was wrong the day this was written: incubator's deploy.yml is +# push-triggered and self-hosted (legal), its pr-checks.yml is +# PR-triggered and hosted, and the rule lived as a sentence in +# pr-checks.yml's header, kept true by whoever remembered it. This guard +# is that sentence moved into CI — the same move drill-recorded made +# after three releases shipped through a documented-but-unenforced gate +# (its header: "that is not a gate, it is luck with good manners"). +# +# THE RULE IS FILE-LEVEL, DELIBERATELY. The precise rule — no JOB +# reachable from a pull_request trigger runs self-hosted — needs a YAML +# parser, and a second parser in bash is a new class of guard bug bought +# in exchange for permitting a file shape we do not want. So: a file +# FAILS when its trigger block names pull_request (which also matches +# pull_request_target — intended) AND it names a self-hosted runner +# anywhere, even in a different job. The false positive has a clean, +# safer fix — SPLIT THE WORKFLOW; incubator already keeps pr-checks.yml +# apart from deploy.yml, which is the shape this guard asks for. False +# NEGATIVES are what a security guard must not have, and file-level +# granularity has none for the modelled threat: it can only be stricter +# than the precise rule, never laxer. +# +# Self-hosted detection covers two shapes, because the same-line rule +# alone ("runs-on and self-hosted on one line") would pass the +# block-sequence form — a false negative, the one defect this guard is +# not allowed to have: +# +# runs-on: [self-hosted, ci-runner] # same line: caught +# runs-on: # block sequence: the bare +# - self-hosted # key opens a window over +# - ci-runner # its `- …` list items +# +# KNOWN LIMITS, named in action.yml's description too so a consumer +# never reads silence as coverage: +# - workflow_call is not treated as PR-reachable in v1: a pull_request +# caller plus a self-hosted callee is a real path this guard does not +# see. Following `uses:` across files is the YAML-parsing problem +# again, and the family has no such caller today (ceremony's own +# release-exercise.yml is ubuntu-latest). +# - indirection is not resolved: a runner group (`runs-on: {group: …}`) +# or a matrix/expression value can reach self-hosted hardware without +# the string appearing on any line this guard reads. +# +# Comments are skipped on BOTH halves of the rule: a workflow that merely +# mentions self-hosted in prose is not the bug (incubator's pr-checks.yml +# header is exactly that prose), and a guard that cried wolf on comments +# would be turned off within a week. A missing workflows directory is a +# PASS, not an error — most repos in the family have one, but a guard +# that fails on absence is a guard nobody adopts. +# +# A file of its own (not inlined in action.yml) so +# test/runner-isolated.test.sh can drive it against constructed trees — +# the same discipline as the four guards beside it. + +workflows_dir="${1:-${WORKFLOWS_DIR:-.github/workflows}}" + +if [ ! -d "$workflows_dir" ]; then + echo "runner-isolated: no workflows directory at '$workflows_dir' — nothing to scan" + exit 0 +fi + +shopt -s nullglob +files=("$workflows_dir"/*.yml "$workflows_dir"/*.yaml) + +if [ "${#files[@]}" -eq 0 ]; then + echo "runner-isolated: 0 workflow files under '$workflows_dir' — nothing to scan" + exit 0 +fi + +offenders=0 +for file in "${files[@]}"; do + pr_triggered=0 + in_on=0 + in_runs_on=0 + hits=() + + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + + # A blank line ends nothing: it is not a top-level key, and a YAML + # sequence may legally continue past one. + case "$line" in + *[![:space:]]*) ;; + *) continue ;; + esac + + stripped="${line#"${line%%[![:space:]]*}"}" + + # Comment lines are invisible to both halves of the rule. + case "$stripped" in + '#'*) continue ;; + esac + + # A line starting a top-level key opens or closes the trigger block. + # YAML 1.1 parses bare `on` as a boolean, so some repos quote the + # key — a guard that missed '"on":' would silently pass the file it + # most needs to read. + case "$line" in + [![:space:]]*) + in_runs_on=0 + case "$line" in + 'on:'*| '"on":'* | "'on':"*) in_on=1 ;; + *) in_on=0 ;; + esac + ;; + esac + + # Half one: the trigger. Checked on the `on:` line itself (scalar and + # inline-list shapes) and on every line of its block. + if [ "$in_on" -eq 1 ]; then + case "$line" in + *pull_request*) pr_triggered=1 ;; + esac + fi + + # Half two: the runner. The window a bare `runs-on:` key opened over + # its list items closes at the first line that is not a `- …` item. + if [ "$in_runs_on" -eq 1 ]; then + case "$stripped" in + '-'*) + case "$line" in + *self-hosted*) hits+=("$lineno: $line") ;; + esac + ;; + *) in_runs_on=0 ;; + esac + fi + case "$line" in + *runs-on*) + case "$line" in + *self-hosted*) hits+=("$lineno: $line") ;; + esac + case "$stripped" in + 'runs-on:' | 'runs-on:'[[:space:]]*) + rest="${stripped#runs-on:}" + rest="${rest#"${rest%%[![:space:]]*}"}" + case "$rest" in + '' | '#'*) in_runs_on=1 ;; + esac + ;; + esac + ;; + esac + done <"$file" + + if [ "$pr_triggered" -eq 1 ] && [ "${#hits[@]}" -gt 0 ]; then + offenders=$((offenders + 1)) + { + echo "runner-isolated: $file is pull_request-triggered and names a self-hosted runner:" + printf ' %s\n' "${hits[@]}" + } >&2 + fi +done + +if [ "$offenders" -gt 0 ]; then + cat >&2 <