From 21007a86623488cca8683b0a0ed28a917d7baef8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:56:55 +0100 Subject: [PATCH] Isolate private governance from pull requests --- .github/workflows/README.md | 13 +- .github/workflows/canonical-governance.yml | 103 +--------------- .../canonical-private-governance.yml | 114 ++++++++++++++++++ .../canonical_development_protocol_test.go | 38 ++++-- scripts/check_workflow_trust.py | 75 +++++++++++- scripts/tests/test_workflow_trust.py | 62 ++++++++++ 6 files changed, 295 insertions(+), 110 deletions(-) create mode 100644 .github/workflows/canonical-private-governance.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f23433a127..d18c38dc5c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -14,8 +14,17 @@ machine-checked `# required: authenticated git writes` rationale. Each workflow declares its default `GITHUB_TOKEN` permissions explicitly and enumerates scopes instead of using `read-all` or `write-all`. Workflow inputs, -secrets, and `github.token` are passed to `run` steps through `env`; they are -data and must never be interpolated into the generated shell program. +secrets, `github.token`, and attacker-controlled GitHub event metadata are +passed to `run` steps through `env`; they are data and must never be +interpolated into the generated shell program. + +Workflows triggered by `pull_request` cannot reference confidential repository +secrets. Canonical governance therefore keeps its pull-request checks local to +the public checkout. `canonical-private-governance.yml` performs cross-repo +status, control-plane, mobile compatibility, and repo-governance checks only +after a push to `main`, so pull-request code cannot replace the instructions +that receive `WORKFLOW_PAT`. `PULSE_LICENSE_PUBLIC_KEY` is the sole explicit PR +exception because that legacy secret value is intentionally non-confidential. ## Release Continuity diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index eca10d4b14..8ff00c59c1 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -15,10 +15,9 @@ permissions: jobs: governance: runs-on: ubuntu-24.04 - # The release-control audits resolve the workspace layout from the - # local checkout: the repo directory must be named exactly "pulse" - # (the canonical repo id) and the evidence repos must be checked out - # as siblings, mirroring /repos/ on dev machines. + # Keep pull-request governance free of repository credentials. Cross-repo + # evidence checks run after merge in canonical-private-governance.yml, + # whose workflow definition cannot be replaced by pull-request code. defaults: run: working-directory: repos/pulse @@ -30,56 +29,6 @@ jobs: fetch-depth: 0 path: repos/pulse - - name: Checkout pulse-pro evidence repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: rcourtman/pulse-pro - token: ${{ secrets.WORKFLOW_PAT }} - persist-credentials: false - fetch-depth: 1 - path: repos/pulse-pro - - - name: Checkout pulse-enterprise evidence repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: rcourtman/pulse-enterprise - token: ${{ secrets.WORKFLOW_PAT }} - persist-credentials: false - fetch-depth: 1 - path: repos/pulse-enterprise - - - name: Resolve pulse-mobile evidence ref - id: pulse-mobile-ref - shell: bash - env: - CANDIDATE_REF: ${{ github.head_ref }} - GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} - run: | - set -euo pipefail - resolved_ref="main" - if [ -n "${CANDIDATE_REF}" ]; then - matching_ref="$(gh api \ - "repos/rcourtman/pulse-mobile/git/matching-refs/heads/${CANDIDATE_REF}" \ - --jq '.[].ref' 2>/dev/null \ - | grep --fixed-strings --line-regexp -- "refs/heads/${CANDIDATE_REF}" \ - || true)" - if [ -n "${matching_ref}" ]; then - resolved_ref="${CANDIDATE_REF}" - fi - fi - echo "ref=${resolved_ref}" >> "${GITHUB_OUTPUT}" - echo "Using pulse-mobile ref: ${resolved_ref}" - - - name: Checkout pulse-mobile evidence repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: rcourtman/pulse-mobile - ref: ${{ steps.pulse-mobile-ref.outputs.ref }} - token: ${{ secrets.WORKFLOW_PAT }} - persist-credentials: false - fetch-depth: 1 - path: repos/pulse-mobile - - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -103,29 +52,6 @@ jobs: - name: Stub embedded frontend assets for Go tests run: bash scripts/ensure_test_assets.sh - - name: Check Pulse Mobile compatibility contract - run: | - python3 scripts/release_control/generate_mobile_compatibility_test.py - python3 scripts/release_control/mobile_compatibility_test.py - python3 scripts/release_control/generate_mobile_compatibility.py --check --mobile-repo ../pulse-mobile - python3 scripts/release_control/mobile_compatibility.py \ - --mobile-repo ../pulse-mobile \ - --evidence-out "${RUNNER_TEMP}/pulse-mobile-compatibility.json" \ - --pretty - go test ./internal/api \ - -run 'Test(RelayMobileRuntimeRouteInventory|RelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints|RelayMobileAccessScopeDeniesAdjacentAIRoutes|Contract_PatrolAttentionRoutesAcceptMobileRelayCapability)$' \ - -count=1 - go test ./internal/relay \ - -run 'Test(CanonicalActionOutcomeNotificationMatrix|NewPatrolFindingNotification|NewApprovalRequestNotification|NewActionDecisionNotificationUsesCanonicalActionIdentity|NewFixCompletedNotification|NewActionOutcomeNotificationIsHonestAboutVerification)$' \ - -count=1 - - - name: Upload Pulse Mobile compatibility evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pulse-mobile-compatibility - path: ${{ runner.temp }}/pulse-mobile-compatibility.json - if-no-files-found: error - - name: Determine governance diff range id: diff shell: bash @@ -195,28 +121,12 @@ jobs: done exit ${status} - - name: Run status audit - env: - PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse - PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro - PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise - PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile - run: python3 scripts/release_control/status_audit.py --check - - name: Validate Pulse Intelligence release-gate schema run: python3 scripts/release_control/pulse_intelligence_gate.py --validate-only --matrix docs/release-control/v6/internal/pulse-intelligence-release-gate.json - name: Run Pulse Intelligence release-gate unit tests run: python3 scripts/release_control/pulse_intelligence_gate_test.py - - name: Run control plane audit - env: - PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse - PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro - PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise - PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile - run: python3 scripts/release_control/control_plane_audit.py --check - - name: Run registry audit run: python3 scripts/release_control/registry_audit.py --check @@ -274,12 +184,7 @@ jobs: - name: Run subsystem lookup unit tests run: python3 scripts/release_control/subsystem_lookup_test.py - - name: Run repo governance guardrail tests - env: - PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse - PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro - PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise - PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile + - name: Run repo governance guardrail unit tests run: go test ./internal/repoctl -count=1 - name: Run active-target automated readiness assertion proofs diff --git a/.github/workflows/canonical-private-governance.yml b/.github/workflows/canonical-private-governance.yml new file mode 100644 index 0000000000..9a5dd685f7 --- /dev/null +++ b/.github/workflows/canonical-private-governance.yml @@ -0,0 +1,114 @@ +name: Canonical Private Governance + +# Repository credentials are deliberately confined to a workflow definition +# loaded by a push to main. Pull-request code must never be able to replace the +# instructions that receive WORKFLOW_PAT or execute with private checkouts. +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: canonical-private-governance-main + cancel-in-progress: false + +jobs: + private-governance: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: repos/pulse + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + fetch-depth: 1 + path: repos/pulse + + - name: Checkout pulse-pro evidence repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: rcourtman/pulse-pro + token: ${{ secrets.WORKFLOW_PAT }} + persist-credentials: false + fetch-depth: 1 + path: repos/pulse-pro + + - name: Checkout pulse-enterprise evidence repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: rcourtman/pulse-enterprise + token: ${{ secrets.WORKFLOW_PAT }} + persist-credentials: false + fetch-depth: 1 + path: repos/pulse-enterprise + + - name: Checkout pulse-mobile evidence repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: rcourtman/pulse-mobile + token: ${{ secrets.WORKFLOW_PAT }} + persist-credentials: false + fetch-depth: 1 + path: repos/pulse-mobile + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: repos/pulse/go.mod + cache: true + cache-dependency-path: repos/pulse/go.sum + + - name: Stub embedded frontend assets for Go tests + run: bash scripts/ensure_test_assets.sh + + - name: Check Pulse Mobile compatibility contract + run: | + python3 scripts/release_control/generate_mobile_compatibility_test.py + python3 scripts/release_control/mobile_compatibility_test.py + python3 scripts/release_control/generate_mobile_compatibility.py --check --mobile-repo ../pulse-mobile + python3 scripts/release_control/mobile_compatibility.py \ + --mobile-repo ../pulse-mobile \ + --evidence-out "${RUNNER_TEMP}/pulse-mobile-compatibility.json" \ + --pretty + go test ./internal/api \ + -run 'Test(RelayMobileRuntimeRouteInventory|RelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints|RelayMobileAccessScopeDeniesAdjacentAIRoutes|Contract_PatrolAttentionRoutesAcceptMobileRelayCapability)$' \ + -count=1 + go test ./internal/relay \ + -run 'Test(CanonicalActionOutcomeNotificationMatrix|NewPatrolFindingNotification|NewApprovalRequestNotification|NewActionDecisionNotificationUsesCanonicalActionIdentity|NewFixCompletedNotification|NewActionOutcomeNotificationIsHonestAboutVerification)$' \ + -count=1 + + - name: Upload Pulse Mobile compatibility evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pulse-mobile-compatibility + path: ${{ runner.temp }}/pulse-mobile-compatibility.json + if-no-files-found: error + + - name: Run status audit + env: + PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse + PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro + PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise + PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile + run: python3 scripts/release_control/status_audit.py --check + + - name: Run control plane audit + env: + PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse + PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro + PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise + PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile + run: python3 scripts/release_control/control_plane_audit.py --check + + - name: Run repo governance guardrail tests + env: + PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse + PULSE_REPO_ROOT_PULSE_PRO: ${{ github.workspace }}/repos/pulse-pro + PULSE_REPO_ROOT_PULSE_ENTERPRISE: ${{ github.workspace }}/repos/pulse-enterprise + PULSE_REPO_ROOT_PULSE_MOBILE: ${{ github.workspace }}/repos/pulse-mobile + run: go test ./internal/repoctl -count=1 diff --git a/internal/repoctl/canonical_development_protocol_test.go b/internal/repoctl/canonical_development_protocol_test.go index 1e13050f87..ac55a4932b 100644 --- a/internal/repoctl/canonical_development_protocol_test.go +++ b/internal/repoctl/canonical_development_protocol_test.go @@ -1860,20 +1860,13 @@ func TestCanonicalGovernanceRunsInCI(t *testing.T) { workflow := readRepoFile(t, ".github/workflows/canonical-governance.yml") assertContainsAll(t, ".github/workflows/canonical-governance.yml", workflow, []string{ "name: Canonical Governance", - "repository: rcourtman/pulse-pro", - "repository: rcourtman/pulse-enterprise", - "repository: rcourtman/pulse-mobile", + "pull_request:", "Set up Node.js for frontend governance", "cache-dependency-path: repos/pulse/frontend-modern/package-lock.json", "Install locked frontend governance dependencies", "npm ci --ignore-scripts --no-audit --no-fund", - "PULSE_REPO_ROOT_PULSE_PRO", - "PULSE_REPO_ROOT_PULSE_ENTERPRISE", - "PULSE_REPO_ROOT_PULSE_MOBILE", "python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin", "python3 scripts/release_control/browser_verification_guard.py", - "python3 scripts/release_control/status_audit.py --check", - "python3 scripts/release_control/control_plane_audit.py --check", "python3 scripts/release_control/registry_audit.py --check", "python3 scripts/release_control/contract_audit.py --check", "python3 scripts/release_control/readiness_assertion_guard.py --active-target --proof-type automated", @@ -1889,6 +1882,35 @@ func TestCanonicalGovernanceRunsInCI(t *testing.T) { "python3 scripts/release_control/status_audit_test.py", "python3 scripts/release_control/subsystem_lookup_test.py", }) + for _, confidentialReference := range []string{ + "secrets.WORKFLOW_PAT", + "repository: rcourtman/pulse-pro", + "repository: rcourtman/pulse-enterprise", + "repository: rcourtman/pulse-mobile", + } { + if strings.Contains(workflow, confidentialReference) { + t.Fatalf("pull-request governance contains confidential reference %q", confidentialReference) + } + } + + privateWorkflow := readRepoFile(t, ".github/workflows/canonical-private-governance.yml") + assertContainsAll(t, ".github/workflows/canonical-private-governance.yml", privateWorkflow, []string{ + "name: Canonical Private Governance", + "push:", + "repository: rcourtman/pulse-pro", + "repository: rcourtman/pulse-enterprise", + "repository: rcourtman/pulse-mobile", + "secrets.WORKFLOW_PAT", + "PULSE_REPO_ROOT_PULSE_PRO", + "PULSE_REPO_ROOT_PULSE_ENTERPRISE", + "PULSE_REPO_ROOT_PULSE_MOBILE", + "python3 scripts/release_control/status_audit.py --check", + "python3 scripts/release_control/control_plane_audit.py --check", + "go test ./internal/repoctl -count=1", + }) + if strings.Contains(privateWorkflow, "pull_request:") || strings.Contains(privateWorkflow, "workflow_dispatch:") { + t.Fatal("private governance must only load its credential-bearing definition from main pushes") + } install := strings.Index(workflow, "Install locked frontend governance dependencies") guard := strings.Index(workflow, "Run canonical completion guard against changed commits") diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index 574f08a454..8b3e19263e 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -20,8 +20,30 @@ EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}") # github.token because Actions makes that credential available independently of # an explicit secrets.GITHUB_TOKEN reference. SHELL_DATA_CONTEXT_RE = re.compile( - r"(? list[tuple[int, str]] return script +def _has_trigger(lines: list[str], event: str) -> bool: + """Return whether the top-level Actions trigger includes *event*.""" + event_re = re.compile(rf"(? list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings: list[Finding] = [] + if _has_trigger(lines, "pull_request"): + for index, line in enumerate(lines): + code = line.split("#", 1)[0] + secret_names = { + match.group(1) or match.group(3) + for match in SECRET_CONTEXT_RE.finditer(code) + } + if ( + secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS + or DYNAMIC_SECRET_CONTEXT_RE.search(code) + ): + findings.append( + Finding( + path, + index + 1, + "pull_request workflows must not reference confidential " + "repository secrets; isolate privileged work in a non-PR workflow", + ) + ) + permission_declarations = [ index for index, line in enumerate(lines) @@ -127,6 +192,14 @@ def audit_workflow(path: Path) -> list[Finding]: "workflow inputs and secrets must enter run scripts through env", ) ) + elif UNTRUSTED_GITHUB_CONTEXT_RE.search(expression): + findings.append( + Finding( + path, + script_index + 1, + "untrusted GitHub metadata must enter run scripts through env", + ) + ) match = USES_RE.search(code) if not match: diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index c830375806..32976bf5b4 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -121,6 +121,7 @@ jobs: runs-on: ubuntu-24.04 steps: - run: echo "${{ inputs.name }}" + - run: echo "${{ inputs['name'] }}" - run: | echo "${{ secrets.ACCESS_TOKEN }}" echo "${{ github.token }}" @@ -132,9 +133,70 @@ jobs: ) self.assertEqual( sum("must enter run scripts through env" in finding for finding in findings), + 4, + ) + + def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None: + findings = self.audit( + """on: [pull_request] +permissions: {} +jobs: + unsafe: + runs-on: ubuntu-24.04 + steps: + - run: echo "${{ github.event.pull_request.title }}" + - run: | + echo "${{ github.head_ref }}" + echo "${{ github.event.workflow_run.head_branch }}" + - env: + TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\\n' "$TITLE" >/dev/null + - run: echo "${{ github.event.pull_request.base.sha }}" +""" + ) + self.assertEqual( + sum("untrusted GitHub metadata" in finding for finding in findings), 3, ) + def test_pull_request_workflows_cannot_receive_confidential_secrets(self) -> None: + findings = self.audit( + """on: + push: + pull_request: +permissions: {} +jobs: + unsafe: + runs-on: ubuntu-24.04 + steps: + - env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} + GH_TOKEN_BRACKET: ${{ secrets['WORKFLOW_PAT'] }} + DYNAMIC_SECRET: ${{ secrets[vars.SECRET_NAME] }} + PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + PUBLIC_KEY_BRACKET: ${{ secrets['PULSE_LICENSE_PUBLIC_KEY'] }} + run: echo checked +""" + ) + self.assertEqual( + sum("must not reference confidential repository secrets" in finding for finding in findings), + 3, + ) + + trusted_push = self.audit( + """on: push +permissions: {} +jobs: + trusted: + runs-on: ubuntu-24.04 + steps: + - env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} + run: echo checked +""" + ) + self.assertEqual(trusted_push, []) + def test_repository_workflows_satisfy_contract(self) -> None: findings = workflow_trust.audit_directory(REPO_ROOT / ".github" / "workflows") self.assertEqual([finding.render() for finding in findings], [])