Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1,16 @@
* @randoneering
# Code owners for pgFirstAid.
# When a pull request changes a matched path, GitHub auto-assigns
# @randoneering as the required reviewer. The ruleset on `main` already
# requires 1 approving review, so this provides an explicit, auditable
# reviewer for security-relevant paths rather than a second review.

# Workflow integrity: any change to the GitHub Actions workflows, the
# workflow-security regression test, or the CODEOWNERS file itself
# requires review from the owner.
/.github/workflows/ @randoneering
/.github/CODEOWNERS @randoneering

# Regression coverage for the workflow-integrity contract: any change
# to the test file that locks in fork-secret boundaries requires the
# same owner review.
testing/test_workflow_security.py @randoneering
8 changes: 5 additions & 3 deletions .github/workflows/pgdg-cve-scraper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ jobs:
python-version: "3.11"

- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.9"
checksum: "ec7a99cd05e0cd7f80243f135ce1361c76835cb0ee60055d14d20eba8eba1460"
enable-cache: true

- name: Sync deps
run: uv sync --quiet
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/pr-workflow-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ jobs:
pr-workflow-guard.yml \
neon-before-after-validate.yml \
neon-integration-pg-matrix.yml \
nixos-local-test.yml; do
nixos-local-test.yml \
pgdg-cve-scraper.yml \
release-notes-scout.yml; do
gh api \
"repos/${HEAD_REPO}/contents/.github/workflows/${f}?ref=${HEAD_SHA}" \
--jq .content | base64 -d > "/tmp/pr-workflows/${f}"
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/release-notes-scout.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ jobs:
python-version: "3.11"

- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.9"
checksum: "ec7a99cd05e0cd7f80243f135ce1361c76835cb0ee60055d14d20eba8eba1460"
enable-cache: true

- name: Sync deps
run: uv sync --quiet
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ requires-python = ">=3.11"
dependencies = [
"psycopg2-binary>=2.9.12",
"pytest>=8.0",
"pyyaml>=6.0.1",
]

[tool.pytest.ini_options]
Expand Down
137 changes: 120 additions & 17 deletions testing/test_workflow_security.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
from pathlib import Path

import yaml


REPO_ROOT = Path(__file__).parent.parent
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
Expand All @@ -22,6 +24,73 @@
)


SETUP_UV_USES = (
"astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9"
)
UV_VERSION_VALUE = "0.12.9"
UV_CHECKSUM_VALUE = (
"ec7a99cd05e0cd7f80243f135ce1361c76835cb0ee60055d14d20eba8eba1460"
)
GUARD_FETCH_FILES = (
"pr-safe-checks.yml",
"pr-workflow-guard.yml",
"neon-before-after-validate.yml",
"neon-integration-pg-matrix.yml",
"nixos-local-test.yml",
"pgdg-cve-scraper.yml",
"release-notes-scout.yml",
)


def _parse_install_uv_steps(workflow: str) -> list[dict]:
document = yaml.safe_load(workflow)
jobs = document.get("jobs") or {}
steps = []
for job in jobs.values():
if not isinstance(job, dict):
continue
for step in job.get("steps") or []:
if isinstance(step, dict) and step.get("name") == "Install uv":
steps.append(step)
return steps


def _assert_pinned_uv_install(workflow: str) -> None:
# Whole-file negatives: a differently named step cannot reintroduce curl|sh.
assert "curl -LsSf https://astral.sh/uv/install.sh | sh" not in workflow
assert 'echo "$HOME/.local/bin" >> "$GITHUB_PATH"' not in workflow

# Parser-based: bind every positive assertion to exactly one Install uv step.
install_steps = _parse_install_uv_steps(workflow)
assert len(install_steps) == 1, (
f"expected exactly one 'Install uv' step, found {len(install_steps)}"
)
step = install_steps[0]
assert step.get("uses") == SETUP_UV_USES, step.get("uses")
assert "run" not in step, "Install uv step must not also have a run: block"
with_keys = step.get("with") or {}
assert with_keys.get("version") == UV_VERSION_VALUE
assert with_keys.get("checksum") == UV_CHECKSUM_VALUE
assert with_keys.get("enable-cache") is True

# No second action entry that uses astral-sh/setup-uv under a different name.
document = yaml.safe_load(workflow)
extra_setup_uv = []
for job in (document.get("jobs") or {}).values():
if not isinstance(job, dict):
continue
for step in job.get("steps") or []:
if not isinstance(step, dict):
continue
uses = step.get("uses")
if not (isinstance(uses, str) and uses.startswith("astral-sh/setup-uv@")):
continue
if step.get("name") == "Install uv":
continue
extra_setup_uv.append(step)
assert extra_setup_uv == [], extra_setup_uv


def test_secret_backed_pr_jobs_skip_forks_before_runner_selection():
for workflow_name in PRIVILEGED_WORKFLOWS:
workflow = (WORKFLOW_DIR / workflow_name).read_text()
Expand All @@ -46,12 +115,13 @@ def test_distributed_neon_template_uses_guarded_pull_request_target():

def test_pr_safe_checks_is_hosted_and_secret_free():
workflow = (WORKFLOW_DIR / "pr-safe-checks.yml").read_text()
document = yaml.safe_load(workflow)

