mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
+10
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user