Close workflow trust policy gaps

This commit is contained in:
pulse-triage[bot]
2026-08-30 15:06:00 +01:00
parent 21007a8662
commit a0dfdadc5e
7 changed files with 36 additions and 23 deletions
+4 -3
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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:
@@ -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
@@ -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
+18 -9
View File
@@ -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"(?<![\w.])(?:inputs|secrets)(?:\.|\s*\[)|(?<![\w.])github\.token\b"
r"(?<![\w.])(?:inputs|secrets)\b|(?<![\w.])github\.token\b"
)
# GitHub documents these event fields as attacker-controlled strings. They may
# be passed through env, but interpolating them into a generated shell program
@@ -38,9 +38,7 @@ SECRET_CONTEXT_RE = re.compile(
r"\[\s*(['\"])([A-Za-z_][A-Za-z0-9_]*)\2\s*\]"
r")"
)
DYNAMIC_SECRET_CONTEXT_RE = re.compile(
r"(?<![\w.])secrets\s*\[(?!\s*['\"][A-Za-z_][A-Za-z0-9_]*['\"]\s*\])"
)
SECRET_CONTEXT_TOKEN_RE = re.compile(r"(?<![\w.])secrets\b")
# This value is intentionally public and only uses secret storage as a legacy
# configuration mechanism. Confidential credentials have no PR exception.
NON_CONFIDENTIAL_PULL_REQUEST_SECRETS = frozenset({"PULSE_LICENSE_PUBLIC_KEY"})
@@ -127,13 +125,24 @@ def audit_workflow(path: Path) -> 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(
+4 -2
View File
@@ -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(