assert "name: PR Safe Checks" in workflow
assert "pull_request:" in workflow
assert "types: [opened, synchronize, reopened]" in workflow
assert re.search(r"(?m)^ pull_request:\s*$", workflow)
assert re.search(r"(?m)^ types: \[opened, synchronize, reopened\]\s*$", workflow)
assert "paths:" not in workflow
assert "runs-on: ubuntu-latest" in workflow
assert re.search(r"(?m)^ runs-on: ubuntu-latest\s*$", workflow)
assert "permissions:\n contents: read" in workflow
assert "write" not in workflow
assert "self-hosted" not in workflow
Expand All @@ -72,6 +142,15 @@ def test_pr_safe_checks_is_hosted_and_secret_free():
assert "test_both_view_sql_files_cover_all_health_checks" in workflow
assert "test_expected_check_groups_cover_all_defined_checks" not in workflow

# Parser-based: assert pr-safe-checks uses the pinned setup-uv on its actual step.
install_steps = _parse_install_uv_steps(workflow)
assert len(install_steps) == 1
assert install_steps[0].get("uses") == SETUP_UV_USES

# Sanity: pull_request_target trigger is absent; this job must not receive secrets.
on_section = document.get(True) or document.get("on") or {}
assert "pull_request_target" not in on_section


def test_release_drafter_uses_hosted_runner():
workflow = (WORKFLOW_DIR / "release-drafter.yml").read_text()
Expand All @@ -81,26 +160,21 @@ def test_release_drafter_uses_hosted_runner():

def test_pr_workflow_guard_is_trusted_base_only():
workflow = (WORKFLOW_DIR / "pr-workflow-guard.yml").read_text()
document = yaml.safe_load(workflow)

assert "name: Workflow Security Guard" in workflow
assert re.search(r"(?m)^ pull_request_target:\s*$", workflow)
assert re.search(r"(?m)^ types: \[opened, synchronize, reopened\]\s*$", workflow)
assert "permissions:\n contents: read" in workflow
assert "runs-on: ubuntu-latest" in workflow
assert re.search(r"(?m)^ runs-on: ubuntu-latest\s*$", workflow)
assert re.search(
r"(?m)^ name: Workflow Security Guard\s*$", workflow
)
assert "self-hosted" not in workflow
assert "secrets." not in workflow
assert "workflow_run" not in workflow
assert "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9" in workflow
assert "version: \"0.12.9\"" in workflow
assert (
"checksum: \"ec7a99cd05e0cd7f80243f135ce1361c76835cb0ee60055d14d20eba8eba1460\""
in workflow
)
assert "enable-cache: true" in workflow
assert "persist-credentials: false" in workflow

# Guard must checkout base, not the PR head.
assert "ref: ${{ github.event.pull_request.head.sha }}" not in workflow
# Guard must fetch PR files via the API as data, not by checking out PR head.
Expand All @@ -113,6 +187,30 @@ def test_pr_workflow_guard_is_trusted_base_only():
assert "cp -r /tmp/pr-workflows/." not in workflow
assert "rsync -a /tmp/pr-workflows" not in workflow

# Parser-based: guard's Install uv step is pinned.
install_steps = _parse_install_uv_steps(workflow)
assert len(install_steps) == 1
assert install_steps[0].get("uses") == SETUP_UV_USES

# Guard trigger must be pull_request_target (Trusted context).
on_section = document.get(True) or document.get("on") or {}
assert "pull_request_target" in on_section


def test_pr_workflow_guard_fetches_scraper_workflows():
# The two scraper workflows grant contents: write + pull-requests: write.
# The trusted guard must fetch them so the verifier evaluates the PR version,
# not the base-branch copy.
workflow = (WORKFLOW_DIR / "pr-workflow-guard.yml").read_text()
fetch_step = workflow.split("Fetch PR workflow files as data", 1)[1].split(
"\n - name:", 1
)[0]

for name in GUARD_FETCH_FILES:
assert name in fetch_step, (
f"Workflow Security Guard must fetch {name} from the PR head"
)


def test_pr_workflow_guard_overlay_replaces_yaml_only():
workflow = (WORKFLOW_DIR / "pr-workflow-guard.yml").read_text()
Expand All @@ -136,10 +234,15 @@ def test_pr_safe_checks_no_longer_pipes_curl_to_sh():
workflow = (WORKFLOW_DIR / "pr-safe-checks.yml").read_text()
assert "curl -LsSf https://astral.sh/uv/install.sh | sh" not in workflow
assert 'echo "$HOME/.local/bin" >> "$GITHUB_PATH"' not in workflow
assert "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9" in workflow
assert "version: \"0.12.9\"" in workflow
assert (
"checksum: \"ec7a99cd05e0cd7f80243f135ce1361c76835cb0ee60055d14d20eba8eba1460\""
in workflow


def test_pgdg_cve_scraper_uses_pinned_uv():
_assert_pinned_uv_install(
(WORKFLOW_DIR / "pgdg-cve-scraper.yml").read_text()
)
Comment thread
randoneering marked this conversation as resolved.


def test_release_notes_scout_uses_pinned_uv():
_assert_pinned_uv_install(
(WORKFLOW_DIR / "release-notes-scout.yml").read_text()
)
assert "enable-cache: true" in workflow
Loading
Loading