diff --git a/README.md b/README.md index 3821c8f9..b852f6ef 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,8 @@ Please find more details about this security note on [GitHub documentation](http > ⚠️ **`reuse: stop` (warm pools) makes this worse.** With reuse, a runner's disk carries over between jobs, so a later job can read a previous job's residue (checked-out code, caches, credentials written to disk). Only use `reuse: stop` for a **single trusted repository's** CI. Never combine it with public-repo / untrusted-PR workloads. The default `reuse: terminate` gives every job a fresh instance. +**Runners carry only their unique label.** The action registers every runner with `--no-default-labels`, so it never gets the implicit `self-hosted` / `Linux` / `X64` labels. A job can only be scheduled onto it via the exact per-run `label` output, never via `runs-on: [self-hosted, linux, x64]`. Without this, any job in the repository — including a fork PR's own job once its workflow is approved — could queue for up to 24 hours and take the next runner a trusted run starts, along with its instance role, VPC access and the registration token in IMDS user-data. Keep `runs-on: ${{ needs.start-runner.outputs.label }}` in your workflows; a generic `self-hosted` target will no longer match. Still restrict fork workflow approval to all outside collaborators on public repos (**Settings → Actions → Fork pull request workflows from outside collaborators**): a unique label is defence in depth, not a substitute. + ## Changelog See [CHANGELOG.md](CHANGELOG.md) for release notes and breaking changes. Pin the moving major tag (`@v4`) for the latest release in that line, or a specific version (`@v4.0.0`) to pin exactly. diff --git a/dist/index.js b/dist/index.js index 1a3505a3..83130d03 100644 --- a/dist/index.js +++ b/dist/index.js @@ -105472,7 +105472,7 @@ function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegist 'cd /home/runner/actions-runner', 'rm -f .runner .credentials .credentials_rsaparams', 'gh_runner_phone_home configuring', - 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --ephemeral --unattended --disableupdate', + 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --no-default-labels --ephemeral --unattended --disableupdate', 'GH_RUNNER_STEP=registered', 'gh_runner_phone_home registered', 'sudo -u runner -H ./run.sh', @@ -105579,6 +105579,14 @@ function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegist // RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone. Runner has its own // home under /home/runner/ and writes config.sh state there. // +// - --no-default-labels on config.sh: the runner carries ONLY the unique +// per-run label, never the implicit self-hosted/Linux/X64 set. Without +// it, any job in the repo (including a fork PR's own job, once approved +// or from a returning contributor) could target `runs-on: [self-hosted, +// linux, x64]`, queue for up to 24h, and grab the runner the next time a +// trusted run starts one -- along with its instance role, subnet, EIP and +// the registration token in IMDS user-data. Requires runner >= 2.299.0. +// // - --ephemeral --unattended --disableupdate on config.sh: one-job // runner, no interactive prompts, no runtime self-update during the // session. GitHub auto-deregisters ephemeral runners after their job, @@ -105708,7 +105716,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo 'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1', 'GH_RUNNER_STEP=configuring', 'gh_runner_phone_home configuring', - `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`, + `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --no-default-labels --ephemeral --unattended --disableupdate`, 'GH_RUNNER_STEP=registered', 'gh_runner_phone_home registered', './run.sh', diff --git a/src/aws.js b/src/aws.js index 1d319877..a226ef9d 100644 --- a/src/aws.js +++ b/src/aws.js @@ -432,7 +432,7 @@ function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegist 'cd /home/runner/actions-runner', 'rm -f .runner .credentials .credentials_rsaparams', 'gh_runner_phone_home configuring', - 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --ephemeral --unattended --disableupdate', + 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --no-default-labels --ephemeral --unattended --disableupdate', 'GH_RUNNER_STEP=registered', 'gh_runner_phone_home registered', 'sudo -u runner -H ./run.sh', @@ -539,6 +539,14 @@ function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegist // RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone. Runner has its own // home under /home/runner/ and writes config.sh state there. // +// - --no-default-labels on config.sh: the runner carries ONLY the unique +// per-run label, never the implicit self-hosted/Linux/X64 set. Without +// it, any job in the repo (including a fork PR's own job, once approved +// or from a returning contributor) could target `runs-on: [self-hosted, +// linux, x64]`, queue for up to 24h, and grab the runner the next time a +// trusted run starts one -- along with its instance role, subnet, EIP and +// the registration token in IMDS user-data. Requires runner >= 2.299.0. +// // - --ephemeral --unattended --disableupdate on config.sh: one-job // runner, no interactive prompts, no runtime self-update during the // session. GitHub auto-deregisters ephemeral runners after their job, @@ -668,7 +676,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo 'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1', 'GH_RUNNER_STEP=configuring', 'gh_runner_phone_home configuring', - `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`, + `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --no-default-labels --ephemeral --unattended --disableupdate`, 'GH_RUNNER_STEP=registered', 'gh_runner_phone_home registered', './run.sh', diff --git a/tests/phone-home-detail.test.js b/tests/phone-home-detail.test.js index b1c50e89..bdf0bfc9 100644 --- a/tests/phone-home-detail.test.js +++ b/tests/phone-home-detail.test.js @@ -136,7 +136,7 @@ describe('phone-home failure-detail capture (real bash execution)', () => { '\nGH_REGISTER_SCRIPT', ).replace(/^#!\/bin\/bash\n/, ''); - const marker = 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --ephemeral --unattended --disableupdate'; + const marker = 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --no-default-labels --ephemeral --unattended --disableupdate'; const value = runAndCapturePhoneHome(registerScriptBody, { marker, fakeCommand: LONG_MULTILINE_FAKE_CMD.call, @@ -186,7 +186,7 @@ describe('phone-home failure-detail capture (real bash execution)', () => { const ud = buildUserData({ ...args, reuse: 'terminate' }); const heredocBody = extractBetween(ud, "sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'\n", '\nRUNNER_BOOTSTRAP'); - const marker = './config.sh --url "https://github.com/o/r" --token "TOK" --labels "l" --ephemeral --unattended --disableupdate'; + const marker = './config.sh --url "https://github.com/o/r" --token "TOK" --labels "l" --no-default-labels --ephemeral --unattended --disableupdate'; const value = runAndCapturePhoneHome(heredocBody, { marker, fakeCommand: LONG_MULTILINE_FAKE_CMD.call, diff --git a/tests/userdata.test.js b/tests/userdata.test.js index 262b9759..8c878d76 100644 --- a/tests/userdata.test.js +++ b/tests/userdata.test.js @@ -97,6 +97,21 @@ describe('buildUserData', () => { expect(ud).toContain('--ephemeral --unattended --disableupdate'); }); + // Without --no-default-labels the runner also gets self-hosted/Linux/X64, + // which lets any job in the repo (e.g. a fork PR's own job) target + // `runs-on: [self-hosted, linux, x64]` and take the runner from the + // trusted run that started it. + test('registers with --no-default-labels so only the unique label matches (cold launch)', () => { + const line = buildUserData(args).split('\n').find(l => l.includes('./config.sh ')); + expect(line).toContain('--labels "runner-abc12" --no-default-labels'); + }); + + test('registers with --no-default-labels on every warm-pool boot (reuse: stop)', () => { + const lines = buildUserData({ ...args, reuse: 'stop' }).split('\n').filter(l => l.includes('./config.sh ')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('--labels "$GH_LABEL" --no-default-labels'); + }); + test('arms a TTL self-destruct shutdown when max-lifetime-minutes > 0', () => { const ud = buildUserData({ ...args, maxLifetimeMinutes: '360' }); expect(ud).toContain('shutdown -h +360 || true');