mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Keep OIDC attestations on hosted runners
Change-source: pulse-maintainer
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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: {{}}
|
||||
|
||||
Reference in New Issue
Block a user