Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/tests/test-pr-event-workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Regression checks for PR workflow trust boundaries."""
from pathlib import Path
import unittest

import yaml

ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS = ROOT / '.github' / 'workflows'
SOURCE = '.github/workflows/pull-request-events.yaml'
HEAD_GUARD = 'github.event.pull_request.head.repo.full_name == github.repository'
FORBIDDEN_TRIGGER = 'pull_request' + '_target'


def workflow(name):
return yaml.load((WORKFLOWS / name).read_text(), Loader=yaml.BaseLoader)


class WorkflowTrustTests(unittest.TestCase):
def test_no_target_triggers(self):
for path in WORKFLOWS.iterdir():
if path.suffix not in ('.yaml', '.yml'):
continue
data = yaml.load(path.read_text(), Loader=yaml.BaseLoader)
with self.subTest(path=path.name):
self.assertNotIn(FORBIDDEN_TRIGGER, data.get('on', {}))
self.assertNotIn(FORBIDDEN_TRIGGER, path.read_text())

def test_signal_has_no_privilege_or_untrusted_outputs(self):
signal = workflow('pull-request-events.yaml')
self.assertEqual(set(signal['on']), {'pull_request'})
self.assertEqual(set(signal['on']['pull_request']['types']), {'labeled'})
self.assertEqual(signal['permissions'], {})
self.assertEqual(set(signal['jobs']), {'relay'})
relay = signal['jobs']['relay']
self.assertEqual(relay['permissions'], {})
self.assertEqual(len(relay['steps']), 1)
self.assertEqual(relay['steps'][0]['run'], ':')
self.assertNotIn('uses', relay['steps'][0])
self.assertNotIn('secrets.', (WORKFLOWS / 'pull-request-events.yaml').read_text())

def test_trusted_backport_resolves_and_forwards_only_verified_events(self):
backport = workflow('backport.yaml')
self.assertEqual(backport['permissions'], {})
self.assertEqual(backport['on']['push']['branches'], ['main'])
self.assertEqual(backport['on']['workflow_run']['workflows'], ['Pull request events'])
self.assertEqual(backport['on']['workflow_run']['types'], ['completed'])
event, call = backport['jobs']['event'], backport['jobs']['backport']
condition = event['if']
for clause in ("github.ref == format('refs/heads/{0}', github.event.repository.default_branch)",
"endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch))",
"github.event_name == 'push'", "github.event.workflow_run.conclusion == 'success'",
"github.event.workflow_run.event == 'pull_request'"):
self.assertIn(clause, condition)
readonly = {'actions': 'read', 'contents': 'read', 'pull-requests': 'read'}
self.assertEqual(event['permissions'], readonly)
self.assertEqual(call['permissions'], readonly)
step = event['steps'][0]
self.assertEqual(step['with']['source-workflow'], SOURCE)
self.assertEqual(call['with']['source-workflow'], SOURCE)
self.assertEqual(event['outputs']['events-json'], '${{ steps.event.outputs.events-json }}')
self.assertEqual(call['needs'], 'event')
self.assertEqual(call['if'], "needs.event.outputs.eligible == 'true'")
self.assertEqual(call['strategy']['matrix']['event'], '${{ fromJSON(needs.event.outputs.events-json) }}')
self.assertEqual(call['with']['pr-event'], '${{ toJSON(matrix.event) }}')
resolver = step['uses'].rsplit('@', 1)[1]
reusable = call['uses'].rsplit('@', 1)[1]
self.assertEqual(resolver, 'resolve-pr-event/v1')
self.assertEqual(reusable, 'backport/v2')

def test_pr_code_jobs_are_read_only_and_drop_checkout_credentials(self):
for name in ('e2e.yaml', 'e2e-ginkgo.yaml', 'unit-tests.yaml', 'lint.yaml'):
data = workflow(name)
with self.subTest(workflow=name):
self.assertEqual(data['permissions'], {'contents': 'read'})
for job_name, job in data['jobs'].items():
for step in job.get('steps', []):
if step.get('uses', '').startswith('actions/checkout@'):
self.assertEqual(step.get('with', {}).get('persist-credentials'), 'false', job_name)

def test_privileged_pr_jobs_require_same_repository(self):
for name, job in (('auto-approve-bot-prs.yaml', 'auto-approve'),
('conflict-check.yaml', 'conflict-check'),
('clean-github-cache.yaml', 'clean-github-cache'),
('actionlint.yml', 'actionlint')):
data = workflow(name)
with self.subTest(workflow=name):
self.assertEqual(data['permissions'], {})
self.assertIn(HEAD_GUARD, data['jobs'][job]['if'])
ginkgo = workflow('e2e-ginkgo.yaml')['jobs']['ginkgo-e2e-tests']
self.assertIn(HEAD_GUARD, ginkgo['if'])
self.assertIn("github.event_name != 'pull_request'", ginkgo['if'])
self.assertIn("github.event_name == 'workflow_dispatch'", ginkgo['if'])
lint = workflow('lint.yaml')['jobs']['golangci']
secret_steps = [s for s in lint['steps'] if 'secrets.' in str(s)]
self.assertTrue(secret_steps)
for step in secret_steps:
self.assertIn(HEAD_GUARD, step['if'])


if __name__ == '__main__':
unittest.main()
19 changes: 16 additions & 3 deletions .github/workflows/actionlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,29 @@ on:
pull_request:
paths:
- ".github/workflows/**"
- ".github/tests/test-pr-event-workflows.py"

permissions:
contents: read
pull-requests: write
permissions: {}

jobs:
actionlint:
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
pull-requests: write
uses: loft-sh/github-actions/.github/workflows/actionlint.yaml@actionlint/v1
with:
reporter: github-pr-check

workflow-security-tests:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Install YAML parser
run: python3 -m pip install PyYAML==6.0.1
- name: Test PR workflow trust boundaries
run: python3 .github/tests/test-pr-event-workflows.py
17 changes: 9 additions & 8 deletions .github/workflows/auto-approve-bot-prs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ on:
types: [opened, synchronize]

# checks/statuses are what the shared workflow's CI poll reads. They must be
# granted here, not there: a reusable workflow can only downgrade the caller's
# granted by the caller job, not just the shared workflow: a reusable workflow can only downgrade the caller's
# GITHUB_TOKEN permissions, never elevate them, and anything omitted defaults to
# none. GITHUB_TOKEN is scoped strictly by this block whatever the repository's
# none. GITHUB_TOKEN is scoped strictly by the job permissions block whatever the repository's
# visibility, so omitting them makes the poll fail and the bot silently never
# approve, while both the check and the job still report success.
permissions:
contents: read
pull-requests: write
checks: read
statuses: read
permissions: {}

jobs:
auto-approve:
if: startsWith(github.head_ref, 'backport/')
if: github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'backport/')
permissions:
contents: read
pull-requests: write
checks: read
statuses: read
uses: loft-sh/github-actions/.github/workflows/auto-approve-bot-prs.yaml@auto-approve-bot-prs/v1 # a889ee9
with:
trusted-authors: 'loft-bot'
Expand Down
51 changes: 46 additions & 5 deletions .github/workflows/backport.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,56 @@
name: Automatic backport action

on:
pull_request_target:
types: ["labeled", "closed"]
# Merges still reach backporting when the PR signal cannot run, for example
# while the PR has conflicts. The resolver verifies merged default-branch PRs.
push:
branches: [main]
workflow_run: # zizmor: ignore[dangerous-triggers] -- API-verified merged PRs only; no PR code or artifacts are executed
workflows: ["Pull request events"]
types: [completed]

permissions:
contents: read
permissions: {}

cache-mode: none

jobs:
event:
if: >-
github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
endsWith(github.workflow_ref, format('@refs/heads/{0}', github.event.repository.default_branch)) &&
(github.event_name == 'push' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
pull-requests: read
outputs:
eligible: ${{ steps.event.outputs.eligible }}
events-json: ${{ steps.event.outputs.events-json }}
steps:
- name: Resolve merged pull requests from GitHub
id: event
uses: loft-sh/github-actions/.github/actions/resolve-pr-event@resolve-pr-event/v1
with:
source-workflow: .github/workflows/pull-request-events.yaml

backport:
uses: loft-sh/github-actions/.github/workflows/backport.yaml@backport/v1
needs: event
if: needs.event.outputs.eligible == 'true'
permissions:
actions: read
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
event: ${{ fromJSON(needs.event.outputs.events-json) }}
uses: loft-sh/github-actions/.github/workflows/backport.yaml@backport/v2
with:
pr-event: ${{ toJSON(matrix.event) }}
source-workflow: .github/workflows/pull-request-events.yaml
secrets:
gh-access-token: ${{ secrets.GH_ACCESS_TOKEN }}
linear-token: ${{ secrets.LINEAR_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/clean-github-cache.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ on:
types:
- closed

permissions:
actions: write
permissions: {}

jobs:
clean-github-cache:
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
actions: write
uses: loft-sh/github-actions/.github/workflows/clean-github-cache.yaml@clean-github-cache/v1
5 changes: 2 additions & 3 deletions .github/workflows/conflict-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ on:
branches:
- 'v**'

permissions:
contents: read
pull-requests: write
permissions: {}

jobs:
conflict-check:
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
pull-requests: write
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/e2e-ginkgo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: vCluster E2E CI (Ginkgo)

permissions:
contents: read
pull-requests: write

on:
workflow_dispatch:
Expand Down Expand Up @@ -72,6 +71,9 @@ jobs:
label-filter-input: ${{ inputs.ginkgo-label }}

detect_changes:
permissions:
contents: read
pull-requests: read
# PR-only: the release path is a workflow_dispatch --ref <tag> dispatch that
# always validates and bypasses change detection; entry jobs break
# skip-propagation with !cancelled().
Expand Down Expand Up @@ -106,6 +108,7 @@ jobs:
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false

- run: git fetch --force --tags

Expand Down Expand Up @@ -174,6 +177,7 @@ jobs:
- detect_changes
if: >-
!cancelled() && needs.build.result == 'success' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
needs.parse_label-filter.outputs.skip-edited != 'true' &&
(
(github.event_name == 'pull_request' && needs.detect_changes.outputs.has_changed == 'true') ||
Expand All @@ -190,6 +194,7 @@ jobs:
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
persist-credentials: false

- run: git fetch --force --tags

Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: E2E CI

permissions:
contents: read

on:
pull_request:
branches:
Expand All @@ -20,6 +23,9 @@ env:

jobs:
detect_changes:
permissions:
contents: read
pull-requests: read
# PR-only: the release path is a workflow_dispatch --ref <tag> dispatch that
# always validates, so it bypasses change detection. Entry jobs break the
# skip-propagation with !cancelled().
Expand Down Expand Up @@ -51,6 +57,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
fetch-depth: 0

- run: git fetch --force --tags
Expand Down Expand Up @@ -102,6 +109,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- id: set-paths-matrix
run: |
set -x
Expand Down Expand Up @@ -134,6 +143,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
name: Setup Helm
Expand Down Expand Up @@ -225,6 +236,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
name: Setup Helm
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Lint

permissions:
contents: read

on:
pull_request:
branches:
Expand Down Expand Up @@ -32,6 +35,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/pull-request-events.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Pull request events

on:
pull_request:
# Merges use the trusted push consumer; signaling close as well would repeat
# the same backport. Labels added after merge still need their own signal.
types: [labeled]

permissions: {}

jobs:
relay:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
# The trusted consumer resolves PR identity through GitHub's API. Nothing
# supplied by this PR revision (code, artifacts, or event JSON) is consumed.
- name: Signal pull request activity
run: ':'
Loading
Loading