diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f824f3..90f715c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,43 @@ concurrency: cancel-in-progress: true jobs: + policy-dependencies: + name: Locked policy wheels (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Create a disposable policy-client venv + run: | + python -I -m venv "$RUNNER_TEMP/policy-venv" + printf '%s\n' "$RUNNER_TEMP/policy-venv/bin" >> "$GITHUB_PATH" + - uses: ./.github/actions/bootstrap-patched-pip # local + - name: Prove isolated installation and wheel integrity + run: python -I tests/test_policy_dependency_install.py + contracts: name: Shared contract tests + permissions: + contents: read + pull-requests: read + needs: [policy-dependencies] + if: always() runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Require both native policy-wheel controls + env: + POLICY_RESULT: ${{ needs.policy-dependencies.result }} + run: test "$POLICY_RESULT" = success - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -49,12 +81,15 @@ jobs: - name: Install Python test dependencies run: >- - python -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2 + python -I -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2 - name: Test retained shared contracts run: >- python -m unittest tests.test_qualification_policy + tests.test_protected_policy_inputs + tests.test_protected_policy_check + tests.test_protected_policy_issue tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema @@ -66,6 +101,18 @@ jobs: tests.test_cli_effective_trust tests.test_composer_trust_contract + - name: Exercise the read-only policy client on this public PR + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + POLICY_PULL_REQUEST: ${{ github.event.pull_request.number }} + POLICY_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + python -I scripts/protected_policy_check.py \ + --pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" \ + > "$RUNNER_TEMP/protected-policy-read.json" + cat "$RUNNER_TEMP/protected-policy-read.json" + - name: Test actual Composer trust selection run: COMPOSER_BINARY="$(command -v composer)" phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result diff --git a/.github/workflows/protected-policy.yml b/.github/workflows/protected-policy.yml new file mode 100644 index 0000000..a8ea545 --- /dev/null +++ b/.github/workflows/protected-policy.yml @@ -0,0 +1,77 @@ +name: Protected workflow policy + +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + protected-policy: + name: Validate bounded workflow inputs + if: >- + github.repository == 'durable-workflow/.github' && + github.ref == 'refs/heads/main' && + github.event_name == 'pull_request_target' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: protected-policy-gate + steps: + - name: Check out the immutable protected base + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Create the isolated policy runtime + run: | + python -I -m venv "$RUNNER_TEMP/protected-policy-python" + printf '%s\n' "$RUNNER_TEMP/protected-policy-python/bin" >> "$GITHUB_PATH" + - name: Select verified dependency and API trust + uses: ./.github/actions/bootstrap-patched-pip # local + with: + clients: pip,python,curl,git + - name: Install only the protected hash-locked parser + run: >- + python -I -m pip --isolated install --require-hashes + --only-binary=:all: --no-deps -r qualification/requirements.lock + - name: Require protected source and the configured App identity + env: + POLICY_GATE_APP_ID: ${{ vars.POLICY_GATE_APP_ID }} + shell: bash + run: | + set -euo pipefail + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + [[ "$POLICY_GATE_APP_ID" =~ ^[1-9][0-9]{0,18}$ ]] + - name: Create the pilot-only checks token + id: app + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + env: + NODE_OPTIONS: --use-openssl-ca + NODE_EXTRA_CA_CERTS: '' + NODE_USE_SYSTEM_CA: '0' + NODE_TLS_REJECT_UNAUTHORIZED: '1' + with: + app-id: ${{ vars.POLICY_GATE_APP_ID }} + private-key: ${{ secrets.POLICY_GATE_APP_KEY }} + owner: durable-workflow + repositories: .github + permission-checks: write + - name: Validate data and issue the bound App check + env: + GITHUB_TOKEN: ${{ github.token }} + POLICY_CHECKS_TOKEN: ${{ steps.app.outputs.token }} + POLICY_GATE_APP_ID: ${{ vars.POLICY_GATE_APP_ID }} + POLICY_PULL_REQUEST: ${{ github.event.pull_request.number }} + POLICY_BASE_SHA: ${{ github.sha }} + POLICY_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: >- + python -I scripts/protected_policy_issue.py + --pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" + --expected-head "$POLICY_HEAD_SHA" diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 3041384..656ba6d 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -50,8 +50,8 @@ jobs: --coverage-clover build/composer-trust-coverage.xml - name: Produce source coverage run: | - python -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2 - coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_sdk_metrics_contract tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract + python -I -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2 + coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_inputs tests.test_protected_policy_check tests.test_protected_policy_issue tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_sdk_metrics_contract tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract coverage xml -o coverage.xml - uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 env: diff --git a/qualification/README.md b/qualification/README.md index e040e15..9ef3121 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -29,13 +29,140 @@ scanner, with the analysis token only on the final scanner step. It may produce coverage first using trusted repository code and approved actions. Other steps cannot reference secrets; existing cache restrictions still apply. Forks do not run this job. -The scanner rejects `pull_request_target`, mutable container +The scanner rejects `pull_request_target` outside the single content-bound +public pilot described below, mutable container images, unreviewed `workflow_run` consumers, and privileged artifact consumers without exact producer and digest provenance. Product repositories own their tests, documentation deployment, publication, and deployment workflows. Editorial wording is outside this policy. +## Isolated, hash-locked policy bootstrap + +Server's policy preflight requires action revision +`fea245682582d0620054e8547a5f3c968f9ef0f0` and exactly: + +```sh +python -I -m pip --isolated install --require-hashes --only-binary=:all: --no-deps -r .central-action-policy/qualification/requirements.lock +``` + +The reviewed central checkout supplies the lock. It permits only the reviewed +PyYAML 6.0.2 CPython 3.13 glibc Linux wheels for amd64 and arm64. Native CI checks +both wheel installations, a tampered hash, and working-directory `pip.py` and +`pip/` shadows. Missing isolation/integrity/binary/no-dependency flags, another +requirements path, extra options, a missing bootstrap or an older bootstrap +revision fail the Server contract. The same command is available for staged +adoption in other targets. Their legacy forms remain explicit migration work, +not evidence of the locked-policy contract. This source check does not establish +an independent protected execution boundary; that gate requires its own issuer +and repository-rule acceptance. + +### Protected gate input layer + +`scripts/protected_policy_inputs.py` captures an open public pull request against +the expected repository ID and exact `main` commit. It reads the candidate's +exact commit/tree identities and regular workflow blobs, plus literal referenced +helper blobs needed by the existing Composer inspection. Paths, file counts, +individual sizes, total bytes and encoding are bounded. Symlinks, submodules, +truncated trees and a changed PR identity refuse. Materialized files are data +with mode `0600`; candidate commands are never executed by this layer. + +The caller must supply a bounded read-only GitHub API client from protected +source, keep candidate data outside the interpreter's import path, and call +`require_current` again immediately before issuing a result. This module does +not create a Check Run, grant merge authority, configure an Environment or prove +an issuer binding. Those live integration requirements remain open. Private +repositories are explicitly refused until their separate gate is qualified. + +### Read-only protected-policy pilot client + +`scripts/protected_policy_check.py` connects that input layer to the reviewed +validator for the public `.github` pilot. It binds the immutable repository ID, +requires the expected protected `main` SHA, and rechecks main and PR identity +after validation. Candidate workflow/helper files are temporary nonexecutable +data outside Python's import path. The policy always comes from the reviewed +central checkout; there is no candidate policy-path override. + +After the authenticated bootstrap exports its CA file, empty additional-root +directory and digest, a read-only caller uses: + +```sh +python -I scripts/protected_policy_check.py \ + --pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" +``` + +The client requires curl 8.4 or newer, an explicit matching CA bundle and no +additional roots. Transfers are HTTPS GETs to the fixed GitHub API host, with +no redirects or automatic content decoding. Each response is capped at 8 MiB; +there is a 15-second request budget and a shared 120-second API budget. JSON +depth/node counts, endpoint shapes and request counts are also bounded. A read +token, when supplied as `GITHUB_TOKEN`, travels on stdin rather than process +arguments. The caller grants only Contents read and Pull requests read. Never +give the reader the App signing key or its Checks-write token. + +Success prints a bounded identity/digest report. Failure prints escaped JSON +and exits nonzero; candidate text cannot become a GitHub workflow command. +Ordinary PR CI exercises the real read client and validator. That candidate +controlled job remains a component test. Protected base execution, the App +issuer, immediate identity recheck, environment-denial tests and issuer-bound +repository rules still require separate live acceptance before enforcement. + +### Prepared App issuer component + +`scripts/protected_policy_issue.py` prepares the checks-only issuer for that +same public pilot. It runs the validator itself rather than accepting a +candidate-supplied report. The read-only `GITHUB_TOKEN` and separate +`POLICY_CHECKS_TOKEN` installation token have different roles; the issuer never +accepts or reads an App signing key. `POLICY_GATE_APP_ID` must name the reviewed +installation's App. The App must have Checks write and implicit Metadata read +only, installed solely on the pilot repository. + +The intended protected caller uses: + +```sh +python -I scripts/protected_policy_issue.py \ + --pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" \ + --expected-head "$POLICY_HEAD_SHA" +``` + +It checks the protected-main workflow context and exact event head, validates +bounded candidate data, creates one incomplete Check Run, and verifies the +returned check name, original head and App ID. Only then does it re-read the +protected main and complete PR identity immediately before completing that +same check successfully. Read and write credentials use stdin, with explicit +reviewed TLS roots and the existing transfer/time/JSON bounds. The App client +has no arbitrary endpoint or repository input. Refusal exits nonzero and never +reports success; a check already created may remain incomplete. A later head +cannot inherit an earlier head's successful check. + +Caller environment markers are defense in depth, not proof of secret denial. +The prepared workflow checks out `github.sha` from protected main, verifies +both the workflow SHA and actual checkout match it, installs the hash-locked +parser, then uses the pinned official App-token action. Its token is restricted +to Checks write on the one public pilot. The key is an Environment secret and +never enters the reader/issuer program. The token action selects only the +verified OpenSSL CA file and empty additional-root directory; bundled, extra +and additional system roots are excluded for that credentialed Node step. + +The policy permits this one exact UTF-8 workflow SHA-256, target and path. +Another path/repository, changed checkout/permission/credential handling, +different content or additional exception refuses. Updating that root requires +another explicit source review. All other `pull_request_target` workflows +remain forbidden. This does not validate arbitrary changes to central policy +code; root-of-trust changes still require independent maintainer/Security review. + +This bootstrap revision is prepared source, not a live enforcement claim. It +cannot authorize its own adoption. Security must accept the exact source; +the account holder must establish the App and key; and the live pilot must +prove Environment denial, wrong-issuer nonqualification, candidate nonexecution, +stale-head refusal and a safe successful check before the required-check rule +is bound to that App. The known Environment configuration alone is not proof. +No App key, live write or private-repository adoption is exercised by the +constructed-response tests. Existing Server/publication holds remain. + +The transport uses GitHub's [create and update Check Run API](https://docs.github.com/en/rest/checks/runs) +and checks the documented [workflow environment variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables). + ## Clean trust for dependency requests Linux jobs that fetch Composer dependencies use this order after checkout: diff --git a/qualification/policy.json b/qualification/policy.json index 72b3d6d..ae9ccca 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -22,6 +22,9 @@ "actions/configure-pages": { "45bfe0192ca1faeb007ade9deae92b16b8254a0d": "v6" }, + "actions/create-github-app-token": { + "64ab729a6294000f5aa4a4bc0b73d1f32bc45510": "v3.3.0" + }, "actions/deploy-pages": { "cd2ce8fcbc39b97be8ca5fce6e763baed58fa128": "v5" }, @@ -73,6 +76,7 @@ "4d407b29186a635f0cc27475ef0bc0ae605a8866": "1.86.0" }, "durable-workflow/.github": { + "fea245682582d0620054e8547a5f3c968f9ef0f0": "clean-trust", "75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust", "ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10": "patched-pip" }, @@ -132,7 +136,13 @@ "validator_runner": "ubuntu-latest" }, "privileged_workflow_run_consumers": {}, - "pull_request_target_exceptions": [] + "pull_request_target_exceptions": [ + { + "target": "github-control-plane", + "path": ".github/workflows/protected-policy.yml", + "sha256": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31" + } + ] }, "targets": { "ai": { @@ -262,6 +272,7 @@ "workflows": [ { "action_policy_preflight": true, + "locked_policy_dependency_required": true, "matrix_independent": false, "path": "phpunit-feature.yml", "required_check": "PHPUnit feature suite" diff --git a/qualification/requirements.lock b/qualification/requirements.lock new file mode 100644 index 0000000..2cf2a45 --- /dev/null +++ b/qualification/requirements.lock @@ -0,0 +1,5 @@ +# PyPI PyYAML 6.0.2 wheels for CPython 3.13 on glibc Linux amd64/arm64. +# Source: https://pypi.org/pypi/PyYAML/6.0.2/json +PyYAML==6.0.2 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 diff --git a/qualification/schema.json b/qualification/schema.json index c6ced5b..47baf9c 100644 --- a/qualification/schema.json +++ b/qualification/schema.json @@ -250,7 +250,17 @@ "type": "object" }, "pull_request_target_exceptions": { - "maxItems": 0, + "items": { + "additionalProperties": false, + "properties": { + "target": {"const": "github-control-plane"}, + "path": {"const": ".github/workflows/protected-policy.yml"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "required": ["target", "path", "sha256"], + "type": "object" + }, + "maxItems": 1, "type": "array" } }, @@ -300,6 +310,9 @@ "workflow": { "additionalProperties": false, "properties": { + "locked_policy_dependency_required": { + "type": "boolean" + }, "action_policy_preflight": { "type": "boolean" }, diff --git a/scripts/protected_policy_check.py b/scripts/protected_policy_check.py new file mode 100644 index 0000000..7be09ca --- /dev/null +++ b/scripts/protected_policy_check.py @@ -0,0 +1,239 @@ +"""Read-only pilot runner for the protected workflow-policy check. + +An enforcement caller must use the reviewed central checkout. Candidate inputs +remain data in a separate temporary directory. Ordinary CI can exercise this +client, but does not issue a Check Run or establish its App identity. The +protected issuer must independently recheck the PR identity. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time +from typing import Any + +# Python -I excludes both the working directory and the script directory. +# Add only this reviewed checkout, never the candidate materialization root. +ROOT = Path(__file__).resolve().parents[1] +if __package__ in {None, ""}: + sys.path.insert(0, str(ROOT)) + +from scripts.protected_policy_inputs import ( # noqa: E402 + MAX_FILES, MAX_TREE_ENTRIES, InputRefused, collect_inputs, read_identity, require_current, write_inputs, +) + +PILOT_REPOSITORY = "durable-workflow/.github" +PILOT_REPOSITORY_ID = 575230056 +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +MAX_REQUESTS = MAX_FILES + 16 +MAX_JSON_DEPTH = 64 +MAX_JSON_NODES = MAX_TREE_ENTRIES * 16 +TOTAL_SECONDS = 120 +REQUEST_SECONDS = 15 +ENDPOINT = re.compile( + r"/repos/[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}/(?:" + r"pulls/[1-9][0-9]*|branches/main|git/commits/[0-9a-f]{40}|" + r"git/trees/[0-9a-f]{40}\?recursive=1|git/blobs/[0-9a-f]{40})" +) + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, value in pairs: + if key in result: + raise InputRefused("GitHub JSON contains a duplicate member") + result[key] = value + return result + + +def reject_constant(_value: str) -> None: + raise InputRefused("GitHub JSON contains a non-finite number") + + +def decode_metadata(raw: str) -> dict[str, Any]: + try: + value = json.loads(raw, object_pairs_hook=unique_object, parse_constant=reject_constant) + except (ValueError, RecursionError) as error: + raise InputRefused("GitHub did not provide unambiguous bounded JSON") from error + if not isinstance(value, dict): + raise InputRefused("The selected GitHub endpoints must return JSON objects") + # Validate the standard decoder's result without recursively walking an + # attacker-controlled structure or relying on Python's parser stack limit. + pending, count = [(value, 0)], 0 + while pending: + item, depth = pending.pop() + count += 1 + if depth > MAX_JSON_DEPTH or count > MAX_JSON_NODES: + raise InputRefused("GitHub JSON exceeds the structure bound") + if isinstance(item, dict): + children = item.values() + elif isinstance(item, list): + children = item + else: + children = () + if len(children) > MAX_JSON_NODES: + raise InputRefused("GitHub JSON exceeds the collection bound") + pending.extend((child, depth + 1) for child in children) + return value + + +class GitHubReadClient: + """Use curl's byte and wall-clock limits, without redirects or decompression.""" + + def __init__(self, bundle: Path, directory: Path, expected_digest: str, token: str = "", *, + run=subprocess.run, clock=time.monotonic): + self.run, self.clock = run, clock + self.deadline = clock() + TOTAL_SECONDS + self.requests = 0 + if token and re.fullmatch(r"[A-Za-z0-9_.-]+", token) is None: + raise InputRefused("The read credential has an invalid format") + self.config = f'header = "Authorization: Bearer {token}"\n' if token else "" + if (bundle.is_symlink() or not bundle.is_file() or bundle.stat().st_size > 2 * 1024 * 1024 + or directory.is_symlink() or not directory.is_dir() or any(directory.iterdir())): + raise InputRefused("Explicit reviewed CA file and empty additional-root directory are required") + if (re.fullmatch(r"[0-9a-f]{64}", expected_digest) is None + or hashlib.sha256(bundle.read_bytes()).hexdigest() != expected_digest): + raise InputRefused("The selected CA file does not match the reviewed bootstrap digest") + self.bundle, self.directory = str(bundle.resolve()), str(directory.resolve()) + try: + version = run(["/usr/bin/curl", "--disable", "--version"], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, timeout=2, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise InputRefused("Cannot inspect the fixed curl executable") from error + match = re.match(rb"curl (\d+)\.(\d+)\.(\d+)\b", version.stdout) + if version.returncode or not match or tuple(map(int, match.groups())) < (8, 4, 0): + raise InputRefused("curl 8.4 or newer is required for unknown-length response bounds") + + def __call__(self, path: str) -> Any: + if ENDPOINT.fullmatch(path) is None or any(part in {".", ".."} for part in path.split("/")): + raise InputRefused("The policy reader permits only exact GitHub metadata/blob endpoints") + return self._request_json(path, method="GET", expected_status=b"200") + + def _request_json(self, path: str, *, method: str, expected_status: bytes, body=None) -> Any: + """Bound the transport used by the reader and the fixed-endpoint issuer. + + This is internal transport, not a candidate-controlled request API. + Each caller restricts its own endpoints and allowed operations. + """ + remaining = self.deadline - self.clock() + if remaining <= 0 or self.requests >= MAX_REQUESTS: + raise InputRefused("The policy API time or request bound is exhausted") + self.requests += 1 + timeout = min(REQUEST_SECONDS, remaining) + with tempfile.TemporaryDirectory(prefix="policy-response-") as temporary: + response = Path(temporary) / "body.json" + data_arguments = [] + if body is not None: + data = json.dumps(body, sort_keys=True, allow_nan=False).encode() + if len(data) > 16 * 1024: + raise InputRefused("The policy check request exceeds its byte bound") + payload = Path(temporary) / "request.json" + with payload.open("xb") as handle: + payload.chmod(0o600) + handle.write(data) + data_arguments = ["--header", "Content-Type: application/json", "--data-binary", "@" + str(payload)] + with (Path(temporary) / "curl-error").open("wb") as errors: + command = [ + "/usr/bin/curl", "--disable", "--silent", "--show-error", "--fail", + "--globoff", "--proto", "=https", "--max-redirs", "0", + "--max-time", str(timeout), "--connect-timeout", str(min(5, timeout)), + "--max-filesize", str(MAX_RESPONSE_BYTES), "--cacert", self.bundle, + "--capath", self.directory, "--header", "Accept: application/vnd.github+json", + "--header", "Accept-Encoding: identity", "--header", "X-GitHub-Api-Version: 2022-11-28", + "--request", method, *data_arguments, + "--config", "-", "--output", str(response), "--write-out", "%{http_code}", + "--url", "https://api.github.com" + path, + ] + try: + result = self.run(command, input=self.config.encode(), stdout=subprocess.PIPE, + stderr=errors, timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise InputRefused("The bounded GitHub request did not complete") from error + if result.returncode or result.stdout != expected_status: + status = result.stdout.decode("ascii", errors="replace")[:3] + raise InputRefused(f"GitHub request refused: curl exit {result.returncode}, HTTP {status}") + if self.clock() >= self.deadline: + raise InputRefused("The total policy API deadline expired") + if not response.is_file() or response.stat().st_size > MAX_RESPONSE_BYTES: + raise InputRefused("The GitHub response is absent or exceeds the byte bound") + try: + value = decode_metadata(response.read_text(encoding="utf-8")) + except InputRefused: + raise + except UnicodeError as error: + raise InputRefused("GitHub JSON must use UTF-8") from error + if self.clock() >= self.deadline: + raise InputRefused("The total policy API deadline expired during decoding") + return value + + +def require_protected_base(read, base_sha: str) -> None: + record = read(f"/repos/{PILOT_REPOSITORY}/branches/main") + try: + if record["name"] != "main" or record["protected"] is not True or record["commit"]["sha"] != base_sha: + raise InputRefused("The pilot's protected main branch moved or cannot be verified") + except (KeyError, TypeError) as error: + raise InputRefused("Incomplete protected-branch metadata") from error + + +def validate_snapshot(read, number: int, base_sha: str, *, validator=None) -> dict[str, Any]: + from scripts.qualification_policy import PolicyError, load_policy, validate_local_action_references + + require_protected_base(read, base_sha) + identity = read_identity(read, PILOT_REPOSITORY, PILOT_REPOSITORY_ID, number, base_sha) + files = collect_inputs(read, identity) + policy_path = ROOT / "qualification/policy.json" + try: + policy = load_policy(policy_path) + except PolicyError as error: + raise InputRefused("The reviewed central policy is invalid") from error + check = validator or validate_local_action_references + with tempfile.TemporaryDirectory(prefix="policy-candidate-") as temporary: + candidate = Path(temporary) / "data" + write_inputs(files, candidate) + try: + actions = check(policy, candidate / ".github/workflows", "github-control-plane") + except (PolicyError, RecursionError) as error: + raise InputRefused(str(error)) from error + require_protected_base(read, base_sha) + require_current(read, identity) + digests = {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(files.items())} + return { + "schema": "durable-workflow.protected-policy-read/v1", "repository": identity.repository, + "repository_id": identity.repository_id, "pull_request": identity.number, + "base_sha": identity.base_sha, "head_sha": identity.head_sha, + "head_repository": identity.head_repository, "head_repository_id": identity.head_repository_id, + "policy_sha256": hashlib.sha256(policy_path.read_bytes()).hexdigest(), + "inputs_sha256": hashlib.sha256(json.dumps(digests, sort_keys=True).encode()).hexdigest(), + "input_count": len(files), "approved_action_count": len(actions), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pull-request", type=int, required=True) + parser.add_argument("--base-sha", required=True) + args = parser.parse_args() + try: + read = GitHubReadClient( + Path(os.environ["CURL_CA_BUNDLE"]), Path(os.environ["SSL_CERT_DIR"]), + os.environ["DW_CI_CA_SHA256"], os.environ.get("GITHUB_TOKEN", ""), + ) + report = validate_snapshot(read, args.pull_request, args.base_sha) + print(json.dumps(report, sort_keys=True)) + return 0 + except (InputRefused, OSError, KeyError) as error: + print(json.dumps({"policy_refused": str(error)[:1000]}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/protected_policy_inputs.py b/scripts/protected_policy_inputs.py new file mode 100644 index 0000000..00dddf2 --- /dev/null +++ b/scripts/protected_policy_inputs.py @@ -0,0 +1,223 @@ +"""Read a PR's policy inputs as bounded data, without checking out candidate code.""" + +from __future__ import annotations + +import base64 +import binascii +import re +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + + +MAX_TREE_ENTRIES = 20_000 +MAX_FILES = 256 +MAX_FILE_BYTES = 1024 * 1024 +MAX_TOTAL_BYTES = 8 * 1024 * 1024 +SHA = re.compile(r"[0-9a-f]{40}") +SLUG = re.compile(r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}") +HELPERS = re.compile(r"(?:\./)?(?:scripts|\.github)/[A-Za-z0-9_./-]+") +ReadJson = Callable[[str], Any] + + +class InputRefused(ValueError): + """Candidate identity or input data cannot be verified within the bounds.""" + + +def checked_sha(value: object) -> str: + if not isinstance(value, str) or SHA.fullmatch(value) is None: + raise InputRefused("An exact GitHub commit or blob identity is required") + return value + + +def checked_slug(value: object) -> str: + if ( + not isinstance(value, str) + or SLUG.fullmatch(value) is None + or any(part in {".", ".."} for part in value.split("/")) + ): + raise InputRefused("A GitHub owner/repository identity is required") + return value + + +def checked_path(value: object) -> str: + if not isinstance(value, str) or len(value) > 1024 or not value: + raise InputRefused("Invalid policy input path") + path = PurePosixPath(value) + if ( + path.is_absolute() + or not path.parts + or path.as_posix() != value + or any(part in {".", ".."} for part in path.parts) + or "\\" in value + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise InputRefused("Policy input path must be canonical and repository-relative") + return value + + +def positive_id(value: object) -> int: + if type(value) is not int or value <= 0: + raise InputRefused("A positive GitHub object ID is required") + return value + + +@dataclass(frozen=True) +class PullIdentity: + repository: str + repository_id: int + number: int + base_sha: str + head_sha: str + head_repository: str + head_repository_id: int + + +def read_identity( + read: ReadJson, repository: str, repository_id: int, number: int, expected_base_sha: str, +) -> PullIdentity: + checked_slug(repository) + if not repository.startswith("durable-workflow/"): + raise InputRefused("The protected gate is limited to Durable Workflow repositories") + positive_id(repository_id) + positive_id(number) + checked_sha(expected_base_sha) + record = read(f"/repos/{repository}/pulls/{number}") + try: + base, head = record["base"], record["head"] + if ( + record["state"] != "open" + or positive_id(record["number"]) != number + or base["ref"] != "main" + or base["sha"] != expected_base_sha + or base["repo"]["full_name"] != repository + or positive_id(base["repo"]["id"]) != repository_id + or base["repo"]["private"] is not False + or head["repo"]["private"] is not False + ): + raise InputRefused("The PR must remain open against the expected repository main branch") + return PullIdentity( + repository, repository_id, number, checked_sha(base["sha"]), checked_sha(head["sha"]), + checked_slug(head["repo"]["full_name"]), positive_id(head["repo"]["id"]), + ) + except (KeyError, TypeError) as error: + raise InputRefused("Incomplete GitHub PR identity") from error + + +def require_current(read: ReadJson, identity: PullIdentity) -> None: + if read_identity( + read, identity.repository, identity.repository_id, identity.number, identity.base_sha, + ) != identity: + raise InputRefused("PR head, base or repository changed during policy validation") + + +def _tree_entries(read: ReadJson, identity: PullIdentity) -> dict[str, dict]: + prefix = f"/repos/{identity.head_repository}/git" + commit = read(f"{prefix}/commits/{identity.head_sha}") + try: + if checked_sha(commit["sha"]) != identity.head_sha: + raise InputRefused("GitHub returned another candidate commit") + tree_sha = checked_sha(commit["tree"]["sha"]) + tree = read(f"{prefix}/trees/{tree_sha}?recursive=1") + entries = tree["tree"] + if ( + tree.get("truncated") is not False + or checked_sha(tree["sha"]) != tree_sha + or not isinstance(entries, list) + or len(entries) > MAX_TREE_ENTRIES + ): + raise InputRefused("Candidate tree is incomplete or exceeds the input bound") + result = {} + for entry in entries: + path = checked_path(entry["path"]) + if path in result: + raise InputRefused("Candidate tree contains a duplicate path") + result[path] = entry + return result + except (KeyError, TypeError) as error: + raise InputRefused("Malformed GitHub tree metadata") from error + + +def _text_blob(read: ReadJson, repository: str, entry: dict) -> bytes: + if ( + entry.get("type") != "blob" + or entry.get("mode") not in {"100644", "100755"} + or type(entry.get("size")) is not int + or not 0 <= entry["size"] <= MAX_FILE_BYTES + ): + raise InputRefused("Policy input must be a bounded regular file, never a symlink or submodule") + sha = checked_sha(entry.get("sha")) + blob = read(f"/repos/{repository}/git/blobs/{sha}") + try: + content = blob["content"] + if ( + blob["sha"] != sha + or blob["encoding"] != "base64" + or type(blob["size"]) is not int + or blob["size"] != entry["size"] + or not isinstance(content, str) + or len(content) > MAX_FILE_BYTES * 2 + ): + raise InputRefused("GitHub blob identity, size or encoding does not match the candidate tree") + raw = base64.b64decode(content.replace("\n", ""), validate=True) + if len(raw) != entry["size"]: + raise InputRefused("GitHub blob contents do not match the declared size") + raw.decode("utf-8") + return raw + except (KeyError, TypeError, UnicodeError, binascii.Error) as error: + raise InputRefused("Policy input must be valid UTF-8 in a verified GitHub blob") from error + + +def _referenced_inputs(raw: bytes, entries: dict[str, dict], collected: dict[str, bytes]) -> list[str]: + result = [] + for helper in HELPERS.findall(raw.decode("utf-8")): + helper = checked_path(helper.removeprefix("./")) + if helper in entries and entries[helper].get("type") != "tree" and helper not in collected: + result.append(helper) + return result + + +def collect_inputs(read: ReadJson, identity: PullIdentity) -> dict[str, bytes]: + entries = _tree_entries(read, identity) + pending = sorted( + path for path in entries + if PurePosixPath(path).parent.as_posix() == ".github/workflows" and path.endswith((".yml", ".yaml")) + ) + if not pending: + raise InputRefused("Candidate has no workflow files") + collected: dict[str, bytes] = {} + total = 0 + while pending: + path = pending.pop() + if path in collected: + continue + if len(collected) >= MAX_FILES: + raise InputRefused("Too many workflow or referenced helper inputs") + raw = _text_blob(read, identity.head_repository, entries[path]) + total += len(raw) + if total > MAX_TOTAL_BYTES: + raise InputRefused("Workflow and helper inputs exceed the total byte bound") + collected[path] = raw + pending.extend(_referenced_inputs(raw, entries, collected)) + require_current(read, identity) + return collected + + +def write_inputs(files: dict[str, bytes], destination: Path) -> None: + """Materialize data in a new directory, without preserving executable modes.""" + if len(files) > MAX_FILES: + raise InputRefused("Policy materialization exceeds the file bound") + for name, contents in files.items(): + checked_path(name) + if not isinstance(contents, bytes) or len(contents) > MAX_FILE_BYTES: + raise InputRefused("Policy materialization requires bounded byte strings") + if sum(len(value) for value in files.values()) > MAX_TOTAL_BYTES: + raise InputRefused("Policy materialization exceeds the byte bound") + destination.mkdir(mode=0o700) + for name, contents in files.items(): + path = destination / checked_path(name) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with path.open("xb") as handle: + handle.write(contents) + path.chmod(0o600) diff --git a/scripts/protected_policy_issue.py b/scripts/protected_policy_issue.py new file mode 100644 index 0000000..499a270 --- /dev/null +++ b/scripts/protected_policy_issue.py @@ -0,0 +1,152 @@ +"""Prepared checks-only App issuer for the public central-policy pilot. + +Run only from reviewed central code in the protected execution environment. +This component is not a workflow binding or proof of live enforcement. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] +if __package__ in {None, ""}: + sys.path.insert(0, str(ROOT)) + +from scripts.protected_policy_check import ( # noqa: E402 + PILOT_REPOSITORY, PILOT_REPOSITORY_ID, GitHubReadClient, + require_protected_base, validate_snapshot, +) +from scripts.protected_policy_inputs import ( # noqa: E402 + InputRefused, checked_sha, positive_id, read_identity, require_current, +) + +CHECK_NAME = "Protected workflow policy" +WORKFLOW_REF = f"{PILOT_REPOSITORY}/.github/workflows/protected-policy.yml@refs/heads/main" + + +def require_protected_execution(environment, base_sha: str) -> None: + """Check caller context; actual credential denial still relies on GitHub.""" + expected = { + "GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": PILOT_REPOSITORY, + "GITHUB_REPOSITORY_ID": str(PILOT_REPOSITORY_ID), "GITHUB_REF": "refs/heads/main", + "GITHUB_SHA": checked_sha(base_sha), "GITHUB_EVENT_NAME": "pull_request_target", + "GITHUB_WORKFLOW_REF": WORKFLOW_REF, + "GITHUB_WORKFLOW_SHA": base_sha, + } + if any(environment.get(name) != value for name, value in expected.items()): + raise InputRefused("The issuer requires the reviewed protected-main workflow context") + + +class CheckRunIssuer(GitHubReadClient): + """Write one Check Run, verifying its App before completing it successfully.""" + + def __init__(self, *args, expected_app_id: int, **options): + self.expected_app_id = positive_id(expected_app_id) + self.check_id = None + self.head_sha = None + super().__init__(*args, **options) + if not self.config: + raise InputRefused("A separate checks-only installation token is required") + + def __call__(self, _path): + raise InputRefused("The App credential is restricted to the check issuer") + + def _verify(self, record, *, status: str, conclusion) -> int: + try: + check_id = positive_id(record["id"]) + if (positive_id(record["app"]["id"]) != self.expected_app_id + or record["name"] != CHECK_NAME or record["head_sha"] != self.head_sha + or record["status"] != status or record["conclusion"] != conclusion + or (self.check_id is not None and check_id != self.check_id)): + raise InputRefused("The Check Run identity or App issuer does not match") + return check_id + except (KeyError, TypeError) as error: + raise InputRefused("GitHub returned incomplete Check Run metadata") from error + + def begin(self, head_sha: str) -> None: + if self.head_sha is not None: + raise InputRefused("The issuer permits only one Check Run") + self.head_sha = checked_sha(head_sha) + record = self._request_json( + f"/repos/{PILOT_REPOSITORY}/check-runs", method="POST", expected_status=b"201", + body={"name": CHECK_NAME, "head_sha": self.head_sha, "status": "in_progress"}, + ) + # A wrong installation may create an incomplete check, never a success. + self.check_id = self._verify(record, status="in_progress", conclusion=None) + + def complete(self, report: dict) -> dict: + if self.check_id is None or report.get("head_sha") != self.head_sha: + raise InputRefused("No matching App-issued check is ready for completion") + summary = "\n".join([ + "Reviewed central policy accepted bounded, data-only workflow inputs.", + f"Policy SHA-256: {report['policy_sha256']}", + f"Inputs SHA-256: {report['inputs_sha256']}", + ]) + record = self._request_json( + f"/repos/{PILOT_REPOSITORY}/check-runs/{self.check_id}", + method="PATCH", expected_status=b"200", + body={"status": "completed", "conclusion": "success", "output": { + "title": "Workflow policy accepted", "summary": summary, + }}, + ) + self._verify(record, status="completed", conclusion="success") + # A completed issuer object cannot be reused to publish another result. + self.check_id = None + return {"check_name": CHECK_NAME, "app_id": self.expected_app_id, "head_sha": self.head_sha} + + +def qualify_and_issue(read, issuer, number: int, base_sha: str, expected_head: str) -> dict: + checked_sha(expected_head) + identity = read_identity(read, PILOT_REPOSITORY, PILOT_REPOSITORY_ID, number, base_sha) + if identity.head_sha != expected_head: + raise InputRefused("The event's expected head is no longer current") + report = validate_snapshot(read, number, base_sha) + if any(report.get(key) != value for key, value in { + "repository": identity.repository, "repository_id": identity.repository_id, + "pull_request": identity.number, "head_sha": identity.head_sha, "base_sha": identity.base_sha, + "head_repository": identity.head_repository, "head_repository_id": identity.head_repository_id, + }.items()): + raise InputRefused("The validated snapshot does not match the original PR identity") + issuer.begin(identity.head_sha) + # Recheck after the App identity is known, immediately before success. + require_protected_base(read, base_sha) + require_current(read, identity) + return {**report, **issuer.complete(report)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pull-request", type=int, required=True) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--expected-head", required=True) + args = parser.parse_args() + try: + require_protected_execution(os.environ, args.base_sha) + bundle = Path(os.environ["CURL_CA_BUNDLE"]) + directory = Path(os.environ["SSL_CERT_DIR"]) + digest = os.environ["DW_CI_CA_SHA256"] + read = GitHubReadClient(bundle, directory, digest, os.environ["GITHUB_TOKEN"]) + app_id = os.environ["POLICY_GATE_APP_ID"] + if re.fullmatch(r"[1-9]\d{0,18}", app_id, flags=re.ASCII) is None: + raise InputRefused("A canonical positive App ID is required") + issuer = CheckRunIssuer( + bundle, directory, digest, os.environ["POLICY_CHECKS_TOKEN"], + expected_app_id=int(app_id), + ) + issuer.deadline = min(issuer.deadline, read.deadline) + print(json.dumps(qualify_and_issue( + read, issuer, args.pull_request, args.base_sha, args.expected_head, + ), sort_keys=True)) + return 0 + except (InputRefused, OSError, KeyError) as error: + print(json.dumps({"policy_refused": str(error)[:1000]}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 27759a2..210ff38 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -7,6 +7,7 @@ import contextlib import email.utils import errno +import hashlib import http.client import json import os @@ -17,7 +18,7 @@ import urllib.error import urllib.parse import urllib.request -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterator, Mapping from datetime import UTC from pathlib import Path from typing import Any @@ -28,6 +29,23 @@ CENTRAL_ACTION_REPOSITORY = "durable-workflow/.github" COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") SUPPORTED_JAVASCRIPT_ACTION_RUNTIMES = ["node24"] +LOCKED_POLICY_BOOTSTRAP_REVISION = "fea245682582d0620054e8547a5f3c968f9ef0f0" +LOCKED_POLICY_INSTALL_COMMAND = ( + "python -I -m pip --isolated install --require-hashes --only-binary=:all: --no-deps " + "-r .central-action-policy/qualification/requirements.lock" +) +# Complete caller-supplied environment for the reviewed preflight. Startup +# settings below are approved only as literal empty clears, never as hooks. +PREFLIGHT_CI_ENVIRONMENT = { + "CI", "CARGO_TERM_COLOR", "RUST_BACKTRACE", "PYTHONUNBUFFERED", + "PYTHONDONTWRITEBYTECODE", "PYTHONUTF8", "FORCE_COLOR", "NO_COLOR", +} +PREFLIGHT_CLEAR_STARTUP_ENVIRONMENT = {"BASH_ENV", "ENV", "LD_PRELOAD", "NODE_OPTIONS"} +PROTECTED_POLICY_EXCEPTION = { + "target": "github-control-plane", + "path": ".github/workflows/protected-policy.yml", + "sha256": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31", +} EXPECTED_TARGETS = { "ai": ("ai", "main"), "cli": ("cli", "main"), @@ -443,7 +461,7 @@ def validate_policy(policy: dict[str, Any]) -> None: "pull_request_target_exceptions", }: raise PolicyError("qualification policy must declare the complete workflow trust contract") - if workflow_trust["pull_request_target_exceptions"] != []: + if workflow_trust["pull_request_target_exceptions"] not in ([], [PROTECTED_POLICY_EXCEPTION]): raise PolicyError("pull_request_target exceptions require a separately reviewed policy revision") artifact_handoffs = workflow_trust["privileged_artifact_handoffs"] if not isinstance(artifact_handoffs, dict) or set(artifact_handoffs) != { @@ -688,51 +706,69 @@ def _is_action_step(step: object, repository: str) -> bool: ) +def _reviewed_preflight_action_options(step: object) -> bool: + return ( + isinstance(step, dict) + and not set(step) - {"name", "id", "uses", "with", "env", "if", "continue-on-error"} + and isinstance(step.get("if", ""), str) + and _condition_without_expression_wrapper(step.get("if", "")) + in {"", "github.server_url == 'https://github.com'"} + and step.get("continue-on-error") in (None, False, "false") + ) + + def _verify_action_policy_preflight( target_name: str, label: str, jobs: dict[str, Any], gate_job_name: str, approved_bootstraps: set[str] | None = None, + locked_dependency_required: bool = False, + workflow_document: Mapping[str, Any] | None = None, ) -> None: preflight = jobs.get("action-policy") if not isinstance(preflight, dict): raise PolicyError(f"{label} has no central action policy preflight job") + workflow_context = workflow_document or {} + _verify_preflight_execution_context(workflow_context, preflight, label) accepted_conditions = {"", "github.server_url == 'https://github.com'"} if ( preflight.get("name") != "Central action policy preflight" or _condition_without_expression_wrapper(preflight.get("if", "")) not in accepted_conditions or preflight.get("runs-on") != "ubuntu-latest" or preflight.get("timeout-minutes") != "5" - or preflight.get("needs") is not None - or preflight.get("permissions") is not None - or preflight.get("env") is not None ): raise PolicyError(f"{label} central action policy preflight job is not the reviewed read-only shape") steps = preflight.get("steps") + if locked_dependency_required and (not isinstance(steps, list) or len(steps) != 6): + raise PolicyError(f"{label} central action policy preflight requires the locked-policy pip bootstrap") if isinstance(steps, list) and len(steps) == 6: bootstrap = steps[3] revisions = approved_bootstraps if approved_bootstraps is not None else {"ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10"} references = {"durable-workflow/.github/.github/actions/bootstrap-patched-pip@" + sha for sha in revisions} if ( - not isinstance(bootstrap, dict) - or set(bootstrap) - {"name", "uses"} + not _reviewed_preflight_action_options(bootstrap) + or "with" in bootstrap or bootstrap.get("uses") not in references ): raise PolicyError(f"{label} central action policy preflight has an unreviewed pip bootstrap") + if locked_dependency_required and not bootstrap["uses"].endswith("@" + LOCKED_POLICY_BOOTSTRAP_REVISION): + raise PolicyError(f"{label} central action policy preflight requires the locked-policy pip bootstrap") steps = steps[:3] + steps[4:] if not isinstance(steps, list) or len(steps) != 5 or not all(isinstance(step, dict) for step in steps): raise PolicyError(f"{label} central action policy preflight must contain the five reviewed steps") candidate, central, setup_python, install, validate = steps if ( not _is_action_step(candidate, "actions/checkout") + or not _reviewed_preflight_action_options(candidate) or candidate.get("with") != {"persist-credentials": "false"} ): raise PolicyError(f"{label} central action policy preflight must check out credential-free candidate source") if ( not _is_action_step(central, "actions/checkout") + or not _reviewed_preflight_action_options(central) or central.get("with") != { "repository": CENTRAL_ACTION_REPOSITORY, @@ -744,22 +780,38 @@ def _verify_action_policy_preflight( raise PolicyError(f"{label} central action policy preflight must use protected central policy source") if ( not _is_action_step(setup_python, "actions/setup-python") + or not _reviewed_preflight_action_options(setup_python) or setup_python.get("with") != {"python-version": "3.13"} ): raise PolicyError(f"{label} central action policy preflight must use the reviewed Python runtime") reviewed_install_commands = { "python -m pip install PyYAML==6.0.2", "python -m pip install --only-binary :all: PyYAML==6.0.2", + "python -I -m pip install PyYAML==6.0.2", + "python -I -m pip install --only-binary :all: PyYAML==6.0.2", + LOCKED_POLICY_INSTALL_COMMAND, } - if set(install) - {"name", "run"} or _normalized_shell(install.get("run")) not in reviewed_install_commands: + if locked_dependency_required: + reviewed_install_commands = {LOCKED_POLICY_INSTALL_COMMAND} + if set(install) - {"name", "run", "working-directory", "shell", "env"} or _normalized_shell(install.get("run")) not in reviewed_install_commands: raise PolicyError(f"{label} central action policy preflight must install the reviewed policy dependency") expected_validation = ( "python .central-action-policy/scripts/qualification_policy.py validate " f"--policy .central-action-policy/qualification/policy.json --target {target_name} " "--workflow-directory .github/workflows" ) - if set(validate) - {"name", "run"} or _normalized_shell(validate.get("run")) != expected_validation: + if set(validate) - {"name", "run", "working-directory", "shell", "env"} or _normalized_shell(validate.get("run")) != expected_validation: raise PolicyError(f"{label} central action policy preflight does not invoke the reviewed validator") + if not all( + _runs_at_workspace_root(workflow_context, preflight, step) + for step in (install, validate) + ): + raise PolicyError(f"{label} central action policy install and validator must run from the workspace root") + if not all( + _uses_reviewed_run_shell(workflow_context, preflight, step) + for step in (install, validate) + ): + raise PolicyError(f"{label} central action policy install and validator require a reviewed shell") gate = jobs[gate_job_name] if "action-policy" not in _job_needs(gate, f"{label} required check gate"): @@ -774,8 +826,10 @@ def _verify_action_policy_preflight( if not any( isinstance(step, dict) and _condition_without_expression_wrapper(step.get("if", "")) in accepted_conditions + and step.get("continue-on-error") in (None, False, "false") and step.get("env") == reviewed_gate["env"] and _normalized_shell(step.get("run")) == reviewed_gate["run"] + and _uses_reviewed_run_shell(workflow_context, gate, step) for step in gate.get("steps") or [] ): raise PolicyError(f"{label} required check does not fail closed on the central action policy result") @@ -821,7 +875,11 @@ def verify_workflow_source(name: str, branch: str, workflow: dict[str, Any], sou f"matching jobs={stable_jobs}" ) if workflow.get("action_policy_preflight") is True: - _verify_action_policy_preflight(name, label, jobs, stable_jobs[0], approved_bootstraps) + _verify_action_policy_preflight( + name, label, jobs, stable_jobs[0], approved_bootstraps, + workflow.get("locked_policy_dependency_required", False), + workflow_document=document, + ) def _parse_yaml(source: str, label: str) -> Any: @@ -831,23 +889,33 @@ def _parse_yaml(source: str, label: str) -> Any: raise PolicyError(f"cannot parse {label} as YAML: {error}") from error +def _walk_yaml_nodes(document: Any) -> Iterator[Any]: + pending = [document] + visited: set[int] = set() + + # Shared YAML aliases must not multiply traversal work. + while pending: + node = pending.pop() + if not isinstance(node, (dict, list)): + yield node + continue + if id(node) in visited: + continue + visited.add(id(node)) + yield node + children = list(node.values()) if isinstance(node, dict) else node + pending.extend(reversed(children)) + + def _workflow_action_references(source: str, label: str) -> list[str]: - document = _parse_yaml(source, label) references: list[str] = [] + for node in _walk_yaml_nodes(_parse_yaml(source, label)): + if isinstance(node, dict) and "uses" in node: + reference = node["uses"] + if not isinstance(reference, str): + raise PolicyError(f"{label} has a non-string action reference") + references.append(reference) - def visit(node: Any) -> None: - if isinstance(node, dict): - for key, value in node.items(): - if key == "uses": - if not isinstance(value, str): - raise PolicyError(f"{label} has a non-string action reference") - references.append(value) - visit(value) - elif isinstance(node, list): - for value in node: - visit(value) - - visit(document) return references @@ -921,16 +989,7 @@ def _job_needs(job: dict[str, Any], label: str) -> set[str]: def _string_values(value: Any) -> list[str]: - values: list[str] = [] - if isinstance(value, dict): - for nested in value.values(): - values.extend(_string_values(nested)) - elif isinstance(value, list): - for nested in value: - values.extend(_string_values(nested)) - elif isinstance(value, str): - values.append(value) - return values + return [node for node in _walk_yaml_nodes(value) if isinstance(node, str)] def _references_secret_context(value: Any) -> bool: @@ -1236,6 +1295,36 @@ def _run_defaults(scope: Mapping[str, Any]) -> Mapping[str, Any]: return run if isinstance(run, dict) else {} +def _effective_run_setting( + workflow: Mapping[str, Any], job: Mapping[str, Any], step: Mapping[str, Any], + key: str, default: Any, +) -> Any: + for scope in (step, _run_defaults(job), _run_defaults(workflow)): + if key in scope: + return scope[key] + return default + + +def _runs_at_workspace_root( + workflow: Mapping[str, Any], job: Mapping[str, Any], step: Mapping[str, Any] +) -> bool: + directory = _effective_run_setting(workflow, job, step, "working-directory", ".") + return isinstance(directory, str) and ( + directory in {".", "./"} + or re.fullmatch(r"\$\{\{\s*github\.workspace\s*\}\}(?:/\.?)?", directory) is not None + ) + + +def _uses_reviewed_run_shell( + workflow: Mapping[str, Any], job: Mapping[str, Any], step: Mapping[str, Any] +) -> bool: + # Keep the runner default and built-in POSIX script invocation. An arbitrary + # template can ignore the script or change its directory before running it. + runner_default = object() + shell = _effective_run_setting(workflow, job, step, "shell", runner_default) + return shell is runner_default or isinstance(shell, str) and shell in {"bash", "sh"} + + def _uses_only_allowed_environment( scopes: tuple[Mapping[str, Any], ...], allowed_environment: object ) -> bool: @@ -1251,6 +1340,48 @@ def _uses_only_allowed_environment( return True +def _preflight_environment_is_reviewed( + workflow: Mapping[str, Any], job: Mapping[str, Any], step: Mapping[str, Any] +) -> bool: + scopes = (workflow, job, step) + allowed = list(PREFLIGHT_CI_ENVIRONMENT | PREFLIGHT_CLEAR_STARTUP_ENVIRONMENT) + if not _uses_only_allowed_environment(scopes, allowed): + return False + for scope in scopes: + environment = scope.get("env") or {} + if not all( + value == "" if key in PREFLIGHT_CLEAR_STARTUP_ENVIRONMENT + else isinstance(value, (str, bool, int, float)) + for key, value in environment.items() + ): + return False + return True + + +def _verify_preflight_execution_context( + workflow: Mapping[str, Any], job: Mapping[str, Any], label: str +) -> None: + allowed = {"name", "if", "runs-on", "timeout-minutes", "steps", "defaults", "env"} + if set(job) - allowed: + raise PolicyError(f"{label} central action policy preflight job is not the reviewed read-only shape") + for scope in (workflow, job): + defaults = scope.get("defaults") + if defaults is None: + continue + if not isinstance(defaults, dict) or set(defaults) - {"run"}: + raise PolicyError(f"{label} central action policy preflight has unreviewed defaults") + run = defaults.get("run", {}) + if not isinstance(run, dict) or set(run) - {"shell", "working-directory"}: + raise PolicyError(f"{label} central action policy preflight has unreviewed run defaults") + steps = job.get("steps") + if ( + isinstance(steps, list) + and all(isinstance(step, dict) for step in steps) + and not all(_preflight_environment_is_reviewed(workflow, job, step) for step in steps) + ): + raise PolicyError(f"{label} central action policy preflight environment is not reviewed") + + def _trusted_validator_action_step(step: object, expected_step: object) -> bool: if ( not isinstance(step, dict) @@ -1496,6 +1627,17 @@ def output_name_for(expected: str) -> str | None: ) +def _reviewed_policy_workflow( + policy: dict[str, Any], target_name: str, workflow_path: str, source: str, +) -> bool: + return ( + policy["workflow_trust"]["pull_request_target_exceptions"] == [PROTECTED_POLICY_EXCEPTION] + and target_name == PROTECTED_POLICY_EXCEPTION["target"] + and Path(workflow_path).parts[-3:] == Path(PROTECTED_POLICY_EXCEPTION["path"]).parts + and hashlib.sha256(source.encode("utf-8")).hexdigest() == PROTECTED_POLICY_EXCEPTION["sha256"] + ) + + def _scan_workflow_trust( policy: dict[str, Any], target_name: str, @@ -1509,7 +1651,7 @@ def _scan_workflow_trust( triggers = document.get("on") if not isinstance(triggers, dict): raise PolicyError(f"{label} must declare explicit workflow triggers") - if "pull_request_target" in triggers: + if "pull_request_target" in triggers and not _reviewed_policy_workflow(policy, target_name, workflow_path, source): raise PolicyError(f"{label} uses forbidden pull_request_target execution") top_writes = _permissions_with_write(document.get("permissions"), f"{label} top-level") @@ -1856,6 +1998,7 @@ def audit_action_releases( COMPOSER_LOCAL_COMMANDS = {"about", "help", "list"} TRUST_ACTION_PREFIX = "durable-workflow/.github/.github/actions/" COMPOSER_TRUST_FIELDS = {"SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "COMPOSER_CAFILE", "COMPOSER_CAPATH", "COMPOSER_DISABLE_TLS", "COMPOSER", "COMPOSER_HOME", "DW_CI_CA_SHA256", "PIP_CERT", "REQUESTS_CA_BUNDLE"} +COMPOSER_HELPER_PATTERN = re.compile(r"(?:\./)?(?:scripts|\.github)/[A-Za-z0-9_./-]+") def _composer_subcommand_needs_network(arguments: list[str]) -> bool: @@ -1896,17 +2039,45 @@ def _is_composer_shell_helper(path: Path, contents: str) -> bool: return path.suffix in {"", ".sh", ".bash", ".ksh", ".zsh"} or re.match(r"^#![^\n]*\b(?:bash|sh|dash|ksh|zsh)\b", contents) is not None +def _shell_changes_working_directory(source: str) -> bool: + try: + lexer = shlex.shlex(source, posix=True, punctuation_chars=";&|()\n") + lexer.whitespace = " \t\r" + lexer.whitespace_split = True + return any( + token in {"cd", "pushd", "popd"} + or re.search(r"(?:^|[;&|()\n])\s*(?:cd|pushd|popd)(?:\s|$)", token) is not None + for token in lexer + ) + except ValueError: + # Shell helpers with an unprovable directory context are not evidence. + return True + + +def _composer_helper_references(source: str) -> list[str]: + # Explicit non-shell interpreters keep their existing program semantics. + # Remove only that invocation, so a following shell helper is still seen. + non_shell = r"(? str: contents = [source] visited = set() if visited is None else visited - for name in re.findall(r"(?:\./)?(?:scripts|\.github)/[A-Za-z0-9_./-]+", source): + helpers = _composer_helper_references(source) + if helpers and _shell_changes_working_directory(source): + raise PolicyError("Composer relative helper inspection requires an unchanged workspace root") + for name in helpers: path = (root / name).resolve() if not path.is_relative_to(root.resolve()): raise PolicyError("Composer source contract must not read outside the candidate repository") if path.is_file() and path not in visited: visited.add(path) helper = path.read_text(encoding="utf-8") - if _is_composer_shell_helper(path, helper): + shell_invocation = re.search( + r"\b(?:bash|sh|dash|ksh|zsh)\s+(?:-[\w-]+\s+)*" + re.escape(name) + r"(?=\s|[;&|()]|$)", source, + ) + if _is_composer_shell_helper(path, helper) or shell_invocation: contents.append(_composer_step_source(helper, root, visited)) return "\n".join(contents) @@ -1957,14 +2128,22 @@ def _require_composer_request_trust(step: dict[str, Any], state: tuple[bool, boo raise PolicyError(f"{label} changes Composer trust inside its request step or helper") -def _composer_job_sinks(job: dict[str, Any], root: Path, approved: set[str], local: bool, label: str) -> list[str]: +def _composer_job_sinks( + job: dict[str, Any], root: Path, approved: set[str], local: bool, label: str, + workflow: Mapping[str, Any], +) -> list[str]: if set(job.get("env") or {}) & COMPOSER_TRUST_FIELDS: raise PolicyError(f"{label} declares unverified job-level Composer trust overrides") evidence = [] state = (False, False, False) for index, step in enumerate(job.get("steps") or []): step_label = f"{label}:step-{index + 1}" - expanded = _composer_step_source(step.get("run", ""), root) + source = step.get("run", "") + if _composer_helper_references(source) and not ( + _runs_at_workspace_root(workflow, job, step) and _uses_reviewed_run_shell(workflow, job, step) + ): + raise PolicyError(f"{step_label} Composer relative helper inspection requires the workspace root and a reviewed shell") + expanded = _composer_step_source(source, root) if _composer_network_commands(expanded): _require_composer_request_trust(step, state, step_label, expanded) evidence.append(step_label) @@ -1985,15 +2164,17 @@ def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: if set(document.get("env") or {}) & COMPOSER_TRUST_FIELDS: raise PolicyError(f"{filename} declares unverified workflow-level Composer trust overrides") for job_name, job in (document.get("jobs") or {}).items(): - evidence.extend(_composer_job_sinks(job, root, approved, target == "github-control-plane", f"{filename}:{job_name}")) + evidence.extend(_composer_job_sinks(job, root, approved, target == "github-control-plane", f"{filename}:{job_name}", document)) return evidence def validate_local_action_references( policy: dict[str, Any], directory: Path, target_name: str = "github-control-plane" ) -> list[str]: + # The reviewed workflow exception hashes Git blob bytes. Text-mode reads + # normalize line endings and would admit unreviewed byte variants. sources = { - path.as_posix(): path.read_text(encoding="utf-8") for path in sorted(directory.glob("*.y*ml")) if path.is_file() + path.as_posix(): path.read_bytes().decode("utf-8") for path in sorted(directory.glob("*.y*ml")) if path.is_file() } evidence = scan_workflow_sources(policy, target_name, sources) verify_composer_network_sinks(policy, target_name, sources, directory.parent.parent) diff --git a/tests/test_composer_trust_contract.py b/tests/test_composer_trust_contract.py index 584b750..a6594ee 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -28,9 +28,12 @@ def setUp(self): {"run": "composer install --prefer-dist"}, ] - def check(self, steps=None, jobs=None): + def check(self, steps=None, jobs=None, defaults=None): jobs = jobs or {"test": {"steps": self.steps if steps is None else steps}} - source = yaml.safe_dump({"jobs": jobs}) + document = {"jobs": jobs} + if defaults is not None: + document["defaults"] = defaults + source = yaml.safe_dump(document) return verify_composer_network_sinks(self.policy, "fixture", {"ci.yml": source}, self.root) def test_reviewed_order_is_enumerated(self): @@ -133,6 +136,119 @@ def test_extensionless_and_nested_repository_helpers_are_inspected(self): self.check([{"run": "scripts/build"}]) self.assertEqual(1, len(self.check([*self.steps[:3], {"run": "scripts/build"}]))) + def relative_helper_fixture(self): + (self.root / "scripts").mkdir() + (self.root / "scripts/request.sh").write_text("#!/bin/sh\nprintf done\n") + (self.root / "alternate/scripts").mkdir(parents=True) + (self.root / "alternate/scripts/request.sh").write_text("#!/bin/sh\ncomposer install\n") + + def test_relative_helpers_cannot_be_redirected_by_run_directories(self): + self.relative_helper_fixture() + for scope in ("step", "job", "workflow"): + step = {"run": "scripts/request.sh"} + job = {"steps": [step]} + defaults = {"run": {"working-directory": "alternate"}} + workflow_defaults = None + if scope == "step": + step["working-directory"] = "alternate" + elif scope == "job": + job["defaults"] = defaults + else: + workflow_defaults = defaults + with self.subTest(scope=scope), self.assertRaisesRegex(PolicyError, "workspace root"): + self.check(jobs={"test": job}, defaults=workflow_defaults) + + def test_relative_helpers_refuse_directory_changes_in_shell_source(self): + self.relative_helper_fixture() + for command in ( + "cd alternate\nscripts/request.sh", + "pushd alternate; scripts/request.sh", + "builtin cd alternate && scripts/request.sh", + "bash -c 'cd alternate; scripts/request.sh'", + ): + with self.subTest(command=command), self.assertRaisesRegex(PolicyError, "workspace root"): + self.check([{"run": command}]) + (self.root / "scripts/outer.sh").write_text("cd alternate\nscripts/request.sh\n") + with self.assertRaisesRegex(PolicyError, "workspace root"): + self.check([{"run": "scripts/outer.sh"}]) + + def test_explicit_workspace_roots_keep_guarded_helper_requests(self): + (self.root / "scripts").mkdir() + (self.root / "scripts/request.sh").write_text("composer install\n") + for directory in (".", "./", "${{ github.workspace }}", "${{github.workspace}}/."): + for scope in ("step", "job", "workflow"): + step = {"run": "scripts/request.sh"} + job = {"steps": [*self.steps[:3], step]} + defaults = {"run": {"working-directory": directory}} + workflow_defaults = None + if scope == "step": + step["working-directory"] = directory + elif scope == "job": + job["defaults"] = defaults + else: + workflow_defaults = defaults + with self.subTest(scope=scope, directory=directory): + self.assertEqual(1, len(self.check(jobs={"test": job}, defaults=workflow_defaults))) + + def test_helper_directory_uses_the_most_specific_run_setting(self): + self.relative_helper_fixture() + step = {"run": "scripts/request.sh", "working-directory": "."} + job = {"steps": [step], "defaults": {"run": {"working-directory": "alternate"}}} + self.assertEqual([], self.check(jobs={"test": job})) + del step["working-directory"] + job["defaults"]["run"]["working-directory"] = "${{ github.workspace }}" + self.assertEqual([], self.check(jobs={"test": job}, defaults={"run": {"working-directory": "alternate"}})) + + def test_non_helper_commands_keep_their_existing_directory_behavior(self): + self.assertEqual([], self.check([{"run": "cargo test", "working-directory": "alternate"}])) + self.assertEqual([], self.check([{"run": "cd alternate\ncargo test"}])) + + def test_non_shell_programs_keep_their_existing_directory_behavior(self): + for command in ("python3 scripts/check.py", "python -I scripts/check.py", "node scripts/check.mjs", "php scripts/check.php"): + with self.subTest(command=command): + self.assertEqual([], self.check([{"run": command, "working-directory": "alternate"}])) + with self.assertRaisesRegex(PolicyError, "workspace root"): + self.check([{"run": "python3 scripts/check.py; scripts/request.sh", "working-directory": "alternate"}]) + for command in ("custom-python3 scripts/request.sh", "scripts/python3 scripts/request.sh"): + with self.subTest(command=command), self.assertRaisesRegex(PolicyError, "workspace root"): + self.check([{"run": command, "working-directory": "alternate"}]) + + def test_shell_invocation_keeps_helper_inspection_despite_its_extension(self): + (self.root / "scripts").mkdir() + (self.root / "scripts/request.py").write_text("composer install\n") + with self.assertRaisesRegex(PolicyError, "workspace root"): + self.check([{"run": "bash scripts/request.py", "working-directory": "alternate"}]) + with self.assertRaisesRegex(PolicyError, "lack the reviewed"): + self.check([{"run": "bash scripts/request.py"}]) + self.assertEqual(1, len(self.check([*self.steps[:3], {"run": "bash scripts/request.py"}]))) + + def test_relative_helpers_refuse_custom_shell_context(self): + self.relative_helper_fixture() + for scope in ("step", "job", "workflow"): + step = {"run": "scripts/request.sh"} + job = {"steps": [step]} + shell = "bash -c 'cd alternate; bash {0}' ignored" + defaults = {"run": {"shell": shell}} + workflow_defaults = None + if scope == "step": + step["shell"] = shell + elif scope == "job": + job["defaults"] = defaults + else: + workflow_defaults = defaults + with self.subTest(scope=scope), self.assertRaisesRegex(PolicyError, "reviewed shell"): + self.check(jobs={"test": job}, defaults=workflow_defaults) + + def test_helper_shell_uses_the_most_specific_safe_setting(self): + self.relative_helper_fixture() + for shell in ("bash", "sh"): + step = {"run": "scripts/request.sh", "shell": shell} + job = {"steps": [step], "defaults": {"run": {"shell": "bash -c true {0}"}}} + self.assertEqual([], self.check(jobs={"test": job})) + del step["shell"] + job["defaults"]["run"]["shell"] = shell + self.assertEqual([], self.check(jobs={"test": job}, defaults={"run": {"shell": "bash -c true {0}"}})) + def test_job_and_workflow_overrides_cannot_bypass_the_step_guards(self): with self.assertRaisesRegex(PolicyError, "job-level"): self.check(jobs={"test": {"env": {"GIT_SSL_NO_VERIFY": "1"}, "steps": self.steps}}) diff --git a/tests/test_policy_dependency_install.py b/tests/test_policy_dependency_install.py new file mode 100644 index 0000000..e3a2092 --- /dev/null +++ b/tests/test_policy_dependency_install.py @@ -0,0 +1,87 @@ +"""Qualify the policy parser wheels in an isolated, task-owned Python venv.""" + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +REQUIREMENTS = ROOT / "qualification/requirements.lock" +RELATIVE_REQUIREMENTS = ".central-action-policy/qualification/requirements.lock" +INSTALL_FLAGS = ["--require-hashes", "--only-binary=:all:", "--no-deps"] + + +class PolicyDependencyInstallTest(unittest.TestCase): + def setUp(self): + import pip + + self.assertNotEqual(sys.prefix, sys.base_prefix, "Use a disposable venv") + self.assertEqual("26.2.2", pip.__version__) + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.environment = dict(os.environ) + for name in ("PYTHONPATH", "PYTHONSAFEPATH", "PIP_EXTRA_INDEX_URL", "PIP_TRUSTED_HOST"): + self.environment.pop(name, None) + self.environment.update({ + "PIP_CONFIG_FILE": os.devnull, + "PIP_INDEX_URL": "https://pypi.org/simple", + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PIP_RETRIES": "0", + "PIP_DEFAULT_TIMEOUT": "15", + }) + + def pip(self, arguments, directory=None, isolated=True): + return subprocess.run( + [sys.executable, *(["-I"] if isolated else []), "-m", "pip", + *(["--isolated"] if isolated else []), *arguments], + cwd=directory or self.directory, env=self.environment, + capture_output=True, text=True, timeout=60, + ) + + def test_actual_installer_ignores_both_working_directory_shadows(self): + for kind in ("module", "package"): + with self.subTest(shadow=kind): + removed = self.pip(["uninstall", "--yes", "PyYAML"]) + self.assertEqual(0, removed.returncode, removed.stdout + removed.stderr) + directory = self.directory / kind + directory.mkdir() + lock = directory / RELATIVE_REQUIREMENTS + lock.parent.mkdir(parents=True) + shutil.copyfile(REQUIREMENTS, lock) + module = directory / "pip.py" + if kind == "package": + module = directory / "pip/__init__.py" + module.parent.mkdir() + module.write_text("from pathlib import Path\nPath('shadow-ran').write_text('executed')\nraise SystemExit(29)\n") + raw = self.pip(["--version"], directory, isolated=False) + self.assertEqual(29, raw.returncode, raw.stderr) + marker = directory / "shadow-ran" + self.assertTrue(marker.is_file()) + marker.unlink() + installed = self.pip(["install", *INSTALL_FLAGS, "-r", RELATIVE_REQUIREMENTS], directory) + self.assertEqual(0, installed.returncode, installed.stdout + installed.stderr) + self.assertFalse(marker.exists()) + loaded = subprocess.run( + [sys.executable, "-I", "-c", "import sys,yaml; from pathlib import Path; assert yaml.__version__ == '6.0.2'; assert yaml.__with_libyaml__; assert Path(yaml.__file__).is_relative_to(sys.prefix)"], + cwd=directory, env=self.environment, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(0, loaded.returncode, loaded.stderr) + + def test_a_tampered_wheel_hash_cannot_download(self): + lock = self.directory / "tampered.lock" + lock.write_text(re.sub(r"sha256:[0-9a-f]{64}", "sha256:" + "0" * 64, REQUIREMENTS.read_text())) + destination = self.directory / "rejected" + result = self.pip(["download", *INSTALL_FLAGS, "--no-cache-dir", "--dest", str(destination), "-r", str(lock)]) + self.assertNotEqual(0, result.returncode) + self.assertIn("DO NOT MATCH THE HASHES", result.stdout + result.stderr) + self.assertEqual([], list(destination.glob("*.whl"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_protected_policy_check.py b/tests/test_protected_policy_check.py new file mode 100644 index 0000000..f946ec9 --- /dev/null +++ b/tests/test_protected_policy_check.py @@ -0,0 +1,286 @@ +import contextlib +import hashlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from scripts import protected_policy_check as gate +from scripts.protected_policy_inputs import InputRefused +from tests import test_protected_policy_inputs as fixtures + + +class FakeCurl: + def __init__(self): + self.version = b"curl 8.5.0 test\n" + self.body = b'{"ok":true}' + self.status = b"200" + self.exit_code = 0 + self.timeout = False + self.calls = [] + self.after_request = lambda: None + + def __call__(self, command, **options): + if "--version" in command: + return subprocess.CompletedProcess(command, 0, self.version) + self.calls.append((command, options)) + if self.timeout: + raise subprocess.TimeoutExpired(command, options["timeout"]) + Path(command[command.index("--output") + 1]).write_bytes(self.body) + self.after_request() + return subprocess.CompletedProcess(command, self.exit_code, self.status) + + +class GitHubReadClientTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.bundle = self.directory / "reviewed.pem" + # A synthetic digest fixture. No TLS or network connection is made here. + self.bundle.write_bytes(b"synthetic reviewed bundle") + self.digest = hashlib.sha256(self.bundle.read_bytes()).hexdigest() + self.roots = self.directory / "no-additional-roots" + self.roots.mkdir() + self.curl = FakeCurl() + self.now = 0.0 + + def client(self, token=""): + return gate.GitHubReadClient(self.bundle, self.roots, self.digest, token, + run=self.curl, clock=lambda: self.now) + + def read(self, client=None): + return (client or self.client())("/repos/durable-workflow/.github/pulls/7") + + def test_explicit_tls_and_data_only_get_use_no_redirect_or_decompression(self): + self.assertEqual({"ok": True}, self.read()) + command, options = self.curl.calls[0] + self.assertEqual(["/usr/bin/curl", "--disable"], command[:2]) + self.assertEqual("=https", command[command.index("--proto") + 1]) + self.assertEqual(str(self.bundle), command[command.index("--cacert") + 1]) + self.assertEqual(str(self.roots), command[command.index("--capath") + 1]) + self.assertEqual(str(gate.MAX_RESPONSE_BYTES), command[command.index("--max-filesize") + 1]) + self.assertIn("Accept-Encoding: identity", command) + self.assertNotIn("--compressed", command) + self.assertNotIn("--location", command) + self.assertNotIn("--insecure", command) + self.assertLessEqual(options["timeout"], gate.REQUEST_SECONDS) + + def test_read_token_is_passed_only_on_stdin_not_process_arguments(self): + self.read(self.client("synthetic_read_token")) + command, options = self.curl.calls[0] + self.assertNotIn("synthetic_read_token", " ".join(command)) + self.assertEqual(b'header = "Authorization: Bearer synthetic_read_token"\n', options["input"]) + + def test_unsafe_or_write_endpoints_refuse_before_any_http_call(self): + client = self.client() + for path in ("https://elsewhere.test/", "/repos/durable-workflow/.github/check-runs", + "/repos/../.github/pulls/7", "/repos/a/b/pulls/7?redirect=elsewhere", + "/repos/a/b/git/blobs/" + "a" * 39): + with self.subTest(path=path), self.assertRaises(InputRefused): + client(path) + self.assertEqual([], self.curl.calls) + + def test_newline_or_quote_in_read_token_refuses(self): + for token in ('bad"token', "bad\nheader", "bad\\config"): + with self.subTest(token=token), self.assertRaises(InputRefused): + self.client(token) + + def test_old_curl_cannot_claim_unknown_length_response_protection(self): + for version in (b"curl 8.3.0\n", b"curl 7.88.1\n", b"unknown\n"): + self.curl.version = version + with self.subTest(version=version), self.assertRaises(InputRefused): + self.client() + + def test_wrong_bundle_and_additive_roots_refuse_before_requests(self): + self.bundle.write_bytes(b"different") + with self.assertRaises(InputRefused): + self.client() + self.bundle.write_bytes(b"synthetic reviewed bundle") + (self.roots / "extra.pem").write_bytes(b"extra") + with self.assertRaises(InputRefused): + self.client() + + def test_bundle_symlink_refuses(self): + real = self.directory / "real.pem" + self.bundle.rename(real) + self.bundle.symlink_to(real) + with self.assertRaises(InputRefused): + self.client() + + def test_redirect_and_failed_status_never_qualify(self): + for status, exit_code in ((b"302", 0), (b"404", 22), (b"500", 22)): + self.curl.status, self.curl.exit_code = status, exit_code + with self.subTest(status=status), self.assertRaises(InputRefused): + self.read() + + def test_malformed_duplicate_and_nonfinite_json_refuse(self): + for body in (b"not json", b'{"a":1,"a":2}', b'{"x":NaN}', b'{"x":Infinity}', b"\xff", + '{"ok":true}'.encode("utf-16"), b"[]", + b'{"nested":' + b"[" * 2000 + b"]" * 2000 + b"}"): + self.curl.body = body + with self.subTest(size=len(body), prefix=body[:32]), self.assertRaises(InputRefused): + self.read() + + def test_json_collection_bound_is_independent_of_the_transfer_limit(self): + self.curl.body = json.dumps({"items": list(range(100))}).encode() + with patch.object(gate, "MAX_JSON_NODES", 32), self.assertRaises(InputRefused): + self.read() + + def test_oversized_body_refuses_even_if_a_transport_ignores_its_limit(self): + with patch.object(gate, "MAX_RESPONSE_BYTES", 64): + self.curl.body = b"x" * 65 + with self.assertRaisesRegex(InputRefused, "byte bound"): + self.read() + + def test_transport_timeout_is_a_refusal_without_echoing_credentials(self): + self.curl.timeout = True + client = self.client("synthetic_read_token") + with self.assertRaises(InputRefused) as error: + self.read(client) + self.assertNotIn("synthetic_read_token", str(error.exception)) + + def test_total_deadline_and_request_count_do_not_start_another_transfer(self): + client = self.client() + self.now = gate.TOTAL_SECONDS + 1 + with self.assertRaises(InputRefused): + self.read(client) + self.assertEqual([], self.curl.calls) + self.now = 0 + with patch.object(gate, "MAX_REQUESTS", 1): + client = self.client() + self.read(client) + with self.assertRaises(InputRefused): + self.read(client) + self.assertEqual(1, len(self.curl.calls)) + + def test_expiry_during_a_transfer_cannot_return_a_pass(self): + client = self.client() + self.curl.after_request = lambda: setattr(self, "now", gate.TOTAL_SECONDS) + with self.assertRaisesRegex(InputRefused, "deadline"): + self.read(client) + + def test_last_request_uses_only_the_remaining_time(self): + client = self.client() + self.now = gate.TOTAL_SECONDS - 0.5 + self.read(client) + command, options = self.curl.calls[0] + self.assertEqual(0.5, options["timeout"]) + self.assertEqual("0.5", command[command.index("--max-time") + 1]) + + +SAFE_WORKFLOW = b"""name: Candidate +on: + pull_request: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: python scripts/candidate.py +""" + + +class ProtectedPolicySnapshotTest(unittest.TestCase): + def setUp(self): + self.api = fixtures.ProtectedPolicyInputTest(methodName="runTest") + self.api.setUp() + self.api.pull()["base"]["repo"]["id"] = gate.PILOT_REPOSITORY_ID + self.branch = {"name": "main", "protected": True, "commit": {"sha": fixtures.BASE}} + self.api.records[f"/repos/{gate.PILOT_REPOSITORY}/branches/main"] = self.branch + self.api.entries.clear() + self.api.add_file(fixtures.WORKFLOW, SAFE_WORKFLOW) + self.api.add_file("scripts/candidate.py", b"raise SystemExit('candidate must not execute')\n", "100755") + + def validate(self, **options): + return gate.validate_snapshot(self.api.read, 7, fixtures.BASE, **options) + + def test_real_validator_reads_candidate_data_without_executing_it(self): + report = self.validate() + self.assertEqual(fixtures.HEAD, report["head_sha"]) + self.assertEqual(2, report["input_count"]) + self.assertEqual(gate.PILOT_REPOSITORY_ID, report["repository_id"]) + self.assertEqual(64, len(report["inputs_sha256"])) + self.assertNotIn("candidate must not execute", json.dumps(report)) + + def test_validator_gets_nonexecutable_files_and_temporary_data_is_retired(self): + from scripts.qualification_policy import validate_local_action_references + + observed = [] + + def inspect(policy, directory, target): + observed.append(directory.parent.parent) + helper = directory.parent.parent / "scripts/candidate.py" + self.assertEqual(0o600, helper.stat().st_mode & 0o777) + return validate_local_action_references(policy, directory, target) + + self.validate(validator=inspect) + self.assertFalse(observed[0].exists()) + + def test_forbidden_workflow_is_a_refusal_not_a_success_report(self): + self.api.entries.clear() + self.api.add_file(fixtures.WORKFLOW, SAFE_WORKFLOW.replace(b"pull_request:", b"pull_request_target:")) + with self.assertRaisesRegex(InputRefused, "forbidden"): + self.validate() + + def test_unprotected_main_refuses_before_candidate_blobs(self): + self.branch["protected"] = False + with self.assertRaises(InputRefused): + self.validate() + self.assertFalse(any("/git/blobs/" in request for request in self.api.requests)) + + def test_base_or_head_movement_after_validation_refuses(self): + def move_base(*_arguments): + self.branch["commit"]["sha"] = "d" * 40 + return [] + + with self.assertRaises(InputRefused): + self.validate(validator=move_base) + self.branch["commit"]["sha"] = fixtures.BASE + + def move_head(*_arguments): + self.api.pull()["head"]["sha"] = "d" * 40 + return [] + + with self.assertRaises(InputRefused): + self.validate(validator=move_head) + + def test_wrong_immutable_repository_id_refuses(self): + self.api.pull()["base"]["repo"]["id"] += 1 + with self.assertRaises(InputRefused): + self.validate() + + def test_error_output_cannot_become_a_github_workflow_command(self): + arguments = ["gate", "--pull-request", "7", "--base-sha", fixtures.BASE] + environment = {"CURL_CA_BUNDLE": "synthetic", "SSL_CERT_DIR": "synthetic", "DW_CI_CA_SHA256": "synthetic"} + output = io.StringIO() + with patch.object(sys, "argv", arguments), patch.dict("os.environ", environment, clear=True): + with patch.object(gate, "GitHubReadClient"), patch.object(gate, "validate_snapshot", side_effect=InputRefused("bad\n::error::candidate")): + with contextlib.redirect_stderr(output): + self.assertEqual(1, gate.main()) + self.assertEqual(1, len(output.getvalue().splitlines())) + self.assertEqual("bad\n::error::candidate", json.loads(output.getvalue())["policy_refused"]) + + def test_isolated_cli_ignores_candidate_python_modules_in_its_working_directory(self): + with tempfile.TemporaryDirectory() as temporary: + candidate = Path(temporary) / "scripts" + candidate.mkdir() + (candidate / "__init__.py").write_text("raise SystemExit('candidate imported')\n") + result = subprocess.run( + [sys.executable, "-I", str(gate.ROOT / "scripts/protected_policy_check.py"), "--help"], + cwd=temporary, capture_output=True, timeout=5, check=False, + ) + self.assertEqual(0, result.returncode, result.stderr.decode()) + self.assertNotIn(b"candidate imported", result.stderr) + + def test_cli_does_not_offer_a_candidate_policy_override(self): + arguments = ["gate", "--pull-request", "7", "--base-sha", fixtures.BASE, "--policy", "candidate.json"] + with patch.object(sys, "argv", arguments), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + gate.main() + self.assertEqual(2, error.exception.code) diff --git a/tests/test_protected_policy_inputs.py b/tests/test_protected_policy_inputs.py new file mode 100644 index 0000000..5a9966f --- /dev/null +++ b/tests/test_protected_policy_inputs.py @@ -0,0 +1,251 @@ +import base64 +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path +from unittest.mock import patch + +from scripts.protected_policy_inputs import ( + MAX_FILE_BYTES, + MAX_FILES, + InputRefused, + checked_path, + checked_slug, + collect_inputs, + read_identity, + require_current, + write_inputs, +) + + +BASE = "a" * 40 +HEAD = "b" * 40 +TREE = "c" * 40 +REPOSITORY = "durable-workflow/.github" +FORK = "contributor/policy-change" +WORKFLOW = ".github/workflows/ci.yml" + + +class ProtectedPolicyInputTest(unittest.TestCase): + def setUp(self): + self.entries = [] + self.records = { + f"/repos/{REPOSITORY}/pulls/7": { + "state": "open", "number": 7, + "base": {"ref": "main", "sha": BASE, "repo": { + "id": 100, "full_name": REPOSITORY, "private": False, + }}, + "head": {"sha": HEAD, "repo": { + "id": 200, "full_name": FORK, "private": False, + }}, + }, + f"/repos/{FORK}/git/commits/{HEAD}": {"sha": HEAD, "tree": {"sha": TREE}}, + f"/repos/{FORK}/git/trees/{TREE}?recursive=1": { + "sha": TREE, "truncated": False, "tree": self.entries, + }, + } + self.requests = [] + self.add_file(WORKFLOW, b"on: push\njobs: {}\n") + self.identity = read_identity(self.read, REPOSITORY, 100, 7, BASE) + + def read(self, path): + self.requests.append(path) + return deepcopy(self.records[path]) + + def add_file(self, path, contents, mode="100644"): + sha = f"{len(self.entries) + 1:040x}" + entry = {"path": path, "sha": sha, "mode": mode, "type": "blob", "size": len(contents)} + self.entries.append(entry) + self.records[f"/repos/{FORK}/git/blobs/{sha}"] = { + "sha": sha, "size": len(contents), "encoding": "base64", + "content": base64.encodebytes(contents).decode("ascii"), + } + return entry + + def blob(self, entry=None): + entry = entry or self.entries[0] + return self.records[f"/repos/{FORK}/git/blobs/{entry['sha']}"] + + def pull(self): + return self.records[f"/repos/{REPOSITORY}/pulls/7"] + + def collect(self): + return collect_inputs(self.read, self.identity) + + def test_only_workflows_and_referenced_helpers_are_fetched_from_the_exact_fork_commit(self): + self.blob()["content"] = base64.encodebytes(b"run: scripts/one.sh\n").decode() + self.entries[0]["size"] = self.blob()["size"] = len(b"run: scripts/one.sh\n") + self.add_file("scripts/one.sh", b"#!/bin/sh\n./scripts/two.sh\n", "100755") + self.add_file("scripts/two.sh", b"#!/bin/sh\nexit 0\n", "100755") + unreferenced = self.add_file("pip.py", b"raise SystemExit('must never load')") + files = self.collect() + self.assertEqual({WORKFLOW, "scripts/one.sh", "scripts/two.sh"}, set(files)) + self.assertNotIn(f"/repos/{FORK}/git/blobs/{unreferenced['sha']}", self.requests) + self.assertIn(f"/repos/{FORK}/git/commits/{HEAD}", self.requests) + + def test_head_movement_refuses_the_result(self): + self.pull()["head"]["sha"] = "d" * 40 + with self.assertRaises(InputRefused): + require_current(self.read, self.identity) + + def test_base_movement_refuses_the_result(self): + self.pull()["base"]["sha"] = "d" * 40 + with self.assertRaises(InputRefused): + self.collect() + + def test_wrong_repository_id_refuses_before_candidate_reads(self): + self.pull()["base"]["repo"]["id"] = 101 + with self.assertRaises(InputRefused): + read_identity(self.read, REPOSITORY, 100, 7, BASE) + + def test_closed_pull_request_cannot_issue_a_result(self): + self.pull()["state"] = "closed" + with self.assertRaises(InputRefused): + require_current(self.read, self.identity) + + def test_private_repository_is_not_silently_onboarded(self): + self.pull()["base"]["repo"]["private"] = True + with self.assertRaises(InputRefused): + require_current(self.read, self.identity) + + def test_private_fork_is_not_silently_onboarded(self): + self.pull()["head"]["repo"]["private"] = True + with self.assertRaises(InputRefused): + require_current(self.read, self.identity) + + def test_external_base_repository_is_not_onboarded(self): + with self.assertRaises(InputRefused): + read_identity(self.read, "elsewhere/repository", 100, 7, BASE) + + def test_deleted_fork_refuses(self): + self.pull()["head"]["repo"] = None + with self.assertRaises(InputRefused): + require_current(self.read, self.identity) + + def test_unsafe_paths_and_repository_names_refuse(self): + for value in (".", "../escape", "/absolute", "a//b", "a/../b", "a\\b", "a\nb", "a\0b"): + with self.subTest(path=value), self.assertRaises(InputRefused): + checked_path(value) + for value in ("../repo", "owner/..", "https://github.com/owner/repo", "owner/repo/more", "x\ny/repo"): + with self.subTest(slug=value), self.assertRaises(InputRefused): + checked_slug(value) + + def test_symlink_workflow_refuses_without_fetching_its_contents(self): + self.entries[0]["mode"] = "120000" + with self.assertRaises(InputRefused): + self.collect() + self.assertFalse(any("/git/blobs/" in path for path in self.requests)) + + def test_referenced_symlink_helper_refuses(self): + contents = b"run: scripts/link.sh\n" + self.entries[0]["size"] = self.blob()["size"] = len(contents) + self.blob()["content"] = base64.encodebytes(contents).decode() + self.add_file("scripts/link.sh", b"../../outside", "120000") + with self.assertRaises(InputRefused): + self.collect() + + def test_submodule_cannot_supply_a_workflow(self): + self.entries[0].update({"mode": "160000", "type": "commit"}) + with self.assertRaises(InputRefused): + self.collect() + + def test_truncated_tree_refuses(self): + self.records[f"/repos/{FORK}/git/trees/{TREE}?recursive=1"]["truncated"] = True + with self.assertRaises(InputRefused): + self.collect() + + def test_another_commit_or_tree_cannot_supply_candidate_inputs(self): + commit = self.records[f"/repos/{FORK}/git/commits/{HEAD}"] + commit["sha"] = "d" * 40 + with self.assertRaises(InputRefused): + self.collect() + commit["sha"] = HEAD + self.records[f"/repos/{FORK}/git/trees/{TREE}?recursive=1"]["sha"] = "d" * 40 + with self.assertRaises(InputRefused): + self.collect() + + def test_duplicate_path_refuses(self): + self.entries.append(deepcopy(self.entries[0])) + with self.assertRaises(InputRefused): + self.collect() + + def test_oversized_file_refuses_before_blob_download(self): + self.entries[0]["size"] = MAX_FILE_BYTES + 1 + with self.assertRaises(InputRefused): + self.collect() + self.assertFalse(any("/git/blobs/" in path for path in self.requests)) + + def test_wrong_declared_blob_size_refuses(self): + self.blob()["size"] += 1 + with self.assertRaises(InputRefused): + self.collect() + + def test_wrong_blob_identity_refuses(self): + self.blob()["sha"] = "d" * 40 + with self.assertRaises(InputRefused): + self.collect() + + def test_invalid_base64_refuses(self): + self.blob()["content"] = "$not-base64" + with self.assertRaises(InputRefused): + self.collect() + + def test_binary_workflow_refuses(self): + self.entries[0]["size"] = self.blob()["size"] = 1 + self.blob()["content"] = "/w==" + with self.assertRaises(InputRefused): + self.collect() + + def test_total_byte_bound_applies_across_files(self): + with patch("scripts.protected_policy_inputs.MAX_TOTAL_BYTES", 4): + with self.assertRaises(InputRefused): + self.collect() + + def test_file_count_bound_applies_to_workflow_fanout(self): + for number in range(MAX_FILES): + self.add_file(f".github/workflows/extra-{number}.yml", b"on: push\n") + with self.assertRaises(InputRefused): + self.collect() + + def test_materialization_is_inert_and_does_not_preserve_executable_bits(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + marker = root / "executed" + files = {"scripts/attack.sh": f"#!/bin/sh\ntouch '{marker}'\n".encode()} + destination = root / "candidate" + write_inputs(files, destination) + self.assertFalse(marker.exists()) + self.assertEqual(0o600, (destination / "scripts/attack.sh").stat().st_mode & 0o777) + self.assertEqual(files["scripts/attack.sh"], (destination / "scripts/attack.sh").read_bytes()) + + def test_materialization_refuses_existing_destination_and_traversal(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with self.assertRaises(FileExistsError): + write_inputs({WORKFLOW: b"on: push"}, root) + with self.assertRaises(InputRefused): + write_inputs({"../outside": b"must not write"}, root / "candidate") + self.assertFalse((root / "outside").exists()) + + def test_materialization_refuses_a_symlink_destination(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + outside = root / "outside" + outside.mkdir() + redirected = root / "candidate" + redirected.symlink_to(outside, target_is_directory=True) + with self.assertRaises(FileExistsError): + write_inputs({WORKFLOW: b"on: push"}, redirected) + self.assertEqual([], list(outside.iterdir())) + + def test_materialization_refuses_non_bytes_and_oversized_files_before_creation(self): + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "candidate" + for contents in ("not bytes", b"x" * (MAX_FILE_BYTES + 1)): + with self.subTest(kind=type(contents).__name__), self.assertRaises(InputRefused): + write_inputs({WORKFLOW: contents}, destination) + self.assertFalse(destination.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_protected_policy_issue.py b/tests/test_protected_policy_issue.py new file mode 100644 index 0000000..9fcb6a1 --- /dev/null +++ b/tests/test_protected_policy_issue.py @@ -0,0 +1,318 @@ +import contextlib +import hashlib +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +from scripts import protected_policy_issue as issue +from scripts.protected_policy_inputs import InputRefused +from tests import test_protected_policy_check as snapshot_fixtures +from tests.test_protected_policy_inputs import BASE, HEAD + + +APP_ID = 12345 +CHECK_ID = 67890 + + +class FakeIssuerCurl: + """Only constructed HTTP responses. No App credential or network is used.""" + + def __init__(self): + self.calls = [] + self.app_id = APP_ID + self.head = HEAD + self.name = issue.CHECK_NAME + self.status_override = None + self.after_begin = lambda: None + self.after_complete = lambda: None + + def __call__(self, command, **options): + if "--version" in command: + return subprocess.CompletedProcess(command, 0, b"curl 8.5.0 fixture\n") + payload_file = command[command.index("--data-binary") + 1][1:] + payload = json.loads(Path(payload_file).read_text()) + self.calls.append((command, options, payload)) + beginning = command[command.index("--request") + 1] == "POST" + record = { + "id": CHECK_ID, "name": self.name, "head_sha": self.head, + "app": {"id": self.app_id}, "status": "in_progress" if beginning else "completed", + "conclusion": None if beginning else "success", + } + Path(command[command.index("--output") + 1]).write_text(json.dumps(record)) + (self.after_begin if beginning else self.after_complete)() + status = self.status_override or (b"201" if beginning else b"200") + return subprocess.CompletedProcess(command, 0, status) + + +class ProtectedPolicyIssuerTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + self.bundle = root / "ca.pem" + self.bundle.write_bytes(b"synthetic reviewed CA") + self.digest = hashlib.sha256(self.bundle.read_bytes()).hexdigest() + self.roots = root / "empty" + self.roots.mkdir() + self.curl = FakeIssuerCurl() + self.now = 0.0 + self.issuer = issue.CheckRunIssuer( + self.bundle, self.roots, self.digest, "synthetic_app_token", + expected_app_id=APP_ID, run=self.curl, clock=lambda: self.now, + ) + self.snapshot = snapshot_fixtures.ProtectedPolicySnapshotTest(methodName="runTest") + self.snapshot.setUp() + self.api = self.snapshot.api + + def run_gate(self, *, head=HEAD): + return issue.qualify_and_issue(self.api.read, self.issuer, 7, BASE, head) + + def test_real_validator_then_app_check_then_final_identity_recheck(self): + report = self.run_gate() + self.assertEqual(APP_ID, report["app_id"]) + self.assertEqual(HEAD, report["head_sha"]) + self.assertEqual(2, len(self.curl.calls)) + self.assertEqual("in_progress", self.curl.calls[0][2]["status"]) + self.assertNotIn("conclusion", self.curl.calls[0][2]) + self.assertEqual("success", self.curl.calls[1][2]["conclusion"]) + self.assertNotIn("candidate must not execute", json.dumps(report)) + self.assertEqual(f"/repos/{issue.PILOT_REPOSITORY}/pulls/7", self.api.requests[-1]) + + def test_check_write_endpoint_is_fixed_to_the_pilot(self): + self.run_gate() + urls = [command[command.index("--url") + 1] for command, _, _ in self.curl.calls] + self.assertEqual([ + f"https://api.github.com/repos/{issue.PILOT_REPOSITORY}/check-runs", + f"https://api.github.com/repos/{issue.PILOT_REPOSITORY}/check-runs/{CHECK_ID}", + ], urls) + + def test_app_token_is_stdin_only_and_cannot_make_generic_reads(self): + self.run_gate() + for command, options, _ in self.curl.calls: + self.assertNotIn("synthetic_app_token", " ".join(command)) + self.assertEqual(b'header = "Authorization: Bearer synthetic_app_token"\n', options["input"]) + self.assertIn("Accept-Encoding: identity", command) + self.assertNotIn("--location", command) + self.assertNotIn("--compressed", command) + with self.assertRaises(InputRefused): + self.issuer(f"/repos/{issue.PILOT_REPOSITORY}/pulls/7") + + def test_wrong_issuer_can_never_complete_a_success(self): + self.curl.app_id = APP_ID + 1 + with self.assertRaisesRegex(InputRefused, "App issuer"): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + self.assertNotIn("conclusion", self.curl.calls[0][2]) + + def test_ordinary_actions_issuer_is_not_the_expected_app(self): + self.curl.app_id = 15368 + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_empty_installation_token_refuses(self): + with self.assertRaises(InputRefused): + issue.CheckRunIssuer( + self.bundle, self.roots, self.digest, "", expected_app_id=APP_ID, + run=self.curl, clock=lambda: self.now, + ) + self.assertEqual([], self.curl.calls) + + def test_noninteger_app_id_is_not_accepted(self): + self.curl.app_id = str(APP_ID) + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_wrong_returned_name_never_completes(self): + self.curl.name = "unrelated" + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_wrong_returned_head_never_completes(self): + self.curl.head = "d" * 40 + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_head_changed_after_app_identity_refuses_success(self): + self.curl.after_begin = lambda: self.api.pull()["head"].update(sha="d" * 40) + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_base_changed_after_app_identity_refuses_success(self): + self.curl.after_begin = lambda: self.snapshot.branch["commit"].update(sha="d" * 40) + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_unprotected_base_after_app_identity_refuses_success(self): + self.curl.after_begin = lambda: self.snapshot.branch.update(protected=False) + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_stale_event_never_reads_candidate_blobs_or_writes_checks(self): + with self.assertRaises(InputRefused): + self.run_gate(head="d" * 40) + self.assertEqual([], self.curl.calls) + self.assertFalse(any("/git/blobs/" in path for path in self.api.requests)) + + def test_real_forbidden_policy_never_creates_a_check(self): + self.api.entries.clear() + self.api.add_file(".github/workflows/bad.yml", b"on:\n pull_request_target:\njobs: {}\n") + with self.assertRaisesRegex(InputRefused, "forbidden"): + self.run_gate() + self.assertEqual([], self.curl.calls) + + def test_http_redirect_is_not_a_verified_app_response(self): + self.curl.status_override = b"302" + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_expired_transport_cannot_finish_the_pending_check(self): + self.curl.after_begin = lambda: setattr(self, "now", 121.0) + with self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual(1, len(self.curl.calls)) + + def test_completion_response_identity_is_still_verified(self): + self.curl.after_begin = lambda: setattr(self.curl, "app_id", APP_ID + 1) + with self.assertRaises(InputRefused): + self.run_gate() + + def test_failed_completion_is_not_reported_as_a_success(self): + self.curl.after_begin = lambda: setattr(self.curl, "status_override", b"500") + with self.assertRaises(InputRefused): + self.run_gate() + + def test_an_unbound_snapshot_report_cannot_create_a_check(self): + report = issue.validate_snapshot(self.api.read, 7, BASE) + report["head_repository_id"] += 1 + with patch.object(issue, "validate_snapshot", return_value=report), self.assertRaises(InputRefused): + self.run_gate() + self.assertEqual([], self.curl.calls) + + def test_issuer_cannot_be_reused(self): + self.run_gate() + with self.assertRaises(InputRefused): + self.issuer.begin(HEAD) + self.assertEqual(2, len(self.curl.calls)) + + def test_completion_requires_matching_original_head(self): + with self.assertRaises(InputRefused): + self.issuer.complete({"head_sha": HEAD}) + self.issuer.begin(HEAD) + with self.assertRaises(InputRefused): + self.issuer.complete({"head_sha": "d" * 40}) + self.assertEqual(1, len(self.curl.calls)) + + def test_cli_refuses_noncanonical_app_ids_before_creating_an_issuer(self): + environment = { + **ProtectedIssuerCallerTest("runTest").environment(), + "CURL_CA_BUNDLE": str(self.bundle), "SSL_CERT_DIR": str(self.roots), + "DW_CI_CA_SHA256": self.digest, "GITHUB_TOKEN": "synthetic_read_token", + "POLICY_CHECKS_TOKEN": "synthetic_app_token", + } + args = ["issuer", "--pull-request", "7", "--base-sha", BASE, "--expected-head", HEAD] + for app_id in ("12٢", "12", "١", "0", "01", "1" * 20): + output = io.StringIO() + with self.subTest(app_id=app_id), patch.object(sys, "argv", args): + with patch.dict(os.environ, {**environment, "POLICY_GATE_APP_ID": app_id}, clear=True): + with patch.object(issue, "GitHubReadClient"), patch.object(issue, "CheckRunIssuer") as issuer: + with contextlib.redirect_stderr(output): + self.assertEqual(1, issue.main()) + issuer.assert_not_called() + self.assertEqual( + "A canonical positive App ID is required", + json.loads(output.getvalue())["policy_refused"], + ) + + def test_valid_cli_wires_separate_clients_to_one_total_deadline(self): + environment = { + **ProtectedIssuerCallerTest("runTest").environment(), + "CURL_CA_BUNDLE": str(self.bundle), "SSL_CERT_DIR": str(self.roots), + "DW_CI_CA_SHA256": self.digest, "GITHUB_TOKEN": "synthetic_read_token", + "POLICY_GATE_APP_ID": str(APP_ID), "POLICY_CHECKS_TOKEN": "synthetic_app_token", + } + read = Mock(side_effect=self.api.read) + read.deadline = 80.0 + args = ["issuer", "--pull-request", "7", "--base-sha", BASE, "--expected-head", HEAD] + output = io.StringIO() + with patch.object(sys, "argv", args), patch.dict(os.environ, environment, clear=True): + with patch.object(issue, "GitHubReadClient", return_value=read) as read_factory: + with patch.object(issue, "CheckRunIssuer", return_value=self.issuer) as write_factory: + with contextlib.redirect_stdout(output): + self.assertEqual(0, issue.main()) + self.assertEqual(80.0, self.issuer.deadline) + self.assertEqual("synthetic_read_token", read_factory.call_args.args[3]) + self.assertEqual("synthetic_app_token", write_factory.call_args.args[3]) + self.assertEqual(HEAD, json.loads(output.getvalue())["head_sha"]) + self.assertNotIn("synthetic_", output.getvalue()) + + +class ProtectedIssuerCallerTest(unittest.TestCase): + def environment(self): + return { + "GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": issue.PILOT_REPOSITORY, + "GITHUB_REPOSITORY_ID": str(issue.PILOT_REPOSITORY_ID), "GITHUB_REF": "refs/heads/main", + "GITHUB_SHA": BASE, "GITHUB_EVENT_NAME": "pull_request_target", + "GITHUB_WORKFLOW_REF": issue.WORKFLOW_REF, + "GITHUB_WORKFLOW_SHA": BASE, + } + + def test_protected_caller_context_matches(self): + issue.require_protected_execution(self.environment(), BASE) + + def test_each_missing_or_wrong_context_field_refuses(self): + environment = self.environment() + for name in environment: + wrong = {**environment, name: "wrong"} + missing = {key: value for key, value in environment.items() if key != name} + with self.subTest(name=name), self.assertRaises(InputRefused): + issue.require_protected_execution(wrong, BASE) + with self.subTest(missing=name), self.assertRaises(InputRefused): + issue.require_protected_execution(missing, BASE) + + def test_nonmain_execution_fails_before_credentials_or_clients(self): + args = ["issuer", "--pull-request", "7", "--base-sha", BASE, "--expected-head", HEAD] + output = io.StringIO() + with patch.object(sys, "argv", args), patch.dict(os.environ, {}, clear=True): + with patch.object(issue, "GitHubReadClient") as client, contextlib.redirect_stderr(output): + self.assertEqual(1, issue.main()) + client.assert_not_called() + self.assertIn("protected-main", json.loads(output.getvalue())["policy_refused"]) + + def test_isolated_cli_does_not_import_candidate_python(self): + with tempfile.TemporaryDirectory() as temporary: + scripts = Path(temporary) / "scripts" + scripts.mkdir() + (scripts / "__init__.py").write_text("raise SystemExit('candidate imported')\n") + result = subprocess.run( + [sys.executable, "-I", str(issue.ROOT / "scripts/protected_policy_issue.py"), "--help"], + cwd=temporary, capture_output=True, timeout=5, check=False, + ) + self.assertEqual(0, result.returncode, result.stderr.decode()) + self.assertNotIn(b"candidate imported", result.stderr) + + def test_malformed_app_id_does_not_echo_its_value(self): + environment = {**self.environment(), "CURL_CA_BUNDLE": "synthetic", "SSL_CERT_DIR": "synthetic", + "DW_CI_CA_SHA256": "synthetic", "GITHUB_TOKEN": "synthetic_read_token", + "POLICY_GATE_APP_ID": "invalid_sensitive_value"} + args = ["issuer", "--pull-request", "7", "--base-sha", BASE, "--expected-head", HEAD] + output = io.StringIO() + with patch.object(sys, "argv", args), patch.dict(os.environ, environment, clear=True): + with patch.object(issue, "GitHubReadClient"), patch.object(issue, "CheckRunIssuer") as issuer: + with contextlib.redirect_stderr(output): + self.assertEqual(1, issue.main()) + issuer.assert_not_called() + self.assertNotIn("invalid_sensitive_value", output.getvalue()) diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index 95dc47d..b5e8acd 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -5,13 +5,22 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch + +import yaml from scripts.qualification_policy import ( PolicyError, + LOCKED_POLICY_BOOTSTRAP_REVISION, + LOCKED_POLICY_INSTALL_COMMAND, + _workflow_action_references, + _references_secret_context, + _string_values, _verify_action_policy_preflight, scan_workflow_sources, validate_local_action_references, validate_policy, + verify_workflow_source, ) ROOT = Path(__file__).resolve().parents[1] @@ -77,7 +86,7 @@ def test_current_control_repository_workflows_satisfy_policy(self) -> None: self.assertTrue(actions) self.assertTrue(all("@" in action for action in actions)) - def test_policy_preflight_accepts_binary_only_without_relaxing_dependency_command(self) -> None: + def preflight_jobs(self, target: str = "ai") -> dict: checkout, _ = self.approved_action("actions/checkout") setup_python, _ = self.approved_action("actions/setup-python") jobs = { @@ -99,7 +108,7 @@ def test_policy_preflight_accepts_binary_only_without_relaxing_dependency_comman {"uses": f"actions/setup-python@{setup_python}", "with": {"python-version": "3.13"}}, {"run": "python -m pip install PyYAML==6.0.2"}, {"run": "python .central-action-policy/scripts/qualification_policy.py validate " - "--policy .central-action-policy/qualification/policy.json --target ai " + f"--policy .central-action-policy/qualification/policy.json --target {target} " "--workflow-directory .github/workflows"}, ], }, @@ -112,9 +121,15 @@ def test_policy_preflight_accepts_binary_only_without_relaxing_dependency_comman }], }, } + return jobs + + def test_policy_preflight_accepts_binary_only_without_relaxing_dependency_command(self) -> None: + jobs = self.preflight_jobs() for command in ( "python -m pip install PyYAML==6.0.2", "python -m pip install --only-binary :all: PyYAML==6.0.2", + "python -I -m pip install PyYAML==6.0.2", + "python -I -m pip install --only-binary :all: PyYAML==6.0.2", ): with self.subTest(command=command): jobs["action-policy"]["steps"][3]["run"] = command @@ -149,6 +164,258 @@ def test_policy_preflight_accepts_binary_only_without_relaxing_dependency_comman with self.assertRaisesRegex(PolicyError, "unreviewed pip bootstrap"): _verify_action_policy_preflight("ai", "fixture", jobs, "gate") + def test_server_requires_the_isolated_hash_locked_policy_install(self) -> None: + jobs = self.preflight_jobs("server") + steps = jobs["action-policy"]["steps"] + steps.insert(3, {"uses": "durable-workflow/.github/.github/actions/bootstrap-patched-pip@" + LOCKED_POLICY_BOOTSTRAP_REVISION}) + steps[4]["run"] = LOCKED_POLICY_INSTALL_COMMAND + revisions = {LOCKED_POLICY_BOOTSTRAP_REVISION} + _verify_action_policy_preflight("server", "fixture", jobs, "gate", revisions, True) + for flag in ("-I ", "--isolated ", "--require-hashes ", "--only-binary=:all: ", "--no-deps "): + changed = copy.deepcopy(jobs) + changed["action-policy"]["steps"][4]["run"] = LOCKED_POLICY_INSTALL_COMMAND.replace(flag, "") + with self.subTest(removed=flag), self.assertRaisesRegex(PolicyError, "reviewed policy dependency"): + _verify_action_policy_preflight("server", "fixture", changed, "gate", revisions, True) + for command in ( + LOCKED_POLICY_INSTALL_COMMAND.replace("qualification/requirements.lock", "requirements.lock"), + LOCKED_POLICY_INSTALL_COMMAND.replace(".central-action-policy/", ""), + LOCKED_POLICY_INSTALL_COMMAND + " --extra-index-url https://example.com", + LOCKED_POLICY_INSTALL_COMMAND + " --trusted-host example.com", + "python -m pip install PyYAML==6.0.2", + ): + changed = copy.deepcopy(jobs) + changed["action-policy"]["steps"][4]["run"] = command + with self.subTest(command=command), self.assertRaisesRegex(PolicyError, "reviewed policy dependency"): + _verify_action_policy_preflight("server", "fixture", changed, "gate", revisions, True) + changed = copy.deepcopy(jobs) + changed["action-policy"]["steps"].pop(3) + with self.assertRaisesRegex(PolicyError, "locked-policy pip bootstrap"): + _verify_action_policy_preflight("server", "fixture", changed, "gate", revisions, True) + changed = copy.deepcopy(jobs) + changed["action-policy"]["steps"][3]["uses"] = steps[3]["uses"].replace(LOCKED_POLICY_BOOTSTRAP_REVISION, "75587fc33ff40c34f32ef777293dad56bb6f48c7") + with self.assertRaisesRegex(PolicyError, "locked-policy pip bootstrap"): + _verify_action_policy_preflight("server", "fixture", changed, "gate", revisions | {"75587fc33ff40c34f32ef777293dad56bb6f48c7"}, True) + self.assertTrue(self.policy["targets"]["server"]["workflows"][0]["locked_policy_dependency_required"]) + + def locked_preflight_jobs(self) -> dict: + jobs = self.preflight_jobs("server") + jobs["action-policy"]["steps"].insert(3, { + "uses": "durable-workflow/.github/.github/actions/bootstrap-patched-pip@" + LOCKED_POLICY_BOOTSTRAP_REVISION, + }) + jobs["action-policy"]["steps"][4]["run"] = LOCKED_POLICY_INSTALL_COMMAND + jobs["gate"]["name"] = "PHPUnit feature suite" + return jobs + + def verify_server_preflight(self, jobs: dict, defaults: dict | None = None) -> None: + document = {"jobs": jobs} + if defaults is not None: + document["defaults"] = defaults + source = "on:\n push:\n branches: [main]\n pull_request:\n workflow_dispatch:\n" + source += yaml.safe_dump(document, sort_keys=False) + verify_workflow_source( + "server", "main", self.policy["targets"]["server"]["workflows"][0], + source, {LOCKED_POLICY_BOOTSTRAP_REVISION}, + ) + + def test_preflight_refuses_redirected_install_and_validator_directories(self) -> None: + for directory in ("alternate", "../other", "${{ github.workspace }}/alternate", "${{ inputs.directory }}", "$GITHUB_WORKSPACE", None): + for scope in ("workflow", "job", "install", "validate"): + jobs = self.locked_preflight_jobs() + defaults = {"run": {"working-directory": directory}} + workflow_defaults = None + if scope == "workflow": + workflow_defaults = defaults + elif scope == "job": + jobs["action-policy"]["defaults"] = defaults + else: + index = 4 if scope == "install" else 5 + jobs["action-policy"]["steps"][index]["working-directory"] = directory + with self.subTest(scope=scope, directory=directory), self.assertRaisesRegex(PolicyError, "workspace root"): + self.verify_server_preflight(jobs, workflow_defaults) + + def test_preflight_accepts_explicit_workspace_root_directories(self) -> None: + for directory in (".", "./", "${{ github.workspace }}", "${{github.workspace}}/.", "${{ github.workspace }}/"): + for scope in ("workflow", "job", "step"): + jobs = self.locked_preflight_jobs() + defaults = {"run": {"working-directory": directory}} + workflow_defaults = None + if scope == "workflow": + workflow_defaults = defaults + elif scope == "job": + jobs["action-policy"]["defaults"] = defaults + else: + for step in jobs["action-policy"]["steps"][4:]: + step["working-directory"] = directory + with self.subTest(scope=scope, directory=directory): + self.verify_server_preflight(jobs, workflow_defaults) + + def test_preflight_directory_uses_the_most_specific_run_setting(self) -> None: + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["defaults"] = {"run": {"working-directory": "."}} + self.verify_server_preflight(jobs, {"run": {"working-directory": "alternate"}}) + jobs["action-policy"]["defaults"]["run"]["working-directory"] = "alternate" + for step in jobs["action-policy"]["steps"][4:]: + step["working-directory"] = "${{ github.workspace }}" + self.verify_server_preflight(jobs) + del jobs["action-policy"]["steps"][5]["working-directory"] + with self.assertRaisesRegex(PolicyError, "workspace root"): + self.verify_server_preflight(jobs) + + def test_preflight_refuses_inherited_custom_shells(self) -> None: + for scope in ("workflow", "job"): + jobs = self.locked_preflight_jobs() + defaults = {"run": {"shell": "bash -c true {0}"}} + workflow_defaults = defaults if scope == "workflow" else None + if scope == "job": + jobs["action-policy"]["defaults"] = defaults + with self.subTest(scope=scope), self.assertRaisesRegex(PolicyError, "reviewed shell"): + self.verify_server_preflight(jobs, workflow_defaults) + + def test_preflight_refuses_custom_dynamic_and_non_shell_invocation(self) -> None: + for shell in ("bash {0}", "${{ inputs.shell }}", "python", "", None): + for index in (4, 5): + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["steps"][index]["shell"] = shell + with self.subTest(shell=shell, index=index), self.assertRaisesRegex(PolicyError, "reviewed shell"): + self.verify_server_preflight(jobs) + + def test_preflight_accepts_default_and_builtin_safe_shells(self) -> None: + self.verify_server_preflight(self.locked_preflight_jobs()) + for shell in ("bash", "sh"): + for scope in ("workflow", "job", "step"): + jobs = self.locked_preflight_jobs() + defaults = {"run": {"shell": shell}} + workflow_defaults = defaults if scope == "workflow" else None + if scope == "job": + jobs["action-policy"]["defaults"] = defaults + elif scope == "step": + for step in jobs["action-policy"]["steps"][4:]: + step["shell"] = shell + with self.subTest(shell=shell, scope=scope): + self.verify_server_preflight(jobs, workflow_defaults) + + def test_preflight_shell_uses_the_most_specific_safe_setting(self) -> None: + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["defaults"] = {"run": {"shell": "bash"}} + jobs["gate"]["defaults"] = {"run": {"shell": "sh"}} + self.verify_server_preflight(jobs, {"run": {"shell": "bash -c true {0}"}}) + jobs["action-policy"]["defaults"]["run"]["shell"] = "bash -c true {0}" + for step in jobs["action-policy"]["steps"][4:]: + step["shell"] = "sh" + self.verify_server_preflight(jobs) + del jobs["action-policy"]["steps"][5]["shell"] + with self.assertRaisesRegex(PolicyError, "reviewed shell"): + self.verify_server_preflight(jobs) + + def test_preflight_result_gate_cannot_inherit_a_custom_shell(self) -> None: + jobs = self.locked_preflight_jobs() + jobs["gate"]["defaults"] = {"run": {"shell": "bash -c true {0}"}} + with self.assertRaisesRegex(PolicyError, "fail closed"): + self.verify_server_preflight(jobs) + jobs["gate"]["steps"][0]["shell"] = "bash" + self.verify_server_preflight(jobs) + + def test_preflight_result_gate_cannot_ignore_its_failure(self) -> None: + jobs = self.locked_preflight_jobs() + self.verify_server_preflight(jobs) + for ignored in (False, "false"): + jobs["gate"]["steps"][0]["continue-on-error"] = ignored + with self.subTest(ignored=ignored): + self.verify_server_preflight(jobs) + for ignored in (True, "true", "${{ inputs.ignore }}", "${{ false }}"): + jobs["gate"]["steps"][0]["continue-on-error"] = ignored + with self.subTest(ignored=ignored), self.assertRaisesRegex(PolicyError, "fail closed"): + self.verify_server_preflight(jobs) + + def verify_preflight_context(self, jobs: dict, workflow_document: dict | None = None) -> None: + _verify_action_policy_preflight( + "server", "source-only context fixture", jobs, "gate", + {LOCKED_POLICY_BOOTSTRAP_REVISION}, True, workflow_document, + ) + + def test_preflight_job_rejects_unreviewed_execution_fields(self) -> None: + for key, value in ( + ("container", "ubuntu:24.04"), + ("services", {"sidecar": {"image": "busybox:latest"}}), + ("strategy", {"matrix": {"candidate": ["one"]}}), + ("environment", "unreviewed"), + ("continue-on-error", True), + ("concurrency", "unreviewed"), + ("outputs", {"value": "unreviewed"}), + ): + jobs = self.locked_preflight_jobs() + jobs["action-policy"][key] = value + with self.subTest(key=key), self.assertRaisesRegex(PolicyError, "reviewed read-only shape"): + self.verify_preflight_context(jobs) + + def test_preflight_actions_cannot_skip_or_change_the_reviewed_context(self) -> None: + for index in range(4): + for key, value in (("if", "false"), ("if", False), ("continue-on-error", True), ("working-directory", "alternate")): + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["steps"][index][key] = value + with self.subTest(index=index, key=key, value=value), self.assertRaises(PolicyError): + self.verify_preflight_context(jobs) + + def test_preflight_startup_bindings_are_data_only_and_refused(self) -> None: + for name in ("BASH_ENV", "ENV", "LD_PRELOAD", "NODE_OPTIONS", "PYTHONPATH", "PATH", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_PARAMETERS", "UNREVIEWED_SETTING"): + jobs = self.locked_preflight_jobs() + document = {"env": {name: "candidate-controlled-placeholder"}} + with self.subTest(name=name), self.assertRaisesRegex(PolicyError, "environment"): + self.verify_preflight_context(jobs, document) + + def test_preflight_step_startup_bindings_refuse_without_running_them(self) -> None: + for index in range(6): + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["steps"][index]["env"] = {"LD_PRELOAD": "candidate-controlled-placeholder"} + with self.subTest(index=index), self.assertRaisesRegex(PolicyError, "environment"): + self.verify_preflight_context(jobs) + + def test_preflight_preserves_reviewed_diagnostics_and_literal_clears(self) -> None: + jobs = self.locked_preflight_jobs() + document = {"env": {"CI": "true", "CARGO_TERM_COLOR": "always", "RUST_BACKTRACE": "1"}} + jobs["action-policy"]["env"] = {"PYTHONUNBUFFERED": "1"} + for index, step in enumerate(jobs["action-policy"]["steps"]): + step["env"] = {"FORCE_COLOR": "1"} + if index < 4: + step["id"] = f"reviewed_{index}" + step["if"] = "${{ github.server_url == 'https://github.com' }}" + step["continue-on-error"] = "false" + self.verify_preflight_context(jobs, document) + clears = {name: "" for name in ("BASH_ENV", "ENV", "LD_PRELOAD", "NODE_OPTIONS")} + self.verify_preflight_context(self.locked_preflight_jobs(), {"env": clears}) + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["env"] = clears + self.verify_preflight_context(jobs) + + def test_preflight_environment_is_closed_including_shadowed_bindings(self) -> None: + jobs = self.locked_preflight_jobs() + jobs["action-policy"]["env"] = {"BASH_ENV": ""} + for document in ( + {"env": {"BASH_ENV": "candidate-controlled-placeholder"}}, + {"env": {"NODE_OPTIONS": "${{ inputs.options }}"}}, + {"env": {"GIT_CONFIG_COUNT": ""}}, + {"env": {"CI": ["not a scalar"]}}, + {"env": "not a mapping"}, + ): + with self.subTest(document=document), self.assertRaisesRegex(PolicyError, "environment"): + self.verify_preflight_context(jobs, document) + + def test_preflight_defaults_reject_unknown_execution_settings(self) -> None: + for scope in ("workflow", "job"): + jobs = self.locked_preflight_jobs() + defaults = {"run": {"unreviewed": "value"}} + document = {"defaults": defaults} if scope == "workflow" else None + if scope == "job": + jobs["action-policy"]["defaults"] = defaults + with self.subTest(scope=scope), self.assertRaisesRegex(PolicyError, "defaults"): + self.verify_preflight_context(jobs, document) + self.verify_preflight_context(self.locked_preflight_jobs(), {"defaults": {"run": {}}}) + + def test_preflight_shape_does_not_close_unrelated_test_jobs(self) -> None: + jobs = self.locked_preflight_jobs() + jobs["other"] = {"container": "ubuntu:24.04", "env": {"BASH_ENV": "other-job-placeholder"}} + self.verify_preflight_context(jobs) + def test_approved_immutable_action_with_version_comment_is_accepted(self) -> None: commit, version = self.approved_action("actions/checkout") evidence = self.scan(workflow(f"- uses: actions/checkout@{commit} # {version}")) @@ -175,6 +442,187 @@ def test_pull_request_target_is_rejected(self) -> None: with self.assertRaisesRegex(PolicyError, "pull_request_target"): self.scan(workflow("- run: true", trigger="pull_request_target")) + def protected_workflow(self) -> str: + return (ROOT / ".github/workflows/protected-policy.yml").read_text(encoding="utf-8") + + def test_only_exact_reviewed_protected_workflow_is_allowed(self) -> None: + sources = {".github/workflows/protected-policy.yml": self.protected_workflow()} + report = scan_workflow_sources(self.policy, "github-control-plane", sources) + self.assertIn(".github/workflows/protected-policy.yml", report) + + def test_local_reviewed_workflow_keeps_its_original_lf_bytes(self) -> None: + source = (ROOT / ".github/workflows/protected-policy.yml").read_bytes() + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / ".github/workflows" + directory.mkdir(parents=True) + (directory / "protected-policy.yml").write_bytes(source) + + validate_local_action_references(self.policy, directory) + + def test_local_workflow_line_endings_cannot_reuse_the_reviewed_hash(self) -> None: + source = (ROOT / ".github/workflows/protected-policy.yml").read_bytes() + for ending in (b"\r\n", b"\r"): + changed = source.replace(b"\n", ending) + self.assertNotEqual(source, changed) + with self.subTest(ending=ending), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / ".github/workflows" + directory.mkdir(parents=True) + (directory / "protected-policy.yml").write_bytes(changed) + + with self.assertRaisesRegex(PolicyError, "pull_request_target"): + validate_local_action_references(self.policy, directory) + + def test_ordinary_local_workflows_keep_supported_line_endings(self) -> None: + source = workflow("- run: true").encode("utf-8") + for ending in (b"\n", b"\r\n", b"\r"): + with self.subTest(ending=ending), tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory / "ci.yml").write_bytes(source.replace(b"\n", ending)) + + validate_local_action_references(self.policy, directory) + + def test_shared_yaml_nodes_do_not_multiply_action_reference_work(self) -> None: + visits = 0 + + class CountedMapping(dict): + def touch(self): + nonlocal visits + visits += 1 + if visits > 64: + raise AssertionError("Alias traversal exceeded the fixture's work budget") + + def items(self): + self.touch() + return super().items() + + def values(self): + self.touch() + return super().values() + + reference = "actions/checkout@" + self.approved_action("actions/checkout")[0] + document = CountedMapping({"uses": reference}) + for _ in range(14): + document = CountedMapping({"nested": [document, document]}) + + with patch("scripts.qualification_policy._parse_yaml", return_value=document): + self.assertEqual([reference], _workflow_action_references("", "shared graph")) + + def test_real_yaml_aliases_keep_all_distinct_action_references(self) -> None: + first = "actions/checkout@" + self.approved_action("actions/checkout")[0] + second = "actions/setup-python@" + self.approved_action("actions/setup-python")[0] + source = f"shared: &shared [{{uses: {first}}}, {{uses: {second}}}]\n" + previous = "shared" + for index in range(8): + name = f"level{index}" + source += f"{name}: &{name} [*{previous}, *{previous}]\n" + previous = name + + self.assertEqual({first, second}, set(_workflow_action_references(source, "real aliases"))) + + def test_recursive_yaml_alias_keeps_the_existing_parser_refusal(self) -> None: + reference = "actions/checkout@" + self.approved_action("actions/checkout")[0] + source = f"cycle: &cycle [*cycle]\nstep: {{uses: {reference}}}\n" + + with self.assertRaisesRegex(PolicyError, "recursive node"): + _workflow_action_references(source, "recursive alias") + + def test_secret_context_shared_graph_has_bounded_full_scan_work(self) -> None: + visits = 0 + + class CountedMapping(dict): + def values(self): + nonlocal visits + visits += 1 + if visits > 64: + raise AssertionError("Secret-context alias traversal exceeded the fixture work budget") + return super().values() + + shared = CountedMapping({"value": "ordinary text"}) + for _ in range(14): + shared = CountedMapping({"nested": [shared, shared]}) + document = { + "on": {"pull_request": None}, + "permissions": {"contents": "read"}, + "shared": shared, + "jobs": {"test": {"runs-on": "ubuntu-latest", "steps": [{"run": "true"}]}}, + } + with patch("scripts.qualification_policy._parse_yaml", return_value=document): + self.scan("") + self.assertLessEqual(visits, 64) + + def test_shared_alias_scan_keeps_secret_refusals_and_distinct_actions(self) -> None: + first, first_version = self.approved_action("actions/checkout") + second, second_version = self.approved_action("actions/setup-python") + graph = "leaf: &leaf [ordinary text]\n" + previous = "leaf" + for index in range(8): + name = f"level{index}" + graph += f"{name}: &{name} [*{previous}, *{previous}]\n" + previous = name + source = graph + workflow( + f"- uses: actions/checkout@{first} # {first_version}", + f"- uses: actions/setup-python@{second} # {second_version}", + ) + evidence = self.scan(source)[".github/workflows/test.yml"] + self.assertEqual(2, len(evidence["external_actions"])) + with self.assertRaisesRegex(PolicyError, "references a secret"): + self.scan("late: ${{ secrets.EXAMPLE }}\n" + source) + shared_secret_source = source.replace("ordinary text", '"${{ secrets.EXAMPLE }}"') + with self.assertRaisesRegex(PolicyError, "references a secret"): + self.scan(shared_secret_source) + self.assertTrue(_references_secret_context({"nested": ["safe", "${{ secrets.EXAMPLE }}"]})) + self.assertFalse(_references_secret_context({"nested": ["plain secrets.EXAMPLE"]})) + + def test_string_values_preserve_order_for_ordinary_yaml_trees(self) -> None: + self.assertEqual( + ["first", "second", "third"], + _string_values({"a": ["first", ["second"]], "b": {"c": "third"}}), + ) + + def test_preceding_policy_still_refuses_the_protected_workflow(self) -> None: + self.policy["workflow_trust"]["pull_request_target_exceptions"] = [] + sources = {".github/workflows/protected-policy.yml": self.protected_workflow()} + with self.assertRaisesRegex(PolicyError, "pull_request_target"): + scan_workflow_sources(self.policy, "github-control-plane", sources) + + def test_same_workflow_cannot_gain_authority_at_another_path_or_repository(self) -> None: + source = self.protected_workflow() + for target, path in ( + ("github-control-plane", ".github/workflows/copied.yml"), + ("server", ".github/workflows/protected-policy.yml"), + ): + sources = {path: source} + with self.subTest(target=target, path=path), self.assertRaisesRegex(PolicyError, "pull_request_target"): + scan_workflow_sources(self.policy, target, sources) + + def test_protected_source_mutations_cannot_use_the_exception(self) -> None: + source = self.protected_workflow() + for before, after in ( + ("ref: ${{ github.sha }}", "ref: ${{ github.event.pull_request.head.sha }}"), + ("environment: protected-policy-gate", "environment: production"), + ("permission-checks: write", "permission-contents: write"), + ("repositories: .github", "repositories: cloud"), + ("NODE_OPTIONS: --use-openssl-ca", "NODE_OPTIONS: --use-bundled-ca"), + ('--expected-head "$POLICY_HEAD_SHA"', '--expected-head "a"'), + ): + changed = source.replace(before, after) + self.assertNotEqual(source, changed) + sources = {".github/workflows/protected-policy.yml": changed} + with self.subTest(before=before), self.assertRaisesRegex(PolicyError, "pull_request_target"): + scan_workflow_sources(self.policy, "github-control-plane", sources) + + def test_modified_or_additional_exceptions_require_another_review(self) -> None: + original = self.policy["workflow_trust"]["pull_request_target_exceptions"][0] + for changed in ( + [{**original, "target": "server"}], + [{**original, "path": ".github/workflows/copied.yml"}], + [{**original, "sha256": "0" * 64}], + [original, original], + ): + self.policy["workflow_trust"]["pull_request_target_exceptions"] = changed + with self.subTest(changed=changed), self.assertRaisesRegex(PolicyError, "separately reviewed"): + validate_policy(self.policy) + def test_pull_request_jobs_cannot_use_environments_or_secrets(self) -> None: source = workflow("- run: echo ${{ secrets.TOKEN }}") source = source.replace(" runs-on: ubuntu-latest", " runs-on: ubuntu-latest\n environment: production")