-
Notifications
You must be signed in to change notification settings - Fork 56
fix(ci): block fork code on self-hosted Swift runner #4102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.1-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| #!/usr/bin/env python3 | ||
| """Verify that fork pull requests cannot reach the Swift self-hosted runner.""" | ||
|
|
||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| CALLER_PATH = Path(".github/workflows/tests.yml") | ||
| CALLEE_PATH = Path(".github/workflows/swift-sdk-build.yml") | ||
| JOB_NAME = "swift-sdk-build" | ||
| CALLEE_POLICY = ( | ||
| "github.event_name!='pull_request'||" | ||
| "github.event.pull_request.head.repo.full_name==github.repository" | ||
| ) | ||
| CALLER_POLICY = ( | ||
| "always()&&(" | ||
| f"{CALLEE_POLICY}" | ||
| ")&&needs.changes.outputs.swift-sdk-changed=='true'" | ||
| "&&needs.changes.result!='failure'" | ||
| "&&needs.rs-workspace-tests.result!='failure'" | ||
| "&&needs.rs-wallet-tests.result!='failure'" | ||
| ) | ||
|
|
||
|
|
||
| def job_block(workflow: str, job_name: str) -> str: | ||
| """Return one top-level job block without accepting sibling job guards.""" | ||
| lines = workflow.splitlines(keepends=True) | ||
| start_pattern = re.compile(rf"^ {re.escape(job_name)}:\s*(?:#.*)?$") | ||
| sibling_pattern = re.compile(r"^ [A-Za-z0-9_-]+:\s*(?:#.*)?$") | ||
|
|
||
| start = next( | ||
| ( | ||
| index | ||
| for index, line in enumerate(lines) | ||
| if start_pattern.match(line.rstrip("\n")) | ||
| ), | ||
| None, | ||
| ) | ||
| if start is None: | ||
| return "" | ||
|
|
||
| end = len(lines) | ||
| for index in range(start + 1, len(lines)): | ||
| if sibling_pattern.match(lines[index].rstrip("\n")): | ||
| end = index | ||
| break | ||
| return "".join(lines[start:end]) | ||
|
|
||
|
|
||
| def job_property(block: str, property_name: str) -> str: | ||
| """Return one job-level property, excluding step-level lookalikes.""" | ||
| lines = block.splitlines(keepends=True) | ||
| start_pattern = re.compile(rf"^ {re.escape(property_name)}:\s*(?:.*)?$") | ||
| sibling_pattern = re.compile(r"^ [A-Za-z0-9_-]+:\s*(?:.*)?$") | ||
|
|
||
| start = next( | ||
| ( | ||
| index | ||
| for index, line in enumerate(lines) | ||
| if start_pattern.match(line.rstrip("\n")) | ||
| ), | ||
| None, | ||
| ) | ||
| if start is None: | ||
| return "" | ||
|
|
||
| end = len(lines) | ||
| for index in range(start + 1, len(lines)): | ||
| if sibling_pattern.match(lines[index].rstrip("\n")): | ||
| end = index | ||
| break | ||
| return "".join(lines[start:end]) | ||
|
|
||
|
|
||
| def normalized_if(block: str) -> str: | ||
| """Normalize one job condition for an exact policy comparison.""" | ||
| condition = job_property(block, "if") | ||
| if not condition: | ||
| return "" | ||
|
|
||
| first_line, *continuation = condition.splitlines() | ||
| first_value = first_line.split(":", 1)[1].strip() | ||
| if first_value in {">", ">-", "|", "|-"}: | ||
| first_value = "" | ||
| expression = first_value + "".join(line.strip() for line in continuation) | ||
| if expression.startswith("$" + "{{") and expression.endswith("}}"): | ||
| expression = expression[3:-2] | ||
| return re.sub(r"\s+", "", expression) | ||
|
|
||
|
|
||
| def check_policy(root: Path) -> list[str]: | ||
| caller = (root / CALLER_PATH).read_text(encoding="utf-8") | ||
| callee = (root / CALLEE_PATH).read_text(encoding="utf-8") | ||
| caller_job = job_block(caller, JOB_NAME) | ||
| callee_job = job_block(callee, JOB_NAME) | ||
| errors = [] | ||
|
|
||
| if not re.search(r"(?m)^ pull_request\s*:", caller): | ||
| errors.append(f"{CALLER_PATH}: expected pull_request trigger") | ||
| if not re.search( | ||
| r"uses:\s*\./\.github/workflows/swift-sdk-build\.yml(?:\s|$)", | ||
| job_property(caller_job, "uses"), | ||
| ): | ||
| errors.append(f"{CALLER_PATH}: {JOB_NAME} must call the Swift workflow") | ||
| if normalized_if(caller_job) != CALLER_POLICY: | ||
| errors.append(f"{CALLER_PATH}: {JOB_NAME} lacks the strict fork PR policy") | ||
| if normalized_if(callee_job) != CALLEE_POLICY: | ||
| errors.append(f"{CALLEE_PATH}: {JOB_NAME} lacks the strict fork PR policy") | ||
| if not re.search( | ||
| r"(?m)^\s*runs-on:.*\bself-hosted\b", job_property(callee_job, "runs-on") | ||
| ): | ||
| errors.append(f"{CALLEE_PATH}: expected self-hosted runner boundary") | ||
| if not re.search( | ||
| r"(?m)^\s*bash\s+packages/swift-sdk/run_tests\.sh\s*$", callee_job | ||
| ): | ||
| errors.append(f"{CALLEE_PATH}: expected Swift test entrypoint") | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| def main() -> int: | ||
| root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() | ||
| try: | ||
| errors = check_policy(root) | ||
| except (OSError, UnicodeDecodeError) as error: | ||
| print(f"self-hosted Swift runner policy check failed: {error}", file=sys.stderr) | ||
| return 2 | ||
|
|
||
| if errors: | ||
| for error in errors: | ||
| print(f"error: {error}", file=sys.stderr) | ||
| return 1 | ||
|
|
||
| print("Self-hosted Swift runner policy OK: fork pull requests are rejected") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ on: | |
| jobs: | ||
| swift-sdk-build: | ||
| name: Swift SDK build + tests (warnings as errors) | ||
| if: >- | ||
| github.event_name != 'pull_request' | ||
| || github.event.pull_request.head.repo.full_name == github.repository | ||
|
Comment on lines
+9
to
+11
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: New guard omits the repo's established Sibling self-hosted workflows allow source: ['sonnet5-general'] |
||
| runs-on: [self-hosted, macOS, ARM64] | ||
| timeout-minutes: 90 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,9 @@ jobs: | |
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Verify self-hosted Swift runner policy | ||
| run: python3 .github/scripts/check-swift-self-hosted-runner.py | ||
|
|
||
| - uses: dorny/paths-filter@v4 | ||
| id: filter-js | ||
| if: ${{ github.event_name != 'workflow_dispatch' }} | ||
|
|
@@ -342,7 +345,14 @@ jobs: | |
| # At most one of the two Rust jobs runs (none for e.g. Swift-only PRs); | ||
| # skipped jobs report result 'skipped', not 'failure', so this only | ||
| # blocks on a Rust job that actually ran and failed. | ||
| if: ${{ always() && needs.changes.outputs.swift-sdk-changed == 'true' && needs.changes.result != 'failure' && needs.rs-workspace-tests.result != 'failure' && needs.rs-wallet-tests.result != 'failure' }} | ||
| if: >- | ||
| ${{ always() | ||
| && (github.event_name != 'pull_request' | ||
| || github.event.pull_request.head.repo.full_name == github.repository) | ||
| && needs.changes.outputs.swift-sdk-changed == 'true' | ||
| && needs.changes.result != 'failure' | ||
| && needs.rs-workspace-tests.result != 'failure' | ||
| && needs.rs-wallet-tests.result != 'failure' }} | ||
| secrets: inherit | ||
| uses: ./.github/workflows/swift-sdk-build.yml | ||
|
Comment on lines
+348
to
357
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Guard lives entirely inside fork-controlled content, so it doesn't close DS-CAND-103 against a real attacker For pull_request events, GitHub runs workflow YAML from the PR merge commit, and the local reusable workflow resolves from that same commit. A malicious fork therefore controls the caller guard, the callee guard, and the checked-out policy checker, and can weaken all three together. Keep these checks as defense in depth, but close the trust boundary with a server-side control the PR cannot edit, such as required environment approval or repository/organization approval for outside-collaborator workflow runs. source: ['codex-general', 'sonnet5-general'] |
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 Nitpick: Regex-based YAML folding is fragile to harmless reformatting
The checker depends on exact indentation and manual line folding, so semantically neutral YAML reformatting can cause false CI failures. It fails closed, but structured YAML parsing would reduce maintenance risk.
source: ['sonnet5-general']