mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Keep privileged jobs on ephemeral hosted runners
Retire the unused self-hosted live qualification workflow and reject future secret- or write-capable jobs on persistent or dynamically selected runners. Keep live Patrol qualification as a disposable lab operation. Change-source: pulse-maintainer
This commit is contained in:
@@ -63,6 +63,12 @@ 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.
|
||||
|
||||
Those privileged jobs also run only on a reviewed, literal GitHub-hosted image.
|
||||
Persistent self-hosted runners can retain executable state from an earlier job,
|
||||
and dynamic runner expressions can silently move a credential boundary. Local
|
||||
self-hosted acceleration therefore remains free of repository secrets and
|
||||
read-only.
|
||||
|
||||
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.
|
||||
The audit follows workflow data and values read from the event payload through
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
name: Patrol Live Qualification
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repeat_profile:
|
||||
description: Manifest repetition profile
|
||||
required: true
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- nightly
|
||||
- qualification
|
||||
authorize_live_faults:
|
||||
description: Authorize reversible faults in the dedicated canary lab
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: patrol-live-qualification
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
watch-live-lab:
|
||||
name: Watch / ${{ matrix.scenario }}
|
||||
if: >-
|
||||
vars.PULSE_PATROL_QUAL_LIVE_ENABLED == 'true' &&
|
||||
(github.event_name == 'schedule' || inputs.authorize_live_faults == true)
|
||||
runs-on: [self-hosted, patrol-qualification-lab]
|
||||
environment: patrol-qualification-lab
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
scenario:
|
||||
- watch.healthy-mixed
|
||||
- watch.docker-unhealthy
|
||||
- watch.existing-finding-reconfirmation
|
||||
- watch.docker-restart-loop
|
||||
- watch.correlated-dependency
|
||||
- watch.two-independent-faults
|
||||
- watch.prompt-injection-label
|
||||
|
||||
steps:
|
||||
- name: Checkout pinned source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Validate qualification catalogue
|
||||
run: go run ./cmd/patrol-qualify -mode validate
|
||||
|
||||
- name: Run reversible Watch qualification
|
||||
env:
|
||||
PULSE_QUALIFY_PASSWORD: ${{ secrets.PULSE_PATROL_QUAL_PASSWORD }}
|
||||
PULSE_QUAL_URL: ${{ vars.PULSE_PATROL_QUAL_URL }}
|
||||
PULSE_QUAL_USER: ${{ vars.PULSE_PATROL_QUAL_USER }}
|
||||
PULSE_QUAL_MODEL: ${{ vars.PULSE_PATROL_QUAL_MODEL }}
|
||||
PULSE_QUAL_EXPECTED_VERSION: ${{ vars.PULSE_PATROL_QUAL_EXPECTED_VERSION }}
|
||||
PULSE_QUAL_DOCKER_CONTEXT: ${{ vars.PULSE_PATROL_QUAL_DOCKER_CONTEXT }}
|
||||
PULSE_QUAL_ARTIFACT_ROOT: ${{ vars.PULSE_PATROL_QUAL_ARTIFACT_ROOT }}
|
||||
DISPATCH_REPEAT_PROFILE: ${{ inputs.repeat_profile }}
|
||||
run: |
|
||||
test -n "$PULSE_QUALIFY_PASSWORD"
|
||||
test -n "$PULSE_QUAL_URL"
|
||||
test -n "$PULSE_QUAL_USER"
|
||||
test -n "$PULSE_QUAL_DOCKER_CONTEXT"
|
||||
test -n "$PULSE_QUAL_EXPECTED_VERSION"
|
||||
test -n "$PULSE_QUAL_ARTIFACT_ROOT"
|
||||
repeat_profile="${DISPATCH_REPEAT_PROFILE:-nightly}"
|
||||
artifact_root="$PULSE_QUAL_ARTIFACT_ROOT/${GITHUB_RUN_ID}/${{ matrix.scenario }}"
|
||||
go run ./cmd/patrol-qualify \
|
||||
-mode live \
|
||||
-scenario "${{ matrix.scenario }}" \
|
||||
-url "$PULSE_QUAL_URL" \
|
||||
-user "$PULSE_QUAL_USER" \
|
||||
-model "$PULSE_QUAL_MODEL" \
|
||||
-expected-pulse-version "$PULSE_QUAL_EXPECTED_VERSION" \
|
||||
-docker-context "$PULSE_QUAL_DOCKER_CONTEXT" \
|
||||
-repeat-profile "$repeat_profile" \
|
||||
-artifacts "$artifact_root" \
|
||||
-authorize-live-faults
|
||||
|
||||
# Raw reports stay on the access-controlled lab runner. They can contain
|
||||
# private resource identity and must not be uploaded to a public Actions
|
||||
# artifact. Publish only a separately reviewed comparison.md/json.
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/patrol-qualification-regression.yml"
|
||||
- ".github/workflows/patrol-qualification-live.yml"
|
||||
- "cmd/patrol-qualify/**"
|
||||
- "internal/ai/qualification/**"
|
||||
- "internal/ai/patrol*.go"
|
||||
@@ -14,11 +13,12 @@ on:
|
||||
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
|
||||
- "tests/qualification/patrol/**"
|
||||
- "docs/AI_PATROL_QUALIFICATION.md"
|
||||
- "docs/release-control/v6/internal/subsystems/ai-runtime.md"
|
||||
- "frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/patrol-qualification-regression.yml"
|
||||
- ".github/workflows/patrol-qualification-live.yml"
|
||||
- "cmd/patrol-qualify/**"
|
||||
- "internal/ai/qualification/**"
|
||||
- "internal/ai/patrol*.go"
|
||||
@@ -27,6 +27,9 @@ on:
|
||||
- "internal/api/ai_handlers.go"
|
||||
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
|
||||
- "tests/qualification/patrol/**"
|
||||
- "docs/AI_PATROL_QUALIFICATION.md"
|
||||
- "docs/release-control/v6/internal/subsystems/ai-runtime.md"
|
||||
- "frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md"
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -512,26 +512,22 @@ the provider exposes them.
|
||||
|
||||
- Pull requests: schema/catalog validation, unit tests, strict parsing,
|
||||
scorer replay, transcript replay, and no credentials or homelab access.
|
||||
- Nightly: recorded regression corpus plus a small Watch live-lab sample on a
|
||||
dedicated self-hosted runner. Results are diagnostic until the required
|
||||
repeat count is complete.
|
||||
- Release qualification: pinned Pulse revision and disposable canary lab,
|
||||
all Watch scenarios first, then investigation, then separately authorized
|
||||
rejection and approved-remediation tracks. Artifacts must be retained
|
||||
outside the working tree with checksums.
|
||||
- Nightly: recorded regression corpus on a fresh GitHub-hosted runner. No
|
||||
private-lab credential or live fault capability enters repository Actions.
|
||||
- Live and release qualification: pinned Pulse revision and disposable canary
|
||||
lab, all Watch scenarios first, then investigation, then separately
|
||||
authorized rejection and approved-remediation tracks. Artifacts must be
|
||||
retained outside the working tree with checksums.
|
||||
- Production: observation only. Never manufacture a qualification fault in
|
||||
production infrastructure.
|
||||
|
||||
`.github/workflows/patrol-qualification-live.yml` implements the opt-in
|
||||
nightly Watch lab. It runs only on a runner labelled
|
||||
`patrol-qualification-lab`, behind the `patrol-qualification-lab` environment,
|
||||
and only when `PULSE_PATROL_QUAL_LIVE_ENABLED=true`. The environment supplies
|
||||
the Pulse URL/user/password, explicit Docker context, exact expected Pulse
|
||||
runtime version, optional model override, and an access-controlled runner-local
|
||||
artifact root. Raw reports are
|
||||
deliberately not uploaded to public Actions artifacts because they can contain
|
||||
private resource identity. The seven Watch scenarios run sequentially so
|
||||
model overrides and Patrol run association cannot race.
|
||||
Run live qualification directly from a disposable canary-lab host with the
|
||||
Pulse URL/user/password, explicit Docker context, exact expected Pulse runtime
|
||||
version, optional model override, and an access-controlled artifact root. The
|
||||
repository does not schedule this work on a self-hosted Actions runner: a
|
||||
persistent runner can retain executable state between jobs, and the live
|
||||
reports can contain private resource identity. Keep the seven Watch scenarios
|
||||
sequential so model overrides and Patrol run association cannot race.
|
||||
|
||||
## Product decisions
|
||||
|
||||
|
||||
@@ -6669,6 +6669,14 @@ baseline before fault injection. The Watch correlation and Pro investigation
|
||||
fixtures use Alpine's `nc` applet for the disposable HTTP dependency; a missing
|
||||
optional daemon must fail baseline convergence and never be scored as a model
|
||||
miss.
|
||||
Repository Actions may validate the Patrol catalogue and replay recorded
|
||||
qualification evidence only on a fresh GitHub-hosted runner. Live and release
|
||||
qualification require private-lab credentials, fault authority, and reports
|
||||
that can contain private resource identity, so they must run directly from a
|
||||
disposable canary-lab host at a pinned Pulse revision, never from a persistent
|
||||
self-hosted Actions runner. Keep raw artifacts outside the working tree in an
|
||||
access-controlled root with checksums, and execute the reviewed Watch scenarios
|
||||
sequentially so model overrides and run association cannot race.
|
||||
Governed Docker remediation qualification must not define a deliberate
|
||||
container stop as a required Watch finding. Patrol intentionally suppresses an
|
||||
ordinary stopped workload unless independent evidence establishes that it was
|
||||
|
||||
@@ -512,26 +512,22 @@ the provider exposes them.
|
||||
|
||||
- Pull requests: schema/catalog validation, unit tests, strict parsing,
|
||||
scorer replay, transcript replay, and no credentials or homelab access.
|
||||
- Nightly: recorded regression corpus plus a small Watch live-lab sample on a
|
||||
dedicated self-hosted runner. Results are diagnostic until the required
|
||||
repeat count is complete.
|
||||
- Release qualification: pinned Pulse revision and disposable canary lab,
|
||||
all Watch scenarios first, then investigation, then separately authorized
|
||||
rejection and approved-remediation tracks. Artifacts must be retained
|
||||
outside the working tree with checksums.
|
||||
- Nightly: recorded regression corpus on a fresh GitHub-hosted runner. No
|
||||
private-lab credential or live fault capability enters repository Actions.
|
||||
- Live and release qualification: pinned Pulse revision and disposable canary
|
||||
lab, all Watch scenarios first, then investigation, then separately
|
||||
authorized rejection and approved-remediation tracks. Artifacts must be
|
||||
retained outside the working tree with checksums.
|
||||
- Production: observation only. Never manufacture a qualification fault in
|
||||
production infrastructure.
|
||||
|
||||
`.github/workflows/patrol-qualification-live.yml` implements the opt-in
|
||||
nightly Watch lab. It runs only on a runner labelled
|
||||
`patrol-qualification-lab`, behind the `patrol-qualification-lab` environment,
|
||||
and only when `PULSE_PATROL_QUAL_LIVE_ENABLED=true`. The environment supplies
|
||||
the Pulse URL/user/password, explicit Docker context, exact expected Pulse
|
||||
runtime version, optional model override, and an access-controlled runner-local
|
||||
artifact root. Raw reports are
|
||||
deliberately not uploaded to public Actions artifacts because they can contain
|
||||
private resource identity. The seven Watch scenarios run sequentially so
|
||||
model overrides and Patrol run association cannot race.
|
||||
Run live qualification directly from a disposable canary-lab host with the
|
||||
Pulse URL/user/password, explicit Docker context, exact expected Pulse runtime
|
||||
version, optional model override, and an access-controlled artifact root. The
|
||||
repository does not schedule this work on a self-hosted Actions runner: a
|
||||
persistent runner can retain executable state between jobs, and the live
|
||||
reports can contain private resource identity. Keep the seven Watch scenarios
|
||||
sequential so model overrides and Patrol run association cannot race.
|
||||
|
||||
## Product decisions
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ func TestForcePatrolAcceptanceOwnsStatusAndProducesOneHistoryRecord(t *testing.T
|
||||
if history[0].ID != acceptance.RunID {
|
||||
t.Fatalf("history run id = %q, want %q", history[0].ID, acceptance.RunID)
|
||||
}
|
||||
if history[0].TriggerReason != string(TriggerReasonManual) {
|
||||
t.Fatalf("history trigger = %q, want direct manual run", history[0].TriggerReason)
|
||||
}
|
||||
if calls := providerCalls.Load(); calls != 1 {
|
||||
t.Fatalf("provider calls after completion = %d, want 1", calls)
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ NONEMPTY_FLOW_MAPPING_RE = re.compile(
|
||||
r'''^\s*(?:-\s*)?(?:[A-Za-z0-9_.-]+|"[^"]+"|'[^']+')'''
|
||||
r"\s*:\s*\{(?!\s*\}\s*$)"
|
||||
)
|
||||
# OIDC-backed delivery identity is only trusted when GitHub owns the runner
|
||||
# Secrets and delivery identity are only trusted when GitHub owns the runner
|
||||
# lifecycle. Keep the accepted image labels explicit and reviewable so a job
|
||||
# cannot move to persistent or dynamically selected compute without changing
|
||||
# this contract. These are the hosted images used by Pulse's attestation jobs.
|
||||
TRUSTED_OIDC_RUNNER_LABELS = frozenset(
|
||||
# this contract.
|
||||
TRUSTED_PRIVILEGED_RUNNER_LABELS = frozenset(
|
||||
{"ubuntu-24.04", "windows-2025", "macos-15"}
|
||||
)
|
||||
|
||||
@@ -1107,7 +1107,7 @@ def _audit_oidc_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
|
||||
continue
|
||||
if (
|
||||
len(runner_declarations) != 1
|
||||
or runner_declarations[0][1] not in TRUSTED_OIDC_RUNNER_LABELS
|
||||
or runner_declarations[0][1] not in TRUSTED_PRIVILEGED_RUNNER_LABELS
|
||||
):
|
||||
finding_index = (
|
||||
runner_declarations[0][0] if runner_declarations else job_index
|
||||
@@ -1125,6 +1125,89 @@ def _audit_oidc_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
|
||||
return findings
|
||||
|
||||
|
||||
def _audit_privileged_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
|
||||
"""Keep secrets and repository write authority off mutable runner boundaries."""
|
||||
findings: list[Finding] = []
|
||||
jobs_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines)
|
||||
if JOBS_RE.match(line.split("#", 1)[0])
|
||||
),
|
||||
len(lines),
|
||||
)
|
||||
jobs_end = (
|
||||
_mapping_end_index(lines, jobs_index)
|
||||
if jobs_index < len(lines)
|
||||
else len(lines)
|
||||
)
|
||||
top_level_write = any(
|
||||
not match.group(1)
|
||||
and _permission_mapping_has_write(lines, index)
|
||||
for index, line in enumerate(lines)
|
||||
if (match := PERMISSIONS_RE.match(line.split("#", 1)[0]))
|
||||
)
|
||||
top_level_secret = _has_secret_reference(
|
||||
lines[:jobs_index] + lines[jobs_end:]
|
||||
)
|
||||
|
||||
for job_index, end_index, _ in _runner_job_ranges(lines):
|
||||
job_lines = lines[job_index:end_index]
|
||||
direct_indent = _direct_mapping_indent(lines, job_index, end_index)
|
||||
if direct_indent is None:
|
||||
continue
|
||||
permission_indexes = [
|
||||
index
|
||||
for index in range(job_index + 1, end_index)
|
||||
if (
|
||||
(match := PERMISSIONS_RE.match(lines[index].split("#", 1)[0]))
|
||||
and len(match.group(1)) == direct_indent
|
||||
)
|
||||
]
|
||||
job_write = (
|
||||
any(
|
||||
_permission_mapping_has_write(lines, index, end_index)
|
||||
for index in permission_indexes
|
||||
)
|
||||
if permission_indexes
|
||||
else top_level_write
|
||||
)
|
||||
if not (top_level_secret or _has_secret_reference(job_lines) or job_write):
|
||||
continue
|
||||
|
||||
runner_declarations: list[tuple[int, str]] = []
|
||||
for index in range(job_index + 1, end_index):
|
||||
code = lines[index].split("#", 1)[0]
|
||||
match = re.match(
|
||||
rf"^(\s*){_yaml_key('runs-on')}\s*:\s*(.*?)\s*$", code
|
||||
)
|
||||
if match and len(match.group(1)) == direct_indent:
|
||||
runner_declarations.append(
|
||||
(index, match.group(2).strip().strip("'\""))
|
||||
)
|
||||
|
||||
# A reusable-workflow caller cannot choose compute. Its called local
|
||||
# jobs are audited independently, including inherited secrets and
|
||||
# permissions at their actual runner boundary.
|
||||
if not runner_declarations:
|
||||
continue
|
||||
if (
|
||||
len(runner_declarations) != 1
|
||||
or runner_declarations[0][1] not in TRUSTED_PRIVILEGED_RUNNER_LABELS
|
||||
):
|
||||
findings.append(
|
||||
Finding(
|
||||
path,
|
||||
runner_declarations[0][0] + 1,
|
||||
"secret- or write-capable jobs must use exactly one reviewed "
|
||||
"literal GitHub-hosted runner label; persistent, dynamic, or "
|
||||
"self-hosted runners can retain credentials or code between jobs",
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]:
|
||||
"""Keep unsigned cache state out of credential- and write-capable jobs."""
|
||||
findings: list[Finding] = []
|
||||
@@ -1269,6 +1352,7 @@ def audit_workflow(path: Path) -> list[Finding]:
|
||||
findings = _audit_yaml_trust_shape(path, lines)
|
||||
findings.extend(_audit_runner_job_timeouts(path, lines))
|
||||
findings.extend(_audit_oidc_runner_trust(path, lines))
|
||||
findings.extend(_audit_privileged_runner_trust(path, lines))
|
||||
findings.extend(_audit_privileged_job_caches(path, lines))
|
||||
findings.extend(_audit_workflow_run_trigger(path, lines))
|
||||
has_workflow_run_trigger = _has_trigger(lines, "workflow_run")
|
||||
|
||||
@@ -204,6 +204,53 @@ jobs:
|
||||
any("trusted delivery identity" in finding for finding in findings)
|
||||
)
|
||||
|
||||
def test_secrets_and_write_permissions_require_hosted_runner(self) -> None:
|
||||
for privileged in (
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
privileged:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- run: echo safe
|
||||
""",
|
||||
"""permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
privileged:
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: echo safe
|
||||
""",
|
||||
):
|
||||
with self.subTest(privileged=privileged):
|
||||
findings = self.audit(privileged)
|
||||
self.assertTrue(
|
||||
any("retain credentials or code" in finding for finding in findings)
|
||||
)
|
||||
|
||||
findings = self.audit(
|
||||
"""permissions: {}
|
||||
jobs:
|
||||
privileged:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- run: echo safe
|
||||
accelerated:
|
||||
runs-on: [self-hosted, Linux, X64, build]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: echo credential-free
|
||||
"""
|
||||
)
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_reusable_workflow_caller_owns_oidc_runner_boundary(self) -> None:
|
||||
findings = self.audit(
|
||||
"""permissions: {}
|
||||
|
||||
Reference in New Issue
Block a user