mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
Isolate private governance from pull requests
This commit is contained in:
parent
1fbcaa04a4
commit
21007a8662
6 changed files with 295 additions and 110 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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], [])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue