#!/usr/bin/env python3 from pathlib import Path import unittest from check_workflow_security import check_workflow SAFE_SHA = "a" * 40 ROOT = Path(__file__).resolve().parents[1] CI_PATH = Path(".github/workflows/ci.yml") class WorkflowSecurityTests(unittest.TestCase): def check(self, text: str, path: Path | None = None) -> list[str]: return check_workflow(path or Path(".github/workflows/test.yml"), text) def test_accepts_read_only_pull_request_workflow_with_pinned_action(self) -> None: errors = self.check( f"""name: Test on: pull_request: {{}} jobs: test: permissions: {{contents: read}} steps: - name: Checkout uses: "actions/checkout@{SAFE_SHA}" # reviewed pin with: persist-credentials: "false" """ ) self.assertEqual(errors, []) def test_accepts_benign_ci_names_runners_matrices_and_commands(self) -> None: workflow = (ROOT / CI_PATH).read_text(encoding="utf-8") changed = ( workflow.replace("name: CI - Sanity Checks", "name: Continuous integration") .replace(" analyze:\n", " static-analysis:\n", 1) .replace("name: Code Analysis", "name: Repository checks", 1) .replace("runs-on: ubuntu-latest", "runs-on: internal-linux", 1) .replace("- sanitizer: address", "- sanitizer: memory", 1) .replace("dart run scripts/check_analyzer.dart", "dart run tool/check.dart", 1) ) self.assertEqual(self.check(changed, CI_PATH), []) def test_accepts_a_different_immutable_action_pin(self) -> None: workflow = f"jobs:\n test:\n steps:\n - uses: actions/setup-go@{'b' * 40}\n" self.assertEqual(self.check(workflow), []) def test_rejects_mutable_action_reference(self) -> None: errors = self.check("jobs:\n test:\n steps:\n - uses: actions/checkout@v7\n") self.assertTrue(any("full commit SHA" in error for error in errors)) def test_rejects_missing_checkout_credential_guard_on_pull_requests(self) -> None: errors = self.check( f"""on: [pull_request] jobs: test: steps: - uses: actions/checkout@{SAFE_SHA} with: fetch-depth: 1 - run: echo 'persist-credentials: false elsewhere is not enough' """ ) self.assertTrue(any("discard GitHub credentials" in error for error in errors)) def test_rejects_secrets_in_pull_request_workflow(self) -> None: errors = self.check( "on:\n pull_request:\njobs:\n test:\n env:\n TOKEN: ${{ secrets.TOKEN }}\n" ) self.assertTrue(any("must not reference repository secrets" in error for error in errors)) def test_rejects_block_or_flow_write_permission_on_pull_requests(self) -> None: block_errors = self.check( "on:\n pull_request:\njobs:\n test:\n permissions:\n contents: write\n" ) flow_errors = self.check( "on: {pull_request: {}}\npermissions: {contents: write}\n" ) self.assertTrue(any("must not request write permissions" in error for error in block_errors)) self.assertTrue(any("must not request write permissions" in error for error in flow_errors)) def test_rejects_privileged_untrusted_triggers(self) -> None: target_errors = self.check("on:\n pull_request_target:\n") run_errors = self.check("on: [push, workflow_run]\n") self.assertTrue(any("pull_request_target" in error for error in target_errors)) self.assertTrue(any("workflow_run" in error for error in run_errors)) def test_rejects_mutable_raw_github_download(self) -> None: errors = self.check( "jobs:\n test:\n steps:\n - run: curl https://raw.githubusercontent.com/o/r/main/tool.sh\n" ) self.assertTrue(any("immutable commit" in error for error in errors)) def test_rejects_ci_fail_open_constructs(self) -> None: continued = self.check( "jobs:\n test:\n steps:\n - continue-on-error: ${{ github.event_name == 'push' }}\n run: ./check\n", CI_PATH, ) suppressed = self.check( "jobs:\n test:\n steps:\n - run: ./check || true\n", CI_PATH, ) explicit_false = self.check( "jobs:\n test:\n steps:\n - continue-on-error: false\n run: ./check\n", CI_PATH, ) self.assertTrue(any("continue-on-error" in error for error in continued)) self.assertTrue(any("suppress failure" in error for error in suppressed)) self.assertEqual(explicit_false, []) def test_comments_do_not_create_security_findings(self) -> None: errors = self.check( """on: push: # pull_request_target: jobs: test: steps: # uses: actions/checkout@main - run: echo safe """ ) self.assertEqual(errors, []) if __name__ == "__main__": unittest.main()