From d939aa6f6e29cc69afc212b0907664a0ae0d1937 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 09:11:58 +0900 Subject: [PATCH 1/2] test(security): reproduce workflow permission substring bypass --- .../tests/test_workflow_permission_policy.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 services/analysis-engine/tests/test_workflow_permission_policy.py diff --git a/services/analysis-engine/tests/test_workflow_permission_policy.py b/services/analysis-engine/tests/test_workflow_permission_policy.py new file mode 100644 index 000000000..ee244b579 --- /dev/null +++ b/services/analysis-engine/tests/test_workflow_permission_policy.py @@ -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) From 2f121f14d64fa0cb343e96e24fbd6ca522203db6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 09:12:07 +0900 Subject: [PATCH 2/2] fix(security): parse effective workflow contents permissions --- scripts/checks/workflow_permissions.py | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 scripts/checks/workflow_permissions.py diff --git a/scripts/checks/workflow_permissions.py b/scripts/checks/workflow_permissions.py new file mode 100644 index 000000000..8411346d6 --- /dev/null +++ b/scripts/checks/workflow_permissions.py @@ -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