From 980b5fc7bd5271731c58c3e181e149f164aebca3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:37:50 +0000 Subject: [PATCH 1/4] Isolate protected pilot context checks from ordinary preflight --- .github/workflows/ci.yml | 4 + .github/workflows/protected-policy.yml | 77 ++++++ qualification/README.md | 34 +++ qualification/policy.json | 11 +- qualification/requirements.lock | 5 + qualification/schema.json | 12 +- scripts/protected_policy_check.py | 241 +++++++++++++++++++ scripts/protected_policy_context.py | 112 +++++++++ scripts/protected_policy_inputs.py | 223 +++++++++++++++++ scripts/protected_policy_issue.py | 152 ++++++++++++ scripts/qualification_policy.py | 21 +- tests/test_protected_policy_check.py | 286 ++++++++++++++++++++++ tests/test_protected_policy_context.py | 99 ++++++++ tests/test_protected_policy_inputs.py | 251 +++++++++++++++++++ tests/test_protected_policy_issue.py | 318 +++++++++++++++++++++++++ 15 files changed, 1841 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/protected-policy.yml create mode 100644 qualification/requirements.lock create mode 100644 scripts/protected_policy_check.py create mode 100644 scripts/protected_policy_context.py create mode 100644 scripts/protected_policy_inputs.py create mode 100644 scripts/protected_policy_issue.py create mode 100644 tests/test_protected_policy_check.py create mode 100644 tests/test_protected_policy_context.py create mode 100644 tests/test_protected_policy_inputs.py create mode 100644 tests/test_protected_policy_issue.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b9a177..d65f2f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,10 @@ jobs: run: >- python -m unittest tests.test_qualification_policy + tests.test_protected_policy_context + tests.test_protected_policy_check + tests.test_protected_policy_inputs + tests.test_protected_policy_issue tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema 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/qualification/README.md b/qualification/README.md index aa68c2e..9ebb95f 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -154,3 +154,37 @@ helpers. It is not a shell sandbox, a multi-language program analyzer or a claim about third-party actions' internal requests. Maintainers still review dynamic request paths and their actual configuration. Enable the target setting after migrating all its request paths so partial rollouts remain visible. + + +## Protected pilot source successor + +The selective successor to [the reverted pilot](https://github.com/durable-workflow/.github/pull/240) +keeps ordinary consumer preflight and Composer helper interpretation at the +accepted rollback behavior. Its shared-policy changes allow only the pinned +App-token action and the byte-exact protected workflow. The stricter input +language lives in `scripts/protected_policy_context.py`, called only by the +protected reader before the shared validator. Candidate files remain bounded +nonexecutable data; the reader and issuer run from the protected base. + +The pilot refuses relative helper references with a non-root execution context, +unreviewed shell, directory changes or dynamic shell evaluation. YAML traversal +bounds depth and expanded alias work and refuses recursive aliases, duplicate +keys and merges. These refusals are deliberately conservative and do not change +ordinary caller behavior. Run the dedicated controls with: + +```sh +python -m unittest tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue +``` + +Current integration is incomplete: the Rust visual workflow's candidate-root +analytics check is outside this supported root-context language. Its acceptance +control remains failing until a reviewed compatibility correction preserves the +actual checker semantics. Do not weaken the guard, waive the control or claim a +complete pilot qualification from the other passing controls. + +The source remains inactive. No App/key, protected variable or secret, +environment, event-policy setting, required rule or live check is provisioned. +The existing [AI contribution issue](https://github.com/durable-workflow/ai/issues/27) +waits for accepted source and a separately qualified one-repository pilot. Human +account/key authorization and the live positive/negative, expected-issuer, +concurrency and rollback controls remain separate prerequisites. diff --git a/qualification/policy.json b/qualification/policy.json index 2935d97..3f75e12 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -25,6 +25,9 @@ "actions/configure-pages": { "45bfe0192ca1faeb007ade9deae92b16b8254a0d": "v6" }, + "actions/create-github-app-token": { + "64ab729a6294000f5aa4a4bc0b73d1f32bc45510": "v3.3.0" + }, "actions/deploy-pages": { "cd2ce8fcbc39b97be8ca5fce6e763baed58fa128": "v5" }, @@ -165,7 +168,13 @@ } }, "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": { 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 9fb3517..007ba0e 100644 --- a/qualification/schema.json +++ b/qualification/schema.json @@ -316,7 +316,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": {"const": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31"} + }, + "required": ["target", "path", "sha256"], + "type": "object" + }, + "maxItems": 1, "type": "array" } }, diff --git a/scripts/protected_policy_check.py b/scripts/protected_policy_check.py new file mode 100644 index 0000000..a0d84c0 --- /dev/null +++ b/scripts/protected_policy_check.py @@ -0,0 +1,241 @@ +"""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, +) +from scripts.protected_policy_context import validate_context # noqa: E402 + +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) + validate_context(files) + 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_context.py b/scripts/protected_policy_context.py new file mode 100644 index 0000000..3c2dc46 --- /dev/null +++ b/scripts/protected_policy_context.py @@ -0,0 +1,112 @@ +"""Conservative input language for the protected pilot, not ordinary preflight. + +Candidate text is data. Unsupported execution contexts refuse instead of +extending the shared validator into a shell interpreter. +""" + +from __future__ import annotations + +from pathlib import PurePosixPath +import re +from typing import Any + +import yaml + +from scripts.protected_policy_inputs import HELPERS, InputRefused, checked_path + +MAX_YAML_NODES = 50_000 +MAX_YAML_DEPTH = 64 +ROOT_DIRECTORIES = {None, ".", "./", "${{ github.workspace }}"} +SHELLS = {None, "bash", "sh"} +UNSUPPORTED_SHELL = re.compile(r"(? dict[str, Any]: + loader = yaml.BaseLoader(source) + try: + root = loader.get_single_node() + pending = [(root, 0, frozenset())] + count = 0 + while pending: + node, depth, ancestors = pending.pop() + count += 1 + if count > MAX_YAML_NODES or depth > MAX_YAML_DEPTH: + raise InputRefused("Protected pilot YAML exceeds its node or depth bound") + if node is None or id(node) in ancestors: + raise InputRefused("Protected pilot YAML is empty or recursive") + lineage = ancestors | {id(node)} + if isinstance(node, yaml.MappingNode): + names = set() + for key, value in node.value: + if not isinstance(key, yaml.ScalarNode) or key.value in names or key.value == "<<": + raise InputRefused("Protected pilot YAML keys must be unique scalars without merges") + names.add(key.value) + pending.extend(((key, depth + 1, lineage), (value, depth + 1, lineage))) + elif isinstance(node, yaml.SequenceNode): + pending.extend((child, depth + 1, lineage) for child in node.value) + document = loader.construct_document(root) + except (yaml.YAMLError, RecursionError) as error: + raise InputRefused("Protected pilot YAML cannot be read within its bounds") from error + finally: + loader.dispose() + if not isinstance(document, dict): + raise InputRefused("Protected pilot requires a workflow mapping") + return document + + +def run_defaults(scope: dict[str, Any]) -> dict[str, Any]: + defaults = scope.get("defaults", {}) + if not isinstance(defaults, dict) or not isinstance(defaults.get("run", {}), dict): + raise InputRefused("Protected pilot requires explicit run defaults") + return defaults.get("run", {}) + + +def check_helpers(source: str, files: dict[str, bytes], visited: set[str]) -> None: + for reference in HELPERS.findall(source): + name = checked_path(reference.removeprefix("./")) + if name not in files or name in visited: + continue + visited.add(name) + text = files[name].decode("utf-8") + suffix = PurePosixPath(name).suffix + if suffix in {"", ".sh", ".bash", ".ksh", ".zsh"} or re.match(r"^#![^\n]*\b(?:bash|sh|dash|ksh|zsh)\b", text): + if UNSUPPORTED_SHELL.search(text): + raise InputRefused("Protected pilot helper has an unsupported directory or dynamic shell context") + check_helpers(text, files, visited) + + +def validate_context(files: dict[str, bytes]) -> None: + for name, raw in sorted(files.items()): + if PurePosixPath(name).parent.as_posix() != ".github/workflows" or not name.endswith((".yml", ".yaml")): + continue + document = bounded_document(raw.decode("utf-8")) + jobs = document.get("jobs", {}) + if not isinstance(jobs, dict): + raise InputRefused("Protected pilot requires a jobs mapping") + for job in jobs.values(): + if not isinstance(job, dict) or not isinstance(job.get("steps", []), list): + raise InputRefused("Protected pilot requires explicit jobs and step lists") + for step in job.get("steps", []): + if not isinstance(step, dict): + raise InputRefused("Protected pilot requires step mappings") + source = step.get("run", "") + if not isinstance(source, str): + raise InputRefused("Protected pilot requires literal run text") + if not HELPERS.search(source): + continue + contexts = [run_defaults(document), run_defaults(job), step] + directory = next((scope["working-directory"] for scope in reversed(contexts) + if "working-directory" in scope), None) + shell = next((scope["shell"] for scope in reversed(contexts) if "shell" in scope), None) + if not isinstance(directory, (str, type(None))) or directory not in ROOT_DIRECTORIES: + raise InputRefused("Protected pilot relative helpers require the workspace root") + if not isinstance(shell, (str, type(None))) or shell not in SHELLS: + raise InputRefused("Protected pilot relative helpers require a reviewed shell") + for scope in (document, job, step): + environment = scope.get("env", {}) + if not isinstance(environment, dict) or STARTUP_ENVIRONMENT.intersection(environment): + raise InputRefused("Protected pilot helper startup environment is unsupported") + if UNSUPPORTED_SHELL.search(source): + raise InputRefused("Protected pilot relative helpers have an unsupported directory or dynamic shell context") + check_helpers(source, files, set()) 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 e9a17e6..5a212cb 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 @@ -28,6 +29,11 @@ CENTRAL_ACTION_REPOSITORY = "durable-workflow/.github" COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") SUPPORTED_JAVASCRIPT_ACTION_RUNTIMES = ["node24"] +PROTECTED_POLICY_EXCEPTION = { + "target": "github-control-plane", + "path": ".github/workflows/protected-policy.yml", + "sha256": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31", +} EXPECTED_TARGETS = { "ai": ("ai", "main"), "cli": ("cli", "main"), @@ -444,7 +450,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) != { @@ -1857,7 +1863,13 @@ 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: + reviewed_pilot = ( + 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"] + ) + if "pull_request_target" in triggers and not reviewed_pilot: raise PolicyError(f"{label} uses forbidden pull_request_target execution") top_writes = _permissions_with_write(document.get("permissions"), f"{label} top-level") @@ -2469,7 +2481,10 @@ def validate_local_action_references( policy: dict[str, Any], directory: Path, target_name: str = "github-control-plane" ) -> list[str]: 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") if target_name == "github-control-plane" + and path.name == "protected-policy.yml" else path.read_text(encoding="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_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_context.py b/tests/test_protected_policy_context.py new file mode 100644 index 0000000..09bc572 --- /dev/null +++ b/tests/test_protected_policy_context.py @@ -0,0 +1,99 @@ +from pathlib import Path +import unittest +from unittest.mock import patch + +from scripts import protected_policy_context as context +from scripts.protected_policy_inputs import InputRefused + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ".github/workflows/example.yml" + + +def workflow(run="python scripts/candidate.py", *, top="", job="", step=""): + return (f"name: Example\non:\n pull_request:\n{top}jobs:\n test:\n" + f" runs-on: ubuntu-latest\n{job} steps:\n" + f" - run: {run}\n{step}").encode() + + +class ProtectedContextTest(unittest.TestCase): + def validate(self, raw=None, **files): + context.validate_context({WORKFLOW: raw or workflow(), **files}) + + def test_current_control_workflows_have_supported_contexts(self): + files = {path.relative_to(ROOT).as_posix(): path.read_bytes() + for path in (ROOT / ".github/workflows").glob("*.y*ml")} + context.validate_context(files) + + def test_plain_root_helper_and_literal_root_overrides_are_supported(self): + for directory in (".", "./", "${{ github.workspace }}"): + with self.subTest(directory=directory): + self.validate(workflow(step=f" working-directory: '{directory}'\n shell: bash\n")) + + def test_workflow_job_and_step_directories_refuse_before_validator(self): + for options in ({"top": "defaults:\n run:\n working-directory: other\n"}, + {"job": " defaults:\n run:\n working-directory: other\n"}, + {"step": " working-directory: other\n"}): + with self.subTest(options=options), self.assertRaisesRegex(InputRefused, "workspace root"): + self.validate(workflow(**options)) + + def test_effective_root_override_is_explicit_and_supported(self): + self.validate(workflow(top="defaults:\n run:\n working-directory: other\n", + step=" working-directory: .\n")) + + def test_custom_shell_and_startup_environment_refuse(self): + for step in (" shell: python\n", " shell: 'bash --rcfile custom {0}'\n", + " env:\n BASH_ENV: scripts/startup.sh\n"): + with self.subTest(step=step), self.assertRaises(InputRefused): + self.validate(workflow(step=step)) + + def test_directory_and_dynamic_shell_forms_are_deliberately_unsupported(self): + for run in ("cd other; bash scripts/validate.sh", "pushd .; bash scripts/validate.sh", + "bash scripts/validate.sh; popd", "eval data; bash scripts/validate.sh", + "$(command -v bash) scripts/validate.sh", "`which bash` scripts/validate.sh", + "echo 'cd is data'; bash scripts/validate.sh"): + with self.subTest(run=run), self.assertRaises(InputRefused): + self.validate(workflow(run)) + + def test_directory_changes_in_referenced_shell_closure_refuse(self): + with self.assertRaisesRegex(InputRefused, "helper"): + self.validate(workflow("bash scripts/validate.sh"), **{ + "scripts/validate.sh": b"bash scripts/nested.sh\n", + "scripts/nested.sh": b"cd other\ncomposer install\n", + }) + + def test_python_helper_text_is_data_and_not_a_shell_program(self): + self.validate(**{"scripts/candidate.py": b"message = 'cd elsewhere'\n"}) + + def test_alias_steps_are_inspected_and_recursive_aliases_refuse(self): + raw = b"""name: Alias +on: {pull_request: null} +jobs: + test: + steps: + - &step + run: python scripts/candidate.py + working-directory: elsewhere + - *step +""" + with self.assertRaises(InputRefused): + self.validate(raw) + with self.assertRaisesRegex(InputRefused, "recursive"): + context.bounded_document("jobs: &jobs {test: *jobs}") + + def test_duplicate_and_merge_keys_refuse(self): + for source in ("jobs: {}\njobs: {}", "jobs: {<<: {test: {}}}"): + with self.subTest(source=source), self.assertRaises(InputRefused): + context.bounded_document(source) + + def test_node_depth_and_alias_expansion_have_independent_bounds(self): + with patch.object(context, "MAX_YAML_DEPTH", 4), self.assertRaises(InputRefused): + context.bounded_document("jobs: [[[[[[]]]]]]") + with patch.object(context, "MAX_YAML_NODES", 20), self.assertRaises(InputRefused): + context.bounded_document("first: &a [x, y, z]\nsecond: &b [*a, *a, *a]\nthird: [*b, *b, *b]") + + def test_malformed_context_shapes_refuse(self): + for raw in (b"[]", b"jobs: []", b"jobs: {test: {steps: {}}}", + workflow(step=" working-directory: [elsewhere]\n"), + workflow(job=" defaults: []\n")): + with self.subTest(raw=raw), self.assertRaises(InputRefused): + self.validate(raw) 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()) From 3afc0ccd63b5a460a4547895e55abd0915a16d39 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:05:55 +0000 Subject: [PATCH 2/4] Preserve root checker semantics and qualify the protected parser --- .github/workflows/ci.yml | 21 ++++++ .github/workflows/rust-docs-visual.yml | 5 +- .github/workflows/sonar.yml | 2 +- qualification/README.md | 11 +-- scripts/protected_policy_context.py | 91 ++++++++++++++++--------- tests/test_policy_dependency_install.py | 87 +++++++++++++++++++++++ tests/test_protected_policy_context.py | 18 +++-- 7 files changed, 190 insertions(+), 45 deletions(-) create mode 100644 tests/test_policy_dependency_install.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65f2f8..baf9e53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,24 @@ jobs: - name: Test browser evidence boundary run: npm run test:visual-capture + + protected-parser-install: + name: Protected parser installer controls + runs-on: ubuntu-latest + timeout-minutes: 10 + 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 installer runtime + run: | + python -I -m venv "$RUNNER_TEMP/protected-parser-install" + printf '%s\n' "$RUNNER_TEMP/protected-parser-install/bin" >> "$GITHUB_PATH" + - uses: ./.github/actions/bootstrap-patched-pip # local + with: + clients: pip,python + - name: Exercise the actual isolated hash-locked installer + run: python -I tests/test_policy_dependency_install.py diff --git a/.github/workflows/rust-docs-visual.yml b/.github/workflows/rust-docs-visual.yml index 91b3669..e41de76 100644 --- a/.github/workflows/rust-docs-visual.yml +++ b/.github/workflows/rust-docs-visual.yml @@ -97,7 +97,10 @@ jobs: cp docs/layout.css target/doc/layout.css mkdir -p target/doc/analytics cp docs/analytics/analytics.js target/doc/analytics/ - python3 scripts/check-docs-analytics.py target/doc + + - name: Check candidate analytics from the workspace root + if: steps.classify.outputs.required == 'true' + run: python3 candidate/scripts/check-docs-analytics.py candidate/target/doc - name: Capture candidate state matrix if: steps.classify.outputs.required == 'true' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index d622a65..8e896c9 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -51,7 +51,7 @@ jobs: - 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 tests.test_composer_container_trust tests.test_composer_container_effective_trust + coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs 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 tests.test_composer_container_trust tests.test_composer_container_effective_trust 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 9ebb95f..a7db18b 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -176,11 +176,12 @@ ordinary caller behavior. Run the dedicated controls with: python -m unittest tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue ``` -Current integration is incomplete: the Rust visual workflow's candidate-root -analytics check is outside this supported root-context language. Its acceptance -control remains failing until a reviewed compatibility correction preserves the -actual checker semantics. Do not weaken the guard, waive the control or claim a -complete pilot qualification from the other passing controls. +Integration also requires the Rust analytics checker's source-root correction +and actual old/new execution controls. The visual workflow keeps the candidate +build in its existing directory, then invokes that checker from the workspace +root with an explicit build directory. The SDK owner must prove that the checker +resolves its own source assets and preserves all content assertions. A passing +central context test does not substitute for that upstream compatibility proof. The source remains inactive. No App/key, protected variable or secret, environment, event-policy setting, required rule or live check is provisioned. diff --git a/scripts/protected_policy_context.py b/scripts/protected_policy_context.py index 3c2dc46..f196efb 100644 --- a/scripts/protected_policy_context.py +++ b/scripts/protected_policy_context.py @@ -8,6 +8,7 @@ from pathlib import PurePosixPath import re +import shlex from typing import Any import yaml @@ -18,10 +19,38 @@ MAX_YAML_DEPTH = 64 ROOT_DIRECTORIES = {None, ".", "./", "${{ github.workspace }}"} SHELLS = {None, "bash", "sh"} -UNSUPPORTED_SHELL = re.compile(r"(? bool: + if "`" in source or "$(" in source: + return True + try: + words = shlex.shlex(source.replace("\\\n", ""), posix=True, punctuation_chars=";&|()\n") + words.whitespace = " \t\r" + words.whitespace_split = True + # This is a conservative word check, not command-position inference. + # In particular, --source and --source-repository are ordinary flags. + return any(word in DIRECTORY_OR_EVALUATION_WORDS for word in words) + except ValueError: + return True + + +def node_children(node): + if isinstance(node, yaml.SequenceNode): + return node.value + if not isinstance(node, yaml.MappingNode): + return [] + names, children = set(), [] + for key, value in node.value: + if not isinstance(key, yaml.ScalarNode) or key.value in names or key.value == "<<": + raise InputRefused("Protected pilot YAML keys must be unique scalars without merges") + names.add(key.value) + children.extend((key, value)) + return children + + def bounded_document(source: str) -> dict[str, Any]: loader = yaml.BaseLoader(source) try: @@ -36,15 +65,7 @@ def bounded_document(source: str) -> dict[str, Any]: if node is None or id(node) in ancestors: raise InputRefused("Protected pilot YAML is empty or recursive") lineage = ancestors | {id(node)} - if isinstance(node, yaml.MappingNode): - names = set() - for key, value in node.value: - if not isinstance(key, yaml.ScalarNode) or key.value in names or key.value == "<<": - raise InputRefused("Protected pilot YAML keys must be unique scalars without merges") - names.add(key.value) - pending.extend(((key, depth + 1, lineage), (value, depth + 1, lineage))) - elif isinstance(node, yaml.SequenceNode): - pending.extend((child, depth + 1, lineage) for child in node.value) + pending.extend((child, depth + 1, lineage) for child in node_children(node)) document = loader.construct_document(root) except (yaml.YAMLError, RecursionError) as error: raise InputRefused("Protected pilot YAML cannot be read within its bounds") from error @@ -71,11 +92,36 @@ def check_helpers(source: str, files: dict[str, bytes], visited: set[str]) -> No text = files[name].decode("utf-8") suffix = PurePosixPath(name).suffix if suffix in {"", ".sh", ".bash", ".ksh", ".zsh"} or re.match(r"^#![^\n]*\b(?:bash|sh|dash|ksh|zsh)\b", text): - if UNSUPPORTED_SHELL.search(text): + if unsupported_shell(text): raise InputRefused("Protected pilot helper has an unsupported directory or dynamic shell context") check_helpers(text, files, visited) +def validate_helper_step(document, job, step, files): + if not isinstance(step, dict): + raise InputRefused("Protected pilot requires step mappings") + source = step.get("run", "") + if not isinstance(source, str): + raise InputRefused("Protected pilot requires literal run text") + if not HELPERS.search(source): + return + contexts = [run_defaults(document), run_defaults(job), step] + directory = next((scope["working-directory"] for scope in reversed(contexts) + if "working-directory" in scope), None) + shell = next((scope["shell"] for scope in reversed(contexts) if "shell" in scope), None) + if not isinstance(directory, (str, type(None))) or directory not in ROOT_DIRECTORIES: + raise InputRefused("Protected pilot relative helpers require the workspace root") + if not isinstance(shell, (str, type(None))) or shell not in SHELLS: + raise InputRefused("Protected pilot relative helpers require a reviewed shell") + for scope in (document, job, step): + environment = scope.get("env", {}) + if not isinstance(environment, dict) or STARTUP_ENVIRONMENT.intersection(environment): + raise InputRefused("Protected pilot helper startup environment is unsupported") + if unsupported_shell(source): + raise InputRefused("Protected pilot relative helpers have an unsupported directory or dynamic shell context") + check_helpers(source, files, set()) + + def validate_context(files: dict[str, bytes]) -> None: for name, raw in sorted(files.items()): if PurePosixPath(name).parent.as_posix() != ".github/workflows" or not name.endswith((".yml", ".yaml")): @@ -88,25 +134,4 @@ def validate_context(files: dict[str, bytes]) -> None: if not isinstance(job, dict) or not isinstance(job.get("steps", []), list): raise InputRefused("Protected pilot requires explicit jobs and step lists") for step in job.get("steps", []): - if not isinstance(step, dict): - raise InputRefused("Protected pilot requires step mappings") - source = step.get("run", "") - if not isinstance(source, str): - raise InputRefused("Protected pilot requires literal run text") - if not HELPERS.search(source): - continue - contexts = [run_defaults(document), run_defaults(job), step] - directory = next((scope["working-directory"] for scope in reversed(contexts) - if "working-directory" in scope), None) - shell = next((scope["shell"] for scope in reversed(contexts) if "shell" in scope), None) - if not isinstance(directory, (str, type(None))) or directory not in ROOT_DIRECTORIES: - raise InputRefused("Protected pilot relative helpers require the workspace root") - if not isinstance(shell, (str, type(None))) or shell not in SHELLS: - raise InputRefused("Protected pilot relative helpers require a reviewed shell") - for scope in (document, job, step): - environment = scope.get("env", {}) - if not isinstance(environment, dict) or STARTUP_ENVIRONMENT.intersection(environment): - raise InputRefused("Protected pilot helper startup environment is unsupported") - if UNSUPPORTED_SHELL.search(source): - raise InputRefused("Protected pilot relative helpers have an unsupported directory or dynamic shell context") - check_helpers(source, files, set()) + validate_helper_step(document, job, step, files) 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_context.py b/tests/test_protected_policy_context.py index 09bc572..dc54fa7 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -33,8 +33,9 @@ def test_workflow_job_and_step_directories_refuse_before_validator(self): for options in ({"top": "defaults:\n run:\n working-directory: other\n"}, {"job": " defaults:\n run:\n working-directory: other\n"}, {"step": " working-directory: other\n"}): + raw = workflow(**options) with self.subTest(options=options), self.assertRaisesRegex(InputRefused, "workspace root"): - self.validate(workflow(**options)) + self.validate(raw) def test_effective_root_override_is_explicit_and_supported(self): self.validate(workflow(top="defaults:\n run:\n working-directory: other\n", @@ -43,20 +44,27 @@ def test_effective_root_override_is_explicit_and_supported(self): def test_custom_shell_and_startup_environment_refuse(self): for step in (" shell: python\n", " shell: 'bash --rcfile custom {0}'\n", " env:\n BASH_ENV: scripts/startup.sh\n"): + raw = workflow(step=step) with self.subTest(step=step), self.assertRaises(InputRefused): - self.validate(workflow(step=step)) + self.validate(raw) def test_directory_and_dynamic_shell_forms_are_deliberately_unsupported(self): for run in ("cd other; bash scripts/validate.sh", "pushd .; bash scripts/validate.sh", "bash scripts/validate.sh; popd", "eval data; bash scripts/validate.sh", "$(command -v bash) scripts/validate.sh", "`which bash` scripts/validate.sh", - "echo 'cd is data'; bash scripts/validate.sh"): + "echo cd; bash scripts/validate.sh", "c\"\"d other; bash scripts/validate.sh", + ". scripts/startup.conf; bash scripts/validate.sh"): + raw = workflow(run) with self.subTest(run=run), self.assertRaises(InputRefused): - self.validate(workflow(run)) + self.validate(raw) + + def test_source_flags_do_not_become_shell_builtins(self): + self.validate(workflow("python scripts/validate.py --source=scripts --source-repository project")) def test_directory_changes_in_referenced_shell_closure_refuse(self): + raw = workflow("bash scripts/validate.sh") with self.assertRaisesRegex(InputRefused, "helper"): - self.validate(workflow("bash scripts/validate.sh"), **{ + self.validate(raw, **{ "scripts/validate.sh": b"bash scripts/nested.sh\n", "scripts/nested.sh": b"cd other\ncomposer install\n", }) From 7e2e930d20f7fa4bd70a52029c526c0b4b922e78 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:31:09 +0000 Subject: [PATCH 3/4] Close protected helper shell language and bind complex compatibility inputs --- qualification/README.md | 25 +++++-- scripts/protected_policy_context.py | 92 +++++++++++++++++++++----- tests/test_protected_policy_context.py | 61 +++++++++++++++++ 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index a7db18b..5ca1076 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -166,11 +166,26 @@ language lives in `scripts/protected_policy_context.py`, called only by the protected reader before the shared validator. Candidate files remain bounded nonexecutable data; the reader and issuer run from the protected base. -The pilot refuses relative helper references with a non-root execution context, -unreviewed shell, directory changes or dynamic shell evaluation. YAML traversal -bounds depth and expanded alias work and refuses recursive aliases, duplicate -keys and merges. These refusals are deliberately conservative and do not change -ordinary caller behavior. Run the dedicated controls with: +The pilot accepts new run text only as newline-separated literal commands with +fixed interpreter forms. Shell expansion, operators, escapes, inline environment +assignments, evaluator options and constructed command names refuse. Literal +data flags such as `--source` remain supported. Referenced shell helpers obey the +same language, including when `bash` reads a file with a misleading extension. +Python helper text remains data; this is not whole-program behavior analysis. + +Five existing control workflows contain complex shell programs. Their complete +raw file SHA-256 identities are a finite compatibility catalog in the protected +checker source, not candidate-supplied assertions or a general Bash parser. A +changed file, changed execution context, relocated copy or new complex program +cannot inherit that exception; updating the catalog requires exact source +review. The catalog does not exempt referenced shell-helper bytes, root-directory +checks or interpreter/startup environment checks. The retained cancellation +workflow is a source compatibility identity, not admission to run its fixtures. + +Relative helper references require the workspace root and a reviewed shell. +YAML traversal bounds depth and expanded alias work and refuses recursive +aliases, duplicate keys and merges. These conservative protected-pilot rules do +not change ordinary caller behavior. Run the dedicated controls with: ```sh python -m unittest tests.test_protected_policy_context tests.test_protected_policy_check tests.test_protected_policy_inputs tests.test_protected_policy_issue diff --git a/scripts/protected_policy_context.py b/scripts/protected_policy_context.py index f196efb..47d2d0d 100644 --- a/scripts/protected_policy_context.py +++ b/scripts/protected_policy_context.py @@ -6,6 +6,7 @@ from __future__ import annotations +import hashlib from pathlib import PurePosixPath import re import shlex @@ -19,22 +20,63 @@ MAX_YAML_DEPTH = 64 ROOT_DIRECTORIES = {None, ".", "./", "${{ github.workspace }}"} SHELLS = {None, "bash", "sh"} -DIRECTORY_OR_EVALUATION_WORDS = {"cd", "pushd", "popd", "eval", "source", "."} -STARTUP_ENVIRONMENT = {"BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS", "LD_PRELOAD"} +STARTUP_ENVIRONMENT = { + "BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS", "LD_PRELOAD", "PATH", "CDPATH", + "BASH_COMPAT", "BASH_LOADABLES_PATH", "GLOBIGNORE", "PROMPT_COMMAND", "PS4", +} +SHEBANGS = {"#!/bin/sh", "#!/bin/bash", "#!/usr/bin/sh", "#!/usr/bin/bash", + "#!/usr/bin/env sh", "#!/usr/bin/env bash"} +# Literal words have no shell expansions, operators, escapes, globs or quote +# concatenation. Quoting may group data containing spaces, never shell syntax. +WORD = r'''(?:[A-Za-z0-9_./:@%+,=-]+|'[A-Za-z0-9_./:@%+,= -]*'|"[A-Za-z0-9_./:@%+,= -]*")''' +COMMAND = re.compile(rf"{WORD}(?:[ \t]+{WORD})*[ \t]*") +PYTHON_MODULES = {"unittest", "pip", "venv", "http.server"} +# Filled with explicit reviewed workflow-byte identities, never derived from +# candidate text. A changed complex workflow needs a separate source review. +REVIEWED_COMPLEX_WORKFLOWS = { + ".github/workflows/ci.yml": "03500c9892214253cdc7d41d5a35243e63ce3e670b6c97aa81bd87519c2df838", + ".github/workflows/sonar.yml": "d34e7917760baa6f82770b37df3c0cbe1efe01e96e45c16be6a93790f4c48304", + ".github/workflows/protected-policy.yml": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31", + ".github/workflows/rust-docs-visual.yml": "69bc0b89cdeacbabd35c345770de86fe9e20bb3f2a53fc1c7a157973c3db31a6", + ".github/workflows/cancellation-competitors.yml": "05e17afb377ae5e1ca3c20295cea27704d95763a36ac61d8b9cae39a2c5ce324", +} + + +def literal_python(arguments: list[str]) -> bool: + if arguments[:1] == ["-I"]: + arguments = arguments[1:] + if arguments[:1] == ["-m"]: + return len(arguments) >= 2 and arguments[1] in PYTHON_MODULES + return bool(arguments) and not arguments[0].startswith("-") and arguments[0].endswith(".py") + + +def literal_command(words: list[str]) -> bool: + command, arguments = words[0], words[1:] + if command in {"bash", "sh"}: + return bool(arguments) and HELPERS.fullmatch(arguments[0]) is not None + if command in {"python", "python3"}: + return literal_python(arguments) + if command == "node": + return bool(arguments) and not arguments[0].startswith("-") and arguments[0].endswith((".js", ".mjs")) + if command == "coverage": + return bool(arguments) and arguments[0] in {"run", "xml"} + if command == "composer": + return bool(arguments) and arguments[0] in {"install", "update", "validate", "config"} + return HELPERS.fullmatch(command) is not None and PurePosixPath(command).suffix in {"", ".sh", ".bash"} def unsupported_shell(source: str) -> bool: - if "`" in source or "$(" in source: - return True - try: - words = shlex.shlex(source.replace("\\\n", ""), posix=True, punctuation_chars=";&|()\n") - words.whitespace = " \t\r" - words.whitespace_split = True - # This is a conservative word check, not command-position inference. - # In particular, --source and --source-repository are ordinary flags. - return any(word in DIRECTORY_OR_EVALUATION_WORDS for word in words) - except ValueError: + if any(ord(character) < 32 and character not in "\n\t" for character in source): return True + for line in source.splitlines(): + line = line.strip(" \t") + if line.startswith("#!") and line not in SHEBANGS: + return True + if not line or line.startswith("#"): + continue + if COMMAND.fullmatch(line) is None or not literal_command(shlex.split(line)): + return True + return False def node_children(node): @@ -83,7 +125,18 @@ def run_defaults(scope: dict[str, Any]) -> dict[str, Any]: return defaults.get("run", {}) +def shell_targets(source: str) -> set[str]: + forced_shell = set() + for line in source.splitlines(): + if COMMAND.fullmatch(line.strip()) is not None: + words = shlex.split(line) + if words[0] in {"bash", "sh"} and len(words) > 1: + forced_shell.add(words[1].removeprefix("./")) + return forced_shell + + def check_helpers(source: str, files: dict[str, bytes], visited: set[str]) -> None: + forced_shell = shell_targets(source) for reference in HELPERS.findall(source): name = checked_path(reference.removeprefix("./")) if name not in files or name in visited: @@ -91,18 +144,21 @@ def check_helpers(source: str, files: dict[str, bytes], visited: set[str]) -> No visited.add(name) text = files[name].decode("utf-8") suffix = PurePosixPath(name).suffix - if suffix in {"", ".sh", ".bash", ".ksh", ".zsh"} or re.match(r"^#![^\n]*\b(?:bash|sh|dash|ksh|zsh)\b", text): + if (name in forced_shell or suffix in {"", ".sh", ".bash", ".ksh", ".zsh"} + or re.match(r"^#![^\n]*\b(?:bash|sh|dash|ksh|zsh)\b", text)): if unsupported_shell(text): raise InputRefused("Protected pilot helper has an unsupported directory or dynamic shell context") check_helpers(text, files, visited) -def validate_helper_step(document, job, step, files): +def validate_helper_step(document, job, step, files, reviewed_complex=False): if not isinstance(step, dict): raise InputRefused("Protected pilot requires step mappings") source = step.get("run", "") if not isinstance(source, str): raise InputRefused("Protected pilot requires literal run text") + if unsupported_shell(source) and not reviewed_complex: + raise InputRefused("Protected pilot requires closed literal commands or exact reviewed workflow bytes") if not HELPERS.search(source): return contexts = [run_defaults(document), run_defaults(job), step] @@ -115,10 +171,9 @@ def validate_helper_step(document, job, step, files): raise InputRefused("Protected pilot relative helpers require a reviewed shell") for scope in (document, job, step): environment = scope.get("env", {}) - if not isinstance(environment, dict) or STARTUP_ENVIRONMENT.intersection(environment): + if (not isinstance(environment, dict) or STARTUP_ENVIRONMENT.intersection(environment) + or any(str(name).startswith("BASH_FUNC_") for name in environment)): raise InputRefused("Protected pilot helper startup environment is unsupported") - if unsupported_shell(source): - raise InputRefused("Protected pilot relative helpers have an unsupported directory or dynamic shell context") check_helpers(source, files, set()) @@ -127,6 +182,7 @@ def validate_context(files: dict[str, bytes]) -> None: if PurePosixPath(name).parent.as_posix() != ".github/workflows" or not name.endswith((".yml", ".yaml")): continue document = bounded_document(raw.decode("utf-8")) + reviewed_complex = hashlib.sha256(raw).hexdigest() == REVIEWED_COMPLEX_WORKFLOWS.get(name) jobs = document.get("jobs", {}) if not isinstance(jobs, dict): raise InputRefused("Protected pilot requires a jobs mapping") @@ -134,4 +190,4 @@ def validate_context(files: dict[str, bytes]) -> None: if not isinstance(job, dict) or not isinstance(job.get("steps", []), list): raise InputRefused("Protected pilot requires explicit jobs and step lists") for step in job.get("steps", []): - validate_helper_step(document, job, step, files) + validate_helper_step(document, job, step, files, reviewed_complex) diff --git a/tests/test_protected_policy_context.py b/tests/test_protected_policy_context.py index dc54fa7..7fc0e8b 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -69,6 +69,67 @@ def test_directory_changes_in_referenced_shell_closure_refuse(self): "scripts/nested.sh": b"cd other\ncomposer install\n", }) + def test_shell_construction_and_startup_bypasses_refuse_direct_and_nested(self): + programs = ( + "x=cd; $x /tmp; python scripts/validate.py", + "$'cd' /tmp; python scripts/validate.py", + 'x=c; y=d; "$x$y" /tmp; python scripts/validate.py', + "bash -c 'cd /tmp; python scripts/validate.py'", + "BASH_ENV=/tmp/startup bash scripts/validate.sh", + ) + for program in programs: + with self.subTest(program=program): + raw = workflow("|") + (" " + program + "\n").encode() + with self.assertRaisesRegex(InputRefused, "closed literal"): + self.validate(raw) + files = {"scripts/entry.sh": b"bash scripts/nested.py\n", + "scripts/nested.py": (program + "\n").encode()} + with self.assertRaisesRegex(InputRefused, "helper"): + self.validate(workflow("bash scripts/entry.sh"), **files) + + def test_indirect_hidden_helpers_and_evaluator_options_refuse(self): + for run in ('bash "$SCRIPT"', '"$RUNNER" "$TARGET"', + "python -c 'print(1)'", "node --eval data", "env bash scripts/ok.sh", + "bash -O extglob scripts/ok.sh", "python scripts/ok.py > result", + "python scripts/ok.py && python scripts/other.py", + "python scripts/ok.py\\\n scripts/other.py"): + raw = workflow(run) + with self.subTest(run=run), self.assertRaises(InputRefused): + self.validate(raw) + + def test_literal_multiple_commands_flags_and_nested_shell_helpers_pass(self): + raw = workflow("|") + b" python scripts/validate.py --source=scripts --source-repository project\n bash scripts/entry.sh\n" + self.validate(raw, **{"scripts/entry.sh": b"#!/bin/sh\n# source is data here\nbash scripts/nested.sh\n", + "scripts/nested.sh": b"composer install --no-interaction\n"}) + + def test_exact_complex_workflow_binding_cannot_be_supplied_by_candidate(self): + path = ".github/workflows/rust-docs-visual.yml" + raw = (ROOT / path).read_bytes() + context.validate_context({path: raw}) + changed = raw.replace(b"python visual-controller/scripts/visual_evidence.py classify", + b"x=cd; $x /tmp; python visual-controller/scripts/visual_evidence.py classify") + with self.assertRaisesRegex(InputRefused, "closed literal"): + context.validate_context({path: changed}) + with self.assertRaises(InputRefused): + context.validate_context({WORKFLOW: raw}) + + def test_exact_workflow_binding_does_not_exempt_changed_shell_helpers(self): + path = ".github/workflows/protected-policy.yml" + # The workflow is exact; the referenced Python file cannot disguise a + # shell program behind its extension to inherit the workflow exception. + with self.assertRaisesRegex(InputRefused, "helper"): + context.validate_context({path: (ROOT / path).read_bytes(), + "scripts/protected_policy_issue.py": b"#!/bin/bash\nx=cd; $x /tmp\n"}) + + def test_interpreter_startup_and_exported_functions_refuse(self): + for name in ("BASH_ENV", "PATH", "CDPATH", "BASH_FUNC_python%%"): + raw = workflow(step=f" env:\n {name}: injected\n") + with self.subTest(name=name), self.assertRaises(InputRefused): + self.validate(raw) + raw = workflow("bash scripts/entry.sh") + with self.assertRaises(InputRefused): + self.validate(raw, **{"scripts/entry.sh": b"#!/usr/bin/env -S bash -c\ncomposer install\n"}) + def test_python_helper_text_is_data_and_not_a_shell_program(self): self.validate(**{"scripts/candidate.py": b"message = 'cd elsewhere'\n"}) From d226b05baaefd934f7bd121c36d7b75580944e8d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:39:25 +0000 Subject: [PATCH 4/4] Separate exception fixture setup from policy refusal assertions --- tests/test_protected_policy_context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_protected_policy_context.py b/tests/test_protected_policy_context.py index 7fc0e8b..27ebb42 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -84,8 +84,9 @@ def test_shell_construction_and_startup_bypasses_refuse_direct_and_nested(self): self.validate(raw) files = {"scripts/entry.sh": b"bash scripts/nested.py\n", "scripts/nested.py": (program + "\n").encode()} + nested_workflow = workflow("bash scripts/entry.sh") with self.assertRaisesRegex(InputRefused, "helper"): - self.validate(workflow("bash scripts/entry.sh"), **files) + self.validate(nested_workflow, **files) def test_indirect_hidden_helpers_and_evaluator_options_refuse(self): for run in ('bash "$SCRIPT"', '"$RUNNER" "$TARGET"', @@ -117,9 +118,10 @@ def test_exact_workflow_binding_does_not_exempt_changed_shell_helpers(self): path = ".github/workflows/protected-policy.yml" # The workflow is exact; the referenced Python file cannot disguise a # shell program behind its extension to inherit the workflow exception. + files = {path: (ROOT / path).read_bytes(), + "scripts/protected_policy_issue.py": b"#!/bin/bash\nx=cd; $x /tmp\n"} with self.assertRaisesRegex(InputRefused, "helper"): - context.validate_context({path: (ROOT / path).read_bytes(), - "scripts/protected_policy_issue.py": b"#!/bin/bash\nx=cd; $x /tmp\n"}) + context.validate_context(files) def test_interpreter_startup_and_exported_functions_refuse(self): for name in ("BASH_ENV", "PATH", "CDPATH", "BASH_FUNC_python%%"):