From c75ed6497ffdf52e89345ca35c16cc35d7e28ecd Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 10 Nov 2026 22:24:48 +1100 Subject: [PATCH] Allowlist runner hook events and tighten runner egress --- apps/base/iuclabs-runners/job-started.sh | 29 ++++++++++--------- apps/base/iuclabs-runners/networkpolicy.yaml | 25 +++++----------- ci/fixtures/runner-hooks/invalid.txt | 1 + ci/fixtures/runner-hooks/issue-comment.json | 11 +++++++ ci/fixtures/runner-hooks/pr-deleted-fork.json | 5 +++- ci/fixtures/runner-hooks/pr-fork-none.json | 6 +++- ci/fixtures/runner-hooks/pr-fork-spoofed.json | 14 +++++++++ ci/fixtures/runner-hooks/pr-member.json | 6 +++- ci/fixtures/runner-hooks/pr-same-repo.json | 6 +++- ci/test-runner-hooks.sh | 22 +++++++++++--- 10 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 ci/fixtures/runner-hooks/invalid.txt create mode 100644 ci/fixtures/runner-hooks/issue-comment.json create mode 100644 ci/fixtures/runner-hooks/pr-fork-spoofed.json diff --git a/apps/base/iuclabs-runners/job-started.sh b/apps/base/iuclabs-runners/job-started.sh index c7203cd..b6cf911 100755 --- a/apps/base/iuclabs-runners/job-started.sh +++ b/apps/base/iuclabs-runners/job-started.sh @@ -1,23 +1,26 @@ #!/bin/sh set -eu +event="${GITHUB_EVENT_NAME:-}" + refuse() { - echo "job-started hook: refusing ${GITHUB_EVENT_NAME} job: $1" >&2 + echo "job-started hook: refusing '${event}' job: $1" >&2 exit 1 } -case "${GITHUB_EVENT_NAME}" in - pull_request_target | workflow_run) - refuse "event not allowed on self-hosted runners" - ;; +[ -r "${GITHUB_EVENT_PATH:-}" ] || refuse "event payload missing" +jq -e 'type == "object"' "${GITHUB_EVENT_PATH}" >/dev/null 2>&1 || refuse "event payload unparseable" + +case "${event}" in + push | workflow_dispatch | schedule | merge_group | release) ;; pull_request) - same_repo=$(jq -r '.pull_request.head.repo.fork == false' "${GITHUB_EVENT_PATH}") - assoc=$(jq -r '.pull_request.author_association // "NONE"' "${GITHUB_EVENT_PATH}") - if [ "${same_repo}" != true ]; then - case "${assoc}" in - OWNER | MEMBER | COLLABORATOR) ;; - *) refuse "fork pull request from untrusted author (${assoc})" ;; - esac - fi + trusted=$(jq -r ' + .pull_request.head.repo.full_name as $head + | if $head == null then false + elif $head == .repository.full_name then true + else (.pull_request.author_association | IN("OWNER", "MEMBER", "COLLABORATOR")) + end' "${GITHUB_EVENT_PATH}") || refuse "event payload unreadable" + [ "${trusted}" = true ] || refuse "pull request from untrusted fork" ;; + *) refuse "event not allowed on self-hosted runners" ;; esac diff --git a/apps/base/iuclabs-runners/networkpolicy.yaml b/apps/base/iuclabs-runners/networkpolicy.yaml index 44329a1..bcf7a7d 100644 --- a/apps/base/iuclabs-runners/networkpolicy.yaml +++ b/apps/base/iuclabs-runners/networkpolicy.yaml @@ -30,27 +30,16 @@ spec: - 172.16.0.0/12 - 192.168.0.0/16 - 198.18.0.0/15 + - 0.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 224.0.0.0/4 + - 240.0.0.0/4 ports: - protocol: TCP port: 443 - # artifactapi.k8s.syd1.au.unkin.net: traefik-internal LB, DNATed to its pods in-cluster - - to: - - ipBlock: - cidr: 198.18.200.4/32 - ports: - - protocol: TCP - port: 443 - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: traefik-system - podSelector: - matchLabels: - app.kubernetes.io/instance: traefik-internal-traefik-system - ports: - - protocol: TCP - port: websecure - # vault.service.consul + # vault.service.consul servers, from `dig vault.service.consul` - to: - ipBlock: cidr: 198.18.25.78/32 diff --git a/ci/fixtures/runner-hooks/invalid.txt b/ci/fixtures/runner-hooks/invalid.txt new file mode 100644 index 0000000..8fc44f4 --- /dev/null +++ b/ci/fixtures/runner-hooks/invalid.txt @@ -0,0 +1 @@ +not json { diff --git a/ci/fixtures/runner-hooks/issue-comment.json b/ci/fixtures/runner-hooks/issue-comment.json new file mode 100644 index 0000000..6591540 --- /dev/null +++ b/ci/fixtures/runner-hooks/issue-comment.json @@ -0,0 +1,11 @@ +{ + "comment": { + "author_association": "NONE" + }, + "issue": { + "number": 1 + }, + "repository": { + "full_name": "IUCLabs/app" + } +} diff --git a/ci/fixtures/runner-hooks/pr-deleted-fork.json b/ci/fixtures/runner-hooks/pr-deleted-fork.json index a602183..8ae3be4 100644 --- a/ci/fixtures/runner-hooks/pr-deleted-fork.json +++ b/ci/fixtures/runner-hooks/pr-deleted-fork.json @@ -1,8 +1,11 @@ { "pull_request": { - "author_association": "NONE", + "author_association": "MEMBER", "head": { "repo": null } + }, + "repository": { + "full_name": "IUCLabs/app" } } diff --git a/ci/fixtures/runner-hooks/pr-fork-none.json b/ci/fixtures/runner-hooks/pr-fork-none.json index 1c4104b..62cbc06 100644 --- a/ci/fixtures/runner-hooks/pr-fork-none.json +++ b/ci/fixtures/runner-hooks/pr-fork-none.json @@ -3,8 +3,12 @@ "author_association": "NONE", "head": { "repo": { - "fork": true + "fork": true, + "full_name": "evil/app" } } + }, + "repository": { + "full_name": "IUCLabs/app" } } diff --git a/ci/fixtures/runner-hooks/pr-fork-spoofed.json b/ci/fixtures/runner-hooks/pr-fork-spoofed.json new file mode 100644 index 0000000..39f2b52 --- /dev/null +++ b/ci/fixtures/runner-hooks/pr-fork-spoofed.json @@ -0,0 +1,14 @@ +{ + "pull_request": { + "author_association": "NONE", + "head": { + "repo": { + "fork": false, + "full_name": "evil/app" + } + } + }, + "repository": { + "full_name": "IUCLabs/app" + } +} diff --git a/ci/fixtures/runner-hooks/pr-member.json b/ci/fixtures/runner-hooks/pr-member.json index 50ac080..a0ab634 100644 --- a/ci/fixtures/runner-hooks/pr-member.json +++ b/ci/fixtures/runner-hooks/pr-member.json @@ -3,8 +3,12 @@ "author_association": "MEMBER", "head": { "repo": { - "fork": true + "fork": true, + "full_name": "someone/app" } } + }, + "repository": { + "full_name": "IUCLabs/app" } } diff --git a/ci/fixtures/runner-hooks/pr-same-repo.json b/ci/fixtures/runner-hooks/pr-same-repo.json index 81312c5..1048ae9 100644 --- a/ci/fixtures/runner-hooks/pr-same-repo.json +++ b/ci/fixtures/runner-hooks/pr-same-repo.json @@ -3,8 +3,12 @@ "author_association": "CONTRIBUTOR", "head": { "repo": { - "fork": false + "fork": false, + "full_name": "IUCLabs/app" } } + }, + "repository": { + "full_name": "IUCLabs/app" } } diff --git a/ci/test-runner-hooks.sh b/ci/test-runner-hooks.sh index 0189133..64e78d5 100755 --- a/ci/test-runner-hooks.sh +++ b/ci/test-runner-hooks.sh @@ -7,19 +7,33 @@ fail=0 check() { local want=$1 event=$2 fixture=$3 got=0 - GITHUB_EVENT_NAME=$event GITHUB_EVENT_PATH=$fixtures/$fixture "$hook" >/dev/null 2>&1 || got=1 + local -a env=(-u GITHUB_EVENT_NAME -u GITHUB_EVENT_PATH) + [ "$event" = - ] || env+=("GITHUB_EVENT_NAME=$event") + [ "$fixture" = - ] || env+=("GITHUB_EVENT_PATH=$fixtures/$fixture") + env "${env[@]}" "$hook" >/dev/null 2>&1 || got=1 if [ "$got" -ne "$want" ]; then echo "FAIL: $event $fixture exit=$got want=$want" >&2 fail=1 fi } -check 0 push push.json +for e in push workflow_dispatch schedule merge_group release; do + check 0 "$e" push.json +done check 0 pull_request pr-member.json check 0 pull_request pr-same-repo.json check 1 pull_request pr-fork-none.json +check 1 pull_request pr-fork-spoofed.json check 1 pull_request pr-deleted-fork.json -check 1 pull_request_target pr-member.json -check 1 workflow_run push.json +check 1 pull_request push.json +for e in pull_request_target workflow_run pull_request_review pull_request_review_comment issue_comment issues discussion discussion_comment create delete unknown_event; do + check 1 "$e" pr-member.json +done +check 1 pull_request_review pr-fork-none.json +check 1 issue_comment issue-comment.json +check 1 push - +check 1 push missing.json +check 1 push invalid.txt +check 1 - push.json exit "$fail"