Skip to content
Draft
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
59 changes: 59 additions & 0 deletions scripts/checks/workflow_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Verify effective GitHub Actions contents permissions for repository backstops."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml


def _contents_permission(permissions: Any) -> str | None:
if permissions == "read-all":
return "read"
if permissions == "write-all":
return "write"
if isinstance(permissions, dict):
value = permissions.get("contents")
return value if isinstance(value, str) else None
return None


def verify_contents_permissions(workflow_path: str | Path) -> list[str]:
"""Return violations when a workflow can write repository contents.

The workflow-level permission must be explicit and read-only so repository
token defaults cannot silently expand authority. A job-level permissions map
may grant other scopes required by the job, but it must never grant
``contents: write`` or ``write-all``. When a job-level map omits ``contents``,
GitHub assigns no permission for that scope rather than inheriting the
workflow-level value, which is safe for this guard.
"""
path = Path(workflow_path)
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
return [f"{path}: workflow YAML root must be a mapping"]

violations: list[str] = []
workflow_permissions = document.get("permissions")
workflow_contents = _contents_permission(workflow_permissions)
if workflow_contents != "read":
if workflow_contents == "write":
violations.append(f"{path}: workflow permissions grant contents: write")
else:
violations.append(
f"{path}: workflow permissions are not explicitly read-only for contents"
)

jobs = document.get("jobs", {})
if not isinstance(jobs, dict):
return violations + [f"{path}: jobs must be a mapping"]

for job_name, job in jobs.items():
if not isinstance(job, dict) or "permissions" not in job:
continue
job_permissions = job["permissions"]
if job_permissions == "write-all" or _contents_permission(job_permissions) == "write":
violations.append(f"{path}: job {job_name} permissions grant contents: write")

return violations
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Semantic GitHub Actions permission-policy regressions."""

from __future__ import annotations

from pathlib import Path

import pytest
from conftest import load_module


def _permission_policy():
return load_module("scripts/checks/workflow_permissions.py", "workflow_permissions")


def test_repository_backstops_keep_contents_read_only() -> None:
"""Repository backstops must not grant write access to repository contents."""
policy = _permission_policy()
repo_root = Path(__file__).resolve().parents[3]
workflows = repo_root / ".github" / "workflows"

for workflow_name in ("ossf-scorecard.yml", "release.yml", "security-audit.yml"):
assert policy.verify_contents_permissions(workflows / workflow_name) == [], workflow_name


def test_comments_cannot_fake_read_only_permissions(tmp_path: Path) -> None:
"""A comment containing contents: read must not hide effective contents: write."""
workflow = tmp_path / "malicious.yml"
workflow.write_text(
"""name: malicious
permissions:
contents: write
# contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps: []
""",
encoding="utf-8",
)

violations = _permission_policy().verify_contents_permissions(workflow)

assert any("workflow permissions grant contents: write" in item for item in violations)


def test_job_override_cannot_escalate_contents_permission(tmp_path: Path) -> None:
"""A job-level override must not escalate a read-only workflow token."""
workflow = tmp_path / "job-write.yml"
workflow.write_text(
"""name: malicious-job
permissions: read-all
jobs:
audit:
runs-on: ubuntu-latest
permissions:
contents: write
steps: []
""",
encoding="utf-8",
)

violations = _permission_policy().verify_contents_permissions(workflow)

assert any("job audit permissions grant contents: write" in item for item in violations)


def test_missing_explicit_workflow_permissions_fails_closed(tmp_path: Path) -> None:
"""Repository backstops must not inherit mutable repository token defaults."""
workflow = tmp_path / "implicit.yml"
workflow.write_text(
"""name: implicit
jobs:
audit:
runs-on: ubuntu-latest
steps: []
""",
encoding="utf-8",
)

violations = _permission_policy().verify_contents_permissions(workflow)

assert any("workflow permissions are not explicitly read-only" in item for item in violations)