diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d18c38dc5..5d2e6b8bc 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -22,9 +22,10 @@ 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. +after a push to `main`, so unmerged 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 8ff00c59c..2296f246b 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -16,8 +16,8 @@ jobs: governance: runs-on: ubuntu-24.04 # 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. + # evidence checks run after merge in canonical-private-governance.yml, so + # its credential-bearing job never executes from an unmerged PR revision. defaults: run: working-directory: repos/pulse diff --git a/.github/workflows/canonical-private-governance.yml b/.github/workflows/canonical-private-governance.yml index 9a5dd685f..2921382a3 100644 --- a/.github/workflows/canonical-private-governance.yml +++ b/.github/workflows/canonical-private-governance.yml @@ -1,8 +1,9 @@ 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. +# loaded by a push to main. Unmerged pull-request code must never be able to +# replace the instructions that receive WORKFLOW_PAT or execute with private +# checkouts. on: push: branches: diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 200f6fc16..3d84a93d7 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -447,10 +447,10 @@ repository. It classifies every declared capability as `mobile-required`, scopes, required request/response fields, pairing data, and push navigation; and generates both the Go runtime inventory and Pulse Mobile TypeScript projection. `pulse-mobile:config/mobile-api-surface.json` is the consumer -minimum and released-line probe declaration. Canonical Governance must compare -that consumer against the exact Pulse manifest and retain revision-bound -evidence. Pulse Mobile's OTA gate separately proves the app against server -lines already in customers' hands. +minimum and released-line probe declaration. Canonical Private Governance must +compare that consumer against the exact Pulse manifest and retain +revision-bound evidence. Pulse Mobile's OTA gate separately proves the app +against server lines already in customers' hands. Proxy-auth administrator evaluation is a shared auth/API contract. Once `PROXY_AUTH_ROLE_HEADER` is configured, `internal/api/auth.go` must treat a diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 9907e0415..003523f05 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -111,7 +111,7 @@ dark-site signal and must not be conflated with this per-agent alert. 5. Keep mobile relay runtime changes tied to explicit proof in `pulse-mobile:src/relay/__tests__/` 6. Keep the operator Relay incapable of serving v6 grants until it has synchronously drained the authenticated revocation feed, and expose stale feed state through readiness. 7. Keep feed-applied restrictive events tied to proof that already-connected stale grants are disconnected and their persisted reconnect credentials are invalidated. -8. Keep exact-revision Pulse/Pulse Mobile compatibility evidence green in Canonical Governance and keep released-line compatibility green in the Pulse Mobile OTA gate. +8. Keep exact-revision Pulse/Pulse Mobile compatibility evidence green in Canonical Private Governance and keep released-line compatibility green in the Pulse Mobile OTA gate. 9. Keep server-side mobile operational metric changes tied to Relay bridge, device-store, and metric contract tests. These aggregate service signals must not be presented as unique-user, retention, app-open, screen-view, or feature-usage analytics. ## Current State diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index 8b3e19263..03ecf339c 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -20,7 +20,7 @@ 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[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) - } + expressions = EXPRESSION_RE.findall(code) + secret_names: set[str] = set() + has_unresolved_secret_reference = False + for expression in expressions: + static_references = list(SECRET_CONTEXT_RE.finditer(expression)) + secret_names.update( + match.group(1) or match.group(3) for match in static_references + ) + if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len( + static_references + ): + # Whole-context and dynamic references can expose any + # repository secret, so they cannot use the public-key + # exception reserved for a statically named value. + has_unresolved_secret_reference = True if ( secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS - or DYNAMIC_SECRET_CONTEXT_RE.search(code) + or has_unresolved_secret_reference ): findings.append( Finding( diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index 32976bf5b..9cde5aa30 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -122,6 +122,7 @@ jobs: steps: - run: echo "${{ inputs.name }}" - run: echo "${{ inputs['name'] }}" + - run: echo "${{ toJSON(secrets) }}" - run: | echo "${{ secrets.ACCESS_TOKEN }}" echo "${{ github.token }}" @@ -133,7 +134,7 @@ jobs: ) self.assertEqual( sum("must enter run scripts through env" in finding for finding in findings), - 4, + 5, ) def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None: @@ -173,6 +174,7 @@ jobs: GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} GH_TOKEN_BRACKET: ${{ secrets['WORKFLOW_PAT'] }} DYNAMIC_SECRET: ${{ secrets[vars.SECRET_NAME] }} + WHOLE_SECRET_CONTEXT: ${{ toJSON(secrets) }} PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PUBLIC_KEY_BRACKET: ${{ secrets['PULSE_LICENSE_PUBLIC_KEY'] }} run: echo checked @@ -180,7 +182,7 @@ jobs: ) self.assertEqual( sum("must not reference confidential repository secrets" in finding for finding in findings), - 3, + 4, ) trusted_push = self.audit(