Isolate private governance from pull requests

This commit is contained in:
pulse-triage[bot] 2026-08-30 14:56:55 +01:00
parent 1fbcaa04a4
commit 21007a8662
6 changed files with 295 additions and 110 deletions

View file

@ -20,8 +20,30 @@ EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}")
# github.token because Actions makes that credential available independently of
# an explicit secrets.GITHUB_TOKEN reference.
SHELL_DATA_CONTEXT_RE = re.compile(
r"(?<![\w.])(?:inputs|secrets)\.|(?<![\w.])github\.token\b"
r"(?<![\w.])(?:inputs|secrets)(?:\.|\s*\[)|(?<![\w.])github\.token\b"
)
# GitHub documents these event fields as attacker-controlled strings. They may
# be passed through env, but interpolating them into a generated shell program
# lets quotes and shell metacharacters become code before the runner starts it.
UNTRUSTED_GITHUB_CONTEXT_RE = re.compile(
r"(?<![\w.])github\.(?:"
r"head_ref\b|ref\b|"
r"event(?:\.[A-Za-z_][A-Za-z0-9_-]*)*\."
r"(?:body|default_branch|email|head_branch|head_ref|label|message|name|page_name|ref|title)\b"
r")"
)
SECRET_CONTEXT_RE = re.compile(
r"(?<![\w.])secrets(?:"
r"\.([A-Za-z_][A-Za-z0-9_]*)\b|"
r"\[\s*(['\"])([A-Za-z_][A-Za-z0-9_]*)\2\s*\]"
r")"
)
DYNAMIC_SECRET_CONTEXT_RE = re.compile(
r"(?<![\w.])secrets\s*\[(?!\s*['\"][A-Za-z_][A-Za-z0-9_]*['\"]\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@"
WRITE_CREDENTIAL_RATIONALE = "# required: authenticated git writes"
@ -75,10 +97,53 @@ def _run_script_lines(lines: list[str], run_index: int) -> list[tuple[int, str]]
return script
def _has_trigger(lines: list[str], event: str) -> bool:
"""Return whether the top-level Actions trigger includes *event*."""
event_re = re.compile(rf"(?<![\w-]){re.escape(event)}(?![\w-])")
for index, line in enumerate(lines):
code = line.split("#", 1)[0]
match = re.match(r"^on\s*:\s*(.*)$", code)
if not match:
continue
inline = match.group(1).strip()
if inline:
return bool(event_re.search(inline))
for trigger_line in lines[index + 1 :]:
trigger_code = trigger_line.split("#", 1)[0]
if not trigger_code.strip():
continue
if _indent(trigger_code) == 0:
break
if re.match(rf"^\s+{re.escape(event)}\s*:", trigger_code):
return True
return False
return False
def audit_workflow(path: Path) -> list[Finding]:
lines = path.read_text(encoding="utf-8").splitlines()
findings: list[Finding] = []
if _has_trigger(lines, "pull_request"):
for index, line in enumerate(lines):
code = line.split("#", 1)[0]
secret_names = {
match.group(1) or match.group(3)
for match in SECRET_CONTEXT_RE.finditer(code)
}
if (
secret_names - NON_CONFIDENTIAL_PULL_REQUEST_SECRETS
or DYNAMIC_SECRET_CONTEXT_RE.search(code)
):
findings.append(
Finding(
path,
index + 1,
"pull_request workflows must not reference confidential "
"repository secrets; isolate privileged work in a non-PR workflow",
)
)
permission_declarations = [
index
for index, line in enumerate(lines)
@ -127,6 +192,14 @@ def audit_workflow(path: Path) -> list[Finding]:
"workflow inputs and secrets must enter run scripts through env",
)
)
elif UNTRUSTED_GITHUB_CONTEXT_RE.search(expression):
findings.append(
Finding(
path,
script_index + 1,
"untrusted GitHub metadata must enter run scripts through env",
)
)
match = USES_RE.search(code)
if not match:

View file

@ -121,6 +121,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- run: echo "${{ inputs.name }}"
- run: echo "${{ inputs['name'] }}"
- run: |
echo "${{ secrets.ACCESS_TOKEN }}"
echo "${{ github.token }}"
@ -132,9 +133,70 @@ jobs:
)
self.assertEqual(
sum("must enter run scripts through env" in finding for finding in findings),
4,
)
def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None:
findings = self.audit(
"""on: [pull_request]
permissions: {}
jobs:
unsafe:
runs-on: ubuntu-24.04
steps:
- run: echo "${{ github.event.pull_request.title }}"
- run: |
echo "${{ github.head_ref }}"
echo "${{ github.event.workflow_run.head_branch }}"
- env:
TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\\n' "$TITLE" >/dev/null
- run: echo "${{ github.event.pull_request.base.sha }}"
"""
)
self.assertEqual(
sum("untrusted GitHub metadata" in finding for finding in findings),
3,
)
def test_pull_request_workflows_cannot_receive_confidential_secrets(self) -> None:
findings = self.audit(
"""on:
push:
pull_request:
permissions: {}
jobs:
unsafe:
runs-on: ubuntu-24.04
steps:
- env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
GH_TOKEN_BRACKET: ${{ secrets['WORKFLOW_PAT'] }}
DYNAMIC_SECRET: ${{ secrets[vars.SECRET_NAME] }}
PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
PUBLIC_KEY_BRACKET: ${{ secrets['PULSE_LICENSE_PUBLIC_KEY'] }}
run: echo checked
"""
)
self.assertEqual(
sum("must not reference confidential repository secrets" in finding for finding in findings),
3,
)
trusted_push = self.audit(
"""on: push
permissions: {}
jobs:
trusted:
runs-on: ubuntu-24.04
steps:
- env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT }}
run: echo checked
"""
)
self.assertEqual(trusted_push, [])
def test_repository_workflows_satisfy_contract(self) -> None:
findings = workflow_trust.audit_directory(REPO_ROOT / ".github" / "workflows")
self.assertEqual([finding.render() for finding in findings], [])