Keep release workflows free of implicit trust inputs

Remove the pull-request secret exception, drop inert E2E secret references, and disable setup-node caches at release trust boundaries. Document the exact metadata-only privileged trigger exception.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-04 07:28:16 +01:00
parent 4c0403f78c
commit 457aa90458
8 changed files with 54 additions and 42 deletions
+10 -10
View File
@@ -16,9 +16,11 @@ steps from lexical policy checks.
Checkout pins additionally belong to a reviewed allowlist whose current floor
includes GitHub's fail-closed fork-PR protection for privileged events. The
audit prohibits `pull_request_target` entirely and rejects checkout's
`allow-unsafe-pr-checkout` opt-out; privileged work must remain isolated from
pull-request code rather than bypassing the upstream guard.
audit rejects `pull_request_target` except for the exact, metadata-only closed-PR
capacity reclaimer, and rejects checkout's `allow-unsafe-pr-checkout` opt-out.
That exception can only run the protected default-branch helper, has no shell,
cache, artifact, container, secret, or pull-request checkout ingress, and is
machine-checked down to its permissions, dependencies, and generated program.
`workflow_run` is also a privileged trigger. Every handler must filter its
upstream workflow to the literal canonical branch `main`, and checkout steps
@@ -55,13 +57,11 @@ source. The audit recognizes the script inputs of `actions/github-script`,
in those programs. Pass the value through step `env` and read it from the
script's process environment instead.
Jobs that receive confidential repository secrets or a write-capable
Jobs that receive repository secrets or a write-capable
`GITHUB_TOKEN` do not restore or save caches. This includes setup-action
dependency caches, direct Actions caches, and external BuildKit cache imports:
cache contents are unsigned mutable build input, while provenance only records
what the workflow produced. Read-only jobs may still cache locked dependencies;
the intentionally public legacy license key is not treated as a confidential
credential.
what the workflow produced. Read-only jobs may still cache locked dependencies.
Passing data through `env` does not make it safe to append to the runner's
`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, or `GITHUB_STATE` command files.
@@ -73,7 +73,7 @@ and chooses a random multiline delimiter that cannot collide with the value.
This prevents embedded newlines from creating additional outputs or
environment entries.
Workflows triggered by `pull_request` cannot reference confidential repository
Workflows triggered by `pull_request` cannot reference 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, subsystem-registry, subsystem-contract, mobile
@@ -81,8 +81,8 @@ compatibility, and repo-governance checks only after a push to `main`, so
unmerged pull-request code cannot replace the instructions that receive
`WORKFLOW_PAT`. The public job still audits contract structure and every public
path; only private path existence is deferred to that credential-isolated job.
`PULSE_LICENSE_PUBLIC_KEY` is the sole explicit PR exception because that
legacy secret value is intentionally non-confidential.
Public configuration is not exempt: pull-request workflows must use repository
variables or checked-in values rather than the secret context.
## Release Continuity
@@ -68,6 +68,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
- name: Verify compiler capacity
shell: bash
+1 -2
View File
@@ -711,8 +711,7 @@ jobs:
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
package-manager-cache: false
- name: Download verified frontend bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-4
View File
@@ -109,8 +109,6 @@ jobs:
run: |
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
docker build -t pulse-mock-github:test ./tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Start test containers
working-directory: tests/integration
@@ -259,8 +257,6 @@ jobs:
run: |
docker build -t pulse:test --target e2e_runtime --build-arg GO_BUILD_TAGS="" .
docker build -t pulse-mock-github:test ./tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Start non-mock test container
working-directory: tests/integration
@@ -2141,6 +2141,11 @@ artifact-selection behaviour.
secrets, and attacker-controlled event metadata must enter generated runner
scripts through explicit environment variables; `${{ }}` interpolation in
a `run` program is not an acceptable data boundary.
Exact-SHA payload compilation and pre-publication release smoke jobs must
explicitly disable setup-node automatic package-manager caching and must not
opt into setup-node dependency caching. Those jobs consume or qualify the
candidate at a release trust boundary, so mutable cache contents must not
become an undeclared release input.
Whenever that policy changes, update the owning workflow/install proof files
in `scripts/installtests/build_release_assets_test.go` and
`scripts/release_control/release_promotion_policy_*` in the same slice.
@@ -5034,11 +5039,17 @@ Every repository checkout in build, packaging, publication, qualification,
recovery, and deployment automation uses the reviewed immutable
`actions/checkout` v7.0.1 pin. That baseline refuses fork pull-request checkout
on privileged events unless a workflow explicitly opts out; Pulse prohibits
that opt-out and the `pull_request_target` trigger. Dependency refreshes must
update the central workflow-trust allowlist and its regression proof together,
so a routine pin change cannot silently remove this release-automation trust
boundary. `scripts/check_workflow_trust.py`,
`scripts/tests/test_workflow_trust.py`, and
that opt-out. The sole `pull_request_target` exception is the machine-checked,
metadata-only closed-PR capacity reclaimer: it runs only the protected
default-branch helper and has no secret, checkout, cache, artifact, container,
or shell ingress. Pull-request workflows may not reference repository secrets,
including values treated as public configuration. Exact-SHA compilation and
pre-publication release smoke explicitly disable setup-node's automatic npm
cache and do not request dependency caches, preventing mutable cache state from
becoming release input. Dependency refreshes must update the central
workflow-trust allowlist and its regression proof together, so a routine pin
change cannot silently remove this release-automation trust boundary.
`scripts/check_workflow_trust.py`, `scripts/tests/test_workflow_trust.py`, and
`scripts/installtests/build_release_assets_test.go` pin the policy and the
release-workflow integration.
+10 -16
View File
@@ -142,9 +142,6 @@ WRITE_PERMISSION_RE = re.compile(
r'''^\s+(?:[A-Za-z-]+|"[A-Za-z-]+"|'[A-Za-z-]+')\s*:\s*'''
r'''(?:write|"write"|'write')\s*$'''
)
# 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"})
CHECKOUT_PREFIX = "actions/checkout@"
# These action inputs are programs, not ordinary data. GitHub's Actions
# CodeQL models treat the same actions as code-injection sinks: expression
@@ -714,15 +711,12 @@ def _is_untrusted_expression(value: str) -> bool:
)
def _has_confidential_secret_reference(lines: list[str]) -> bool:
"""Return whether workflow lines can resolve a confidential secret."""
def _has_secret_reference(lines: list[str]) -> bool:
"""Return whether workflow lines can resolve any repository secret."""
for line in lines:
for expression in EXPRESSION_RE.findall(line.split("#", 1)[0]):
static_references = list(SECRET_CONTEXT_RE.finditer(expression))
secret_names = {
match.group(1) or match.group(3) for match in static_references
}
if secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS:
if static_references:
return True
if len(SECRET_CONTEXT_TOKEN_RE.findall(expression)) != len(
static_references
@@ -874,7 +868,7 @@ def _is_hardened_closed_pr_cancellation(path: Path, lines: list[str]) -> bool:
]
if dependencies != list(SAFE_PULL_REQUEST_TARGET_ACTIONS):
return False
if _has_confidential_secret_reference(lines):
if _has_secret_reference(lines):
return False
if any(
re.match(r"^\s*(?:container|services|defaults|env)\s*:", line)
@@ -1153,7 +1147,7 @@ def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]:
for index, line in enumerate(lines)
if (match := PERMISSIONS_RE.match(line.split("#", 1)[0]))
)
top_level_confidential_secret = _has_confidential_secret_reference(
top_level_secret = _has_secret_reference(
lines[:jobs_index] + lines[jobs_end:]
)
@@ -1176,8 +1170,8 @@ def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]:
)
privileged = (
top_level_write
or top_level_confidential_secret
or _has_confidential_secret_reference(job_lines)
or top_level_secret
or _has_secret_reference(job_lines)
or job_write
)
if not privileged:
@@ -1294,13 +1288,13 @@ def audit_workflow(path: Path) -> list[Finding]:
if _has_trigger(lines, "pull_request"):
for index, line in enumerate(lines):
if _has_confidential_secret_reference([line]):
if _has_secret_reference([line]):
findings.append(
Finding(
path,
index + 1,
"pull_request workflows must not reference confidential "
"repository secrets; isolate privileged work in a non-PR workflow",
"pull_request workflows must not reference repository "
"secrets; isolate privileged work in a non-PR workflow",
)
)
@@ -3266,6 +3266,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
frontendBundleJob := workflowJobBlock(t, createWorkflow, "frontend_bundle")
backendJob := workflowJobBlock(t, createWorkflow, "backend_tests")
integrationJob := workflowJobBlock(t, createWorkflow, "integration_tests")
releaseSmokeJob := workflowJobBlock(t, createWorkflow, "release_smoke")
validationJob := workflowJobBlock(t, createWorkflow, "validate_release_assets")
privateStageJob := workflowJobBlock(t, createWorkflow, "stage_private_pro_runtime")
readinessJob := workflowJobBlock(t, createWorkflow, "release_readiness")
@@ -3278,6 +3279,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
helmPagesJob := workflowJobBlock(t, convergenceWorkflow, "publish_helm_pages")
demoJob := workflowJobBlock(t, convergenceWorkflow, "update_stable_demo")
compileJob := workflowJobBlock(t, compilerWorkflow, "compile-release-payload")
compileSetupNodeStep := workflowStepBlock(t, compileJob, "Set up Node.js")
releaseSmokeSetupNodeStep := workflowStepBlock(t, releaseSmokeJob, "Set up Node.js")
obtainPayloadJob := workflowJobBlock(t, candidateWorkflow, "obtain-release-payload")
candidateBuildJob := workflowJobBlock(t, candidateWorkflow, "build")
compiledPayloadVerificationStep := workflowStepBlock(t, candidateBuildJob, "Verify exact-SHA compiled payload")
@@ -3371,8 +3374,16 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) {
t.Fatalf("%s runner selection must not depend on the Windows-signing decision", label)
}
}
if !strings.Contains(compileJob, "cache: false") || strings.Contains(compileJob, "cache: 'npm'") {
t.Fatal("release compilation must avoid Actions cache archival")
for label, step := range map[string]string{
"exact-SHA release compilation": compileSetupNodeStep,
"pre-publication release smoke": releaseSmokeSetupNodeStep,
} {
if !strings.Contains(step, "package-manager-cache: false") {
t.Fatalf("%s must disable setup-node automatic package-manager caching", label)
}
if strings.Contains(step, "\n cache:") || strings.Contains(step, "cache-dependency-path:") {
t.Fatalf("%s must not opt into setup-node dependency caching", label)
}
}
if strings.Contains(frontendBundleJob, "cache: 'npm'") {
t.Fatal("PVE frontend bundle must use its persistent runner-local npm cache")
+3 -3
View File
@@ -1119,7 +1119,7 @@ jobs:
3,
)
def test_pull_request_workflows_cannot_receive_confidential_secrets(self) -> None:
def test_pull_request_workflows_cannot_receive_repository_secrets(self) -> None:
findings = self.audit(
"""on:
push:
@@ -1140,8 +1140,8 @@ jobs:
"""
)
self.assertEqual(
sum("must not reference confidential repository secrets" in finding for finding in findings),
4,
sum("must not reference repository secrets" in finding for finding in findings),
6,
)
trusted_push = self.audit(