From 028ccbd35f3762bd9e3c9bea510a2aed8ea13bef Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:01:43 +0100 Subject: [PATCH] Keep OIDC attestations on hosted runners Change-source: pulse-maintainer --- scripts/check_workflow_trust.py | 120 +++++++++++++++++++++++++++ scripts/tests/test_workflow_trust.py | 83 ++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index 47e844930..0aee0104e 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -142,6 +142,13 @@ RUNS_ON_RE = re.compile(rf"^(\s*){_yaml_key('runs-on')}\s*:") TIMEOUT_RE = re.compile( rf"^(\s*){_yaml_key('timeout-minutes')}\s*:\s*(.*?)\s*$" ) +# OIDC-backed delivery identity is 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( + {"ubuntu-24.04", "windows-2025", "macos-15"} +) @dataclass(frozen=True) @@ -218,6 +225,39 @@ def _permission_mapping_has_write( return False +def _permission_mapping_grants_scope_write( + lines: list[str], + permissions_index: int, + scope: str, + end_index: int | None = None, +) -> bool: + """Return whether a permissions block grants literal write to *scope*.""" + match = PERMISSIONS_RE.match(lines[permissions_index].split("#", 1)[0]) + if not match: + return False + inline = match.group(2).strip().strip("'\"").lower() + if inline: + return inline == "write-all" + + child_indent = _direct_mapping_indent(lines, permissions_index, end_index) + if child_indent is None: + return False + scope_write_re = re.compile( + rf"^\s+{_yaml_key(scope)}\s*:\s*(?:write|\"write\"|'write')\s*$" + ) + limit = len(lines) if end_index is None else end_index + for line in lines[permissions_index + 1 : limit]: + code = line.split("#", 1)[0] + if not code.strip(): + continue + indent = _indent(code) + if indent <= _indent(lines[permissions_index]): + break + if indent == child_indent and scope_write_re.match(code): + return True + return False + + def _checkout_block(lines: list[str], uses_index: int) -> list[tuple[int, str]]: """Return lines belonging to the checkout step after its uses declaration.""" uses_indent = _indent(lines[uses_index]) @@ -693,6 +733,85 @@ def _audit_runner_job_timeouts(path: Path, lines: list[str]) -> list[Finding]: return findings +def _audit_oidc_runner_trust(path: Path, lines: list[str]) -> list[Finding]: + """Keep OIDC-backed delivery identity on reviewed GitHub-hosted images.""" + findings: list[Finding] = [] + jobs_index = next( + ( + index + for index, line in enumerate(lines) + if JOBS_RE.match(line.split("#", 1)[0]) + ), + len(lines), + ) + top_level_oidc_write = any( + not match.group(1) + and _permission_mapping_grants_scope_write(lines, index, "id-token") + for index, line in enumerate(lines[:jobs_index]) + if (match := PERMISSIONS_RE.match(line.split("#", 1)[0])) + ) + + for job_index, end_index, _ in _runner_job_ranges(lines): + 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 + ) + ] + oidc_write = ( + any( + _permission_mapping_grants_scope_write( + lines, index, "id-token", end_index + ) + for index in permission_indexes + ) + if permission_indexes + else top_level_oidc_write + ) + if not oidc_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("'\"")) + ) + + # Reusable-workflow callers cannot choose a runner. The called + # workflow's local jobs own and are independently audited for this + # boundary. + if not runner_declarations: + continue + if ( + len(runner_declarations) != 1 + or runner_declarations[0][1] not in TRUSTED_OIDC_RUNNER_LABELS + ): + finding_index = ( + runner_declarations[0][0] if runner_declarations else job_index + ) + findings.append( + Finding( + path, + finding_index + 1, + "id-token write jobs must use exactly one reviewed literal " + "GitHub-hosted runner label; dynamic or self-hosted runners " + "cannot mint trusted delivery identity", + ) + ) + + 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] = [] @@ -835,6 +954,7 @@ def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]: def audit_workflow(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings = _audit_runner_job_timeouts(path, lines) + findings.extend(_audit_oidc_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") diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index 009b7fe47..a45e27bb2 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -133,6 +133,89 @@ jobs: ) self.assertEqual(findings, []) + def test_oidc_write_requires_reviewed_literal_hosted_runner(self) -> None: + trusted = self.audit( + """permissions: {} +jobs: + attest: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + id-token: write + steps: + - run: echo attest +""" + ) + self.assertEqual(trusted, []) + + for runner in ( + "self-hosted", + "[self-hosted, Linux, X64]", + "${{ matrix.runner }}", + "ubuntu-26.04", + ): + with self.subTest(runner=runner): + findings = self.audit( + f"""permissions: {{}} +jobs: + attest: + runs-on: {runner} + timeout-minutes: 10 + permissions: + id-token: write + steps: + - run: echo attest +""" + ) + self.assertTrue( + any("trusted delivery identity" in finding for finding in findings) + ) + + duplicate = self.audit( + """permissions: {} +jobs: + attest: + runs-on: ubuntu-24.04 + runs-on: windows-2025 + timeout-minutes: 10 + permissions: + id-token: write + steps: + - run: echo attest +""" + ) + self.assertTrue( + any("trusted delivery identity" in finding for finding in duplicate) + ) + + def test_top_level_oidc_permission_applies_to_local_jobs(self) -> None: + findings = self.audit( + """permissions: + id-token: write +jobs: + attest: + runs-on: self-hosted + timeout-minutes: 10 + steps: + - run: echo attest +""" + ) + self.assertTrue( + any("trusted delivery identity" in finding for finding in findings) + ) + + def test_reusable_workflow_caller_owns_oidc_runner_boundary(self) -> None: + findings = self.audit( + """permissions: {} +jobs: + attest: + permissions: + id-token: write + uses: ./.github/workflows/attest.yml +""" + ) + self.assertEqual(findings, []) + def test_rejects_implicit_or_unjustified_checkout_credentials(self) -> None: omitted = self.audit( f"""permissions: {{}}